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/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 | 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 = { 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.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..e0e38fe4c6 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. */ @@ -58,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) @@ -108,7 +114,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<string> => { + 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<string, unknown>) : {} + 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..7adb5fb5a9 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<InvalidError>()("SkillInvalidError", { @@ -310,6 +315,19 @@ const layer = Layer.effect( location: "<built-in>", 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.AllBuiltinSkills) { + if (s.skills[builtin.name]) continue + s.skills[builtin.name] = { + name: builtin.name, + description: builtin.description, + location: "<built-in>", + 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<string, string>): Record<string, st } } +/** + * Skill arming on this route. + * + * The route stays sessionless, but a skill that declares `keywords:` or `urls:` arms here + * exactly as it does in a session: the last user message is matched against the keywords, + * and `redrob_context.page_url` (the tab the browser's task is on) against the URL globs. + * Armed bodies go in as ONE extra system message, placed after the caller's own system + * messages so the caller's instructions still come first. + * + * Read through `serviceOption`: a host that serves this route without the skill service + * gets today's behaviour instead of a missing-service failure, and any failure while + * listing skills costs the arming, never the completion. + */ +const attribute = (value: string) => + 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<Message>, +) { + 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(` <skill name="${attribute(entry.name)}" armed-by="${attribute(why)}">`, info.content, " </skill>") + } + if (sections.length === 0) return messages + + const title = payload.redrob_context?.page_title?.trim() + const text = [ + "<armed_skills>", + "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, + "</armed_skills>", + ].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<string, string> = {}, ) { 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({