diff --git a/src-tauri/context-manifest.json b/src-tauri/context-manifest.json index de33fae3..bbc4fb90 100644 --- a/src-tauri/context-manifest.json +++ b/src-tauri/context-manifest.json @@ -15,6 +15,13 @@ "detail": "# brains — working with the tools\n\n**Use the cheapest useful read**: `list_pages` for recents, `search` for\nexact terms, `query` for conceptual questions, `get_page` only once a\nresult handed you a slug. Chain dependent reads; don't fan them out.\nCache `whoami` and `list_integrations` for the session.\n\n**Calendar is its own tool.** For schedules and agendas use\n`list_calendar_events start=… end=…` — never `list_pages` filtered by\ntype: a calendar page's update time is not the event's time.\n\n**Reads are free; writes are not.** A write through an integration needs\nthe user's approval of that specific action, every time.", "detailPath": "src/engines/brains/prompts/brains-context.md" }, + { + "name": "docs", + "dir": "src/apps/docs", + "index": "Working with the user's open Google Doc: reading it, editing wording, drafting content, changing formatting — all through the brains Drive integration, never by typing in the page.", + "detail": "# docs\n\nThe document open beside this conversation is a **live Google Doc**, and it\nlives in the user's Google Drive — not in a brains board. When it is open you\ncan **read** it (the browser reports the page, and any text the user selected\ncomes through in the context block), but you **cannot type into it**: the page\nis a native browser view you have no write access to. Every change you make\ngoes through brains — the `gdrive-files` integration — and Google re-renders\nthe doc. There is no other way to edit it, and you never \"type\" into the page.\n\n**Which doc.** The target is the open Doc's `file_id` — the `/document/d//`\nsegment of its URL. If no Doc is open (no such URL), no document is open yet:\nsay so and ask the user to open one, rather than guessing. Find the\n`gdrive-files` install once via `list_my_integrations`, then call its actions\nthrough `act_on_integration`.\n\n**Reading.** `read_document` returns the body — `format: \"markdown\"` (default)\nfor the text, or `format: \"html\"` when a later edit must preserve exact\nformatting and colour. The body is capped at ~64 KB: an oversize markdown read\nis trimmed (`oversize: true` — a partial body, never write it back), and an\noversize html read is refused outright.\n\n**Editing.** Pick the smallest tool for the change:\n\n- `edit_doc_text` — surgical find → replace on the existing text, leaving\n surrounding formatting untouched. This is the default for fixing or\n rewording existing content. Give whole phrases, and set\n `expected_occurrences` when a find hits more than one place.\n- `write_document` with `mode: \"append\"` — adds plain text at the end,\n non-destructive (not markdown-rendered).\n- `write_document` with `mode: \"replace\"` — rewrites the **entire** body from\n `content_markdown` or `content_html`. This is a full-body flatten: any\n formatting not in your payload is lost (recoverable only from version\n history). Use it only when genuinely rewriting the whole document.\n- Colour and styling that markdown can't express only come through\n `content_html` on a replace. There is **no surgical colour primitive**: to\n recolour one heading you would have to read the full html and write it all\n back, which the 64 KB cap blocks on any real doc. When that's the request,\n say the integration can't do it surgically yet rather than flattening the doc.\n\n**Writes are approval-gated.** A write does not happen when you call it — it is\nheld for the user's approval and only then executed. **Never say the document\nwas changed until the approval has actually gone through.** Once it has,\nre-read the doc and verify the change landed before you report it as done.\n\n**Preserve what's there.** Keep every fact, number, claim, link, and the\nexisting formatting; make the smallest coherent edit to the one open document,\nand never rewrite a different doc or invent content the user didn't ask for.\nNothing may depend on a doc being open — when none is, say what you can see and\nask one question rather than acting.", + "detailPath": "src/apps/docs/context.md" + }, { "name": "exo", "dir": "src/apps/exo", diff --git a/src-tauri/recall-runtime/desktop-sdk.tar.gz b/src-tauri/recall-runtime/desktop-sdk.tar.gz new file mode 100644 index 00000000..22014c95 Binary files /dev/null and b/src-tauri/recall-runtime/desktop-sdk.tar.gz differ diff --git a/src-tauri/recall-runtime/manifest.json b/src-tauri/recall-runtime/manifest.json new file mode 100644 index 00000000..e041df3b --- /dev/null +++ b/src-tauri/recall-runtime/manifest.json @@ -0,0 +1,7 @@ +{ + "version": "2.0.26", + "commit_sha": "482cad45d71ed45aa5f111f4a7b5a56ec0fc1362", + "sha256": "1d7cd142646dd7ea1f0ae23ece9778fb98f4694df3b79537a5579e55f970c8d8", + "platform": "darwin", + "arch": "arm64" +} diff --git a/src-tauri/src/browser_host.rs b/src-tauri/src/browser_host.rs index 38c4fa7b..7e7a32f9 100644 --- a/src-tauri/src/browser_host.rs +++ b/src-tauri/src/browser_host.rs @@ -6,7 +6,7 @@ use tauri_plugin_shell::ShellExt; use brains_browser::host::{Host, WindowPtr}; -use crate::commands::browser::{BrowserReport, REPORT_EVENT}; +use crate::commands::browser::{BrowserNavigated, BrowserReport, NAVIGATED_EVENT, REPORT_EVENT}; /// Start the bundled engine, or say why not. Called once, from `setup`. /// @@ -81,6 +81,16 @@ impl Host for TauriHost { ); } + fn emit_navigated(&self, key: &str, url: &str) { + let _ = self.app.emit( + NAVIGATED_EVENT, + BrowserNavigated { + key: key.to_string(), + url: url.to_string(), + }, + ); + } + fn open_externally(&self, url: &str) { // Deprecated in favour of tauri-plugin-opener, taken the same way // `commands/brains.rs` and the OS-webview backend take it: the shell plugin is diff --git a/src-tauri/src/commands/browser.rs b/src-tauri/src/commands/browser.rs index 27aabd4f..6413bae7 100644 --- a/src-tauri/src/commands/browser.rs +++ b/src-tauri/src/commands/browser.rs @@ -52,6 +52,18 @@ pub struct BrowserReport { pub payload: String, } +/// The event a view emits when its main frame navigates. The opening app maps +/// the URL to meaning (Docs: list vs an open document) — the engine only says +/// which view went where. +pub const NAVIGATED_EVENT: &str = "browser-navigated"; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BrowserNavigated { + pub key: String, + pub url: String, +} + /// The window-unique name for a tab's view. /// /// Prefixed because a webview label is a namespace shared with the app's own diff --git a/src/apps/docs/DocsTab.svelte b/src/apps/docs/DocsTab.svelte index ab010cd5..7e351871 100644 --- a/src/apps/docs/DocsTab.svelte +++ b/src/apps/docs/DocsTab.svelte @@ -11,12 +11,14 @@ import { workspaceTabs } from "$core/runtime/stores/workspace-tabs.svelte"; import { BrowserPanel } from "$panes/canvas"; import { browserAvailability } from "$panes/canvas/browser-driver"; + import { browserUrl } from "$panes/canvas/browser-navigation.svelte"; import type { Availability } from "$engines/browser/client"; import { DOCS_PROFILE, DOCS_URL } from "./site"; import type { ContentPort, SkillContent } from "$panes/session/content-port"; import DocsMock from "./mock/DocsMock.svelte"; import DocsIntro from "./mock/DocsIntro.svelte"; import { ensureDocsState, getDocsState, setDocsTitle, setDocsContent } from "./state.svelte"; + import { docsExtraSkills } from "./skills"; const tab = $derived(workspaceTabs.activeTab); const tabId = $derived(tab?.id ?? ""); @@ -54,14 +56,35 @@ if (tabId) setDocsContent(tabId, newContent); } + // SCOPE = which Actions set shows, and it is CONTEXT: the docs list/home is + // the MAIN screen ("docs-home", cross-doc skills like "Summarize recent + // work"); an open `/document/d//` is a specific document ("doc"). In live + // mode we read the view's current URL (pushed by the engine, see + // browser-navigation) and parse the file id from it; in mock mode content + // presence stands in for "a document is open". + const liveUrl = $derived(live ? browserUrl(tabId) : undefined); + const openDocId = $derived(liveUrl?.match(/\/document\/d\/([A-Za-z0-9_-]+)/)?.[1] ?? null); + const docScope = $derived( + live ? (openDocId ? "doc" : "docs-home") : content.trim() ? "doc" : "docs-home", + ); + const scopeChips = $derived([{ label: title || "Untitled document", icon: "docs" as const }]); - const docScope = $derived(content.trim() ? "doc" : "blank"); const contentPort: ContentPort = { async read(): Promise { + if (live) { + // The skill acts on the real Doc through gdrive-files, not on any local + // text — so what it needs from here is the file id, carried as metadata. + return { + scope: openDocId ? "doc" : "docs-home", + text: "", + title: title || "Untitled document", + metadata: openDocId ? { file_id: openDocId } : undefined, + }; + } const s = tabId ? getDocsState(tabId) : null; return { - scope: (s?.content ?? "").trim() ? "doc" : "blank", + scope: (s?.content ?? "").trim() ? "doc" : "docs-home", text: s?.content ?? "", title: s?.title ?? "Untitled document", }; @@ -95,6 +118,7 @@ room: "workstation", scope: docScope, contentPort, + skills: docsExtraSkills(docScope), }} {scopeChips} /> diff --git a/src/apps/docs/context.json b/src/apps/docs/context.json new file mode 100644 index 00000000..e39fc569 --- /dev/null +++ b/src/apps/docs/context.json @@ -0,0 +1,5 @@ +{ + "name": "docs", + "index": "Working with the user's open Google Doc: reading it, editing wording, drafting content, changing formatting — all through the brains Drive integration, never by typing in the page.", + "detail": "context.md" +} diff --git a/src/apps/docs/context.md b/src/apps/docs/context.md new file mode 100644 index 00000000..ae059cad --- /dev/null +++ b/src/apps/docs/context.md @@ -0,0 +1,50 @@ +# docs + +The document open beside this conversation is a **live Google Doc**, and it +lives in the user's Google Drive — not in a brains board. When it is open you +can **read** it (the browser reports the page, and any text the user selected +comes through in the context block), but you **cannot type into it**: the page +is a native browser view you have no write access to. Every change you make +goes through brains — the `gdrive-files` integration — and Google re-renders +the doc. There is no other way to edit it, and you never "type" into the page. + +**Which doc.** The target is the open Doc's `file_id` — the `/document/d//` +segment of its URL. If no Doc is open (no such URL), no document is open yet: +say so and ask the user to open one, rather than guessing. Find the +`gdrive-files` install once via `list_my_integrations`, then call its actions +through `act_on_integration`. + +**Reading.** `read_document` returns the body — `format: "markdown"` (default) +for the text, or `format: "html"` when a later edit must preserve exact +formatting and colour. The body is capped at ~64 KB: an oversize markdown read +is trimmed (`oversize: true` — a partial body, never write it back), and an +oversize html read is refused outright. + +**Editing.** Pick the smallest tool for the change: + +- `edit_doc_text` — surgical find → replace on the existing text, leaving + surrounding formatting untouched. This is the default for fixing or + rewording existing content. Give whole phrases, and set + `expected_occurrences` when a find hits more than one place. +- `write_document` with `mode: "append"` — adds plain text at the end, + non-destructive (not markdown-rendered). +- `write_document` with `mode: "replace"` — rewrites the **entire** body from + `content_markdown` or `content_html`. This is a full-body flatten: any + formatting not in your payload is lost (recoverable only from version + history). Use it only when genuinely rewriting the whole document. +- Colour and styling that markdown can't express only come through + `content_html` on a replace. There is **no surgical colour primitive**: to + recolour one heading you would have to read the full html and write it all + back, which the 64 KB cap blocks on any real doc. When that's the request, + say the integration can't do it surgically yet rather than flattening the doc. + +**Writes are approval-gated.** A write does not happen when you call it — it is +held for the user's approval and only then executed. **Never say the document +was changed until the approval has actually gone through.** Once it has, +re-read the doc and verify the change landed before you report it as done. + +**Preserve what's there.** Keep every fact, number, claim, link, and the +existing formatting; make the smallest coherent edit to the one open document, +and never rewrite a different doc or invent content the user didn't ask for. +Nothing may depend on a doc being open — when none is, say what you can see and +ask one question rather than acting. diff --git a/src/apps/docs/skills.ts b/src/apps/docs/skills.ts new file mode 100644 index 00000000..4603dc9c --- /dev/null +++ b/src/apps/docs/skills.ts @@ -0,0 +1,235 @@ +// Docs' OWN skill catalogue — contributed to the Actions rail through the +// app-contributed seam (`HostSurface.skills` → `extraSkills`), so the central +// `panes/session/actions/skills.ts` no longer names any docs skill: adding or +// changing one is an edit in THIS folder. +// +// These skills carry `instruction` (not `brief`) — they need the workstation +// runner's composition: the content-port read and the {METADATA} splice that +// delivers the open doc's file id (see DocsTab's contentPort). +// +// SCOPE is context: the docs MAIN screen ("docs-home") and an OPEN document +// ("doc") get different sets — the same way Gmail's inbox and thread scopes +// differ. DocsTab derives the scope from the live tab URL and passes the +// matching slice via `docsExtraSkills(scope)`. + +import type { Skill } from "$panes/session/actions/skills"; + +// Docs main screen: acts ACROSS the user's recent documents, not on one open +// doc — so it belongs to the home scope, never the open-doc scope. +const SUMMARIZE_RECENT: Skill = { + id: "docs-summarize-recent", + label: "Summarize recent work", + hint: "A one-page summary of the last week's docs", + icon: "clock", + scopes: ["docs-home"], + instruction: [ + "Produce a SINGLE-PAGE summary of the work reflected in the user's Google", + "Docs over the last 7 days — what they've been writing and where it stands.", + "", + "Source it from brains, richest-first — never invent:", + "1. Find the user's recently-modified Google Docs. Resolve the", + " `gdrive-files` install via `list_my_integrations`, then use its", + " `search_files` action with a Drive query like", + " `mimeType = 'application/vnd.google-apps.document' and modifiedTime > ''`", + " (order by modifiedTime desc). If that surfaces nothing, fall back to a", + " brains `search`/`query` for recent document pages.", + "2. For each doc that matters, read the body with `read_document`", + " (format 'markdown') — or use its brains page if already ingested.", + "", + "Then write ONE page (aim for ~250–400 words): the themes across the week,", + "what moved forward, decisions made, and what's still open — grouped, not a", + "flat list, and each point tied to the doc it came from. If nothing was", + "touched in the last week, say so plainly rather than padding.", + "", + "Present the summary here in the conversation. Do not create or edit any", + "document unless the user asks — then offer to save it as a new Google Doc", + "via `write_document` (which is approval-gated: never claim it was saved", + "until the approval executes).", + "{BRIEF}", + ].join("\n"), +}; + +// Open document ("doc" scope): the skills act on the ONE Google Doc the user +// has open, through gdrive-files. The file id arrives as content metadata (see +// DocsTab, from the live tab URL). These two blocks are shared so every skill +// grounds and behaves the same way. + +// How every open-doc skill reaches the document. +const DOC_HOW = [ + "The open Google Doc is identified here:", + "{METADATA}", + "Act on it through the `gdrive-files` integration — resolve the install with", + "`list_my_integrations`, then use its actions (`read_document`, `edit_doc_text`,", + "`write_document`, `list_comments`, `reply_to_comment`, `resolve_comment`). If no", + "file id is present, no document is resolvable yet: say so and ask the user to", + "open one — never guess which doc they mean.", +].join("\n"); + +// The discipline every WRITE skill obeys. +const DOC_WRITE_RULES = [ + "This edits the user's real document, so: writes are APPROVAL-GATED — the change", + "is held for the user's approval and only then executed. NEVER say the document", + "was changed until the approval has actually gone through, and re-read it to", + "verify before reporting it done. Preserve every fact, number, claim, and link,", + "and the existing formatting; make the smallest coherent edit. Prefer", + "`edit_doc_text` (surgical find→replace, which leaves surrounding formatting", + "intact) over `write_document` mode:'replace' — a replace flattens the whole", + "body and is only for a deliberate full rewrite.", +].join("\n"); + +// The open-doc skill catalogue is the one we refined against the research board +// (5210bb3f): Draft-from-a-note / Polish / Summarize / Notes→brief / Review +// comments / Redesign. The PROMPTS are ours, verbatim in intent; the MECHANISM +// is adapted to this build — reach the doc through gdrive-files (DOC_HOW), and +// every write is approval-gated (DOC_WRITE_RULES). + +const COMPLETE: Skill = { + id: "docs-complete", + label: "Draft from a note", + hint: "A line in; brains drafts it (asks if thin)", + icon: "expand", + scopes: ["doc"], + instruction: [ + "Complete the open document directly, using relevant information from the", + "user's connected context. Fill meaningful gaps, avoid invented facts, and call", + "out anything important that could not be verified.", + DOC_HOW, + "Read what's there (`read_document`) and pull the user's brains context", + "richest-first — related recent docs, boards, memory, this conversation.", + "ASK IF THIN — if the note is too thin to draft confidently (unclear purpose,", + "audience, or direction), ask a few focused questions with AskUserQuestion", + "first, then STOP and end your turn. The answer comes back as the user's NEXT", + "message; the AskUserQuestion call itself often reports as skipped/dismissed —", + "that is NORMAL, not a decline, so never narrate 'skipped' and never build a", + "local surface to collect the brief.", + "Once it's clear, draft the full document and write it with `write_document`", + "(a full-body write is legitimate here), in the user's voice.", + DOC_WRITE_RULES, + "{BRIEF}", + ].join("\n\n"), +}; + +const POLISH: Skill = { + id: "docs-polish", + label: "Polish my writing", + hint: "Clean up the draft in place", + icon: "sparkle", + scopes: ["doc"], + instruction: [ + "Polish the open document directly — improve clarity, consistency, and finish", + "while preserving its meaning and useful structure. Summarize the meaningful", + "changes when done.", + DOC_HOW, + "Read it (`read_document`), then apply improvements as SURGICAL `edit_doc_text`", + "find→replace edits on whole sentences or phrases, so layout and formatting stay", + "untouched. This is a wording pass — do NOT restructure or re-theme, and never", + "change a fact, number, or claim.", + DOC_WRITE_RULES, + "{BRIEF}", + ].join("\n\n"), +}; + +const SUMMARIZE_DOC: Skill = { + id: "docs-summarize", + label: "Summarize", + hint: "Key points or a one-pager", + icon: "file", + scopes: ["doc"], + instruction: [ + "Summarize the open document — surface decisions, important facts, unresolved", + "questions, and next steps; omit repetition and low-value detail. Answer in the", + "chat.", + DOC_HOW, + "Read it with `read_document` (format 'markdown'). READ-ONLY — do not edit the", + "document.", + "{BRIEF}", + ].join("\n\n"), +}; + +const TO_BRIEF: Skill = { + id: "docs-to-brief", + label: "Notes → brief", + hint: "Into our standard brief template", + icon: "squareCheck", + scopes: ["doc"], + instruction: [ + "Rewrite the open document's freeform notes into the standard brains brief", + "template — Problem / Goal / Scope — enforcing that structure, not a generic", + "rewrite.", + DOC_HOW, + "Read it (`read_document`), map the content into Problem / Goal / Scope, and", + "write the restructured brief with `write_document`. Keep every real fact and", + "decision; don't invent to fill a section — leave it thin and say so if the", + "notes don't cover it.", + DOC_WRITE_RULES, + "{BRIEF}", + ].join("\n\n"), +}; + +const REVIEW_COMMENTS: Skill = { + id: "docs-compare", + label: "Review comments", + hint: "Address comments · diff versions", + icon: "chat", + scopes: ["doc"], + instruction: [ + "Go through the open document's comments and help the user act on them — and, if", + "a second version or source is attached, diff them. Treat the open document as", + "the fixed subject; never ask which doc is open.", + DOC_HOW, + "List the threads with `list_comments`. For each open one, say what it asks and", + "how to address it from what you know in the user's brains. If a comment asserts", + "something factually WRONG, say so and propose a correcting reply rather than", + "complying with a false premise. You may reply (`reply_to_comment`) or resolve", + "(`resolve_comment`) — only when the user asks; those are approval-gated writes,", + "so never say a reply was posted or a thread resolved until the approval", + "executes. If a second source is attached, focus on the meaningful differences,", + "conflicts, and best combined direction; if none is and the user wants a diff,", + "ask only which second source to use.", + "{BRIEF}", + ].join("\n\n"), +}; + +const REDESIGN: Skill = { + id: "docs-redesign", + label: "Redesign", + hint: "Re-architect the whole doc — structure, tables, emphasis", + icon: "refresh", + scopes: ["doc"], + instruction: [ + "Redesign the open document so it is clearer, easier to use, and visually", + "coherent — structure, sectioning, tables, emphasis. This changes DESIGN, never", + "content: preserve every fact, number, claim, and the author's intent, then", + "summarize the design decisions.", + DOC_HOW, + "PROPOSE FIRST — do not rewrite yet. Ask the user a few BIG-PICTURE design", + "questions with AskUserQuestion (never surface editorial or data changes as", + "options), then STOP and end your turn; their answer comes as their NEXT message", + "(AskUserQuestion may report as skipped — normal, not a decline; never build a", + "local surface). Once they pick a direction, restructure and write it with", + "`write_document` (a full-body rewrite is legitimate here).", + "Integration limit to be honest about: fine colour/styling only lands via", + "content_html on a full replace, and a large doc's HTML can't round-trip — so", + "restructure through markdown/native formatting and say plainly if a specific", + "colour treatment can't be applied surgically.", + DOC_WRITE_RULES, + "{BRIEF}", + ].join("\n\n"), +}; + +const DOCS_SKILLS: Skill[] = [ + SUMMARIZE_RECENT, // docs-home + // Open document — the refined research-board set, in its original order. + COMPLETE, + POLISH, + SUMMARIZE_DOC, + TO_BRIEF, + REVIEW_COMMENTS, + REDESIGN, +]; +/** The docs skills for one scope — what DocsTab hands to `HostSurface.skills`. + * The rail merges extras verbatim (it does not scope-filter them), so the app + * slices here. */ +export function docsExtraSkills(scope: string): Skill[] { + return DOCS_SKILLS.filter((s) => s.scopes.includes(scope)); +} diff --git a/src/engines/browser/src/cef/handlers.rs b/src/engines/browser/src/cef/handlers.rs index fa46a05d..1dda85b6 100644 --- a/src/engines/browser/src/cef/handlers.rs +++ b/src/engines/browser/src/cef/handlers.rs @@ -255,7 +255,12 @@ wrap_display_handler! { } let Some(url) = url.map(CefString::to_string) else { return }; if let Some(tab_id) = engine::tab_for_browser(browser.identifier()) { - super::tabs::set_url(tab_id, url); + super::tabs::set_url(tab_id, url.clone()); + // Tell the opening app where its view went, so a workstation can + // switch scope (Docs: list vs an open document) and read the file id. + if let (Some(host), Some(tab)) = (engine::host(), super::tabs::get(tab_id)) { + host.emit_navigated(&tab.key, &url); + } } } diff --git a/src/engines/browser/src/host.rs b/src/engines/browser/src/host.rs index d4c139e5..6cd9a111 100644 --- a/src/engines/browser/src/host.rs +++ b/src/engines/browser/src/host.rs @@ -60,6 +60,12 @@ pub trait Host: Send + Sync + 'static { /// the opening app's business — this only carries it. fn emit_report(&self, key: &str, payload: &str); + /// The view's MAIN FRAME navigated to `url`. `key` is the view's key; what a URL + /// MEANS is the opening app's business (Docs: the doc list vs an open + /// `/document/d//`, which drives the Actions scope and yields the file id). + /// Best-effort, like `emit_report` — a dropped one only delays a scope update. + fn emit_navigated(&self, key: &str, url: &str); + /// Send a URL to the user's real browser, where the address bar is. /// /// The engine never opens a window of its own for a link: a chromeless, diff --git a/src/layout/panes/canvas/browser-navigation.svelte.ts b/src/layout/panes/canvas/browser-navigation.svelte.ts new file mode 100644 index 00000000..0f94e032 --- /dev/null +++ b/src/layout/panes/canvas/browser-navigation.svelte.ts @@ -0,0 +1,51 @@ +// LIVE BROWSER NAVIGATION — the current main-frame URL per view key. +// +// The engine emits `browser-navigated {key, url}` on every main-frame address +// change (host.emit_navigated ← CEF on_address_change). A workstation app reads +// the URL for its own view key to decide scope: Docs treats the doc list as its +// MAIN screen and an open `/document/d//` as a specific document — the id it +// yields is what the docs skills act on. +// +// Chromium-engine only. The OS-webview backend never emits this, so a view under +// it simply reports `undefined` and the app falls back to its own signal (the +// docs mock uses content presence). Reads are reactive; the listener is started +// once, lazily, on first read. + +import { SvelteMap } from "svelte/reactivity"; +import { getTransport } from "$core/runtime/transport"; + +// SvelteMap (not a plain $state record): it tracks per-key reads AND the +// addition of a key the reader asked for before it existed — exactly the shape +// here, where DocsTab reads `browserUrl(tabId)` (undefined at first) and a later +// navigation event adds that key. +const urls = new SvelteMap(); +let started = false; + +function ensureListener(): void { + if (started) return; + started = true; + void (async () => { + try { + await getTransport().listen("browser-navigated", (raw: unknown) => { + const r = raw as { + key?: string; + url?: string; + payload?: { key?: string; url?: string }; + }; + const key = r.key ?? r.payload?.key; + const url = r.url ?? r.payload?.url; + if (key && typeof url === "string") urls.set(key, url); + }); + } catch { + /* transport unavailable in browser preview — reads just stay undefined */ + } + })(); +} + +/** The current main-frame URL for a view key, or undefined if none seen yet. + * Reactive: re-reads when the view navigates. Starts the single engine + * listener on first call. */ +export function browserUrl(key: string): string | undefined { + ensureListener(); + return key ? urls.get(key) : undefined; +} diff --git a/src/layout/panes/session/actions/skill-runner.ts b/src/layout/panes/session/actions/skill-runner.ts index 28baa9b3..2bc01454 100644 --- a/src/layout/panes/session/actions/skill-runner.ts +++ b/src/layout/panes/session/actions/skill-runner.ts @@ -53,7 +53,9 @@ function formatMetadata(metadata?: Record): string { export function composeSkillPrompt(request: SkillRunRequest): string { const { skill, content, tagged, answers } = request; - let prompt = getSkillInstruction(skill.id); + // App-owned skills carry their instruction inline (the whole skill lives in + // the app folder); central skills resolve theirs by id. + let prompt = skill.instruction ?? getSkillInstruction(skill.id); const briefText = formatBriefAnswers(answers); const taggedText = formatTaggedContext(tagged); diff --git a/src/layout/panes/session/actions/skills.test.ts b/src/layout/panes/session/actions/skills.test.ts index 257576f5..4c563fa8 100644 --- a/src/layout/panes/session/actions/skills.test.ts +++ b/src/layout/panes/session/actions/skills.test.ts @@ -2,24 +2,13 @@ import { describe, it, expect } from "vitest"; import { skillsForScope, workflowsForScope, SKILLS, WORKFLOWS } from "./skills"; describe("skillsForScope", () => { - it("returns only 'Write it for me' for blank scope", () => { - const skills = skillsForScope("blank"); - expect(skills).toHaveLength(1); - expect(skills[0].id).toBe("complete"); - expect(skills[0].label).toBe("Write it for me"); - }); - - it("returns 7 skills for doc scope", () => { - const skills = skillsForScope("doc"); - expect(skills).toHaveLength(7); - const ids = skills.map((s) => s.id); - expect(ids).toContain("expand"); - expect(ids).toContain("polish"); - expect(ids).toContain("design"); - expect(ids).toContain("comments"); - expect(ids).toContain("verify"); - expect(ids).toContain("todos"); - expect(ids).toContain("doc-summary"); + it("returns no central skills for docs scopes — docs skills are app-contributed", () => { + // The docs catalogue moved into the app (src/apps/docs/skills.ts) and + // reaches the rail through HostSurface.skills; the central catalogue must + // not name docs skills or the rail would show them twice. + expect(skillsForScope("blank")).toHaveLength(0); + expect(skillsForScope("doc")).toHaveLength(0); + expect(skillsForScope("docs-home")).toHaveLength(0); }); it("returns 3 skills for inbox scope", () => { diff --git a/src/layout/panes/session/actions/skills.ts b/src/layout/panes/session/actions/skills.ts index b067c83c..f0ca770b 100644 --- a/src/layout/panes/session/actions/skills.ts +++ b/src/layout/panes/session/actions/skills.ts @@ -28,6 +28,15 @@ export interface Skill { * that don't need content-port reads or instruction templates. */ brief?: string; + /** + * Optional composed prompt, routed THROUGH the workstation skill-runner — + * the counterpart to `brief` for app-contributed skills that DO need the + * runner's composition: content-port reads and the {CONTENT}/{TITLE}/ + * {METADATA}/{BRIEF} splices (docs skills need {METADATA} for the open + * doc's file id). When set, the runner uses it instead of the central + * skill-instructions lookup; `brief` wins if both are set. + */ + instruction?: string; /** * Optional group label (e.g. "TODAY", "QUICK", "TALK"). Skills with the same * group are rendered under a section header in the Actions rail. @@ -47,66 +56,13 @@ export interface Workflow { scopes: string[]; } -// Skills per spec §6.2 — hints verbatim from ws-data.jsx +// Skills per spec §6.2 — hints verbatim from ws-data.jsx. +// +// DOCS SKILLS LIVE IN THE APP now (`src/apps/docs/skills.ts`, contributed via +// `HostSurface.skills`) — exactly what the app-contributed seam exists for, so +// the central catalogue no longer names them. The `blank`/`doc` scopes below +// are therefore app-owned; gmail's remain central until gmail migrates too. export const SKILLS: Skill[] = [ - // Docs blank - { - id: "complete", - label: "Write it for me", - hint: "Tell brains what this doc is for", // ws-data.jsx:269 - icon: "sparkle", - scopes: ["blank"], - }, - // Docs with content - { - id: "expand", - label: "Draft from context", - hint: "Rewrite in full, with the detail filled in", // ws-data.jsx:274 - icon: "expand", - scopes: ["doc"], - }, - { - id: "polish", - label: "Polish", - hint: "Tighten the writing, keep every fact", // ws-data.jsx:276 - icon: "sparkle", - scopes: ["doc"], - }, - { - id: "design", - label: "Make it look good", - hint: "Lay it out like a real document", // ws-data.jsx:279 - icon: "sparkle", - scopes: ["doc"], - }, - { - id: "comments", - label: "Review comments", - hint: "Answer them from what you know", // ws-data.jsx:281 - icon: "chat", - scopes: ["doc"], - }, - { - id: "verify", - label: "Check the facts", - hint: "Flag anything that is out of date", // ws-data.jsx:284 - icon: "shield", - scopes: ["doc"], - }, - { - id: "todos", - label: "Find my action items", - hint: "What this doc leaves on you", // ws-data.jsx:286 - icon: "squareCheck", - scopes: ["doc"], - }, - { - id: "doc-summary", - label: "Summarize", - hint: "The short version, in the chat", // ws-data.jsx:289 - icon: "file", - scopes: ["doc"], - }, // Gmail inbox { id: "compose",