From d2ade43a4d93deb3d1a98cb8484804dd26b891b4 Mon Sep 17 00:00:00 2001 From: Janghoon Lee <44862514+its-janghoon@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:38:22 +0000 Subject: [PATCH 1/3] feat(skill): arm skills from top-level urls/keywords and the browser page URL Frontmatter may now declare top-level keywords: and urls: lists (merged into the autoInject block). /v1/chat/completions accepts an optional redrob_context {page_url, page_title} and injects the bodies of skills that arm on the last user message or the page URL as one system message. Armed skills leave the standing skill list in sessions, and the v1 session loader now registers every built-in. --- packages/core/src/skill.ts | 3 + packages/core/src/skill/arming.ts | 48 +++++++++++- .../protocol/src/groups/chat-completion.ts | 11 +++ packages/redrob/src/session/system.ts | 8 +- packages/redrob/src/skill/index.ts | 22 +++++- .../server/src/handlers/chat-completion.ts | 73 ++++++++++++++++++- 6 files changed, 160 insertions(+), 5 deletions(-) diff --git a/packages/core/src/skill.ts b/packages/core/src/skill.ts index 8c82d56a46..b829fd9224 100644 --- a/packages/core/src/skill.ts +++ b/packages/core/src/skill.ts @@ -10,6 +10,7 @@ import { FSUtil } from "./fs-util" import { PermissionV2 } from "./permission" import { AbsolutePath } from "./schema" import { SkillDiscovery } from "./skill/discovery" +import { SkillArming } from "./skill/arming" import { State } from "./state" export const DirectorySource = Skill.DirectorySource @@ -148,6 +149,8 @@ const layer = Layer.effect( ) } else autoInject = block.success } + // The short form: top-level `keywords:` and `urls:` lists arm the skill too. + autoInject = SkillArming.fromFrontmatter(markdown.data, autoInject) skills.push({ name, description: frontmatter.description, diff --git a/packages/core/src/skill/arming.ts b/packages/core/src/skill/arming.ts index ca5e7a59e9..739963664b 100644 --- a/packages/core/src/skill/arming.ts +++ b/packages/core/src/skill/arming.ts @@ -18,6 +18,9 @@ import type { Skill } from "@redrob-code/schema/skill" * path segments. The scheme is ignored on both sides, so a glob is written * `docs.google.com/document/**` rather than `https://docs.google.com/document/**`. * + * The block comes from frontmatter `autoInject: { keywords, url }` or from the short + * top-level `keywords:` / `urls:` lists; `fromFrontmatter` merges the two spellings. + * * A skill with no `autoInject` block NEVER auto-arms. It stays explicitly loadable, which * is the point: a skill that arms on everything is a skill that is always in the prompt. */ @@ -108,7 +111,50 @@ const globToRegExp = (glob: string): RegExp => { const matchesUrlGlob = (url: string, glob: string): boolean => { const normalized = normalizeUrl(glob) if (normalized.length === 0) return false - return globToRegExp(normalized).test(normalizeUrl(url)) + const target = normalizeUrl(url) + if (globToRegExp(normalized).test(target)) return true + // `mail.google.com/**` should also cover the bare site root, which `normalizeUrl` turns + // into `mail.google.com` (no trailing slash left for the `/` in the glob to match). + return normalized.endsWith("/**") && globToRegExp(normalized.slice(0, -3)).test(target) +} + +const stringList = (value: unknown): ReadonlyArray => { + if (typeof value === "string") return value.trim().length > 0 ? [value] : [] + if (!Array.isArray(value)) return [] + return value.filter((item): item is string => typeof item === "string" && item.trim().length > 0) +} + +/** + * Top-level `urls:` globs are written the way a person copies them out of the address bar, + * `https://mail.google.com/*`. In that habit a trailing `/*` means "anything on this site", + * so it is widened to `/**`; inside `autoInject.url` the stricter glob rules stay as they are. + */ +const widenSiteGlob = (glob: string): string => (glob.endsWith("/*") ? `${glob}*` : glob) + +/** + * The arming block a frontmatter declares, from either spelling: + * + * - the nested `autoInject: { keywords, url }` block (its decoded value is passed as `block`), + * - top-level `keywords:` and `urls:` lists, the short form the browser skill set uses. + * + * Both are merged, so a document may use either or both. When neither top-level list is + * present the block is returned unchanged, which keeps a plain skill on today's + * explicit-only behaviour. + */ +export const fromFrontmatter = ( + data: unknown, + block: Skill.AutoInject | undefined, +): Skill.AutoInject | undefined => { + const record = typeof data === "object" && data !== null ? (data as Record) : {} + const extraKeywords = stringList(record["keywords"]) + const extraUrls = stringList(record["urls"]).map(widenSiteGlob) + if (extraKeywords.length === 0 && extraUrls.length === 0) return block + const keywords = [...(block?.keywords ?? []), ...extraKeywords] + const url = [...(block?.url ?? []), ...extraUrls] + return { + ...(keywords.length > 0 ? { keywords } : {}), + ...(url.length > 0 ? { url } : {}), + } } /** Returns the skills that auto-arm, in the order they were given. */ diff --git a/packages/protocol/src/groups/chat-completion.ts b/packages/protocol/src/groups/chat-completion.ts index 373baea9d8..6fe5f8dc38 100644 --- a/packages/protocol/src/groups/chat-completion.ts +++ b/packages/protocol/src/groups/chat-completion.ts @@ -101,6 +101,17 @@ export const ChatCompletionRequest = Schema.Struct({ reasoning_effort: Schema.optional(Schema.String), /** Accepted and ignored: a caller may send it, and refusing would be rude. */ user: Schema.optional(Schema.String), + /** + * Redrob extension, optional: what the calling host is looking at. redrob-browser sends the + * task's active tab, and the engine arms skills whose `urls:` globs match `page_url` (see + * docs/LOCAL-ENGINE-API.md). Other clients omit it and nothing changes for them. + */ + redrob_context: Schema.optional( + Schema.Struct({ + page_url: Schema.optional(Schema.String), + page_title: Schema.optional(Schema.String), + }), + ), }).annotate({ identifier: "ChatCompletionRequest" }) export type ChatCompletionRequest = typeof ChatCompletionRequest.Type diff --git a/packages/redrob/src/session/system.ts b/packages/redrob/src/session/system.ts index f1e022937c..deb4c7fd20 100644 --- a/packages/redrob/src/session/system.ts +++ b/packages/redrob/src/session/system.ts @@ -121,13 +121,19 @@ const layer = Layer.effect( if (Permission.disabled(["skill"], agent.permission).has("skill")) return const list = yield* skill.available(agent) + // A skill that declares keywords or URL globs is ARMED: it stays out of the standing + // list and reaches the prompt only through `armedSkills` when its own patterns match. + // Listing every site skill in every session is exactly what arming exists to avoid. + const standing = list.filter( + (entry) => (entry.autoInject?.keywords?.length ?? 0) === 0 && (entry.autoInject?.url?.length ?? 0) === 0, + ) return [ "Skills provide specialized instructions and workflows for specific tasks.", "Use the skill tool to load a skill when a task matches its description.", // the agents seem to ingest the information about skills a bit better if we present a more verbose // version of them here and a less verbose version in tool description, rather than vice versa. - Skill.fmt(list, { verbose: true }), + Skill.fmt(standing, { verbose: true }), ].join("\n") }), diff --git a/packages/redrob/src/skill/index.ts b/packages/redrob/src/skill/index.ts index 191f81b9f0..a069e52f19 100644 --- a/packages/redrob/src/skill/index.ts +++ b/packages/redrob/src/skill/index.ts @@ -8,6 +8,7 @@ import { InstanceState } from "@/effect/instance-state" import { Global } from "@redrob-code/core/global" import { SkillPlugin } from "@redrob-code/core/plugin/skill" import { SkillV2 } from "@redrob-code/core/skill" +import { SkillArming } from "@redrob-code/core/skill/arming" import { Permission } from "@/permission" import { FSUtil } from "@redrob-code/core/fs-util" import { Config } from "@/config/config" @@ -82,8 +83,12 @@ function isSkillFrontmatter(data: unknown): data is { name: string; description? * like one that declared no block at all -- the documented failure mode. */ function autoInjectOf(data: { autoInject?: unknown }): SkillV2.AutoInject | undefined { - if (data.autoInject === undefined) return undefined - return Schema.decodeUnknownOption(SkillV2.AutoInject)(data.autoInject).pipe(Option.getOrUndefined) + const block = + data.autoInject === undefined + ? undefined + : Schema.decodeUnknownOption(SkillV2.AutoInject)(data.autoInject).pipe(Option.getOrUndefined) + // Top-level `keywords:` / `urls:` are the short spelling of the same block. + return SkillArming.fromFrontmatter(data, block) } export class InvalidError extends Schema.TaggedErrorClass()("SkillInvalidError", { @@ -310,6 +315,19 @@ const layer = Layer.effect( location: "", content: CUSTOMIZE_REDROB_SKILL_BODY, } + // The engine's other built-ins (documents, page-control, skill-writer and the + // browser site skills) come from the same list the v2 plugin registers, so a session + // sees and arms the same set the chat-completions route does. + for (const builtin of SkillPlugin.BuiltinSkills) { + if (s.skills[builtin.name]) continue + s.skills[builtin.name] = { + name: builtin.name, + description: builtin.description, + location: "", + content: builtin.content, + ...(builtin.autoInject ? { autoInject: builtin.autoInject } : {}), + } + } yield* loadSkills(s, yield* InstanceState.get(discovered), events) return s }), diff --git a/packages/server/src/handlers/chat-completion.ts b/packages/server/src/handlers/chat-completion.ts index ee9189cdf1..8d3570005f 100644 --- a/packages/server/src/handlers/chat-completion.ts +++ b/packages/server/src/handlers/chat-completion.ts @@ -16,6 +16,8 @@ import { ModelV2 } from "@redrob-code/core/model" import { ProviderV2 } from "@redrob-code/core/provider" import { Mask } from "@redrob-code/core/privacy/mask" import { Privacy } from "@redrob-code/core/privacy/service" +import { SkillV2 } from "@redrob-code/core/skill" +import { SkillArming } from "@redrob-code/core/skill/arming" import { fromCatalogModel } from "@redrob-code/core/session/runner/model" import { LLM, LLMError, LLMResponse, Message } from "@redrob-code/llm" import type { LLMEvent, LLMRequest } from "@redrob-code/llm" @@ -184,12 +186,81 @@ export function callerLabels(headers: Record): Record + value.replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<").replaceAll(">", ">") + +function lastUserText(payload: ChatCompletionRequest): string { + for (let index = payload.messages.length - 1; index >= 0; index -= 1) { + const message = payload.messages[index]! + if (message.role !== "user") continue + const content = message.content + if (typeof content === "string") return content + if (Array.isArray(content)) { + return content + .map((part) => (part["type"] === "text" && typeof part["text"] === "string" ? (part["text"] as string) : "")) + .join("\n") + } + return "" + } + return "" +} + +export const withArmedSkills = Effect.fn("chat.armedSkills")(function* ( + payload: ChatCompletionRequest, + messages: ReadonlyArray, +) { + const service = yield* Effect.serviceOption(SkillV2.Service) + if (Option.isNone(service)) return messages + const skills = yield* service.value.list().pipe(Effect.catchCause(() => Effect.succeed([] as SkillV2.Info[]))) + const pageUrl = payload.redrob_context?.page_url?.trim() || undefined + const armed = SkillArming.arm({ skills, prompt: lastUserText(payload), url: pageUrl }) + if (armed.length === 0) return messages + + const byName = new Map(skills.map((skill) => [skill.name, skill] as const)) + const sections: string[] = [] + for (const entry of armed) { + const info = byName.get(entry.name) + if (info === undefined || info.content.trim().length === 0) continue + const why = [...entry.keywords.map((k) => `keyword ${k}`), ...entry.urls.map((u) => `url ${u}`)].join(", ") + sections.push(` `, info.content, " ") + } + if (sections.length === 0) return messages + + const title = payload.redrob_context?.page_title?.trim() + const text = [ + "", + "These skills armed themselves on this request: their own keywords matched what the user typed,", + "or their URL globs matched the page the task is on" + + (pageUrl ? ` (${title ? `${attribute(title)} - ` : ""}${attribute(pageUrl)}).` : "."), + "Follow them when they apply; they are already loaded.", + ...sections, + "", + ].join("\n") + + const insertAt = messages.findIndex((message) => message.role !== "system") + const at = insertAt === -1 ? messages.length : insertAt + return [...messages.slice(0, at), Message.system(text), ...messages.slice(at)] +}) + const buildRequest = Effect.fn("chat.buildRequest")(function* ( payload: ChatCompletionRequest, labels: Record = {}, ) { const model = yield* resolveModel(payload.model) - const messages = toLLMMessages(payload) + const messages = yield* withArmedSkills(payload, toLLMMessages(payload)) const tools = toLLMTools(payload) const generation = toGeneration(payload) return LLM.request({ From c4fcb15a707a02f8c94022700528b2d8459bfaa2 Mon Sep 17 00:00:00 2001 From: Janghoon Lee <44862514+its-janghoon@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:43:10 +0000 Subject: [PATCH 2/3] feat(skill): ship ten browser site skills and document skill arming Gmail, Google Calendar, Notion, GitHub, Slack web, Naver mail, Naver search, Kakao web, Hometax (lookup only) and Coupang order lookup, each a SKILL.md with urls: and keywords: read from its own frontmatter and registered as built-ins. Keywords ending in Hangul no longer need a trailing word boundary, so particles attached to a noun still arm. skill-writer documents the short keys and how to save a repeated instruction under ~/.config/redrob/skills/. LOCAL-ENGINE-API gains a Skill arming section and the redrob_context request field. --- docs/LOCAL-ENGINE-API.md | 37 +++++++++++++ packages/core/src/plugin/skill.ts | 54 ++++++++++++++++++- .../src/plugin/skill/browser/coupang/SKILL.md | 27 ++++++++++ .../src/plugin/skill/browser/github/SKILL.md | 25 +++++++++ .../src/plugin/skill/browser/gmail/SKILL.md | 27 ++++++++++ .../skill/browser/google-calendar/SKILL.md | 28 ++++++++++ .../src/plugin/skill/browser/hometax/SKILL.md | 31 +++++++++++ .../src/plugin/skill/browser/kakao/SKILL.md | 28 ++++++++++ .../plugin/skill/browser/naver-mail/SKILL.md | 24 +++++++++ .../skill/browser/naver-search/SKILL.md | 23 ++++++++ .../src/plugin/skill/browser/notion/SKILL.md | 22 ++++++++ .../src/plugin/skill/browser/slack/SKILL.md | 22 ++++++++ .../core/src/plugin/skill/skill-writer.md | 17 ++++++ packages/core/src/skill/arming.ts | 5 +- packages/redrob/src/skill/index.ts | 2 +- 15 files changed, 369 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/plugin/skill/browser/coupang/SKILL.md create mode 100644 packages/core/src/plugin/skill/browser/github/SKILL.md create mode 100644 packages/core/src/plugin/skill/browser/gmail/SKILL.md create mode 100644 packages/core/src/plugin/skill/browser/google-calendar/SKILL.md create mode 100644 packages/core/src/plugin/skill/browser/hometax/SKILL.md create mode 100644 packages/core/src/plugin/skill/browser/kakao/SKILL.md create mode 100644 packages/core/src/plugin/skill/browser/naver-mail/SKILL.md create mode 100644 packages/core/src/plugin/skill/browser/naver-search/SKILL.md create mode 100644 packages/core/src/plugin/skill/browser/notion/SKILL.md create mode 100644 packages/core/src/plugin/skill/browser/slack/SKILL.md diff --git a/docs/LOCAL-ENGINE-API.md b/docs/LOCAL-ENGINE-API.md index 8219ee6e82..1e2a9471c5 100644 --- a/docs/LOCAL-ENGINE-API.md +++ b/docs/LOCAL-ENGINE-API.md @@ -64,10 +64,47 @@ An OpenAI chat-completions subset: | `max_tokens` | no | | | `temperature` | no | ignored for models that fix their own sampling | | `reasoning_effort` | no | passed through where the provider accepts it | +| `redrob_context` | no | Redrob extension: `{ page_url, page_title }` of the page the caller is on — see Skill arming | Unknown fields are ignored rather than rejected, so a newer client can talk to an older engine without a version check. +### Skill arming + +A skill arms itself — its body is added to the request without anyone loading it — +when its frontmatter declares `keywords:` or `urls:` (or the nested +`autoInject: { keywords, url }` block; both spellings merge): + +```markdown +--- +name: browser-gmail +description: Use when the task is reading or drafting mail in Gmail. +urls: + - https://mail.google.com/* +keywords: + - gmail +--- +``` + +- **keywords** match the last `user` message, case-insensitively, on word boundaries + (`ai` does not match `said`). A keyword ending in Hangul needs no boundary after it, + so `홈택스` matches `홈택스에서`. +- **urls** match `redrob_context.page_url`. The scheme is ignored, `*` stays inside one + path segment, `**` crosses segments, and a trailing `/*` in `urls:` covers the whole + site including its root. +- Armed bodies go in as one extra `system` message after the caller's own system + messages, labelled with what armed each one. A skill with neither list never arms + and behaves as before; in agent sessions an armable skill is left out of the standing + skill list and appears only when it arms. + +`redrob-browser` sends `redrob_context` on every task turn to the built-in engine: the +agent's own tab once it has opened one, else the http(s) tab the user started from. It +never sends it to a user's own provider. The engine ships ten site skills under +`packages/core/src/plugin/skill/browser/` (Gmail, Google Calendar, Notion, GitHub, +Slack, 네이버 메일, 네이버 검색, 카카오, 홈택스 조회, 쿠팡 주문 조회) and the +`skill-writer` skill, which saves a repeated instruction as a new +`~/.config/redrob/skills//SKILL.md`. + ### Tool ownership is decided by the request This is the whole design. One rule, no modes to configure: diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index 6bd98a479b..953ea19eaf 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -10,6 +10,18 @@ import customizeRedrobContent from "./skill/customize-redrob.md" with { type: "t import documentToolchainContent from "./skill/document-toolchain.md" with { type: "text" } import pageControlContent from "./skill/page-control.md" with { type: "text" } import skillWriterContent from "./skill/skill-writer.md" with { type: "text" } +import browserGmail from "./skill/browser/gmail/SKILL.md" with { type: "text" } +import browserGoogleCalendar from "./skill/browser/google-calendar/SKILL.md" with { type: "text" } +import browserNotion from "./skill/browser/notion/SKILL.md" with { type: "text" } +import browserGithub from "./skill/browser/github/SKILL.md" with { type: "text" } +import browserSlack from "./skill/browser/slack/SKILL.md" with { type: "text" } +import browserNaverMail from "./skill/browser/naver-mail/SKILL.md" with { type: "text" } +import browserNaverSearch from "./skill/browser/naver-search/SKILL.md" with { type: "text" } +import browserKakao from "./skill/browser/kakao/SKILL.md" with { type: "text" } +import browserHometax from "./skill/browser/hometax/SKILL.md" with { type: "text" } +import browserCoupang from "./skill/browser/coupang/SKILL.md" with { type: "text" } +import { ConfigMarkdown } from "../config/markdown" +import { SkillArming } from "../skill/arming" const mutable = (value: T) => value as DeepMutable @@ -79,11 +91,51 @@ export const BuiltinSkills: ReadonlyArray = [ }), ] +/** + * CAP-1: the browser site skills. Each one is a real SKILL.md under `./skill/browser//`, + * frontmatter included, so the file a person edits is the whole truth: its `name`, + * `description`, `urls:` and `keywords:` are read from it here, through the same arming merge + * the disk loader uses. A document whose frontmatter does not parse, or has no name, is skipped + * rather than shipped as a nameless skill. + */ +const BrowserSkillDocuments: ReadonlyArray = [ + ["gmail", browserGmail], + ["google-calendar", browserGoogleCalendar], + ["notion", browserNotion], + ["github", browserGithub], + ["slack", browserSlack], + ["naver-mail", browserNaverMail], + ["naver-search", browserNaverSearch], + ["kakao", browserKakao], + ["hometax", browserHometax], + ["coupang", browserCoupang], +] + +export const BrowserSkills: ReadonlyArray = BrowserSkillDocuments.flatMap(([site, document]) => { + const markdown = ConfigMarkdown.parseOption(document) + const data = markdown?.data as Record | undefined + const name = typeof data?.["name"] === "string" ? (data["name"] as string) : undefined + if (!markdown || !name) return [] + const autoInject = SkillArming.fromFrontmatter(data, undefined) + return [ + SkillV2.Info.make({ + name, + description: typeof data?.["description"] === "string" ? (data["description"] as string) : undefined, + location: AbsolutePath.make(`/builtin/browser/${site}/SKILL.md`), + ...(autoInject ? { autoInject: SkillV2.AutoInject.make(autoInject) } : {}), + content: markdown.content, + }), + ] +}) + +/** Everything the engine registers: the core built-ins, then the browser site skills. */ +export const AllBuiltinSkills: ReadonlyArray = [...BuiltinSkills, ...BrowserSkills] + export const Plugin = define({ id: "skill", effect: Effect.fn(function* (ctx) { yield* ctx.skill.transform((draft) => { - for (const skill of BuiltinSkills) { + for (const skill of AllBuiltinSkills) { // The plugin API is typed from the generated SDK, which has no readonly arrays. draft.source(mutable(SkillV2.EmbeddedSource.make({ type: "embedded", skill }))) } diff --git a/packages/core/src/plugin/skill/browser/coupang/SKILL.md b/packages/core/src/plugin/skill/browser/coupang/SKILL.md new file mode 100644 index 0000000000..8484d35396 --- /dev/null +++ b/packages/core/src/plugin/skill/browser/coupang/SKILL.md @@ -0,0 +1,27 @@ +--- +name: browser-coupang +description: 쿠팡 주문 내역과 배송 상태를 조회할 때 쓴다. 주문, 결제, 취소, 반품은 하지 않는다. +urls: + - https://www.coupang.com/* + - https://mc.coupang.com/* +keywords: + - 쿠팡 + - coupang + - 주문 내역 + - 배송 조회 +--- + +# 쿠팡 주문 조회 + +도구: `open_page`, `read_page`, `click`, 그리고 제공될 때 `login`. + +1. `open_page`로 주문 목록 `https://mc.coupang.com/ssr/desktop/order/list`를 연다. 로그인 화면이면 + `login`(있을 때)을 쓰거나 사용자에게 로그인을 부탁하고 멈춘다. +2. `read_page`는 주문마다 주문일, 상품명, 금액, 배송 상태를 준다. 오래된 주문은 화면의 연도 탭을 + `click`해서 넘긴다. +3. 배송 상세는 해당 주문의 "배송조회"를 `click`한 뒤 `read_page`로 택배사, 운송장 번호, 현재 위치를 + 읽는다. +4. 답에는 주문일, 상품명, 상태를 표처럼 정리한다. + +"구매하기", "결제", "주문취소", "반품신청", "교환신청"은 누르지 않는다. 사용자가 원하면 해당 화면을 +열어 두고 직접 누르도록 안내한다. diff --git a/packages/core/src/plugin/skill/browser/github/SKILL.md b/packages/core/src/plugin/skill/browser/github/SKILL.md new file mode 100644 index 0000000000..9d6f3fb6d5 --- /dev/null +++ b/packages/core/src/plugin/skill/browser/github/SKILL.md @@ -0,0 +1,25 @@ +--- +name: browser-github +description: Use when the task is reading issues, pull requests, releases or code on github.com in the browser. +urls: + - https://github.com/* +keywords: + - github + - pull request + - github issue +--- + +# GitHub in the browser + +Tools: `open_page`, `read_page`, `click`, `type_text`, and `login` when it is offered. + +1. Go straight to the page by URL instead of navigating: + - issues: `https://github.com///issues?q=is:open+` + - pull requests: `https://github.com///pulls?q=is:open+author:` + - one item: `https://github.com///pull/` (add `/files` for the diff) + - releases: `https://github.com///releases` + - search code: `https://github.com/search?q=+repo:/&type=code` +2. `read_page` gives the title, state, labels and the conversation. For a long diff, read the + files tab one file at a time. +3. Writing a comment: `click` the comment box, `type_text`, and stop before "Comment" unless the + user asked you to post. Never merge, close, approve or push anything from the page. diff --git a/packages/core/src/plugin/skill/browser/gmail/SKILL.md b/packages/core/src/plugin/skill/browser/gmail/SKILL.md new file mode 100644 index 0000000000..b4e01ed323 --- /dev/null +++ b/packages/core/src/plugin/skill/browser/gmail/SKILL.md @@ -0,0 +1,27 @@ +--- +name: browser-gmail +description: Use when the task is reading, searching or drafting mail in Gmail in the browser. Covers the search box operators, opening a thread, and drafting a reply without sending it. +urls: + - https://mail.google.com/* +keywords: + - gmail + - inbox + - my email +--- + +# Gmail in the browser + +Tools: `open_page`, `read_page`, `click`, `type_text`, and `login` when it is offered. + +1. Open `https://mail.google.com/mail/u/0/#inbox` with `open_page`. If `read_page` shows a + Google sign-in form, use `login` if it is offered; otherwise stop and ask the user to sign in. + Never type a password yourself. +2. To find mail, `type_text` into the field labelled "Search mail", using operators: + `from:name`, `to:`, `subject:`, `has:attachment`, `after:2026/01/31`, `is:unread`. + Then `click` "Search" (or open `https://mail.google.com/mail/u/0/#search/` directly). +3. `read_page` lists rows as sender, subject, snippet, date. `click` a subject to open the thread, + then `read_page` again for the full text. +4. Replying: `click` "Reply", `type_text` into the message body, and STOP. Tell the user the draft + is ready. Do not click "Send" unless the user asked for that exact message to be sent. + +Do not delete, archive, mark as spam or change labels unless the user asked for it by name. diff --git a/packages/core/src/plugin/skill/browser/google-calendar/SKILL.md b/packages/core/src/plugin/skill/browser/google-calendar/SKILL.md new file mode 100644 index 0000000000..69a101c7a0 --- /dev/null +++ b/packages/core/src/plugin/skill/browser/google-calendar/SKILL.md @@ -0,0 +1,28 @@ +--- +name: browser-google-calendar +description: Use when the task is checking availability, listing events, or creating an event in Google Calendar in the browser. +urls: + - https://calendar.google.com/* +keywords: + - google calendar + - my calendar + - schedule a meeting + - free slot +--- + +# Google Calendar in the browser + +Tools: `open_page`, `read_page`, `click`, `type_text`, and `login` when it is offered. + +1. Open a view by URL rather than clicking through the toolbar: + - a day: `https://calendar.google.com/calendar/r/day/2026/10/19` + - a week: `https://calendar.google.com/calendar/r/week/2026/10/19` + - search: `https://calendar.google.com/calendar/r/search?q=` +2. `read_page` returns each event as title, time and calendar. Report times in the user's own + time zone as the page shows it; do not convert. +3. To create an event, open + `https://calendar.google.com/calendar/r/eventedit?text=&dates=20261020T100000/20261020T110000&details=<notes>`, + `read_page` to confirm the fields, and stop before "Save" unless the user asked you to create it. + Adding guests sends them invitations: ask first. + +If the page is a sign-in form, use `login` if offered; otherwise ask the user to sign in. diff --git a/packages/core/src/plugin/skill/browser/hometax/SKILL.md b/packages/core/src/plugin/skill/browser/hometax/SKILL.md new file mode 100644 index 0000000000..c40a49d614 --- /dev/null +++ b/packages/core/src/plugin/skill/browser/hometax/SKILL.md @@ -0,0 +1,31 @@ +--- +name: browser-hometax +description: 홈택스에서 세금 관련 내역을 조회만 할 때 쓴다(납부 내역, 신고 내역, 현금영수증, 전자세금계산서 조회). 신고·제출·납부는 절대 하지 않는다. +urls: + - https://www.hometax.go.kr/* + - https://hometax.go.kr/* +keywords: + - 홈택스 + - 현금영수증 + - 세금계산서 + - 연말정산 + - 종합소득세 +--- + +# 홈택스 (조회 전용) + +도구: `open_page`, `read_page`, `click`, `type_text`. + +이 스킬은 조회만 한다. 신고서 작성·제출, 세금 납부, 정보 수정, 동의·신청 버튼("제출", "신고", +"납부", "신청", "동의")은 어떤 경우에도 누르지 않는다. 사용자가 제출을 부탁해도 화면을 열어 둔 채 +직접 누르도록 안내한다. + +1. `open_page`로 `https://www.hometax.go.kr/`을 연다. 로그인은 공동·금융인증서나 간편인증이라 + 대신할 수 없다. 로그인 화면이면 사용자에게 직접 로그인해 달라고 하고 멈춘다. +2. 로그인 뒤에는 상단 검색 칸에 `type_text`로 메뉴 이름을 넣는 것이 가장 빠르다: + "현금영수증 사용내역", "전자세금계산서 목록조회", "납부내역 조회", "신고서 조회". +3. 조회 화면에서 기간을 고르고 "조회하기"만 `click`한다. 결과는 `read_page`로 읽어 기간, 건수, + 합계 금액을 정리한다. +4. 화면이 팝업이나 보안 프로그램 설치를 요구하면 그 사실을 사용자에게 전하고 멈춘다. + +주민등록번호 같은 개인정보는 답에 옮겨 적지 않는다. diff --git a/packages/core/src/plugin/skill/browser/kakao/SKILL.md b/packages/core/src/plugin/skill/browser/kakao/SKILL.md new file mode 100644 index 0000000000..c48004c455 --- /dev/null +++ b/packages/core/src/plugin/skill/browser/kakao/SKILL.md @@ -0,0 +1,28 @@ +--- +name: browser-kakao +description: 브라우저에서 카카오 웹 서비스(다음 메일, 카카오맵, 카카오 계정)를 쓸 때 쓴다. +urls: + - https://*.kakao.com/* + - https://mail.daum.net/* + - https://map.kakao.com/* +keywords: + - 카카오 + - 다음 메일 + - 카카오맵 + - kakao +--- + +# 카카오 (웹) + +도구: `open_page`, `read_page`, `click`, `type_text`, 그리고 제공될 때 `login`. + +카카오톡 대화는 웹에서 열리지 않는다. 대화 내용을 찾아 달라는 요청이면 브라우저로는 할 수 없다고 +말한다. + +- 다음 메일: `https://mail.daum.net/`을 연다. 로그인 화면이면 `login`(있을 때)을 쓰거나 사용자에게 + 로그인을 부탁한다. 검색은 상단 검색 칸에 `type_text`, 메일은 제목을 `click`한 뒤 `read_page`. + 답장은 초안만 쓰고 "보내기"는 요청받았을 때만 누른다. +- 카카오맵: `https://map.kakao.com/?q=<검색어>`로 바로 연다. `read_page`로 장소 이름, 주소, 영업시간, + 전화번호를 읽고, 길찾기는 장소를 `click`한 뒤 "길찾기"를 누른다. +- 카카오 계정 설정(`https://accounts.kakao.com/`)은 읽기만 한다. 비밀번호, 연결된 서비스, 보안 설정은 + 바꾸지 않는다. diff --git a/packages/core/src/plugin/skill/browser/naver-mail/SKILL.md b/packages/core/src/plugin/skill/browser/naver-mail/SKILL.md new file mode 100644 index 0000000000..1b473df53b --- /dev/null +++ b/packages/core/src/plugin/skill/browser/naver-mail/SKILL.md @@ -0,0 +1,24 @@ +--- +name: browser-naver-mail +description: 브라우저에서 네이버 메일을 읽거나 검색하거나 답장 초안을 쓸 때 쓴다. +urls: + - https://mail.naver.com/* +keywords: + - 네이버 메일 + - 네이버메일 + - naver mail +--- + +# 네이버 메일 + +도구: `open_page`, `read_page`, `click`, `type_text`, 그리고 제공될 때 `login`. + +1. `open_page`로 `https://mail.naver.com/`을 연다. `read_page`에 로그인 화면이 보이면 `login`이 + 있을 때 그것을 쓰고, 없으면 사용자에게 로그인을 부탁하고 멈춘다. 비밀번호를 직접 입력하지 않는다. +2. 메일을 찾을 때는 상단 "메일 검색" 칸에 `type_text`로 보낸 사람이나 제목 단어를 넣고 검색을 누른다. +3. `read_page`는 목록을 보낸 사람, 제목, 날짜 순으로 준다. 제목을 `click`해서 열고 다시 `read_page`로 + 본문을 읽는다. +4. 답장은 "답장"을 누르고 본문에 `type_text`로 초안만 쓴 뒤 멈춘다. 사용자가 그 내용을 보내라고 하지 + 않았으면 "보내기"를 누르지 않는다. + +메일 삭제, 스팸 처리, 폴더 이동은 사용자가 이름을 들어 요청할 때만 한다. diff --git a/packages/core/src/plugin/skill/browser/naver-search/SKILL.md b/packages/core/src/plugin/skill/browser/naver-search/SKILL.md new file mode 100644 index 0000000000..19a07f33fd --- /dev/null +++ b/packages/core/src/plugin/skill/browser/naver-search/SKILL.md @@ -0,0 +1,23 @@ +--- +name: browser-naver-search +description: 네이버에서 검색해 국내 정보(가게, 뉴스, 블로그 후기, 지식iN, 쇼핑 가격)를 찾을 때 쓴다. +urls: + - https://search.naver.com/* + - https://www.naver.com/* +keywords: + - 네이버 검색 + - 네이버에서 + - 네이버로 찾아 +--- + +# 네이버 검색 + +도구: `open_page`, `read_page`, `click`. + +1. 검색창을 거치지 말고 URL로 바로 연다: + `https://search.naver.com/search.naver?query=<검색어>`. + 탭을 좁히려면 `&where=news`(뉴스), `&where=blog`(블로그), `&where=kin`(지식iN)을 붙인다. + 지도·가게 정보는 `https://map.naver.com/p/search/<검색어>`. +2. `read_page`로 결과의 제목, 출처, 날짜, 요약을 읽는다. 광고(파워링크) 결과는 따로 표시하거나 뺀다. +3. 답에 쓸 결과는 `click`해서 원문을 열고 `read_page`로 확인한 뒤 인용한다. 요약만 보고 단정하지 않는다. +4. 답에는 출처 링크와 글 날짜를 함께 적는다. 블로그 후기는 개인 의견이라고 밝힌다. diff --git a/packages/core/src/plugin/skill/browser/notion/SKILL.md b/packages/core/src/plugin/skill/browser/notion/SKILL.md new file mode 100644 index 0000000000..2be10919d8 --- /dev/null +++ b/packages/core/src/plugin/skill/browser/notion/SKILL.md @@ -0,0 +1,22 @@ +--- +name: browser-notion +description: Use when the task is finding, reading or adding to a Notion page or database in the browser. +urls: + - https://www.notion.so/* + - https://*.notion.site/* +keywords: + - notion +--- + +# Notion in the browser + +Tools: `open_page`, `read_page`, `click`, `type_text`, and `login` when it is offered. + +1. If the user gave a page link, `open_page` it directly. Otherwise open `https://www.notion.so`, + `click` "Search", `type_text` the words into the search field, and `click` the matching result. +2. `read_page` returns the page's text in order, including database rows as table text. Notion + loads long pages lazily: if the text stops mid-page, say so instead of guessing what follows. +3. To add text, `click` the empty line at the end of the page ("Type '/' for commands") and + `type_text`. Edits save automatically, so only write what the user asked for. + +Never delete pages, change sharing, or move pages between workspaces. diff --git a/packages/core/src/plugin/skill/browser/slack/SKILL.md b/packages/core/src/plugin/skill/browser/slack/SKILL.md new file mode 100644 index 0000000000..7efeebd809 --- /dev/null +++ b/packages/core/src/plugin/skill/browser/slack/SKILL.md @@ -0,0 +1,22 @@ +--- +name: browser-slack +description: Use when the task is reading or searching Slack messages in the Slack web app in the browser. +urls: + - https://app.slack.com/* + - https://*.slack.com/* +keywords: + - slack +--- + +# Slack web in the browser + +Tools: `open_page`, `read_page`, `click`, `type_text`, and `login` when it is offered. + +1. Open `https://app.slack.com/client`. If the page asks which workspace, `read_page` and ask the + user which one when there is more than one. +2. To find messages, `click` the search bar at the top, `type_text` the words with modifiers + (`in:#channel`, `from:@name`, `after:2026-10-01`, `has:link`), and press search. +3. `read_page` returns messages as author, time and text. Open a thread by clicking its + "replies" link, then `read_page` again. +4. Writing: `type_text` into the message field of the channel or thread and STOP. Tell the user + what is ready to send. Do not send, react, or start a huddle unless asked for that exact action. diff --git a/packages/core/src/plugin/skill/skill-writer.md b/packages/core/src/plugin/skill/skill-writer.md index 358b505759..af4513d9c9 100644 --- a/packages/core/src/plugin/skill/skill-writer.md +++ b/packages/core/src/plugin/skill/skill-writer.md @@ -48,6 +48,8 @@ it is a skill that quietly does not do the thing you meant. | `icon` | string | icon URL, for surfaces that list skills. | | `autoInject.keywords` | string array | case-insensitive words matched against the prompt on word boundaries. | | `autoInject.url` | string array | globs matched against the current tab URL. | +| `keywords` | string array | short form of `autoInject.keywords`; both are merged. | +| `urls` | string array | short form of `autoInject.url`, written as copied from the address bar (`https://mail.google.com/*`); a trailing `/*` covers the whole site. | `autoInject` is what arms the skill without the user naming it. A skill with **no** `autoInject` block never auto-arms and stays explicitly loadable — which is the right choice @@ -96,6 +98,21 @@ The body is the instruction, and it is read by an agent that has nothing else. S that does not exist produces an agent that calls it and fails. - Keep it short enough to be read whole. A skill is a page, not a manual. +## Saving a repeated instruction + +When the user gives the same instruction a second time ("always do it this way", "like last +time"), offer to save it as a skill, and write one only after they agree: + +1. Name it after the task, not the user (`weekly-sales-report`, not `my-skill`). +2. Write it to the user's own skills folder, `~/.config/redrob/skills/<name>/SKILL.md`, so it + applies in every project. Use `.redrob/skills/<name>/SKILL.md` only when it is about this + repository. +3. Put the user's instruction in the body in their words, as numbered steps. +4. If the instruction is about one website, add `urls:` with that site + (`https://example.com/*`) so it arms itself there. If it is about a kind of request, add + two or three `keywords:` the user actually says. Leave both out otherwise. +5. Tell the user the path you wrote and that it takes effect after a restart. + ## After writing Skills are loaded when redrob starts and are not hot-reloaded. Tell the user to restart diff --git a/packages/core/src/skill/arming.ts b/packages/core/src/skill/arming.ts index 739963664b..e0e38fe4c6 100644 --- a/packages/core/src/skill/arming.ts +++ b/packages/core/src/skill/arming.ts @@ -61,7 +61,10 @@ const matchesKeyword = (prompt: string, keyword: string): boolean => { if (needle.length === 0) return false const haystack = prompt.toLowerCase() const checkStart = isWordCharacter(needle[0]) - const checkEnd = isWordCharacter(needle[needle.length - 1]) + // Korean attaches particles straight to the noun ("홈택스에서", "쿠팡의"), so a keyword ending in + // Hangul needs no boundary after it. The start boundary still applies. + const last = needle[needle.length - 1] + const checkEnd = isWordCharacter(last) && !/\p{Script=Hangul}/u.test(last!) let from = 0 for (;;) { const at = haystack.indexOf(needle, from) diff --git a/packages/redrob/src/skill/index.ts b/packages/redrob/src/skill/index.ts index a069e52f19..7adb5fb5a9 100644 --- a/packages/redrob/src/skill/index.ts +++ b/packages/redrob/src/skill/index.ts @@ -318,7 +318,7 @@ const layer = Layer.effect( // The engine's other built-ins (documents, page-control, skill-writer and the // browser site skills) come from the same list the v2 plugin registers, so a session // sees and arms the same set the chat-completions route does. - for (const builtin of SkillPlugin.BuiltinSkills) { + for (const builtin of SkillPlugin.AllBuiltinSkills) { if (s.skills[builtin.name]) continue s.skills[builtin.name] = { name: builtin.name, From 34ce3f3e4b45c36ab9d7fbb417763b14a1f7aaf5 Mon Sep 17 00:00:00 2001 From: Janghoon Lee <44862514+its-janghoon@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:01:17 +0000 Subject: [PATCH 3/3] chore(client): regenerate for redrob_context on chat completions --- .../client/src/generated-effect/client.ts | 2 + packages/client/src/generated/client.ts | 1 + packages/client/src/generated/types.ts | 91 +++++++++++++++++++ 3 files changed, 94 insertions(+) diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts index c578ca1a51..de2d5cdc31 100644 --- a/packages/client/src/generated-effect/client.ts +++ b/packages/client/src/generated-effect/client.ts @@ -862,6 +862,7 @@ type Endpoint24_0Input = { readonly presence_penalty?: Endpoint24_0Request["payload"]["presence_penalty"] readonly reasoning_effort?: Endpoint24_0Request["payload"]["reasoning_effort"] readonly user?: Endpoint24_0Request["payload"]["user"] + readonly redrob_context?: Endpoint24_0Request["payload"]["redrob_context"] } const Endpoint24_0 = (raw: RawClient["server.chat"]) => (input: Endpoint24_0Input) => raw["chat.completions"]({ @@ -881,6 +882,7 @@ const Endpoint24_0 = (raw: RawClient["server.chat"]) => (input: Endpoint24_0Inpu presence_penalty: input["presence_penalty"], reasoning_effort: input["reasoning_effort"], user: input["user"], + redrob_context: input["redrob_context"], }, }).pipe(Effect.mapError(mapClientError)) diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index 538c7c726a..46ccea28a0 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -1245,6 +1245,7 @@ export function make(options: ClientOptions) { presence_penalty: input["presence_penalty"], reasoning_effort: input["reasoning_effort"], user: input["user"], + redrob_context: input["redrob_context"], }, successStatus: 200, declaredStatuses: [401, 400], diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index efd5444aad..407e60461a 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -3167,6 +3167,9 @@ export type ServerChatCompletionsInput = { readonly presence_penalty?: number | undefined readonly reasoning_effort?: string | undefined readonly user?: string | undefined + readonly redrob_context?: + | { readonly page_url?: string | undefined; readonly page_title?: string | undefined } + | undefined }["model"] readonly messages: { readonly model: string @@ -3210,6 +3213,9 @@ export type ServerChatCompletionsInput = { readonly presence_penalty?: number | undefined readonly reasoning_effort?: string | undefined readonly user?: string | undefined + readonly redrob_context?: + | { readonly page_url?: string | undefined; readonly page_title?: string | undefined } + | undefined }["messages"] readonly tools?: { readonly model: string @@ -3253,6 +3259,9 @@ export type ServerChatCompletionsInput = { readonly presence_penalty?: number | undefined readonly reasoning_effort?: string | undefined readonly user?: string | undefined + readonly redrob_context?: + | { readonly page_url?: string | undefined; readonly page_title?: string | undefined } + | undefined }["tools"] readonly tool_choice?: { readonly model: string @@ -3296,6 +3305,9 @@ export type ServerChatCompletionsInput = { readonly presence_penalty?: number | undefined readonly reasoning_effort?: string | undefined readonly user?: string | undefined + readonly redrob_context?: + | { readonly page_url?: string | undefined; readonly page_title?: string | undefined } + | undefined }["tool_choice"] readonly stream?: { readonly model: string @@ -3339,6 +3351,9 @@ export type ServerChatCompletionsInput = { readonly presence_penalty?: number | undefined readonly reasoning_effort?: string | undefined readonly user?: string | undefined + readonly redrob_context?: + | { readonly page_url?: string | undefined; readonly page_title?: string | undefined } + | undefined }["stream"] readonly max_tokens?: { readonly model: string @@ -3382,6 +3397,9 @@ export type ServerChatCompletionsInput = { readonly presence_penalty?: number | undefined readonly reasoning_effort?: string | undefined readonly user?: string | undefined + readonly redrob_context?: + | { readonly page_url?: string | undefined; readonly page_title?: string | undefined } + | undefined }["max_tokens"] readonly max_completion_tokens?: { readonly model: string @@ -3425,6 +3443,9 @@ export type ServerChatCompletionsInput = { readonly presence_penalty?: number | undefined readonly reasoning_effort?: string | undefined readonly user?: string | undefined + readonly redrob_context?: + | { readonly page_url?: string | undefined; readonly page_title?: string | undefined } + | undefined }["max_completion_tokens"] readonly temperature?: { readonly model: string @@ -3468,6 +3489,9 @@ export type ServerChatCompletionsInput = { readonly presence_penalty?: number | undefined readonly reasoning_effort?: string | undefined readonly user?: string | undefined + readonly redrob_context?: + | { readonly page_url?: string | undefined; readonly page_title?: string | undefined } + | undefined }["temperature"] readonly top_p?: { readonly model: string @@ -3511,6 +3535,9 @@ export type ServerChatCompletionsInput = { readonly presence_penalty?: number | undefined readonly reasoning_effort?: string | undefined readonly user?: string | undefined + readonly redrob_context?: + | { readonly page_url?: string | undefined; readonly page_title?: string | undefined } + | undefined }["top_p"] readonly stop?: { readonly model: string @@ -3554,6 +3581,9 @@ export type ServerChatCompletionsInput = { readonly presence_penalty?: number | undefined readonly reasoning_effort?: string | undefined readonly user?: string | undefined + readonly redrob_context?: + | { readonly page_url?: string | undefined; readonly page_title?: string | undefined } + | undefined }["stop"] readonly seed?: { readonly model: string @@ -3597,6 +3627,9 @@ export type ServerChatCompletionsInput = { readonly presence_penalty?: number | undefined readonly reasoning_effort?: string | undefined readonly user?: string | undefined + readonly redrob_context?: + | { readonly page_url?: string | undefined; readonly page_title?: string | undefined } + | undefined }["seed"] readonly frequency_penalty?: { readonly model: string @@ -3640,6 +3673,9 @@ export type ServerChatCompletionsInput = { readonly presence_penalty?: number | undefined readonly reasoning_effort?: string | undefined readonly user?: string | undefined + readonly redrob_context?: + | { readonly page_url?: string | undefined; readonly page_title?: string | undefined } + | undefined }["frequency_penalty"] readonly presence_penalty?: { readonly model: string @@ -3683,6 +3719,9 @@ export type ServerChatCompletionsInput = { readonly presence_penalty?: number | undefined readonly reasoning_effort?: string | undefined readonly user?: string | undefined + readonly redrob_context?: + | { readonly page_url?: string | undefined; readonly page_title?: string | undefined } + | undefined }["presence_penalty"] readonly reasoning_effort?: { readonly model: string @@ -3726,6 +3765,9 @@ export type ServerChatCompletionsInput = { readonly presence_penalty?: number | undefined readonly reasoning_effort?: string | undefined readonly user?: string | undefined + readonly redrob_context?: + | { readonly page_url?: string | undefined; readonly page_title?: string | undefined } + | undefined }["reasoning_effort"] readonly user?: { readonly model: string @@ -3769,7 +3811,56 @@ export type ServerChatCompletionsInput = { readonly presence_penalty?: number | undefined readonly reasoning_effort?: string | undefined readonly user?: string | undefined + readonly redrob_context?: + | { readonly page_url?: string | undefined; readonly page_title?: string | undefined } + | undefined }["user"] + readonly redrob_context?: { + readonly model: string + readonly messages: ReadonlyArray<{ + readonly role: "system" | "developer" | "user" | "assistant" | "tool" + readonly content?: string | null | ReadonlyArray<{ readonly [x: string]: unknown }> | undefined + readonly tool_calls?: + | ReadonlyArray<{ + readonly id: string + readonly type: "function" + readonly function: { readonly name: string; readonly arguments: string } + }> + | undefined + readonly tool_call_id?: string | undefined + readonly name?: string | undefined + }> + readonly tools?: + | ReadonlyArray<{ + readonly type: "function" + readonly function: { + readonly name: string + readonly description?: string | undefined + readonly parameters?: { readonly [x: string]: unknown } | undefined + } + }> + | undefined + readonly tool_choice?: + | "auto" + | "none" + | "required" + | { readonly type: "function"; readonly function: { readonly name: string } } + | undefined + readonly stream?: boolean | undefined + readonly max_tokens?: number | undefined + readonly max_completion_tokens?: number | undefined + readonly temperature?: number | undefined + readonly top_p?: number | undefined + readonly stop?: string | ReadonlyArray<string> | undefined + readonly seed?: number | undefined + readonly frequency_penalty?: number | undefined + readonly presence_penalty?: number | undefined + readonly reasoning_effort?: string | undefined + readonly user?: string | undefined + readonly redrob_context?: + | { readonly page_url?: string | undefined; readonly page_title?: string | undefined } + | undefined + }["redrob_context"] } export type ServerChatCompletionsOutput = {