diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index 33e2f534f..53d0b09ed 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -29,5 +29,19 @@ "tools": { "github-triage": false, "github-pr-search": false, + // Set any of these to false to disable context editing tools: + // "context_edit": false, + // "context_deref": false, + // "context_history": false, + // "thread_park": false, + // "thread_list": false, + }, + "agent": { + // classifier is enabled by default — used by classifier_threads and distill_threads tools + // "classifier": { "disable": true }, + + // focus and focus-rewrite-history are opt-in + "focus": { "disable": true }, + "focus-rewrite-history": { "disable": true }, }, } diff --git a/AGENTS.md b/AGENTS.md index cfa47fa9a..3e89b87fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -145,6 +145,12 @@ Use `context_history` to navigate the edit DAG: - Test actual implementation, do not duplicate logic into tests - Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`. +## Git Hooks + +- NEVER bypass pre-commit hooks. No `HUSKY=0`, no `--no-verify`. Fix the issue instead. +- Pre-commit runs: prettier format, typecheck, tests. All must pass before commit. +- Commit messages must follow conventional commits (`feat:`, `fix:`, `chore:`, etc). + ## Type Checking - Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly. diff --git a/README.md b/README.md index 9d50bc167..0348389dd 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,7 @@ A fork of [OpenCode](https://github.com/anomalyco/opencode) with agent-driven co ## What Frankencode Adds -Agents can surgically edit their own conversation context — hiding stale tool output, replacing incorrect statements, externalizing verbose content to a content-addressable store — while preserving all original content in a git-like versioned DAG. - ---- +Agents can surgically edit their own conversation context — hiding stale tool output, replacing incorrect statements, externalizing verbose content to a content-addressable store — while preserving all original content in a git-like versioned DAG. A deterministic sweeper automatically cleans up parts marked as discardable or ephemeral. ## Quick Start @@ -19,308 +17,72 @@ bun install bun run --cwd packages/opencode dev ``` -With the focus agent enabled (automatic side-thread parking): - -```bash -OPENCODE_EXPERIMENTAL_FOCUS_AGENT=1 bun run --cwd packages/opencode dev -``` - Requires [Bun](https://bun.sh) 1.3.10+. ---- - -## Context Editing in Practice - -### Hiding stale content - -When a tool result becomes irrelevant (you edited the file since reading it, a grep is outdated), tell the agent to hide it: - -``` -You: That grep result from earlier is stale — I've refactored the auth module since then. Hide it. - -Agent: [calls context_edit(operation: "hide", partID: "prt_...", messageID: "msg_...")] - Hidden part prt_abc123. Original preserved: 7f3a9b2e... -``` - -The part disappears from the agent's context window on the next turn but is preserved in the CAS. The agent can retrieve it later if needed. - -### Replacing incorrect statements - -When the agent made a wrong assumption earlier in the conversation: - -``` -You: Your earlier analysis said the config is in YAML but it's actually TOML. Fix that. - -Agent: [calls context_edit(operation: "replace", partID: "prt_...", messageID: "msg_...", - replacement: "The configuration file uses TOML format (config.toml), not YAML.")] - Replaced part prt_def456. Original preserved: a1b2c3d4... -``` - -The LLM now sees the corrected text instead of the wrong one. The original is in the CAS. - -### Externalizing verbose output - -When a tool produced a huge result but only a few lines matter: - -``` -You: That 200-line grep result — externalize it, we only needed the 3 matches in auth/ - -Agent: [calls context_edit(operation: "externalize", partID: "prt_...", messageID: "msg_...", - summary: "3 matches in src/auth/: middleware.ts:23 validate(), middleware.ts:45 refresh(), handler.ts:12 checkToken()")] - Externalized. 4200 tokens freed. Use context_deref("8f2e...") to retrieve. -``` - -The agent now sees a compact summary instead of 200 lines. If it needs the full output later: - -``` -Agent: [calls context_deref(hash: "8f2e...")] - [full 200-line grep output returned] -``` - -### Annotating findings - -When the agent discovers something worth noting but doesn't need to change: - -``` -You: Add a note to that tool result about the race condition you found. - -Agent: [calls context_edit(operation: "annotate", partID: "prt_...", messageID: "msg_...", - annotation: "Race condition: concurrent requests can exhaust the pool when maxConnections < workerCount")] -``` - -### Browsing edit history - -See what's been edited and navigate the version graph: - -``` -You: Show me the edit history. - -Agent: [calls context_history(operation: "log")] - prt_f1a2b3c4 (HEAD) replace on prt_def456 by build [14:23:01] cas:a1b2c3d4 - prt_e5d6c7b8 hide on prt_abc123 by build [14:22:45] cas:7f3a9b2e - prt_d9e0f1a2 externalize on prt_ghi789 by build [14:21:30] cas:8f2e1d3c -``` - -Fork an alternative edit path: - -``` -You: Fork from the second edit — I want to try keeping that grep result visible. - -Agent: [calls context_history(operation: "fork", nodeID: "prt_e5d6c7b8", branch: "with-grep")] - Branch 'with-grep' created. HEAD moved to branch point. -``` - -Restore a previous version: - -``` -You: Actually, go back to before we hid that grep result. - -Agent: [calls context_history(operation: "checkout", nodeID: "prt_d9e0f1a2")] - Context restored. The hide and replace edits have been undone. -``` - ---- - -## Side Threads - -### Parking off-topic discoveries - -When you or the agent find something worth noting but off-topic: - -``` -You: I noticed the auth middleware has no rate limiting. Park that, let's stay on pagination. - -Agent: [calls thread_park(title: "Auth middleware missing rate limiting", - description: "No rate limiting on /api/auth/* endpoints. Could allow brute-force attacks.", - priority: "high", category: "security", relatedFiles: ["src/auth/middleware.ts"])] - [Side thread thr_abc parked] - Title: Auth middleware missing rate limiting - Priority: high | Category: security - Files: src/auth/middleware.ts -``` - -### Listing side threads - -``` -You: What side threads do we have? - -Agent: [calls thread_list(status: "all")] - thr_abc [parked, high, security] "Auth middleware missing rate limiting" - No rate limiting on /api/auth/* endpoints. Could allow brute-force attacks. - Files: src/auth/middleware.ts - - thr_def [parked, medium, bug] "Race condition in DB connection pool" - Concurrent requests exhaust pool when maxConnections < workerCount. - Files: src/db/pool.ts -``` - -Side threads persist at the project level — they survive across sessions. - ---- - -## Focus Agent - -The focus agent is a hidden agent that runs automatically after each turn (starting from turn 2). It reviews the conversation for off-topic content and parks side threads. - -Enable it: - -```bash -OPENCODE_EXPERIMENTAL_FOCUS_AGENT=1 bun run --cwd packages/opencode dev -``` - -### What it does - -After the main agent finishes a turn, the focus agent: -1. Reads the conversation + the tracked objective -2. Identifies off-topic content (file divergence, tangential errors, rabbit holes) -3. Parks valuable off-topic findings as side threads via `thread_park` -4. Hides or externalizes stale/irrelevant content via `context_edit` - -### What triggers it - -The focus agent activates when: -- The main agent reads files unrelated to the current objective -- Tool results reveal issues in tangential systems -- The agent starts investigating something the user didn't ask about -- Error output comes from unrelated subsystems - -### Example session - -``` -You: Add pagination to the /users API endpoint. - -Agent: [reads src/api/users.ts, starts implementing pagination] - [while reading, notices src/db/pool.ts has a race condition] - I also noticed a race condition in the DB pool... - ---- Focus agent runs post-turn --- - -Focus agent: [calls thread_park(title: "DB pool race condition", ...)] - [calls context_edit(operation: "hide", ...) on the off-topic analysis] - ---- Next turn: agent sees clean context focused on pagination --- - -Agent: Continuing with pagination. I've added offset/limit params to the query... -``` - -The user never sees the focus agent's work directly — it's reflected in cleaner context and parked side threads. - -### System prompt injection - -When the focus agent is enabled, the main agent's system prompt includes: - -``` -## Focus Status -Objective: Add pagination to the /users API endpoint -Parked side threads (1): -- thr_abc [high, security] Auth middleware missing rate limiting - -Stay focused on the objective. If you find unrelated issues, note them briefly. -The focus agent will park them. -``` - ---- - -## Tools Reference - -| Tool | Operation | What it does | -|------|-----------|-------------| -| `context_edit` | `hide` | Remove a part from context. Original preserved in CAS. | -| `context_edit` | `unhide` | Restore a previously hidden part. | -| `context_edit` | `replace` | Replace content with a correction. Original in CAS. | -| `context_edit` | `annotate` | Add a note to a part without changing it. | -| `context_edit` | `externalize` | Move to CAS, leave compact summary + hash inline. | -| `context_deref` | | Retrieve externalized content by CAS hash. | -| `context_history` | `log` | Show linear edit history from HEAD. | -| `context_history` | `tree` | Show full DAG with branches. | -| `context_history` | `checkout` | Restore context to a previous edit version. | -| `context_history` | `fork` | Create a named branch at an edit point. | -| `thread_park` | | Park an off-topic finding as a side thread. | -| `thread_list` | | List side threads (parked, resolved, etc). | - -### Safety constraints - -- Agents can only edit their own assistant messages (not user messages) -- The last 2 turns are immutable (prevents edit loops) -- Max 10 edits per turn -- Cannot hide more than 70% of all parts -- `skill` tool results are protected (never hidden) - ---- - -## Architecture - -### Content-Addressable Store (CAS) - -Every edit preserves the original content in a SQLite table (`cas_object`). Content is SHA-256 hashed and deduplicated (`ON CONFLICT DO NOTHING`). CAS writes are atomic with part updates via `Database.transaction()`. - -### Edit Graph - -Edit history is a DAG (`edit_graph_node` + `edit_graph_head` tables). Each edit is a node with a parent pointer. Named branches and checkout allow exploring alternative edit paths — like git for conversation edits. - -### Side Threads - -Project-level SQLite table (`side_thread`) for deferred findings. Fields: title, description, status (parked/investigating/resolved/deferred), priority, category, related files, CAS references. Survive across sessions. - -### Plugin Hooks - -- `context.edit.before` — intercept and optionally block edit operations -- `context.edit.after` — notify after edit completion - ---- - -## Agents - -| Agent | Visible | Purpose | -|-------|:-------:|---------| -| **build** | Yes | Default full-access agent for development | -| **plan** | Yes | Read-only agent for analysis and exploration | -| **general** | Via `@general` | Subagent for complex searches and multistep tasks | -| **focus** | No (hidden) | Post-turn context curation and side-thread parking | -| **compaction** | No (hidden) | Summarizes conversation when context window fills | -| **title** | No (hidden) | Generates session titles | -| **summary** | No (hidden) | Generates session summaries | - ---- - -## Upstream OpenCode +## Tools + +| Tool | Purpose | +| -------------------- | ---------------------------------------------------------------- | +| `context_edit` | Hide, replace, annotate, externalize, or mark conversation parts | +| `context_deref` | Retrieve externalized content by CAS hash | +| `context_history` | Navigate the edit DAG: log, tree, checkout, fork | +| `thread_park` | Park an off-topic finding as a side thread | +| `thread_list` | List side threads for the project | +| `classifier_threads` | Classify messages by topic (main/side/mixed) | +| `distill_threads` | Classify + park side threads in one step | + +## Commands + +| Command | Action | +| ------------------------ | -------------------------------------------------------------- | +| `/focus` | Classify messages, externalize stale output, park side threads | +| `/focus-rewrite-history` | Rewrite conversation history (asks for confirmation first) | +| `/btw ` | Side conversation — answers without polluting the main thread | +| `/reset-context` | Restore all edited parts to originals from CAS | + +## Documentation + +| Document | Contents | +| ------------------------------------------------------------------------------- | ------------------------------------------------------------------- | +| [Context Editing](docs/context-editing.md) | Tools, operations, targeting, lifecycle markers, safety constraints | +| [Schema Changes](docs/schema.md) | New tables (CAS, edit graph, side threads), modified part fields | +| [Agents](docs/agents.md) | Classifier, focus, focus-rewrite-history agents and configuration | +| [UI Customization](docs/research/UI_CUSTOMIZATION.md) | TUI themes, keybindings, config options | +| [Literature Review](docs/research/DEEP_RESEARCH_POST_FACTUM_CONTEXT_EDITING.md) | 40+ papers, tools, frameworks on context editing | + +## Configuration + +All features are controlled via `opencode.jsonc`: + +```jsonc +{ + "tools": { + // Set to false to disable any tool + // "context_edit": false, + // "distill_threads": false, + }, + "agent": { + // classifier is enabled by default + // focus agents are opt-in: + "focus": { "disable": true }, + "focus-rewrite-history": { "disable": true }, + }, +} +``` + +## Upstream Frankencode tracks upstream OpenCode (`dev` branch). All original features, providers, tools, and configuration work as documented at [opencode.ai/docs](https://opencode.ai/docs). ---- - -## Design Documents - -Research and design docs in [`docs/research/`](docs/research/): - -| Document | Contents | -|----------|----------| -| `REPORT.md` | Deep research on OpenCode architecture | -| `CONTEXT_EDITING_MVP.md` | MVP feature set and narrowed scope | -| `EDITABLE_CONTEXT.md` | Base design for editable threads | -| `EDITABLE_CONTEXT_MODES.md` | 6 context management modes (curator, refocus, handoff, pin & decay, objective tracker) | -| `EDITABLE_CONTEXT_MERKLE.md` | Content-addressable storage and Merkle tree design | -| `EDITABLE_CONTEXT_FOCUS.md` | Focus agent and side thread system | -| `EDITABLE_CONTEXT_PRESS_RELEASE.md` | Amazon-style PR/FAQ | -| `DEEP_RESEARCH_POST_FACTUM_CONTEXT_EDITING.md` | Literature review (40+ papers, tools, frameworks) | -| `UI_CUSTOMIZATION.md` | TUI and web UI customization reference | - ---- - ## Contributing ```bash bun install -bun test --cwd packages/opencode # run tests -bun turbo typecheck # typecheck all packages -bun prettier --check "packages/opencode/src/**/*.ts" # format check +bun test --cwd packages/opencode +bun turbo typecheck ``` -Commit messages follow [conventional commits](https://www.conventionalcommits.org/): `feat(scope):`, `fix(scope):`, `chore(scope):`, etc. - -Pre-commit hooks run automatically: format + typecheck + tests. - ---- +Pre-commit hooks enforce: prettier format, typecheck, tests, conventional commits. ## License diff --git a/docs/agents.md b/docs/agents.md new file mode 100644 index 000000000..d2f8b70be --- /dev/null +++ b/docs/agents.md @@ -0,0 +1,117 @@ +# Frankencode Agents + +Agents added or modified by Frankencode. + +## New Agents + +### classifier (hidden, enabled by default) + +Read-only agent that labels messages by topic. No tools, no side effects. + +- **Purpose:** Classify messages as `main`, `side`, or `mixed` with topic arrays +- **Model:** cheapest available (uses session model) +- **Temperature:** 0 +- **Tools:** none (read-only) +- **Output:** JSON array of `{ messageID, classification, topics, reason }` + +Used by `classifier_threads` and `distill_threads` tools. + +### focus (hidden, disabled by default) + +Label-driven cleanup agent. Acts on classifier output to externalize off-topic content and park side threads. + +- **Purpose:** Context cleanup based on classification labels +- **Temperature:** 0 +- **Max steps:** 15 +- **Tools:** context_edit, context_deref, context_history, thread_park, thread_list, question +- **Privileged:** Can edit any message (user + any agent) +- **Enable:** Set `"focus": {}` in agent config (remove `"disable": true`) + +Invoked via `/focus` command. + +### focus-rewrite-history (hidden, disabled by default) + +Complete conversation rewrite agent. Asks user to confirm objective before proceeding. + +- **Purpose:** Rewrite history to focus on current objective +- **Temperature:** 0 +- **Max steps:** 30 +- **Tools:** same as focus + question for user confirmation +- **Privileged:** Can edit any message +- **Enable:** Set `"focus-rewrite-history": {}` in agent config + +Invoked via `/focus-rewrite-history` command. Always asks for confirmation before rewriting user messages. + +## Modified Agents + +### build / plan + +Unchanged agents from upstream OpenCode. System prompt now includes: + +- Focus status block (objective + parked threads) when context_edit is available +- Instructions to use `context_edit` and `thread_park` for self-managed context cleanup + +## Models + +All Frankencode agents inherit the session's model by default. Override per-agent: + +| Agent | Default | Recommended | +| --------------------- | ------------- | ------------------------------------------------ | +| classifier | session model | Cheapest available (read-only, JSON output only) | +| focus | session model | Small/fast model | +| focus-rewrite-history | session model | Strong model (needs reasoning for rewrites) | + +### Model examples by provider + +| Provider | Cheap (classifier) | Mid (focus) | Strong (rewrite-history) | +| ---------------- | ------------------------------- | -------------------------------- | ---------------------------------- | +| OpenCode Zen | `opencode/gpt-5-nano` | `opencode/claude-haiku-4-5` | `opencode/claude-sonnet-4-6` | +| Z.AI Coding Plan | `zai-coding-plan/glm-4.5-flash` | `zai-coding-plan/glm-4.7-flash` | `zai-coding-plan/glm-5` | +| Anthropic | `anthropic/claude-haiku-4-5` | `anthropic/claude-sonnet-4-6` | `anthropic/claude-opus-4-6` | +| OpenAI | `openai/gpt-5-nano` | `openai/gpt-5-mini` | `openai/gpt-5.4` | +| DeepSeek | `deepseek/deepseek-chat` | `deepseek/deepseek-chat` | `deepseek/deepseek-reasoner` | +| Mistral | `mistral/ministral-8b-latest` | `mistral/devstral-medium-latest` | `mistral/mistral-large-latest` | +| MiniMax | `minimax/MiniMax-M2.1` | `minimax/MiniMax-M2.5-highspeed` | `minimax/MiniMax-M2.5` | +| Kimi | `kimi-for-coding/k2p5` | `kimi-for-coding/k2p5` | `kimi-for-coding/kimi-k2-thinking` | +| Qwen (Alibaba) | `alibaba/qwen3-coder-flash` | `alibaba/qwen3-coder-plus` | `alibaba/qwen3.5-397b-a17b` | + +## Configuration + +```jsonc +// opencode.jsonc +{ + "agent": { + // classifier — enabled by default, override model for cost savings + "classifier": { + // "model": "zai-coding-plan/glm-4.5-flash" + }, + + // focus — disabled by default, enable to use /focus command + "focus": { + "disable": true, + // "model": "zai-coding-plan/glm-4.7-flash" + }, + + // focus-rewrite-history — disabled by default + "focus-rewrite-history": { + "disable": true, + // "model": "zai-coding-plan/glm-5" + }, + }, +} +``` + +## Agent Visibility + +| Agent | Tab-selectable | Mode | Default | +| --------------------- | :------------: | ---------------- | -------- | +| build | Yes | primary | enabled | +| plan | Yes | primary | enabled | +| general | Via `@general` | subagent | enabled | +| explore | Via `@explore` | subagent | enabled | +| classifier | No | subagent | enabled | +| focus | No | primary (hidden) | disabled | +| focus-rewrite-history | No | primary (hidden) | disabled | +| compaction | No | primary (hidden) | enabled | +| title | No | primary (hidden) | enabled | +| summary | No | primary (hidden) | enabled | diff --git a/docs/context-editing.md b/docs/context-editing.md new file mode 100644 index 000000000..51bff6289 --- /dev/null +++ b/docs/context-editing.md @@ -0,0 +1,83 @@ +# Context Editing + +Frankencode adds surgical, reversible context editing to OpenCode. Agents can hide stale tool output, replace incorrect statements, externalize verbose content to a content-addressable store, and mark parts for automatic cleanup — all while preserving original content. + +## Tools + +| Tool | Purpose | +| ------------------------------------------- | -------------------------------------------------------------------- | +| [`context_edit`](#context_edit) | Edit conversation parts (hide, replace, externalize, annotate, mark) | +| [`context_deref`](#context_deref) | Retrieve externalized content by CAS hash | +| [`context_history`](#context_history) | Navigate the edit DAG (log, tree, checkout, fork) | +| [`thread_park`](#thread_park) | Park an off-topic finding as a side thread | +| [`thread_list`](#thread_list) | List side threads | +| [`classifier_threads`](#classifier_threads) | Classify messages by topic (main/side/mixed) | +| [`distill_threads`](#distill_threads) | Classify + park side threads in one step | + +## context_edit + +Edit conversation parts. Targets parts by content search, tool name, or exact ID. + +**Operations:** + +- `hide` — remove from context, original in CAS +- `unhide` — restore a hidden part +- `replace` — swap content, original in CAS +- `externalize` — move to CAS, leave summary inline +- `annotate` — add a note without changing content +- `mark` — set lifecycle hint for automatic cleanup + +**Targeting:** + +- `query: "validateToken"` — search part content +- `toolName: "read"` — match by tool name +- `nthFromEnd: 2` — disambiguate (2nd most recent match) +- `partID` + `messageID` — exact IDs + +**Lifecycle hints (mark operation):** + +- `discardable` — auto-hide after N turns (default 3) +- `ephemeral` — auto-externalize after N turns (default 5) +- `side-thread` — candidate for `/focus` cleanup +- `pinned` — never auto-discard + +**Safety:** + +- Agents edit only their own messages (privileged agents like focus/compaction can edit any) +- Last 2 turns are immutable +- Max 10 edits/turn, max 70% hidden ratio +- `skill` tool results are protected + +## context_deref + +Retrieve content by CAS hash. Returns the original content before it was externalized or hidden. + +## context_history + +Navigate the edit version graph (DAG). + +- `log` — linear history from HEAD +- `tree` — full DAG with branches +- `checkout` — restore to a previous version +- `fork` — create a named branch + +## classifier_threads + +Runs the classifier agent to label each message as `main`, `side`, or `mixed` with topic arrays. Read-only — no side effects. Returns structured JSON. + +## distill_threads + +Runs classifier in a subagent session, parks side threads, and stores per-session thread metadata in Storage at key `["threads-meta", sessionID]`. + +## thread_park / thread_list + +Park and list project-level side threads. Threads survive across sessions. + +## Commands + +| Command | Action | +| ------------------------ | ---------------------------------------------------------------------- | +| `/focus` | Classify messages + externalize stale output + park side threads | +| `/focus-rewrite-history` | Full conversation rewrite with user confirmation (disabled by default) | +| `/btw ` | Side conversation in a subagent — doesn't pollute main thread | +| `/reset-context` | Restore all edited parts to originals from CAS | diff --git a/docs/research/CONTEXT_EDITING_MVP.md b/docs/research/CONTEXT_EDITING_MVP.md deleted file mode 100644 index 286d646f3..000000000 --- a/docs/research/CONTEXT_EDITING_MVP.md +++ /dev/null @@ -1,260 +0,0 @@ -# Frankencode — Context Editing MVP - -> **Frankencode**: a fork of [OpenCode](https://github.com/anomalyco/opencode) with agent-driven context editing. - -Narrowed-down feature set derived from the full research and design work in `EDITABLE_CONTEXT*.md`. - ---- - -## What We're Building - -A context editing system (codename **Frankencode**) where agents can surgically edit their own conversation context — hiding stale content, replacing errors, externalizing verbose output to a content-addressable store — while preserving all original content in a git-like versioned history. - -Three layers: - -1. **CAS + Part Editing** — Content-addressable storage + edit operations on parts -2. **Version Tree** — Git-like edit history with branches and checkout -3. **Focus Agent + Side Threads** — Automated context curation and off-topic parking - ---- - -## Layer 1: CAS + Part Editing - -### Content-Addressable Store (SQLite) - -Every edit preserves the original content by hashing it and storing it in a SQLite table: - -``` -Original part → SHA-256 hash → INSERT INTO cas_object (hash, content, ...) -Part updated with edit metadata → LLM sees the edited version -Agent can dereference hash to retrieve original at any time -``` - -Entirely in SQLite (same DB as everything else). This gives us: -- **Atomic transactions** — CAS write + part edit + graph node in one `Database.transaction()` -- **Queryable** — find all CAS entries for a session, GC orphans with SQL -- **Deduplicated** — `ON CONFLICT DO NOTHING` (same content = same hash) -- No filesystem overhead, no file-per-blob - -### Edit Operations - -| Operation | What It Does | LLM Sees | Original | -|-----------|-------------|----------|----------| -| `hide` | Remove part from context | Nothing | In CAS | -| `unhide` | Restore a hidden part | The part again | N/A | -| `replace` | Swap content | New text | In CAS | -| `annotate` | Add a note | Part + note | Unchanged | -| `externalize` | Move to CAS, leave summary | Summary + hash ref | In CAS | - -### Schema Change - -`edit` field added to `PartBase` (inherited by all 12 part types): - -```typescript -edit?: { - hidden: boolean - casHash?: string // hash into CAS - supersededBy?: string // ID of replacement part - replacementOf?: string // ID of original part - annotation?: string - editedAt: number - editedBy: string // agent name - version?: string // version tree node -} -``` - -No SQL migration needed — parts are stored as JSON blobs. - -### Safety Constraints - -- Agents can only edit their own assistant messages (not user messages, not other agents') -- Cannot edit the last 2 turns (prevents infinite edit loops) -- Max 10 edits per turn, max 70% of parts hidden -- `skill` tool results are protected (never hidden) - -### Tools - -**`context_edit`** — The editing tool: -``` -context_edit(operation: "hide", partID: "prt_abc", messageID: "msg_xyz") -context_edit(operation: "replace", partID: "prt_abc", messageID: "msg_xyz", replacement: "corrected text") -context_edit(operation: "externalize", partID: "prt_abc", messageID: "msg_xyz", summary: "47 matches in src/auth/") -``` - -**`context_deref`** — Retrieve externalized content: -``` -context_deref(hash: "a1b2c3d4") -→ Returns full original content from CAS -``` - -### Pipeline - -``` -Session.messages() → raw from DB -filterCompacted() → existing: truncate at compaction boundary -filterEdited() → NEW: drop hidden/superseded parts -toModelMessages() → convert to LLM format -LLM.stream() → send to provider -``` - -`filterEdited()` inserted at prompt.ts line 301 (messages are re-read from DB every loop iteration, so edits take effect on the next turn). - ---- - -## Layer 2: Version Tree - -Every edit creates a version node. Nodes form a tree (like git commits): - -``` -v1: hide prt_abc ← initial edit -v2: externalize prt_def ← second edit -v3: replace prt_ghi ← third edit (current head) - └─ v3a: unhide prt_abc ← branch: "what if we kept that part?" -``` - -### Data Structure - -```typescript -VersionNode = { - id: string - parentID?: string // forms the tree - sessionID: string - partID: string - operation: string - casHash?: string // content BEFORE this edit - timestamp: number - agent: string -} - -VersionTree = { - sessionID: string - head: string // current tip - branches: Record // name → node ID - nodes: VersionNode[] -} -``` - -Stored in the file-based `Storage` module at key `["version-tree", sessionID]`. - -### Tool - -**`context_history`** — Navigate the version tree: -``` -context_history(operation: "log") → show linear history -context_history(operation: "tree") → show full tree with branches -context_history(operation: "checkout", versionID: "v2") → restore to v2 -context_history(operation: "fork", versionID: "v2", branch: "alt") → create branch from v2 -``` - -`checkout` restores part state by reading the CAS entry for each version node and reversing edits back to the target version. - ---- - -## Layer 3: Focus Agent + Side Threads - -### Focus Agent - -A hidden agent that runs after each main agent turn to keep the conversation on-topic: - -``` -Main agent finishes turn - → Focus agent reviews: "Is this on-topic?" - → Off-topic findings → parked as side threads - → Stale content → hidden or externalized - → Focus status → injected into main agent's system prompt -``` - -**Agent definition:** -- `name: "focus"`, `hidden: true` -- Tools: `context_edit`, `context_deref`, `thread_park`, `thread_list`, `question` -- Model: `small` (fast, cheap — judgment, not generation) -- Max steps: 8, Temperature: 0 -- Runs after step 2+ (not on first turns) - -**Focus status block** (injected into main agent's system prompt): -``` -## Focus Status -Objective: Add pagination to the API -Parked threads: 3 -Stay focused. If you find unrelated issues, note them in one sentence. -``` - -### Side Threads - -Project-level SQLite table for deferred findings: - -```sql -CREATE TABLE side_thread ( - id TEXT PRIMARY KEY, - project_id TEXT NOT NULL REFERENCES project(id) ON DELETE CASCADE, - title TEXT NOT NULL, - description TEXT NOT NULL, - status TEXT DEFAULT 'parked', -- parked | active | resolved - priority TEXT DEFAULT 'medium', -- low | medium | high | critical - category TEXT DEFAULT 'other', -- bug | tech-debt | security | performance | test | other - source_session_id TEXT, - source_part_ids TEXT, -- JSON array - cas_refs TEXT, -- JSON array of CAS hashes - related_files TEXT, -- JSON array - created_by TEXT NOT NULL, - time_created INTEGER NOT NULL, - time_updated INTEGER NOT NULL -); -``` - -**Why project-level:** Side threads survive across sessions. A finding from Session 1 can be investigated in Session 5. - -### Side Thread Tools - -**`thread_park`** — Park a finding: -``` -thread_park(title: "Race condition in DB pool", description: "...", - sourcePartIDs: ["prt_abc"], priority: "medium", category: "bug") -``` - -**`thread_list`** — List parked threads: -``` -thread_list(status: "parked") -→ thr_abc [parked, medium, bug] "Race condition in DB pool" -→ thr_def [parked, medium, security] "Auth middleware missing rate limiting" -``` - -### Objective Tracker (Basic) - -Extracts the user's objective from the first user message. Stored per-session in the file-based `Storage` module. The focus agent uses it to judge what's on-topic. - ---- - -## Implementation Scope - -| Phase | New Files | Modified Files | ~LOC | Key Dependencies | -|:-----:|:---------:|:--------------:|:----:|-----------------| -| 1 | 4 | 3 | ~505 | Storage, Session.updatePart, Bus | -| 2 | 2 | 1 | ~260 | Phase 1 CAS | -| 3 | 6 | 2 | ~410 | Phase 1, Drizzle migration | -| 4 | 0 | 2 | ~22 | All above | -| **Total** | **12** | **8** | **~1,200** | | - -### Critical Path - -``` -Phase 1: CAS module → EditMeta on PartBase → filterEdited() → pipeline insertion - → ContextEdit operations → context_edit tool → context_deref tool → registry -Phase 2: Version tree → context_history tool → registry -Phase 3: Side thread table + migration → side thread module → focus agent → tools → post-turn hook → objective tracker -Phase 4: System prompt injection → plugin hooks -``` - -Phase 1 is self-contained and delivers the core value. Phase 2 adds history navigation. Phase 3 adds automation. Phase 4 ties everything together. - ---- - -## What This Enables - -**Short sessions (< 20 turns):** Agent can `replace` incorrect statements and `annotate` key findings. Minimal overhead. - -**Medium sessions (20-50 turns):** Agent `externalizes` verbose tool output, `hides` superseded exploration. Focus agent parks side threads. Context stays lean. - -**Long sessions (50+ turns):** Version tree provides full edit history. Externalized content recoverable on demand. Side threads capture deferred work. Handoff artifacts (future) carry structured state to new sessions. - -**Cross-session:** Side threads persist at project level. CAS objects persist on disk. Future: handoff artifacts load into new sessions. diff --git a/docs/research/EDITABLE_CONTEXT.md b/docs/research/EDITABLE_CONTEXT.md deleted file mode 100644 index b00048668..000000000 --- a/docs/research/EDITABLE_CONTEXT.md +++ /dev/null @@ -1,600 +0,0 @@ -# Frankencode: Editable Threads by Agents — Design Document - -## Problem Statement - -Currently, agents in OpenCode can only **append** to conversation threads. They produce tool results and text responses that are added sequentially. The only mechanism that "edits" context is **compaction** — a blunt instrument that summarizes everything before a boundary and hides it. Agents cannot: - -- Retract or correct a previous response -- Update a stale tool result with fresh data -- Remove irrelevant or misleading messages from their own context -- Annotate or amend previous reasoning -- Restructure a conversation to improve coherence for subsequent turns - -This document designs a system where agents can **selectively edit, annotate, hide, replace, and restructure** their own conversation threads. - ---- - -## Current Architecture (What We Have) - -### Storage Primitives Already Support Mutation - -The DB layer already provides full CRUD on messages and parts: - -| Operation | Function | Behavior | -|-----------|----------|----------| -| Upsert message | `Session.updateMessage()` | `INSERT ... ON CONFLICT DO UPDATE` on `MessageTable` | -| Delete message | `Session.removeMessage()` | `DELETE` + CASCADE to parts | -| Upsert part | `Session.updatePart()` | `INSERT ... ON CONFLICT DO UPDATE` on `PartTable` | -| Delete part | `Session.removePart()` | `DELETE` from `PartTable` | - -Bus events already fire for all mutations: `MessageV2.Event.Updated`, `Removed`, `PartUpdated`, `PartRemoved`. - -### Context Assembly Pipeline - -``` -Session.messages() → MessageV2.WithParts[] (raw from DB) - ↓ -MessageV2.filterCompacted() → MessageV2.WithParts[] (truncated at compaction boundary) - ↓ -MessageV2.toModelMessages() → ModelMessage[] (AI SDK format) - ↓ -LLM.stream() prepends system prompt, calls streamText() -``` - -### What Tools Can See - -Tools receive `ctx.messages: MessageV2.WithParts[]` — the full conversation history, **read-only**. They cannot mutate it; they can only return output strings. - -### Compaction as Precedent - -Compaction already "edits" threads by: -1. Setting `time.compacted` on tool part states (clears output, shows `[Old tool result content cleared]`) -2. Inserting a `CompactionPart` marker on a synthetic user message -3. `filterCompacted()` hiding everything before the marker - -This proves the architecture can handle mid-thread mutations. Editable threads generalizes this. - ---- - -## Design - -### Core Concept: Part-Level Edits with Visibility Control - -Rather than allowing agents to arbitrarily rewrite history (which would break audit trails), we introduce **part-level edit operations** with a **visibility layer** that controls what the LLM sees vs. what is stored. - -### 1. New Part Fields - -Extend the base part schema in `message-v2.ts`: - -```typescript -// Added to all Part types -{ - // ... existing fields ... - edit?: { - hidden: boolean // If true, excluded from LLM context (but kept in DB) - supersededBy?: PartID // Points to the replacement part - annotation?: string // Agent-provided note about why this was edited - editedAt: number // Timestamp of the edit - editedBy: string // Agent name that made the edit - } -} -``` - -**Why part-level, not message-level:** Messages are the structural unit (user turn / assistant turn). Parts are the content units (text blocks, tool calls, reasoning). Editing at the part level allows surgical precision — hide one bad tool result without losing the rest of the assistant's response. - -### 2. New Visibility Layer: `filterEdited()` - -Add a new filter step in the context assembly pipeline: - -```typescript -// In message-v2.ts -function filterEdited(messages: WithParts[]): WithParts[] { - return messages - .map(msg => ({ - ...msg, - parts: msg.parts.filter(part => !part.edit?.hidden) - })) - .filter(msg => msg.parts.length > 0) // Drop messages with no visible parts -} -``` - -The pipeline becomes: - -``` -Session.messages() - ↓ -filterCompacted() ← existing: truncate at compaction boundary - ↓ -filterEdited() ← NEW: remove hidden parts - ↓ -toModelMessages() ← existing: convert to LLM format -``` - -This keeps the edit metadata in the DB for audit/undo but removes hidden content from the LLM's view. - -### 3. New Tool: `thread_edit` - -A new built-in tool that exposes edit operations to agents: - -```typescript -// packages/opencode/src/tool/thread-edit.ts - -Tool.define("thread_edit", async () => ({ - description: `Edit the conversation thread. Operations: -- hide: Remove a part from LLM context (still stored for audit) -- unhide: Restore a hidden part -- replace: Hide a part and insert a replacement -- annotate: Add a note to a part without changing it -- summarize_range: Replace a range of messages with a summary -- retract: Hide all parts of an assistant message (self-correction)`, - - parameters: z.object({ - operation: z.enum(["hide", "unhide", "replace", "annotate", "summarize_range", "retract"]), - // For hide/unhide/annotate/retract: - target: z.object({ - messageID: z.string().optional(), - partID: z.string().optional(), - }).optional(), - // For replace: - replacement: z.string().optional(), - // For annotate: - annotation: z.string().optional(), - // For summarize_range: - range: z.object({ - fromMessageID: z.string(), - toMessageID: z.string(), - summary: z.string(), - }).optional(), - }), - - async execute(args, ctx: Tool.Context) { - // ... implementation below - } -})) -``` - -#### Operation Semantics - -**`hide`** — Mark a part as hidden. The LLM will no longer see it. -``` -target: { partID: "prt_abc123" } -→ Sets part.edit.hidden = true -→ Publishes PartUpdated event -``` - -**`unhide`** — Restore a previously hidden part. -``` -target: { partID: "prt_abc123" } -→ Sets part.edit.hidden = false -→ Publishes PartUpdated event -``` - -**`replace`** — Hide a part and insert a new TextPart with the replacement content on the same message. -``` -target: { partID: "prt_abc123" }, replacement: "corrected text" -→ Hides original part (edit.hidden = true, edit.supersededBy = newPartID) -→ Inserts new TextPart with replacement content -→ New part has edit.annotation = "Replaced " -``` - -**`annotate`** — Add metadata to a part without changing visibility. -``` -target: { partID: "prt_abc123" }, annotation: "This finding was later contradicted by..." -→ Sets part.edit.annotation (does NOT affect LLM context directly) -``` - -**`summarize_range`** — Replace a range of messages with a single summary (targeted compaction). -``` -range: { fromMessageID: "msg_aaa", toMessageID: "msg_zzz", summary: "..." } -→ Hides all parts in messages within the range -→ Inserts a new synthetic user message with a TextPart containing the summary -→ Marks it as synthetic: true so the TUI can render it distinctly -``` - -**`retract`** — Self-correction: hide all parts of an assistant message. -``` -target: { messageID: "msg_abc123" } -→ Hides all parts of the specified assistant message -→ Sets edit.annotation = "Retracted by agent" -``` - -### 4. Safety Constraints - -Agents should not be able to destroy important context or manipulate the thread maliciously. - -#### 4.1 Ownership Rules - -```typescript -const EDIT_RULES = { - // Agents can only edit their own assistant messages - canEditMessage(agent: string, message: MessageV2.Info): boolean { - if (message.role === "user") return false // Never edit user messages - return message.agent === agent // Only own messages - }, - - // Exception: summarize_range can span any messages (it hides, doesn't delete) - canSummarize(agent: string): boolean { - return agent === "build" || agent === "compaction" // Only primary agents - }, - - // Parts of user messages: read-only - canEditPart(agent: string, part: MessageV2.Part, message: MessageV2.Info): boolean { - if (message.role === "user") return false - return message.agent === agent - } -} -``` - -**Rationale:** Agents should not edit user messages (that's the user's input). Agents should only edit their own output (their assistant messages). This prevents a subagent from corrupting the primary agent's context. - -#### 4.2 Edit Budget - -To prevent runaway self-editing (an agent in a loop editing and re-editing): - -```typescript -const MAX_EDITS_PER_TURN = 10 // Max edit operations per assistant turn -const MAX_HIDDEN_RATIO = 0.7 // Cannot hide more than 70% of all parts -const PROTECTED_RECENT_MESSAGES = 2 // Cannot edit the 2 most recent turns (prevents loops) -``` - -The `PROTECTED_RECENT_MESSAGES` constraint is critical — without it, an agent could hide its own most recent output and create an infinite edit loop. - -#### 4.3 Permission Integration - -The `thread_edit` tool integrates with `PermissionNext`: - -```jsonc -// In opencode.json -{ - "permission": { - "thread_edit": "ask" // Default: ask user before editing thread - // Can be set to "allow" for autonomous operation - } -} -``` - -For `summarize_range` and `retract`, always require permission (even if `thread_edit` is set to `allow`), since these are high-impact operations. - -### 5. Database Migration - -```sql --- No schema change needed for the main tables. --- The `edit` metadata is stored inside the `data` JSON column of the `part` table. --- However, we need an index for efficient lookups of edited parts: - -CREATE INDEX IF NOT EXISTS idx_part_edited - ON part(session_id) - WHERE json_extract(data, '$.edit.hidden') = true; -``` - -Since parts are stored as JSON blobs in the `data` column, the `edit` field is simply a new optional key in the JSON. No migration is needed for the column structure — only an index for query performance. - -### 6. Changes to `toModelMessages()` - -In `message-v2.ts`, the `toModelMessages()` function needs to respect the `edit` field: - -```typescript -// Inside toModelMessages(), when processing parts: -for (const part of msg.parts) { - // Skip hidden parts - if (part.edit?.hidden) continue - - // For parts with supersededBy, skip (the replacement part will be included) - if (part.edit?.supersededBy) continue - - // ... existing conversion logic ... -} -``` - -Alternatively (and preferably), `filterEdited()` runs **before** `toModelMessages()` so the conversion function doesn't need changes. - -### 7. Changes to the Processor Loop - -In `processor.ts`, the message re-read between loop iterations needs to apply the new filter: - -```typescript -// Before each LLM call in the while(true) loop: -const raw = await Session.messages({ sessionID }) -const afterCompaction = MessageV2.filterCompacted(raw) -const afterEdits = MessageV2.filterEdited(afterCompaction) // NEW -const modelMessages = MessageV2.toModelMessages(afterEdits, model) -``` - -This means edits take effect **immediately on the next turn** — if an agent hides a part in turn N, the LLM won't see it in turn N+1. - -### 8. TUI Rendering - -The TUI needs to show edited content differently: - -#### 8.1 Hidden Parts - -Hidden parts should be **collapsed by default** with an indicator: - -``` -┃ [hidden by build agent: "Retracted — contained incorrect file path"] -┃ ▸ Click to expand original content -``` - -#### 8.2 Replaced Parts - -Show the replacement with a subtle indicator: - -``` -┃ The correct implementation uses a HashMap... -┃ ↻ replaced original (was: "The correct implementation uses a TreeMap...") -``` - -#### 8.3 Annotations - -Show as inline notes: - -``` -┃ The API returns a 200 status code. -┃ 📌 build: "Later confirmed this is actually 201 for POST requests" -``` - -#### 8.4 Summarized Ranges - -Show as a collapsed block: - -``` -┃ ━━━ 12 messages summarized ━━━ -┃ Summary: Explored the authentication module, found that JWT tokens -┃ are validated in middleware.ts. Identified 3 potential issues... -┃ ▸ Expand original messages -``` - -### 9. Plugin Hooks - -New hooks for the plugin system: - -```typescript -interface Hooks { - // ... existing hooks ... - - // Called before a thread edit is applied - "thread.edit.before"?: (input: { - operation: string - target?: { messageID?: string; partID?: string } - agent: string - }) => Promise<{ allow: boolean; reason?: string }> - - // Called after a thread edit is applied - "thread.edit.after"?: (input: { - operation: string - target?: { messageID?: string; partID?: string } - agent: string - result: "success" | "denied" - }) => Promise -} -``` - -This lets plugins enforce custom policies (e.g., "never hide tool results from security-audit tools") or log edit operations. - -### 10. Event Bus Integration - -New events on the existing bus: - -```typescript -namespace ThreadEdit { - export const Event = { - PartHidden: Bus.event("thread.edit.part.hidden"), - PartUnhidden: Bus.event("thread.edit.part.unhidden"), - PartReplaced: Bus.event("thread.edit.part.replaced"), - PartAnnotated: Bus.event("thread.edit.part.annotated"), - RangeSummarized: Bus.event("thread.edit.range.summarized"), - MessageRetracted: Bus.event("thread.edit.message.retracted"), - } -} -``` - -The TUI subscribes to these events via SSE to update the display in real-time. - ---- - -## Implementation Plan - -### Phase 1: Foundation (Part-Level Visibility) - -**Files to modify:** - -| File | Change | -|------|--------| -| `packages/opencode/src/session/message-v2.ts` | Add `edit` field to Part schema; implement `filterEdited()` | -| `packages/opencode/src/session/index.ts` | No changes needed (upsert/remove already exist) | -| `packages/opencode/src/session/processor.ts` | Insert `filterEdited()` into the message pipeline | -| `packages/opencode/src/session/llm.ts` | Ensure `filterEdited()` is applied before `toModelMessages()` | - -**New files:** - -| File | Purpose | -|------|---------| -| `packages/opencode/src/session/thread-edit.ts` | Core edit logic: `hide()`, `unhide()`, `replace()`, `annotate()`, `retract()`, `summarizeRange()` with ownership and budget enforcement | - -**Estimated scope:** ~300 lines of new code, ~50 lines of modifications. - -### Phase 2: Tool Exposure - -**New files:** - -| File | Purpose | -|------|---------| -| `packages/opencode/src/tool/thread-edit.ts` | `thread_edit` tool definition wrapping the core logic | - -**Files to modify:** - -| File | Change | -|------|--------| -| `packages/opencode/src/tool/registry.ts` | Register `ThreadEditTool` in the built-in tools list | -| `packages/opencode/src/permission/` | Add default permission rule for `thread_edit` | - -**Estimated scope:** ~150 lines new, ~20 lines modifications. - -### Phase 3: TUI Integration - -**Files to modify:** - -| File | Change | -|------|--------| -| `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx` | Render hidden/replaced/annotated/summarized parts with distinct styling | -| `packages/opencode/src/cli/cmd/tui/context/sdk.tsx` | Subscribe to new `thread.edit.*` SSE events | - -**Estimated scope:** ~200 lines modifications. - -### Phase 4: Plugin Hooks & Events - -**Files to modify:** - -| File | Change | -|------|--------| -| `packages/plugin/src/index.ts` | Add `thread.edit.before` / `thread.edit.after` hook types | -| `packages/opencode/src/session/thread-edit.ts` | Call `Plugin.trigger()` before/after edits | - -**Estimated scope:** ~50 lines. - -### Phase 5: Agent Prompting - -**Files to modify:** - -| File | Change | -|------|--------| -| `packages/opencode/src/agent/prompts/` | Add instructions for when/how agents should use `thread_edit` | - -Prompt additions should teach agents: -- When to retract (found an error in their own previous output) -- When to hide (a tool result is no longer relevant and wastes context) -- When to summarize ranges (long exploration can be compressed) -- When NOT to edit (don't hide errors — the user needs to see them) - ---- - -## Interaction with Existing Systems - -### Compaction - -Compaction and editable threads are complementary: -- **Compaction** is automatic, threshold-based, and summarizes everything before a boundary -- **Thread editing** is agent-directed, surgical, and preserves the thread structure - -`filterEdited()` runs **after** `filterCompacted()`. If compaction has already hidden a message, editing it is a no-op. If an agent hides parts and then compaction triggers, the compaction agent sees the edited (filtered) view. - -### Fork - -When forking a session (`Session.fork()`), edit metadata is preserved on the copied parts. The fork contains the same visibility state as the original at the fork point. - -### Session Sharing - -When sharing a session, hidden parts should be **excluded** from the shared data (they were hidden for a reason). The `share-next.ts` module should apply `filterEdited()` before serializing. - -### Undo/Redo (Snapshots) - -The snapshot system (`packages/opencode/src/snapshot/`) tracks file changes, not conversation edits. Thread edits need their own undo mechanism: - -```typescript -// In thread-edit.ts -interface EditRecord { - id: string - sessionID: SessionID - operation: string - target: { messageID?: MessageID; partID?: PartID } - before: Partial // Snapshot of the part before the edit - after: Partial // State after the edit - timestamp: number - agent: string -} -``` - -Store edit records in a new `thread_edit_log` table (or in the file-based storage). This enables: -- `undo_edit(editID)` — Restore the part to its pre-edit state -- `edit_history(sessionID)` — List all edits for audit - -### Stats - -The `stats` CLI command should include edit metrics: -- Total edits per session -- Parts hidden / replaced / retracted -- Context tokens saved by edits - ---- - -## Edge Cases - -### Agent Edits Its Own Current Turn - -If an agent tries to edit a part from its own current (in-progress) turn, this should be rejected. The `PROTECTED_RECENT_MESSAGES = 2` guard handles this, but additionally, any part with `ToolState.status === "running"` should be uneditable. - -### Concurrent Edits (Subagents) - -If a subagent (via `task` tool) is running in a child session, it should not be able to edit the parent session's thread. Edit operations are scoped to `ctx.sessionID`. - -### Compacted Tool Outputs - -If a tool output has already been compacted (`time.compacted` set), hiding it via `thread_edit` is redundant but harmless. The `[Old tool result content cleared]` placeholder is still shown unless the part is hidden. - -### Empty Messages After Edits - -If all parts of a message are hidden, `filterEdited()` drops the message entirely. This could create gaps (user message with no following assistant message). The `toModelMessages()` function already handles missing messages gracefully — it simply skips them. - -### Token Counting - -After edits, the token count stored on the assistant message (`tokens.input`, `tokens.output`) no longer reflects what the LLM actually saw. This is informational only (cost tracking) and doesn't affect behavior. Future calls will re-count tokens based on the filtered message list. - ---- - -## API Surface Summary - -### New Tool - -``` -thread_edit(operation, target?, replacement?, annotation?, range?) -``` - -### New Part Field - -```typescript -part.edit?: { - hidden: boolean - supersededBy?: PartID - annotation?: string - editedAt: number - editedBy: string -} -``` - -### New Filter Function - -```typescript -MessageV2.filterEdited(messages: WithParts[]): WithParts[] -``` - -### New Plugin Hooks - -``` -thread.edit.before → { allow, reason? } -thread.edit.after → void -``` - -### New Bus Events - -``` -thread.edit.part.hidden -thread.edit.part.unhidden -thread.edit.part.replaced -thread.edit.part.annotated -thread.edit.range.summarized -thread.edit.message.retracted -``` - ---- - -## Why This Design - -**Part-level, not message-level:** Surgical precision. An assistant message may have 5 tool calls and 3 text blocks. Hiding one bad tool result shouldn't lose the other 7 parts. - -**Visibility layer, not deletion:** Audit trail preservation. The original content stays in SQLite. Users can expand hidden content in the TUI. Edit history is reviewable. - -**Agent-scoped ownership:** Prevents cross-agent context manipulation. A subagent can't gaslight the primary agent by editing its messages. - -**Budget limits:** Prevents infinite self-editing loops. An agent that keeps hiding and re-generating would hit the edit budget and be forced to move forward. - -**Builds on existing primitives:** No new tables, no schema migration (JSON fields), reuses the existing upsert/event/bus infrastructure. The `filterEdited()` function mirrors the established `filterCompacted()` pattern. diff --git a/docs/research/EDITABLE_CONTEXT_FOCUS.md b/docs/research/EDITABLE_CONTEXT_FOCUS.md deleted file mode 100644 index 6df9af869..000000000 --- a/docs/research/EDITABLE_CONTEXT_FOCUS.md +++ /dev/null @@ -1,1031 +0,0 @@ -# Editable Context — Focus Agent & Side Thread System - -## The Problem - -During a coding session, conversations naturally branch: - -``` -Main objective: "Add pagination to the API" - → Turn 4: Agent discovers auth middleware has no rate limiting - → Turn 8: Agent finds a race condition in the DB connection pool - → Turn 12: Agent notices the test fixtures are outdated - → Turn 15: Agent realizes the ORM version is 2 majors behind -``` - -Today, these side discoveries have three fates: -1. **Agent chases them** — loses focus, burns context, pagination still isn't done at turn 50 -2. **Agent ignores them** — findings are lost, buried in a 200-turn conversation -3. **User manually notes them** — interrupts flow, burden on the user - -The focus system solves this with two components: -- A **focus agent** that ruthlessly keeps the conversation on-topic -- **Side threads** as a first-class data structure — detected, parked, summarized, and actionable - ---- - -## Why a Separate Focus Agent (Not the Curator) - -The curator and focus agent serve different purposes: - -| | Curator | Focus Agent | -|---|---|---| -| **Question** | "Is this content still *fresh*?" | "Is this content *on-topic*?" | -| **Judgment** | Temporal — age, staleness, token size | Directional — relevance to objective | -| **Actions** | Hide, externalize, prune | Park, redirect, block, promote | -| **Needs to understand objective?** | No (just needs recency) | Yes (deeply) | -| **Personality** | Janitor — quietly cleans up | Project manager — actively redirects | -| **When it runs** | Between turns (cleanup) | During/after turns (enforcement) | - -Merging them overloads one prompt with two kinds of reasoning. The curator looks backward ("what's stale?"). The focus agent looks forward ("where should we be going?"). These are different cognitive tasks. - -### Division of Responsibility - -``` -Main agent produces output - ↓ -Focus agent reviews: "Is this on-topic?" - ├── Yes → pass through - ├── No, but valuable → park as side thread, externalize, redirect agent - └── No, and not valuable → flag for curator - ↓ -Curator reviews: "Is anything stale?" - ├── Stale tool output → externalize or hide - ├── Redundant content → hide - └── Below decay threshold → externalize -``` - -The focus agent runs **first** (it may park content that the curator would otherwise just hide — parking is better than hiding because it preserves the finding). The curator runs **second** on whatever remains. - ---- - -## The Focus Agent - -### Agent Definition - -```typescript -{ - name: "focus", - hidden: true, - description: "Keeps the conversation focused on the current objective", - tools: [ - "thread_park", // Park side threads - "thread_edit", // Hide/externalize divergent content - "thread_externalize", // CAS-store divergent content - "thread_list", // Check existing threads (avoid duplicates) - "question", // Ask user about critical findings - ], - maxSteps: 8, // More steps than curator (needs to park + edit + externalize) - model: "small", // Fast model — judgment, not generation - temperature: 0, -} -``` - -### When It Runs - -The focus agent runs at two points: - -**1. Post-turn (after main agent completes a turn):** - -``` -Main agent finishes turn - → Focus agent reviews the turn's output - → Parks side threads, externalizes divergent content - → Leaves markers in the thread - → Curator runs (cleans up stale content) - → User sees clean, focused thread -``` - -**2. Mid-conversation injection (via system prompt):** - -The focus agent's assessment is injected into the main agent's system prompt so the main agent *self-corrects*: - -``` -## Focus Status (updated by focus agent) - -Current objective: "Add pagination to the API" -On-track: YES -Parked side threads: 3 (use /threads to see) -Warning: Last turn showed signs of diverging into auth concerns. -Stay focused on pagination. If you find unrelated issues, note them -briefly and I will park them. -``` - -This is the "ruthless" part — the main agent reads this every turn and is constantly reminded to stay on track. The focus agent updates this status block after each review. - -### Focus Agent Prompt - -``` -You are the focus agent. Your single job: keep the conversation on the current objective. - -## Current Objective -{objective_tracker.current} - -## Related Files -{objective_tracker.relatedFiles} - -## Your Task -Review the latest turn(s) and: - -1. DETECT divergence: Did the agent start investigating something off-topic? - - File reads/edits outside the objective's scope - - Agent says "I also noticed...", "While looking at X, I found Y..." - - Errors from tangential systems - - The agent going down a rabbit hole without user request - -2. PARK valuable divergences as side threads: - - Use thread_park(title, description, sourcePartIDs, priority, category) - - Externalize the supporting content to CAS via thread_externalize - - Hide the divergent inline content via thread_edit(hide) - - Leave a brief marker in context - -3. REDIRECT by updating the focus status block (injected into main agent's system prompt) - -4. ASK the user if a divergence looks critical (security, data loss, crash): - - "I noticed [issue]. Park it, or switch to it now?" - -5. DO NOT park: - - Content the user explicitly asked about - - Content directly required by the current objective - - Content the agent is actively building on for the objective - -## Parked threads (do not duplicate): -{existing_threads_summary} - -Be ruthless. An agent that does 10 things poorly is worse than one that does -1 thing well. If the agent is exploring something the user didn't ask for, -park it immediately. The user can always promote a parked thread later. -``` - -### Focus Intensity Levels - -The user can tune how aggressive the focus agent is: - -```jsonc -{ - "editableContext": { - "focus": { - "intensity": "moderate" - // "relaxed" — only parks obvious divergences, never interrupts - // "moderate" — parks divergences, asks about critical ones - // "strict" — parks aggressively, redirects mid-turn, blocks rabbit holes - } - } -} -``` - -**Relaxed:** Focus agent runs every 3 turns. Only parks things that are clearly off-topic (different directory, different subsystem). Never asks the user. - -**Moderate (default):** Focus agent runs every turn. Parks divergences, asks about critical findings, updates the focus status block. - -**Strict:** Focus agent runs every turn AND injects a hard directive into the main agent's system prompt: - -``` -STRICT FOCUS MODE: Do NOT investigate anything outside of these files: -{objective_tracker.relatedFiles} -If you encounter an issue in other files, state it in one sentence and move on. -The focus agent will park it. -``` - -In strict mode, the focus agent can also intervene *during* a tool-call loop by setting a flag that the processor checks: - -```typescript -// In processor.ts, inside the tool-call loop: -if (focusAgent.shouldInterrupt(currentToolCall)) { - // Inject a synthetic message: "Focus: you're diverging. Return to {objective}." - // The main agent sees this as a system-level redirect -} -``` - -This is the most aggressive mode — useful for long, expensive sessions where staying on track matters. - ---- - -## Side Threads as First-Class Data Structure - -### Schema - -```typescript -interface SideThread { - id: string // Unique ID (prefixed "thr_") - title: string // "Race condition in DB connection pool" - description: string // 2-3 sentence summary of the finding - status: "parked" | "investigating" | "resolved" | "integrated" | "deferred" - - // Source context - sourceSessionID: string // Where it was discovered - sourceMessageID: string // Which turn - sourcePartIDs: string[] // Specific parts that contain the finding - casRefs: string[] // CAS hashes of externalized supporting content - - // Classification - priority: "critical" | "high" | "medium" | "low" - category: "bug" | "tech-debt" | "security" | "performance" | "test" | "other" - relatedFiles: string[] // Files involved - blockedBy?: string[] // IDs of threads this depends on - blocks?: string[] // IDs of threads this blocks - - // Investigation - investigationSessionID?: string // Child session if promoted to investigation - resolution?: { - summary: string // What was found/done - outcome: "fixed" | "wont-fix" | "duplicate" | "not-a-problem" - resolvedAt: number - } - - // Lifecycle - createdAt: number - createdBy: string // "focus" | "build" | "user" - updatedAt: number - projectID: string -} -``` - -### Storage - -Project-level SQLite table (survives across sessions): - -```sql -CREATE TABLE side_thread ( - id TEXT PRIMARY KEY, - project_id TEXT NOT NULL REFERENCES project(id) ON DELETE CASCADE, - title TEXT NOT NULL, - description TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'parked', - priority TEXT NOT NULL DEFAULT 'medium', - category TEXT NOT NULL DEFAULT 'other', - source_session_id TEXT, - source_message_id TEXT, - source_part_ids TEXT, -- JSON array - cas_refs TEXT, -- JSON array - related_files TEXT, -- JSON array - blocked_by TEXT, -- JSON array of thread IDs - blocks TEXT, -- JSON array of thread IDs - investigation_session_id TEXT, - resolution TEXT, -- JSON object - created_at INTEGER NOT NULL, - created_by TEXT NOT NULL, - updated_at INTEGER NOT NULL -); - -CREATE INDEX idx_side_thread_project ON side_thread(project_id, status); -``` - -**Why project-level:** A side thread discovered in Session 1 might be investigated in Session 5. Project-level means every session sees the full backlog. - -### Thread Lifecycle - -``` - ┌─────────┐ - detect │ │ user: "/park" - ─────────▶│ PARKED │◀────────────── - │ │ - └────┬────┘ - │ - ┌──────────┼──────────┐ - │ investigate │ promote - ▼ ▼ - ┌────────────────┐ ┌──────────────┐ - │ INVESTIGATING │ │ (becomes │ - │ (subagent │ │ main │ - │ session) │ │ objective) │ - └───────┬────────┘ └──────────────┘ - │ - ┌──────┼──────┐ - │ │ - ▼ ▼ -┌──────────┐ ┌──────────┐ -│ RESOLVED │ │ DEFERRED │ -│ (done) │ │ (later) │ -└──────────┘ └──────────┘ - │ - ▼ -┌──────────────┐ -│ INTEGRATED │ -│ (fix merged │ -│ into main) │ -└──────────────┘ -``` - -### Thread Context Extraction - -When the focus agent parks a thread, it doesn't just note the title — it captures the full context chain that led to the discovery: - -```typescript -async function extractThreadContext( - messages: MessageV2.WithParts[], - divergentPartIDs: string[], - sessionID: string, -): Promise { - const casRefs: string[] = [] - - for (const partID of divergentPartIDs) { - // Find the part and its surrounding context - const part = findPart(messages, partID) - if (!part) continue - - // Externalize the part itself - const hash = await CAS.store(partContent(part), { - type: "side_thread_context", - sessionID, - partID, - }) - casRefs.push(hash) - - // Also externalize the triggering tool call if this is a tool result - if (part.type === "tool" && part.state.status === "completed") { - const inputHash = await CAS.store(JSON.stringify(part.state.input), { - type: "side_thread_trigger", - sessionID, - partID, - }) - casRefs.push(inputHash) - } - } - - return casRefs -} -``` - -This means when someone later investigates the thread, they get: -- The exact tool result that revealed the issue -- The tool input that triggered it -- Any surrounding text where the agent discussed the finding - -No need to search through old sessions or guess what the context was. - -### Thread Dependency Tracking - -Side threads can reference each other: - -``` -#thr_abc: "DB pool race condition" - blocks: [#thr_def] - -#thr_def: "Add connection retry logic" - blockedBy: [#thr_abc] -``` - -This is lightweight — just a note, not enforced. The focus agent sets it when it detects relationships: - -``` -Focus agent: "Thread #thr_def (retry logic) depends on understanding #thr_abc -(race condition). Marking dependency." -``` - -When the user investigates `thr_def`, the investigation prompt includes: "Note: this thread depends on #thr_abc (DB pool race condition). You may need to dereference that thread's context too." - ---- - -## Tools - -### `thread_park` - -```typescript -Tool.define("thread_park", async () => ({ - description: `Park a divergent finding as a side thread. Stores the finding with -its context at the project level. Survives across sessions. The finding is -externalized from the current conversation but fully recoverable.`, - - parameters: z.object({ - title: z.string().describe("Short title (under 80 chars)"), - description: z.string().describe("2-3 sentence summary: what was found, why it matters, where it is"), - sourcePartIDs: z.array(z.string()).describe("Part IDs containing the finding"), - priority: z.enum(["critical", "high", "medium", "low"]), - category: z.enum(["bug", "tech-debt", "security", "performance", "test", "other"]), - relatedFiles: z.array(z.string()).optional(), - blockedBy: z.array(z.string()).optional().describe("IDs of threads this depends on"), - blocks: z.array(z.string()).optional().describe("IDs of threads this blocks"), - }), - - async execute(args, ctx) { - // 1. Extract and externalize context to CAS - const casRefs = await extractThreadContext(ctx.messages, args.sourcePartIDs, ctx.sessionID) - - // 2. Create SideThread record - const thread = await SideThread.create({ - ...args, - casRefs, - sourceSessionID: ctx.sessionID, - sourceMessageID: ctx.messageID, - createdBy: ctx.agent, - projectID: Instance.project.id, - }) - - // 3. Hide the divergent inline content - for (const partID of args.sourcePartIDs) { - await ThreadEdit.hide({ - sessionID: ctx.sessionID, - partID, - messageID: /* lookup */, - agent: ctx.agent, - }) - } - - // 4. Insert marker - return { - title: `Parked: ${args.title}`, - metadata: { threadID: thread.id, priority: args.priority, category: args.category }, - output: `[Side thread ${thread.id} parked: "${args.title}" (${args.priority}, ${args.category})] -${args.description} -Related files: ${args.relatedFiles?.join(", ") ?? "none identified"} -Context preserved in ${casRefs.length} CAS objects. Use thread_investigate to explore later.`, - } - } -})) -``` - -### `thread_list` - -```typescript -Tool.define("thread_list", async () => ({ - description: `List side threads for this project.`, - parameters: z.object({ - status: z.enum(["parked", "investigating", "resolved", "deferred", "all"]).optional().default("all"), - }), - async execute(args, ctx) { - const threads = await SideThread.list({ projectID: Instance.project.id, status: args.status }) - if (threads.length === 0) return { output: "No side threads found.", title: "0 threads", metadata: {} } - - const lines = threads.map(t => - `${t.id} [${t.status}, ${t.priority}, ${t.category}] "${t.title}"\n ${t.description}\n Files: ${t.relatedFiles.join(", ") || "—"}` - ) - return { output: lines.join("\n\n"), title: `${threads.length} threads`, metadata: { count: threads.length } } - } -})) -``` - -### `thread_investigate` - -Wraps the existing `task` tool with pre-loaded side thread context: - -```typescript -Tool.define("thread_investigate", async () => ({ - description: `Investigate a parked side thread. Spawns a subagent session -pre-loaded with the thread's full context (dereferenced from CAS).`, - - parameters: z.object({ - threadID: z.string(), - approach: z.string().optional().describe("Specific investigation approach (optional)"), - agent: z.string().optional().default("general").describe("Agent type to use"), - }), - - async execute(args, ctx) { - const thread = await SideThread.get(args.threadID) - if (!thread) return { output: `Thread ${args.threadID} not found`, title: "Error", metadata: {} } - if (thread.status === "investigating") - return { output: `Thread already being investigated in session ${thread.investigationSessionID}`, title: "Already active", metadata: {} } - - // Dereference CAS context - const contextParts: string[] = [] - for (const hash of thread.casRefs) { - const blob = await CAS.get(hash) - contextParts.push(blob.content) - } - - // Build investigation prompt - const prompt = [ - `Investigate this finding:\n`, - `**Title:** ${thread.title}`, - `**Description:** ${thread.description}`, - `**Priority:** ${thread.priority} | **Category:** ${thread.category}`, - `**Related files:** ${thread.relatedFiles.join(", ")}`, - thread.blockedBy?.length ? `**Depends on:** ${thread.blockedBy.join(", ")}` : "", - `\n**Original context:**\n${contextParts.join("\n---\n")}`, - `\n**Approach:** ${args.approach ?? "Determine the scope of the issue, assess severity, and suggest a fix."}`, - ].filter(Boolean).join("\n") - - // Update status - await SideThread.update(args.threadID, { status: "investigating" }) - - // Spawn via existing task infrastructure - // (uses Session.create + SessionPrompt.prompt internally) - const session = await Session.create({ - parentID: ctx.sessionID, - title: `Investigate: ${thread.title}`, - }) - await SideThread.update(args.threadID, { investigationSessionID: session.id }) - - const result = await SessionPrompt.prompt({ - sessionID: session.id, - messageID: MessageID.ascending(), - model: ctx.model ?? thread.sourceModel, - agent: args.agent, - parts: [{ type: "text", text: prompt }], - }) - - // Extract result and update thread - const resultText = result.parts.findLast(p => p.type === "text")?.text ?? "" - await SideThread.update(args.threadID, { - status: "resolved", - resolution: { summary: resultText, outcome: "fixed", resolvedAt: Date.now() }, - }) - - return { - title: `Investigated: ${thread.title}`, - metadata: { threadID: args.threadID, sessionID: session.id }, - output: `Investigation of ${thread.title}:\n\n${resultText}\n\ntask_id: ${session.id}`, - } - } -})) -``` - -### `thread_promote` - -```typescript -Tool.define("thread_promote", async () => ({ - description: `Promote a side thread to the main objective. The current objective -is saved as a new side thread, and this thread becomes the focus.`, - - parameters: z.object({ threadID: z.string() }), - - async execute(args, ctx) { - const thread = await SideThread.get(args.threadID) - if (!thread) return { output: "Thread not found", title: "Error", metadata: {} } - - // Park current objective as a side thread - const currentObjective = await ObjectiveTracker.get(ctx.sessionID) - if (currentObjective) { - await SideThread.create({ - title: currentObjective.current, - description: `Previous main objective, parked when promoting ${args.threadID}`, - priority: "high", - category: "other", - sourceSessionID: ctx.sessionID, - createdBy: ctx.agent, - projectID: Instance.project.id, - casRefs: [], - sourcePartIDs: [], - relatedFiles: currentObjective.relatedFiles ?? [], - }) - } - - // Promote: update objective tracker - await ObjectiveTracker.set(ctx.sessionID, { - current: thread.title, - approach: thread.description, - relatedFiles: thread.relatedFiles, - }) - - // Dereference CAS context into conversation - for (const hash of thread.casRefs) { - const blob = await CAS.get(hash) - // Inject as synthetic text part so the agent has the context - await Session.updatePart({ - id: PartID.ascending(), - messageID: ctx.messageID, - sessionID: ctx.sessionID, - type: "text", - text: `[Context from side thread ${args.threadID}]\n${blob.content}`, - synthetic: true, - }) - } - - await SideThread.update(args.threadID, { status: "integrated" }) - - return { - title: `Promoted: ${thread.title}`, - metadata: { threadID: args.threadID }, - output: `Objective updated to: "${thread.title}"\nPrevious objective parked as side thread.\n${thread.casRefs.length} context objects loaded.`, - } - } -})) -``` - -### `thread_resolve` - -```typescript -Tool.define("thread_resolve", async () => ({ - description: `Mark a side thread as resolved or deferred.`, - parameters: z.object({ - threadID: z.string(), - outcome: z.enum(["fixed", "wont-fix", "duplicate", "not-a-problem", "deferred"]), - summary: z.string().describe("What was done or decided"), - }), - async execute(args, ctx) { - const status = args.outcome === "deferred" ? "deferred" : "resolved" - await SideThread.update(args.threadID, { - status, - resolution: { summary: args.summary, outcome: args.outcome, resolvedAt: Date.now() }, - }) - return { - title: `${args.outcome}: ${args.threadID}`, - metadata: { threadID: args.threadID, outcome: args.outcome }, - output: `Thread ${args.threadID} marked as ${args.outcome}: ${args.summary}`, - } - } -})) -``` - ---- - -## Focus Agent Execution Model - -### Post-Turn Review - -```typescript -// In the session orchestration layer, after main agent finishes a turn: - -async function postTurnReview(sessionID: SessionID, model: Provider.Model, abort: AbortSignal) { - const config = await Config.get() - const focusConfig = config.editableContext?.focus - if (!focusConfig?.enabled) return - - // Check frequency - const turnCount = await getTurnCount(sessionID) - const frequency = focusConfig.intensity === "relaxed" ? 3 : 1 - if (turnCount % frequency !== 0) return - - // Check minimum turns - if (turnCount < (focusConfig.minTurns ?? 4)) return - - const messages = await Session.messages({ sessionID }) - const objective = await ObjectiveTracker.get(sessionID) - const existingThreads = await SideThread.list({ projectID: Instance.project.id, status: "all" }) - - // Create focus agent session (hidden, not shown to user) - const focusAgent = await Agent.get("focus") - const focusModel = focusAgent.model - ? await Provider.getModel(focusAgent.model.providerID, focusAgent.model.modelID) - : model // fall back to main model's small variant - - const focusMsg = await Session.updateMessage({ - id: MessageID.ascending(), - role: "assistant", - sessionID, - agent: "focus", - // Hidden: not rendered in TUI message list - summary: false, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - modelID: focusModel.id, - providerID: focusModel.providerID, - time: { created: Date.now() }, - }) - - const processor = SessionProcessor.create({ - assistantMessage: focusMsg, - sessionID, - model: focusModel, - abort, - }) - - // The focus agent gets: - // - Full message history (to see what the main agent did) - // - Current objective - // - Existing side threads (to avoid duplicates) - // - Its focus tools - await processor.process({ - user: /* synthetic focus review prompt */, - agent: focusAgent, - tools: { - thread_park: ThreadParkTool, - thread_edit: ThreadEditTool, - thread_externalize: ThreadExternalizeTool, - thread_list: ThreadListTool, - question: QuestionTool, // for asking user about critical findings - }, - messages: MessageV2.toModelMessages( - MessageV2.filterEdited(MessageV2.filterCompacted(messages)), - focusModel - ), - system: [buildFocusPrompt(objective, existingThreads, focusConfig.intensity)], - model: focusModel, - }) - - // After focus agent runs, update the focus status block - // (injected into main agent's system prompt on next turn) - await updateFocusStatus(sessionID, objective, existingThreads) -} -``` - -### System Prompt Injection (Focus Pressure) - -The focus agent's assessment is injected into the main agent's system prompt: - -```typescript -// In session/llm.ts, during system prompt assembly: -async function buildSystemPrompt(sessionID, agent, model) { - const parts = [ - agent.prompt ?? SystemPrompt.provider(model), - // ... existing system prompt parts ... - ] - - // Inject focus status if focus agent is enabled - const focusStatus = await FocusStatus.get(sessionID) - if (focusStatus) { - parts.push(focusStatus.block) - } - - return parts -} -``` - -The focus status block looks like: - -```markdown -## Focus Status - -**Objective:** Add pagination to the API -**Related files:** src/api/users.ts, src/api/pagination.ts, src/db/queries.ts -**On-track:** YES -**Parked threads:** 3 - - thr_abc: DB pool race condition (medium, bug) - - thr_def: Auth rate limiting (medium, security) - - thr_ghi: ORM upgrade (low, tech-debt) - -Stay focused on the objective. If you find unrelated issues, note them -in one sentence. The focus agent will park them with full context. -``` - -In **strict** mode, the block is more aggressive: - -```markdown -## STRICT FOCUS MODE - -**Objective:** Add pagination to the API -**Allowed scope:** src/api/**, src/db/queries.ts, tests/api/** -**Parked threads:** 3 - -DO NOT investigate files outside the allowed scope. -DO NOT chase tangential issues. -If you encounter something outside scope, state it in ONE sentence and return to the objective. -``` - -### Focus Agent's Hidden Messages - -The focus agent's own messages (its analysis, tool calls to `thread_park`, etc.) are marked as hidden agent output — they don't appear in the TUI's main message stream. The user sees only: - -1. **Toast notifications:** "Parked: DB pool race condition (medium)" -2. **Thread markers** in the conversation: `[Parked: thr_abc — DB pool race condition]` -3. **The focus status block** in the system prompt (visible if user inspects it) -4. **Side thread panel** in the TUI sidebar (if enabled) - -If the focus agent asks a question (critical finding), that question *does* appear in the TUI — it's surfaced via the `question` tool, same as any other agent question. - ---- - -## Detection Heuristics - -The focus agent uses LLM judgment (it's an agent, not regex), but these heuristics in its prompt guide it: - -### 1. File Divergence - -``` -If the main agent reads or edits files outside the objective's related files -and directories, that's likely a divergence. Exception: dependency files -(package.json, imports) and config files that are needed for the objective. -``` - -### 2. Language Patterns - -``` -Watch for these phrases from the main agent: -- "I also noticed..." -- "While looking at X, I found Y..." -- "Unrelated, but..." -- "Side note:", "By the way..." -- "There's also a problem with..." -- "I should mention..." -These signal the agent has discovered something off-topic. -``` - -### 3. Error Context - -``` -If a tool error comes from a file/system unrelated to the objective, -that's a side discovery. The agent may be tempted to fix it. -Park it unless the error blocks the main objective. -``` - -### 4. Time-on-Task - -``` -If the agent has spent 3+ consecutive turns on something that isn't -directly advancing the objective (no progress on objective files, -no new test results, no code changes in scope), it may be in a rabbit hole. -``` - -### 5. User Intent - -``` -If the user explicitly asked about something, that is NEVER a side thread, -even if it seems off-topic. The user defines the main thread. -Only park things the agent discovered on its own. -``` - ---- - -## User Interaction - -### Slash Commands - -| Command | Action | -|---------|--------| -| `/threads` | List all side threads | -| `/threads parked` | List only parked | -| `/investigate thr_abc` | Investigate with subagent | -| `/promote thr_abc` | Swap into main objective | -| `/resolve thr_abc fixed "done"` | Mark resolved | -| `/park "title" "description"` | Manually park | -| `/focus strict` | Switch to strict focus mode | -| `/focus relaxed` | Switch to relaxed mode | -| `/focus off` | Disable focus agent | - -### User-Initiated Parking - -``` -User: "park that auth issue and let's keep going with pagination" - -Main agent: [calls thread_park] - Parked #thr_def: "Auth middleware missing rate limiting" - Priority: medium | Category: security - Files: src/auth/middleware.ts - [continues with pagination] -``` - -### Focus Agent Question (Critical Finding) - -``` -Focus Agent: While reviewing the last turn, I found that the agent discovered -an unhandled exception in the payment processing module (src/payments/charge.ts:89) -that could cause double-charges. - - [1] Park as critical (investigate soon) - [2] Park as medium - [3] Switch to it now - [4] Ignore -> _ -``` - -The focus agent asks via the `question` tool. Timeout: 60 seconds, default: option 1 (park as critical). - -### Investigation Flow - -``` -User: "what threads do we have?" - -Agent: [calls thread_list] - thr_abc [parked, med, bug] "Race condition in DB connection pool" - thr_def [parked, med, security] "Auth middleware missing rate limiting" - thr_ghi [parked, low, tech-debt] "ORM version 2 majors behind" - -User: "investigate the race condition" - -Agent: [calls thread_investigate("thr_abc")] - → Spawns @general subagent - → Subagent gets: thread description + dereferenced CAS context + related files - → Subagent reads src/db/pool.ts, analyzes the condition - → Returns findings - -Agent: Thread #thr_abc resolved: - "Race condition in connection pool when concurrent requests exceed - maxConnections (10). Fix: add 5s timeout + exponential retry in - pool.ts:45-60. ~15 lines. Want me to apply the fix?" -``` - ---- - -## Integration With Other Systems - -### Focus + Curator (Ordered Pipeline) - -``` -Main agent turn completes - → Focus agent: parks divergences, updates focus status - → Curator: cleans stale content, externalizes old parts - → Pin & Decay: updates relevance scores -``` - -The focus agent runs first because parking is strictly better than hiding — the curator shouldn't hide a finding that should have been parked. After focus runs, the curator cleans up whatever non-divergent stale content remains. - -### Focus + Objective Tracker - -The objective tracker is the focus agent's reference point. Without an objective, the focus agent can't judge what's on-topic. They're tightly coupled: - -- Objective defines scope → focus agent enforces it -- Focus agent detects drift → updates objective (or asks user) -- Thread promotion → objective tracker update -- New session with handoff → objective loaded → focus agent has immediate context - -### Focus + CAS (Merkle) - -When parking, the focus agent externalizes the divergent content to CAS. This is critical because: -- Hidden content is invisible to the LLM -- CAS content is invisible but **recoverable** -- A future investigation can dereference the exact context - -### Focus + Handoff (Cross-Session) - -The handoff artifact includes all side threads: - -```typescript -interface HandoffArtifact { - // ... existing fields ... - sideThreads: SideThread[] // Full thread objects, not just summaries - focusStatus: FocusStatus // Latest focus assessment -} -``` - -New session loads handoff → focus agent activates with full thread backlog → system prompt includes parked threads → agent knows what was deferred. - -### Focus + Todo System - -The existing `todowrite` is the agent's **within-task** scratchpad: -``` -Todo: [pending] Step 1: Add offset/limit params to /users -Todo: [done] Step 2: Update SQL query -Todo: [pending] Step 3: Add Link headers -``` - -Side threads are the **across-task** backlog: -``` -thr_abc: [parked] DB pool race condition -thr_def: [parked] Auth rate limiting -``` - -They don't overlap. Todos track steps toward the objective. Side threads track deferred work outside the objective. - ---- - -## TUI Integration (Fork Only) - -### Sidebar Panel - -``` -┌─ Side Threads (3) ──────────┐ -│ ● thr_abc [med] DB pool race│ -│ ● thr_def [med] Auth rate │ -│ ○ thr_ghi [low] ORM upgrade │ -│ │ -│ ● parked ◉ investigating │ -│ ✓ resolved ◇ deferred │ -└──────────────────────────────┘ -``` - -Keybindings: -- `f` — toggle side thread panel -- Enter on thread → show details dialog -- `i` on thread → investigate -- `p` on thread → promote to objective - -### Focus Status in Header - -The session header shows a focus indicator: - -``` -[claude-sonnet-4-6] @build | Focus: ON (moderate) | Threads: 3 parked -``` - ---- - -## Configuration - -```jsonc -{ - "editableContext": { - "focus": { - "enabled": true, - "intensity": "moderate", // "relaxed" | "moderate" | "strict" - "model": "small", // Use small/fast model for focus agent - "minTurns": 4, // Don't run focus before 4 turns - "maxParksPerRun": 3, // Focus agent can park max 3 threads per run - "askOnCritical": true, // Ask user about critical findings - "askTimeout": 60, // Seconds before defaulting on question - "showInSystemPrompt": true, // Inject focus status into main agent prompt - "showInSidebar": true, // TUI sidebar panel (fork only) - "maxParkedThreads": 30, // Per project - "autoExternalizeContext": true // CAS-store thread context - } - } -} -``` - ---- - -## Implementation - -| Component | Lines | Phase | Depends On | -|-----------|:-----:|-------|------------| -| `SideThread` data model + SQLite table + migration | ~100 | 1 | — | -| `thread_park` tool | ~80 | 1 | SideThread model | -| `thread_list` tool | ~40 | 1 | SideThread model | -| `thread_resolve` tool | ~30 | 1 | SideThread model | -| Focus agent definition (hidden agent) | ~40 | 2 | — | -| Focus agent prompt + focus status block | ~60 | 2 | Objective Tracker | -| Post-turn review orchestration | ~120 | 2 | Focus agent + processor.ts | -| System prompt injection (focus status) | ~40 | 2 | session/llm.ts | -| `thread_investigate` tool (task wrapper) | ~100 | 2 | SideThread + existing `task` tool | -| `thread_promote` tool | ~70 | 2 | SideThread + Objective Tracker | -| Detection heuristics in focus prompt | ~50 | 2 | — | -| Slash commands (`/threads`, `/focus`, etc.) | ~50 | 2 | — | -| Focus intensity modes (relaxed/moderate/strict) | ~60 | 3 | Phase 2 | -| Strict mode mid-turn intervention | ~80 | 3 | processor.ts | -| TUI sidebar panel | ~80 | 3 | Fork only | -| TUI header focus indicator | ~20 | 3 | Fork only | -| Handoff integration (cross-session threads) | ~50 | 3 | Handoff (Mode 4) | -| CAS context extraction on park | ~60 | 3 | CAS (Merkle) | -| **Total** | **~1,130** | | | - -**Phase 1** (~250 LOC): Data model + manual tools. Users can park, list, and resolve threads immediately. -**Phase 2** (~530 LOC): The focus agent itself — automatic detection, system prompt injection, investigation, promotion. -**Phase 3** (~350 LOC): Intensity modes, TUI integration, cross-system integration. diff --git a/docs/research/EDITABLE_CONTEXT_FORK_PLAN.md b/docs/research/EDITABLE_CONTEXT_FORK_PLAN.md deleted file mode 100644 index a06b88daf..000000000 --- a/docs/research/EDITABLE_CONTEXT_FORK_PLAN.md +++ /dev/null @@ -1,687 +0,0 @@ -# Editable Context — Fork Plan - -Fork of `anomalyco/opencode` (`dev` branch). Changes are surgical and upstream-mergeable. - ---- - -## Architecture - -``` -┌───────────────────────────────────────────────────────────────────┐ -│ Core changes (in the fork) │ -│ │ -│ message-v2.ts │ -│ ├── PartBase.extend: add optional `edit` field to all parts │ -│ ├── filterEdited(): drop hidden/superseded parts │ -│ └── toModelMessages(): respects edit.hidden │ -│ │ -│ session/thread-edit.ts (NEW) │ -│ ├── Core edit operations with ownership/budget enforcement │ -│ ├── Bus events (6 event types) │ -│ └── Plugin.trigger() before/after hooks │ -│ │ -│ processor.ts │ -│ └── Insert filterEdited() in message assembly pipeline │ -│ │ -│ tool/thread-edit.ts (NEW) │ -│ └── thread_edit tool definition (Tool.define wrapper) │ -│ │ -│ tool/registry.ts │ -│ └── Register ThreadEditTool in BUILTIN array │ -│ │ -│ server/routes/session.ts │ -│ └── GET /:sessionID/edits endpoint │ -│ │ -│ TUI: cli/cmd/tui/routes/session/index.tsx │ -│ └── Render hidden/replaced/annotated parts, toggle keybind │ -│ │ -│ Web UI: packages/ui/src/components/message-part.tsx │ -│ └── ToolRegistry.register("thread_edit", ...) for web rendering │ -│ │ -│ Plugin hooks: packages/plugin/src/index.ts │ -│ └── thread.edit.before / thread.edit.after hook types │ -└───────────────────────────────────────────────────────────────────┘ -``` - ---- - -## Change 1: Part Schema Extension - -**File:** `packages/opencode/src/session/message-v2.ts` - -Add `edit` to `PartBase` so every part type inherits it. Since parts are stored as JSON blobs in the `data` column, this requires no SQL migration. - -```typescript -// New schema (add near line 81, after PartBase definition) -const EditMeta = z.object({ - hidden: z.boolean(), - supersededBy: PartID.zod.optional(), // points to replacement part - replacementOf: PartID.zod.optional(), // points to original part - annotation: z.string().optional(), - editedAt: z.number(), - editedBy: z.string(), // agent name -}).optional() - -// Extend PartBase (line 81) -const PartBase = z.object({ - id: PartID.zod, - sessionID: SessionID.zod, - messageID: MessageID.zod, - edit: EditMeta, // NEW — inherits to all 12 part types -}) -``` - -**Lines changed:** ~12 (add `EditMeta` schema + one field on `PartBase`). - -**Why it's safe:** `.optional()` means existing parts without `edit` remain valid. Zod parses them as `edit: undefined`. No data migration. No SQL changes (JSON blob column). - ---- - -## Change 2: `filterEdited()` - -**File:** `packages/opencode/src/session/message-v2.ts` - -```typescript -// Add as a new exported function in the MessageV2 namespace -export function filterEdited(messages: WithParts[]): WithParts[] { - return messages - .map(msg => ({ - ...msg, - parts: msg.parts.filter(part => { - if (!part.edit) return true - if (part.edit.hidden) return false - if (part.edit.supersededBy) return false - return true - }) - })) - .filter(msg => msg.parts.length > 0) -} -``` - -**Lines added:** ~15. - ---- - -## Change 3: Pipeline Integration - -**File:** `packages/opencode/src/session/processor.ts` - -In the processor loop, where messages are assembled before each LLM call. The exact location is where `filterCompacted()` is called and the result is passed to `toModelMessages()`: - -```diff - const raw = await Session.messages({ sessionID }) - const afterCompaction = MessageV2.filterCompacted(raw) -+ const afterEdits = MessageV2.filterEdited(afterCompaction) -- const modelMessages = MessageV2.toModelMessages(afterCompaction, model) -+ const modelMessages = MessageV2.toModelMessages(afterEdits, model) -``` - -**Lines changed:** ~3. - ---- - -## Change 4: Core Edit Logic - -**New file:** `packages/opencode/src/session/thread-edit.ts` (~280 lines) - -```typescript -import { Session } from "./index" -import { MessageV2 } from "./message-v2" -import { BusEvent } from "@/bus/bus-event" -import { Database } from "@/storage/db" -import { Plugin } from "@/plugin" -import { ID } from "@/id" -import z from "zod" - -export namespace ThreadEdit { - // ── Types ────────────────────────────────────────────── - - export interface EditResult { - success: boolean - editID?: string - error?: string - } - - // ── Constants ────────────────────────────────────────── - - const MAX_EDITS_PER_TURN = 10 - const MAX_HIDDEN_RATIO = 0.7 - const PROTECTED_RECENT_TURNS = 2 - const PROTECTED_TOOLS = ["skill"] - - // ── Events (published via Bus) ───────────────────────── - - export const Event = { - PartHidden: BusEvent.define("thread.edit.part.hidden", - z.object({ sessionID: z.string(), partID: z.string(), agent: z.string() })), - PartUnhidden: BusEvent.define("thread.edit.part.unhidden", - z.object({ sessionID: z.string(), partID: z.string(), agent: z.string() })), - PartReplaced: BusEvent.define("thread.edit.part.replaced", - z.object({ sessionID: z.string(), oldPartID: z.string(), newPartID: z.string(), agent: z.string() })), - PartAnnotated: BusEvent.define("thread.edit.part.annotated", - z.object({ sessionID: z.string(), partID: z.string(), annotation: z.string(), agent: z.string() })), - MessageRetracted: BusEvent.define("thread.edit.message.retracted", - z.object({ sessionID: z.string(), messageID: z.string(), agent: z.string() })), - RangeSummarized: BusEvent.define("thread.edit.range.summarized", - z.object({ sessionID: z.string(), fromMessageID: z.string(), toMessageID: z.string(), agent: z.string() })), - } - - // ── Validation ───────────────────────────────────────── - - function validateOwnership(agent: string, message: MessageV2.Info): string | null { - if (message.role === "user") return "Cannot edit user messages" - if (message.agent !== agent) return `Cannot edit messages from agent '${message.agent}'` - return null - } - - function validateBudget(messages: MessageV2.WithParts[], currentEditCount: number): string | null { - if (currentEditCount >= MAX_EDITS_PER_TURN) - return `Edit budget exhausted (max ${MAX_EDITS_PER_TURN} per turn)` - const totalParts = messages.reduce((n, m) => n + m.parts.length, 0) - const hiddenParts = messages.reduce( - (n, m) => n + m.parts.filter(p => p.edit?.hidden).length, 0) - if (totalParts > 0 && (hiddenParts + 1) / totalParts > MAX_HIDDEN_RATIO) - return `Cannot hide more than ${MAX_HIDDEN_RATIO * 100}% of all parts` - return null - } - - function isProtectedMessage(messages: MessageV2.WithParts[], messageID: string): boolean { - const idx = messages.findIndex(m => m.info.id === messageID) - if (idx < 0) return true - return idx >= messages.length - PROTECTED_RECENT_TURNS * 2 - } - - // ── Plugin guard ─────────────────────────────────────── - - async function pluginGuard(op: string, sessionID: string, agent: string, - target?: { messageID?: string; partID?: string }): Promise { - const guard = await Plugin.trigger("thread.edit.before", - { operation: op, target, agent, sessionID }, - { allow: true }) - if (!guard.allow) return { success: false, error: guard.reason ?? "Blocked by plugin" } - return null - } - - async function pluginNotify(op: string, sessionID: string, agent: string, success: boolean, - target?: { messageID?: string; partID?: string }) { - await Plugin.trigger("thread.edit.after", - { operation: op, target, agent, sessionID, success }, {}) - } - - // ── Operations ───────────────────────────────────────── - - export async function hide(input: { - sessionID: string; partID: string; messageID: string; agent: string - }): Promise { - const blocked = await pluginGuard("hide", input.sessionID, input.agent, - { messageID: input.messageID, partID: input.partID }) - if (blocked) return blocked - - const msg = await MessageV2.get(input) - if (!msg) return { success: false, error: "Message not found" } - - const ownerErr = validateOwnership(input.agent, msg.info) - if (ownerErr) return { success: false, error: ownerErr } - - const messages = await Session.messages({ sessionID: input.sessionID }) - if (isProtectedMessage(messages, input.messageID)) - return { success: false, error: "Cannot edit recent messages" } - - const part = msg.parts.find(p => p.id === input.partID) - if (!part) return { success: false, error: "Part not found" } - if (part.type === "tool" && PROTECTED_TOOLS.includes((part as any).tool)) - return { success: false, error: `Cannot hide ${(part as any).tool} tool results` } - - const budgetErr = validateBudget(messages, 0) - if (budgetErr) return { success: false, error: budgetErr } - - Session.updatePart({ ...part, - edit: { hidden: true, editedAt: Date.now(), editedBy: input.agent } - }) - Bus.publish(Event.PartHidden, { sessionID: input.sessionID, partID: input.partID, agent: input.agent }) - await pluginNotify("hide", input.sessionID, input.agent, true, - { messageID: input.messageID, partID: input.partID }) - return { success: true, editID: input.partID } - } - - export async function unhide(input: { - sessionID: string; partID: string; messageID: string; agent: string - }): Promise { - const msg = await MessageV2.get(input) - if (!msg) return { success: false, error: "Message not found" } - const part = msg.parts.find(p => p.id === input.partID) - if (!part?.edit?.hidden) return { success: false, error: "Part is not hidden" } - - Session.updatePart({ ...part, edit: undefined }) - Bus.publish(Event.PartUnhidden, { sessionID: input.sessionID, partID: input.partID, agent: input.agent }) - return { success: true } - } - - export async function replace(input: { - sessionID: string; partID: string; messageID: string; agent: string; replacement: string - }): Promise { - const blocked = await pluginGuard("replace", input.sessionID, input.agent, - { messageID: input.messageID, partID: input.partID }) - if (blocked) return blocked - - const msg = await MessageV2.get(input) - if (!msg) return { success: false, error: "Message not found" } - const ownerErr = validateOwnership(input.agent, msg.info) - if (ownerErr) return { success: false, error: ownerErr } - const messages = await Session.messages({ sessionID: input.sessionID }) - if (isProtectedMessage(messages, input.messageID)) - return { success: false, error: "Cannot edit recent messages" } - const part = msg.parts.find(p => p.id === input.partID) - if (!part) return { success: false, error: "Part not found" } - - const newPartID = ID.ascending("prt") - Database.transaction(() => { - Session.updatePart({ ...part, - edit: { hidden: true, supersededBy: newPartID, editedAt: Date.now(), editedBy: input.agent } - }) - Session.updatePart({ - id: newPartID, sessionID: input.sessionID, messageID: input.messageID, - type: "text", text: input.replacement, - edit: { hidden: false, replacementOf: input.partID, editedAt: Date.now(), editedBy: input.agent } - } as any) - }) - Bus.publish(Event.PartReplaced, { - sessionID: input.sessionID, oldPartID: input.partID, newPartID, agent: input.agent }) - await pluginNotify("replace", input.sessionID, input.agent, true, - { messageID: input.messageID, partID: input.partID }) - return { success: true } - } - - export async function annotate(input: { - sessionID: string; partID: string; messageID: string; agent: string; annotation: string - }): Promise { - const msg = await MessageV2.get(input) - if (!msg) return { success: false, error: "Message not found" } - const part = msg.parts.find(p => p.id === input.partID) - if (!part) return { success: false, error: "Part not found" } - - Session.updatePart({ ...part, - edit: { ...(part.edit ?? { hidden: false, editedAt: 0, editedBy: "" }), - annotation: input.annotation, editedAt: Date.now(), editedBy: input.agent } - }) - Bus.publish(Event.PartAnnotated, { - sessionID: input.sessionID, partID: input.partID, annotation: input.annotation, agent: input.agent }) - return { success: true } - } - - export async function retract(input: { - sessionID: string; messageID: string; agent: string - }): Promise { - const blocked = await pluginGuard("retract", input.sessionID, input.agent, - { messageID: input.messageID }) - if (blocked) return blocked - - const msg = await MessageV2.get({ sessionID: input.sessionID, messageID: input.messageID }) - if (!msg) return { success: false, error: "Message not found" } - const ownerErr = validateOwnership(input.agent, msg.info) - if (ownerErr) return { success: false, error: ownerErr } - const messages = await Session.messages({ sessionID: input.sessionID }) - if (isProtectedMessage(messages, input.messageID)) - return { success: false, error: "Cannot retract recent messages" } - - Database.transaction(() => { - for (const part of msg.parts) { - Session.updatePart({ ...part, - edit: { hidden: true, annotation: "Retracted by agent", editedAt: Date.now(), editedBy: input.agent } - }) - } - }) - Bus.publish(Event.MessageRetracted, { sessionID: input.sessionID, messageID: input.messageID, agent: input.agent }) - await pluginNotify("retract", input.sessionID, input.agent, true, { messageID: input.messageID }) - return { success: true } - } - - export async function summarizeRange(input: { - sessionID: string; fromMessageID: string; toMessageID: string; summary: string; agent: string - }): Promise { - if (input.agent !== "build" && input.agent !== "compaction") - return { success: false, error: "Only primary agents can summarize ranges" } - - const blocked = await pluginGuard("summarize_range", input.sessionID, input.agent, - { messageID: input.fromMessageID }) - if (blocked) return blocked - - const messages = await Session.messages({ sessionID: input.sessionID }) - const fromIdx = messages.findIndex(m => m.info.id === input.fromMessageID) - const toIdx = messages.findIndex(m => m.info.id === input.toMessageID) - if (fromIdx < 0 || toIdx < 0 || fromIdx > toIdx) - return { success: false, error: "Invalid message range" } - if (toIdx >= messages.length - PROTECTED_RECENT_TURNS * 2) - return { success: false, error: "Cannot summarize recent messages" } - - const summaryMsgID = ID.descending("msg") - Database.transaction(() => { - for (let i = fromIdx; i <= toIdx; i++) { - for (const part of messages[i].parts) { - Session.updatePart({ ...part, - edit: { hidden: true, - annotation: `Summarized in range ${input.fromMessageID}..${input.toMessageID}`, - editedAt: Date.now(), editedBy: input.agent } - }) - } - } - Session.updateMessage({ - id: summaryMsgID, sessionID: input.sessionID, role: "user", - time: { created: Date.now() }, agent: input.agent, - } as any) - Session.updatePart({ - id: ID.ascending("prt"), sessionID: input.sessionID, messageID: summaryMsgID, - type: "text", text: `[Summary of ${toIdx - fromIdx + 1} messages]\n\n${input.summary}`, - synthetic: true, - edit: { hidden: false, annotation: "Range summary", editedAt: Date.now(), editedBy: input.agent } - } as any) - }) - Bus.publish(Event.RangeSummarized, { - sessionID: input.sessionID, fromMessageID: input.fromMessageID, - toMessageID: input.toMessageID, agent: input.agent }) - await pluginNotify("summarize_range", input.sessionID, input.agent, true, - { messageID: input.fromMessageID }) - return { success: true } - } -} -``` - ---- - -## Change 5: Tool Definition - -**New file:** `packages/opencode/src/tool/thread-edit.ts` (~70 lines) - -```typescript -import z from "zod" -import { Tool } from "./tool" -import { ThreadEdit } from "../session/thread-edit" - -export const ThreadEditTool = Tool.define("thread_edit", async () => ({ - description: `Edit the conversation thread to correct mistakes, remove stale context, or compress explorations. - -Operations: -- hide(partID, messageID): Remove a part from your context -- unhide(partID, messageID): Restore a hidden part -- replace(partID, messageID, replacement): Replace a part with corrected text -- annotate(partID, messageID, annotation): Add a note to a part -- retract(messageID): Hide all parts of a previous assistant message -- summarize_range(fromMessageID, toMessageID, summary): Replace a message range with a summary - -Constraints: only your own assistant messages, not the 2 most recent turns, max 10 edits/turn.`, - - parameters: z.object({ - operation: z.enum(["hide", "unhide", "replace", "annotate", "retract", "summarize_range"]), - partID: z.string().optional(), - messageID: z.string().optional(), - replacement: z.string().optional(), - annotation: z.string().optional(), - fromMessageID: z.string().optional(), - toMessageID: z.string().optional(), - summary: z.string().optional(), - }), - - async execute(args, ctx) { - const base = { sessionID: ctx.sessionID, agent: ctx.agent } - let result: ThreadEdit.EditResult - - switch (args.operation) { - case "hide": - result = await ThreadEdit.hide({ ...base, partID: args.partID!, messageID: args.messageID! }); break - case "unhide": - result = await ThreadEdit.unhide({ ...base, partID: args.partID!, messageID: args.messageID! }); break - case "replace": - result = await ThreadEdit.replace({ ...base, partID: args.partID!, messageID: args.messageID!, replacement: args.replacement! }); break - case "annotate": - result = await ThreadEdit.annotate({ ...base, partID: args.partID!, messageID: args.messageID!, annotation: args.annotation! }); break - case "retract": - result = await ThreadEdit.retract({ ...base, messageID: args.messageID! }); break - case "summarize_range": - result = await ThreadEdit.summarizeRange({ ...base, fromMessageID: args.fromMessageID!, toMessageID: args.toMessageID!, summary: args.summary! }); break - default: - return { title: "Error", metadata: {}, output: `Unknown operation: ${args.operation}` } - } - if (!result.success) - return { title: "Edit failed", metadata: {}, output: `Error: ${result.error}` } - return { title: `Thread edit: ${args.operation}`, metadata: { operation: args.operation, editID: result.editID }, output: `Successfully applied ${args.operation}.` } - } -})) -``` - ---- - -## Change 6: Register the Tool - -**File:** `packages/opencode/src/tool/registry.ts` - -```diff -+ import { ThreadEditTool } from "./thread-edit" - - // In the built-in tools array: - const BUILTIN = [ - // ... existing tools ... -+ ThreadEditTool, - ] -``` - ---- - -## Change 7: Server Endpoint for Edit History - -**File:** `packages/opencode/src/server/routes/session.ts` (+25 lines) - -```typescript -// GET /:sessionID/edits -app.openapi( - createRoute({ - method: "get", - path: "/:sessionID/edits", - operationId: "session.edits", - request: { params: z.object({ sessionID: z.string() }) }, - responses: { 200: { content: { "application/json": { schema: z.array(z.any()) } } } } - }), - async (c) => { - const { sessionID } = c.req.valid("param") - const messages = await Session.messages({ sessionID }) - const edits = messages - .flatMap(m => m.parts) - .filter(p => p.edit) - .map(p => ({ partID: p.id, messageID: p.messageID, ...p.edit })) - return c.json(edits) - } -) -``` - ---- - -## Change 8: TUI Rendering - -**File:** `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx` - -Add a KV-backed toggle alongside existing toggles (near line 155): - -```typescript -const [showEdits, setShowEdits] = kv.signal("edit_indicators_visibility", false) -``` - -Register a command in the command palette: - -```typescript -command.register({ - title: "Toggle edit indicators", - value: "edits.toggle", - category: "View", - onSelect: () => setShowEdits(v => !v), -}) -``` - -In the part rendering section, wrap existing part display with edit-awareness. The TUI uses `@opentui/solid` (Solid.js in terminal), so the component style uses Solid.js primitives, not React/Ink: - -```tsx -// Before rendering each part: -{part.edit?.hidden && !showEdits() ? null : ( - - - [hidden{part.edit?.annotation ? `: ${part.edit.annotation}` : ""}] - - -)} - -// For replacement indicators: - - ↻ replaced - - -// For annotations: - - ⌁ {part.edit.editedBy}: "{part.edit.annotation}" - -``` - -**Lines added:** ~60. - ---- - -## Change 9: Web UI Rendering - -**File:** `packages/ui/src/components/message-part.tsx` - -Register a custom tool renderer for `thread_edit` (near line 2200, after existing `ToolRegistry.register()` calls): - -```typescript -ToolRegistry.register({ - name: "thread_edit", - render(props) { - const metadata = () => props.metadata as { operation?: string; editID?: string } | undefined - return ( - -
- - Thread edit: {metadata()?.operation} - - ({metadata()!.editID}) - -
-
- ) - } -}) -``` - -Additionally, in the part visibility check function `isPartVisible()` (near line 466): - -```diff - export function isPartVisible(part: PartType, ...) { -+ // Hide edited parts unless showing edits -+ if ((part as any).edit?.hidden) return false - if (part.type === "tool") { ... } -``` - -**Lines added:** ~30. - ---- - -## Change 10: Plugin Hook Types - -**File:** `packages/plugin/src/index.ts` - -Add to the `Hooks` interface (near line 233): - -```typescript -"thread.edit.before"?: ( - input: { operation: string; target?: { messageID?: string; partID?: string }; agent: string; sessionID: string }, - output: { allow: boolean; reason?: string }, -) => Promise - -"thread.edit.after"?: ( - input: { operation: string; target?: { messageID?: string; partID?: string }; agent: string; sessionID: string; success: boolean }, - output: {}, -) => Promise -``` - ---- - -## Change 11: Agent Prompt - -**File:** `packages/opencode/src/agent/prompts/` (build agent prompt file) - -``` -## Thread Editing - -You have a thread_edit tool. Use it when: -- You discover a previous response was factually wrong → retract or replace -- A tool result is stale or irrelevant and consuming context → hide -- A long exploration (5+ messages) can be compressed → summarize_range -- You want to leave a note on a previous finding → annotate - -Do NOT: -- Hide errors or failed tool results (the user needs those) -- Edit the last 2 turns (work forward instead) -- Use more than 10 edits per turn -``` - ---- - -## Complete Change Summary - -| # | File | Type | Lines | Description | -|---|------|------|:-----:|-------------| -| 1 | `session/message-v2.ts` | Modify | +12 | `EditMeta` schema on `PartBase` | -| 2 | `session/message-v2.ts` | Modify | +15 | `filterEdited()` function | -| 3 | `session/processor.ts` | Modify | +3 | Insert `filterEdited()` in pipeline | -| 4 | `session/thread-edit.ts` | **New** | +280 | Core edit operations, validation, events, plugin hooks | -| 5 | `tool/thread-edit.ts` | **New** | +70 | Tool definition wrapping core logic | -| 6 | `tool/registry.ts` | Modify | +3 | Register ThreadEditTool | -| 7 | `server/routes/session.ts` | Modify | +25 | Edit history endpoint | -| 8 | `cli/cmd/tui/routes/session/index.tsx` | Modify | +60 | TUI edit rendering + command palette toggle | -| 9 | `ui/src/components/message-part.tsx` | Modify | +30 | Web UI tool renderer + part visibility | -| 10 | `plugin/src/index.ts` | Modify | +12 | Plugin hook type definitions | -| 11 | `agent/prompts/` | Modify | +15 | Agent instructions | -| | | **Total** | **~525** | 2 new files, 8 modified files | - ---- - -## Plugin vs Fork Comparison (Updated) - -| Capability | Plugin | Fork | -|------------|:------:|:----:| -| Edit metadata in the DB | ✓ via `part.metadata.edit` | ✓ via `part.edit` (top-level, cleaner) | -| Replacement parts persisted | ✓ in hidden part's metadata | ✓ as real parts in DB | -| Filtering before LLM | `experimental.*` hook (may change) | Hardcoded in processor pipeline | -| TUI rendering of edits | ✗ impossible | ✓ hidden/replaced/annotated indicators | -| TUI toggle keybind | ✗ | ✓ command palette "Toggle edit indicators" | -| Web UI rendering | ✗ (unless custom build) | ✓ `ToolRegistry.register("thread_edit")` | -| Web UI part visibility | ✗ | ✓ `isPartVisible()` respects `edit.hidden` | -| Custom bus events | ✗ | ✓ 6 event types via `BusEvent.define()` | -| Plugin hooks for edit interception | ✗ | ✓ `thread.edit.before` / `thread.edit.after` | -| Works in non-interactive/SDK mode | Depends on transform hook firing | ✓ pipeline is universal | -| DB transactions for replace | ✗ (sequential PATCH calls) | ✓ atomic `Database.transaction()` | -| Survives upstream hook API changes | ✗ `experimental.*` may break | ✓ own code | -| No fork maintenance | ✓ | ✗ requires rebase | - ---- - -## Rebase Strategy - -The changes touch 8 existing files with small, isolated diffs: - -1. **`message-v2.ts`** — Additive: new schema field on `PartBase`, new exported function. Low conflict. -2. **`processor.ts`** — Single line insertion at the message assembly point. If upstream refactors, this is the only line to fix. -3. **`registry.ts`** — Single import + array entry. Trivial. -4. **`server/routes/session.ts`** — New endpoint appended at end of file. No conflict with existing routes. -5. **`plugin/src/index.ts`** — Additive hook types at end of `Hooks` interface. Low conflict. -6. **`ui/src/components/message-part.tsx`** — New `ToolRegistry.register()` call at end + small change to `isPartVisible()`. Medium conflict risk if upstream adds new tool renderers at the same location. -7. **TUI `session/index.tsx`** — New KV signal + command registration + part rendering guards. **Highest conflict risk** if upstream redesigns the session view. -8. **Agent prompts** — Appended text. Trivial. - -**Strategy:** Keep the 2 new files self-contained with minimal imports. Do not refactor surrounding code. The `thread-edit.ts` module is the only substantial new code; everything else is a 3-15 line insertion. - ---- - -## When to Choose This Plan - -- You need **production-grade reliability** (atomic transactions, no state drift) -- You want **TUI + Web UI integration** (visual edit indicators, command palette toggle) -- You want edit events to propagate to **all clients** (TUI, web, SDK, plugins) -- You're willing to **maintain a fork** and rebase on upstream releases -- You want to **upstream the feature** as a PR to anomalyco/opencode diff --git a/docs/research/EDITABLE_CONTEXT_MERKLE.md b/docs/research/EDITABLE_CONTEXT_MERKLE.md deleted file mode 100644 index e4622c586..000000000 --- a/docs/research/EDITABLE_CONTEXT_MERKLE.md +++ /dev/null @@ -1,706 +0,0 @@ -# Editable Context — Merkle Tree / Content-Addressable Context - -## Core Idea - -Instead of **hiding** content (removing it from the LLM's view entirely) or **compacting** it (summarizing it into prose), introduce a third option: **externalize** it. Replace inline content with a compact reference (hash + summary) linked to the full content in a content-addressable object store. The agent can dereference any hash to page the full content back into context on demand. - -This turns the context window into a **working set** backed by a persistent store — like virtual memory for conversations. - -``` -Before: -┌──────────────────────────────────────────┐ -│ Part: grep result (4,200 tokens) │ -│ Found 47 matches in src/auth/... │ -│ src/auth/middleware.ts:23: validate(...) │ -│ src/auth/middleware.ts:45: refresh(...) │ -│ ... 45 more lines ... │ -└──────────────────────────────────────────┘ - -After externalization: -┌──────────────────────────────────────────┐ -│ Part: grep result (externalized) │ -│ ref: cas://sha256:a1b2c3d4 (4,200 tok) │ -│ summary: "47 matches in src/auth/. │ -│ Key: middleware.ts:23 validate(), │ -│ middleware.ts:45 refresh()" │ -│ Use thread_deref to expand. │ -└──────────────────────────────────────────┘ - │ - ▼ - ┌─────────────────┐ - │ Object Store │ - │ .opencode/cas/ │ - │ a1b2c3d4.json │ - │ (full content) │ - └─────────────────┘ -``` - ---- - -## Why This Is Different From Hide/Compact - -| Strategy | Content in LLM context | Content recoverable | Agent effort to recover | Token cost | -|----------|:---:|:---:|:---:|:---:| -| **Keep** | Full | N/A | None | High | -| **Hide** | Gone | User toggle only | Can't (unless unhide) | Zero | -| **Compact** | Summary only | No (original destroyed) | Can't | Low | -| **Externalize** | Hash + summary | Yes, on demand | `thread_deref(hash)` | Very low (summary only) | - -The key property: **lossless compression with on-demand expansion.** The agent retains awareness (via the summary) that the information exists and what it contains, and can bring it back into context when needed. This is strictly better than hiding — you get the token savings of hiding with the recoverability of keeping. - ---- - -## Existing Infrastructure - -OpenCode already has content-addressable primitives: - -| Component | Location | How It Works | -|-----------|----------|-------------| -| **Snapshot system** | `packages/opencode/src/snapshot/index.ts` | Uses `git write-tree` → returns SHA hash. `Snapshot.track()` creates a tree hash, `Snapshot.restore(hash)` recovers. | -| **PatchPart** | `message-v2.ts:95-102` | `{ type: "patch", hash: string, files: string[] }` — patches stored by hash | -| **SnapshotPart** | `message-v2.ts:87-93` | `{ type: "snapshot", snapshot: string }` — tree hashes stored as part data | -| **Storage module** | `packages/opencode/src/storage/storage.ts` | File-based JSON with read/write locks at `Global.Path.data/storage/`. Key-path → file mapping. | -| **Tool output truncation** | `tool.ts` → `Truncate.output()` | Already truncates tool output at 50K tokens — but throws away the excess | - -The snapshot system proves git's object store works for content-addressable storage. The storage module provides the file-based KV store. We need to combine them into a **conversation content** object store. - ---- - -## Architecture - -### Object Store - -``` -.opencode/cas/ # Content-Addressable Store -├── objects/ -│ ├── a1/b2c3d4e5f6...json # Individual content blobs -│ ├── f7/89abcdef01...json -│ └── ... -├── trees/ -│ ├── {sessionID}/ -│ │ └── {messageID}.json # Merkle tree per message -│ └── ... -└── index.json # Global index: hash → metadata -``` - -### Content Blob - -```typescript -interface ContentBlob { - hash: string // SHA-256 of content - content: string // The full original content - tokens: number // Token count - created: number // Timestamp - sessionID: string // Source session - messageID: string // Source message - partID: string // Source part - type: string // "tool_output" | "text" | "reasoning" | "file" | "range_summary" -} -``` - -### Merkle Tree Node - -```typescript -interface MerkleNode { - hash: string // Hash of this node's content (or hash of children) - summary: string // Human-readable summary (what the LLM sees) - tokens: number // Original token count - summaryTokens: number // Summary token count - children?: string[] // Child hashes (for tree nodes — message/range summaries) - depth: number // 0 = leaf (single part), 1+ = aggregated -} -``` - -The Merkle structure enables hierarchical summarization: - -``` - ┌─────────────────────┐ - │ Session tree root │ - │ hash: abc123 │ - │ "Auth refactor: ... │ - │ explored, found..." │ - └─────────┬───────────┘ - │ - ┌───────────────┼───────────────┐ - ▼ ▼ ▼ - ┌────────────┐ ┌────────────┐ ┌────────────┐ - │ Range 1-10 │ │ Range 11-20│ │ Range 21-30│ - │ hash: def │ │ hash: ghi │ │ hash: jkl │ - │ "Explored │ │ "Implement │ │ "Testing │ - │ auth..." │ │ JWT..." │ │ and fix..." │ - └──────┬─────┘ └──────┬─────┘ └──────┬─────┘ - │ │ │ - ┌────┼────┐ ┌────┼────┐ ┌────┼────┐ - ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ - leaf leaf leaf leaf leaf leaf leaf leaf leaf - (parts) (parts) (parts) -``` - -At any level, the agent can dereference a hash to expand one level deeper. Expanding the root shows the 3 range summaries. Expanding a range shows its individual parts. Expanding a part shows the full original content. - ---- - -## New Tools - -### `thread_externalize` - -Replace a part's inline content with a hash + summary. - -```typescript -Tool.define("thread_externalize", async () => ({ - description: `Move a part's content to the object store, replacing it inline with a compact -summary + hash reference. The original content is preserved and can be retrieved -with thread_deref. Use this to free context window space while keeping the -information available. - -Prefer this over hide when the content might be needed again later.`, - - parameters: z.object({ - partID: z.string().describe("Part to externalize"), - messageID: z.string().describe("Parent message"), - summary: z.string().describe("1-3 line summary of the content for the agent to see inline"), - }), - - async execute(args, ctx) { - // 1. Read the part's full content - // 2. Hash the content (SHA-256) - // 3. Write to object store: .opencode/cas/objects/{hash}.json - // 4. Update the part: - // - Set metadata.cas = { hash, summary, tokens, externalized: true } - // - Original content stays in the part (for DB integrity) - // - The transform hook replaces it with the summary in LLM view - // 5. Return the hash for reference - } -})) -``` - -### `thread_deref` - -Expand a hash reference back into full content in the current context. - -```typescript -Tool.define("thread_deref", async () => ({ - description: `Dereference a content hash, returning the full original content. -Use this when you need to re-examine externalized content. -The content is returned as tool output (not re-inlined into the original part).`, - - parameters: z.object({ - hash: z.string().describe("Content hash to dereference (from cas:// reference)"), - range: z.object({ - start: z.number().optional(), - end: z.number().optional(), - }).optional().describe("Optional line range to fetch a subset"), - }), - - async execute(args, ctx) { - // 1. Look up hash in object store - // 2. Read the content blob - // 3. If range specified, extract subset - // 4. Return content as tool output - // The agent now has the full content in its current turn - } -})) -``` - -### `thread_tree` - -Show the Merkle tree structure for the current session — what's externalized, at what depth, with what summaries. - -```typescript -Tool.define("thread_tree", async () => ({ - description: `Show the content tree for this session. Lists all externalized content -with hashes, summaries, token counts, and tree structure. -Use this to understand what content is available for dereferencing.`, - - parameters: z.object({ - depth: z.number().optional().describe("Max depth to show (default: 1)"), - messageID: z.string().optional().describe("Show tree for specific message only"), - }), - - async execute(args, ctx) { - // Return a formatted tree showing: - // - Session root hash - // - Range summaries (depth 1) - // - Part summaries (depth 2) - // - Token counts at each level - // - Compression ratios - } -})) -``` - ---- - -## Transform Hook Integration - -The `experimental.chat.messages.transform` hook (plugin) or `filterEdited()` (fork) reads `metadata.cas` and replaces inline content: - -```typescript -// For each part with metadata.cas: -if (part.metadata?.cas?.externalized) { - // Replace the part's text/output with the compact reference - if (part.type === "text") { - part.text = `[externalized: cas://${part.metadata.cas.hash} (${part.metadata.cas.tokens} tokens)]\n${part.metadata.cas.summary}` - } - if (part.type === "tool" && part.state?.status === "completed") { - part.state.output = `[externalized: cas://${part.metadata.cas.hash} (${part.metadata.cas.tokens} tokens)]\n${part.metadata.cas.summary}` - } -} -``` - -Token savings: a 4,000-token grep result becomes a ~50-token reference + summary. - ---- - -## Merkle Tree Construction - -### Leaf Level (Per-Part) - -When a part is externalized, it becomes a leaf in the Merkle tree: - -```typescript -const leaf: MerkleNode = { - hash: sha256(part.content), - summary: userProvidedSummary, - tokens: Token.estimate(part.content), - summaryTokens: Token.estimate(userProvidedSummary), - depth: 0, -} -``` - -### Range Level (Per-Range) - -When `summarize_range` runs, it creates a range node whose children are the leaf hashes: - -```typescript -const range: MerkleNode = { - hash: sha256(childHashes.join(":")), // hash of children - summary: rangeSummary, - tokens: children.reduce((n, c) => n + c.tokens, 0), - summaryTokens: Token.estimate(rangeSummary), - children: childHashes, - depth: 1, -} -``` - -### Session Level (Root) - -The session tree root's children are range nodes (or leaf nodes for un-ranged parts): - -```typescript -const root: MerkleNode = { - hash: sha256(topLevelHashes.join(":")), - summary: sessionSummary, - tokens: allNodes.reduce((n, c) => n + c.tokens, 0), - summaryTokens: Token.estimate(sessionSummary), - children: topLevelHashes, - depth: 2, -} -``` - -### Integrity Verification - -Because it's a Merkle tree, you can verify that no content has been tampered with: - -```typescript -function verify(node: MerkleNode, store: ObjectStore): boolean { - if (node.depth === 0) { - // Leaf: hash should match content - const blob = store.get(node.hash) - return sha256(blob.content) === node.hash - } - // Tree: hash should match children - const childHashes = node.children!.join(":") - return sha256(childHashes) === node.hash -} -``` - -This is useful for shared sessions — the recipient can verify the externalized content hasn't been modified. - ---- - -## Where Else This Applies - -### 1. Tool Output Truncation (Replace, Don't Discard) - -Currently, `Truncate.output()` cuts tool output at 50K tokens and throws away the rest. With CAS: - -```typescript -// Instead of: -output = output.slice(0, MAX_TOKENS) + "\n[truncated]" - -// Do: -if (Token.estimate(output) > MAX_TOKENS) { - const hash = await CAS.store(output, { type: "tool_output", sessionID, partID }) - output = output.slice(0, SUMMARY_TOKENS) + - `\n[full output externalized: cas://${hash} (${Token.estimate(output)} tokens)]` -} -``` - -The agent can always dereference the hash to see the full output if it needs lines 201-400 of a grep. - -### 2. Compaction (Merkle Compaction) - -Instead of compaction producing a single summary and hiding everything: - -``` -Before compaction: -[turn 1] [turn 2] [turn 3] ... [turn 30] ← all inline, ~80K tokens - -After traditional compaction: -[summary: 2K tokens] [turn 31] [turn 32] ... - -After Merkle compaction: -[session tree: cas://root_hash] ← 200 tokens - ├── [range 1-10: cas://abc] "Explored auth module, found JWT..." ← 100 tokens - ├── [range 11-20: cas://def] "Implemented token refresh..." ← 100 tokens - └── [range 21-30: cas://ghi] "Fixed race condition in..." ← 100 tokens -[turn 31] [turn 32] ... ← inline, recent -``` - -The agent sees a 500-token tree of summaries instead of 80K tokens of raw history. But unlike compaction, it can expand any branch: `thread_deref("abc")` → full turns 1-10. - -### 3. Cross-Session Knowledge Base - -Handoff artifacts (from Mode 4) can reference CAS objects. A new session loads the handoff summary but can dereference into the previous session's actual content: - -``` -Session 2 loads handoff: - "Previous session explored auth middleware. Key finding: - JWT validation skips expiry check on refresh tokens. - Full analysis: cas://sha256:xyz789" - -Agent in Session 2: - > thread_deref("sha256:xyz789") - → Gets the full 3,000-token analysis from Session 1 -``` - -The CAS objects persist at the project level (`.opencode/cas/`), so they survive across sessions. This gives handoff both the compact summary AND the full backing data. - -### 4. File Read Caching - -When the `read` tool reads a file, the content could be CAS-stored: - -```typescript -// In read tool: -const content = await fs.readFile(path) -const hash = sha256(content) -await CAS.store(content, { type: "file_read", path, sessionID }) - -// If the same file is read again (same hash), return: -"File unchanged since last read (cas://${hash}). Use thread_deref to re-read." -``` - -This prevents the common pattern of the agent reading the same file 5 times during a session, each time consuming full tokens. - -### 5. MCP Tool Results - -MCP server responses can be large and unpredictable. CAS provides a safety net: - -```typescript -// In MCP tool execution wrapper: -const result = await mcpClient.callTool(name, args) -if (Token.estimate(result) > threshold) { - const hash = await CAS.store(result, { type: "mcp_result", tool: name }) - return `[MCP result externalized: cas://${hash}]\n${summarize(result)}` -} -``` - -### 6. Reasoning Blocks - -Model reasoning/thinking blocks are often 5K-20K tokens and rarely re-read. Auto-externalize after the turn completes: - -```typescript -// After reasoning-end: -if (reasoningPart.text.length > REASONING_EXTERNALIZE_THRESHOLD) { - await CAS.store(reasoningPart.text, { type: "reasoning", sessionID }) - // Mark for externalization on next curator pass -} -``` - -### 7. Session Sharing (Efficient) - -Currently, sharing serializes the full conversation. With CAS: - -``` -Shared session = tree root hash + object references -Recipient fetches only the objects they expand -``` - -This is like a git clone — you get the tree structure immediately and fetch blobs on demand. - -### 8. Multi-Session Deduplication - -If two sessions read the same file or get the same grep result, CAS stores it once: - -``` -Session A reads src/auth.ts → cas://sha256:abc (stored once) -Session B reads src/auth.ts → cas://sha256:abc (same hash, no duplicate storage) -``` - ---- - -## Object Store Implementation - -### Option A: File-Based (Simple) - -Use the existing `Storage` module: - -```typescript -export namespace CAS { - export async function store(content: string, meta: BlobMeta): Promise { - const hash = createHash("sha256").update(content).digest("hex") - const key = ["cas", hash.slice(0, 2), hash] - try { - await Storage.read(key) // Already exists - } catch { - await Storage.write(key, { hash, content, tokens: Token.estimate(content), ...meta, created: Date.now() }) - } - return hash - } - - export async function get(hash: string): Promise { - return Storage.read(["cas", hash.slice(0, 2), hash]) - } - - export async function has(hash: string): Promise { - try { await get(hash); return true } catch { return false } - } -} -``` - -**Path:** `~/.local/share/opencode/storage/cas/{first2chars}/{hash}.json` - -Pros: Uses existing infra, read/write locks, migration system. -Cons: JSON overhead (base64 for binary), no deduplication across projects. - -### Option B: Git Object Store (Leverage Existing) - -Use the snapshot system's git repo: - -```typescript -export namespace CAS { - export async function store(content: string): Promise { - // git hash-object -w --stdin - const hash = await Process.text( - ["git", "--git-dir", gitdir(), "hash-object", "-w", "--stdin"], - { input: content } - ) - return hash.text.trim() - } - - export async function get(hash: string): Promise { - // git cat-file -p {hash} - const content = await Process.text( - ["git", "--git-dir", gitdir(), "cat-file", "-p", hash] - ) - return content.text - } -} -``` - -Pros: True content-addressable storage, deduplication built-in, garbage collection via `git gc`, already initialized per-project. -Cons: Tied to git, binary overhead for large objects. - -### Option C: SQLite Table (Best for Queries) - -New table in the existing DB: - -```sql -CREATE TABLE cas_object ( - hash TEXT PRIMARY KEY, - content TEXT NOT NULL, - tokens INTEGER NOT NULL, - type TEXT NOT NULL, - session_id TEXT, - message_id TEXT, - part_id TEXT, - time_created INTEGER NOT NULL -); - -CREATE INDEX idx_cas_session ON cas_object(session_id); -CREATE INDEX idx_cas_type ON cas_object(type); -``` - -Pros: Atomic with other DB operations, queryable, no filesystem overhead. -Cons: SQLite blob storage is less efficient than files for large content, DB size grows. - -### Recommendation: Option A (file-based) for content, Option C (SQLite) for index - -Store large blobs in files (via `Storage`), store the metadata index in SQLite. This mirrors how git works (loose objects in files, pack index in a database). - -```typescript -// Store: blob → file, metadata → SQLite -await Storage.write(["cas", hash.slice(0, 2), hash], { content }) -await db.insert(CASIndex).values({ hash, tokens, type, sessionID, messageID, partID, timeCreated: Date.now() }) - -// Retrieve: index → SQLite, content → file -const meta = await db.select().from(CASIndex).where(eq(CASIndex.hash, hash)) -const blob = await Storage.read(["cas", hash.slice(0, 2), hash]) -``` - ---- - -## Automatic Externalization Policies - -The curator and refocus agents can use CAS automatically based on policies: - -```jsonc -{ - "editableContext": { - "cas": { - "enabled": true, - "store": "file", // "file" | "git" | "sqlite" - - // Auto-externalize thresholds (tokens) - "autoExternalize": { - "tool_output": 2000, // Externalize tool results > 2K tokens - "reasoning": 5000, // Externalize reasoning > 5K tokens - "text": 3000, // Externalize text blocks > 3K tokens - "file_read": 1500, // Externalize file reads > 1.5K tokens - "mcp_result": 1000 // Externalize MCP results > 1K tokens - }, - - // Age-based externalization - "agePolicy": { - "turnsBeforeExternalize": 5, // Externalize parts older than 5 turns - "excludePinned": true // Don't externalize pinned parts - }, - - // Garbage collection - "gc": { - "maxAge": "30d", // Delete blobs older than 30 days - "maxSize": "500MB", // Cap total CAS size - "orphanCleanup": true // Delete blobs with no referencing parts - } - } - } -} -``` - -### Auto-Externalize in Curator - -The background curator can externalize instead of hiding: - -``` -Curator prompt addition: - -When you find content that is stale but might be needed later: -- Use thread_externalize instead of thread_edit(hide) -- Write a 1-3 line summary that captures the essential finding -- The agent can dereference the hash later if needed - -Use externalize for: old tool results, verbose outputs, resolved explorations -Use hide for: completely irrelevant content, errors that were already addressed -``` - -### Auto-Externalize in Refocus - -When refocus runs at the 50% threshold, it can externalize aggressively: - -``` -Before refocus: 50% of 200K context = 100K tokens used -After refocus: - - 40K tokens kept inline (recent + relevant) - - 55K tokens externalized (summaries + hashes = ~5K tokens) - - 5K tokens hidden (truly irrelevant) - = 45K tokens in context (22.5% of capacity) - = 55K tokens recoverable on demand -``` - ---- - -## Integration With Other Modes - -### CAS + Handoff - -Handoff artifacts reference CAS objects: - -```typescript -interface HandoffArtifact { - // ... existing fields ... - references: Array<{ - hash: string - summary: string - tokens: number - relevance: "critical" | "useful" | "background" - }> -} -``` - -New sessions load the handoff and can dereference any reference. The CAS objects live at the project level, so cross-session dereferencing works. - -### CAS + Pin & Decay - -Pinned parts are never externalized. Decayed parts are externalized before being hidden: - -``` -Score > 0.5 → inline (keep in context) -Score 0.1-0.5 → externalize (summary + hash) -Score < 0.1 → hide (if unpinned) or externalize (if pinned) -``` - -### CAS + Compaction - -Merkle compaction replaces traditional compaction as a configurable option: - -```jsonc -{ - "compaction": { - "strategy": "merkle" // "traditional" (default) | "merkle" - } -} -``` - -With `"merkle"`, the compaction agent builds a Merkle tree instead of a flat summary, preserving drill-down capability. - -### CAS + Session Sharing - -Shared sessions include only the tree structure and summaries. The CAS objects are uploaded separately and fetched on demand by the viewer: - -``` -Share payload: - { tree: MerkleNode, objects: string[] } // list of hashes - -Viewer: - - Sees summaries immediately - - Clicks "expand" → fetches object from share server - - Progressive loading, not all-or-nothing -``` - ---- - -## Token Economics - -Example: 50-turn coding session, 150K tokens of raw content - -| Strategy | Tokens in context | Recoverable | Cost reduction | -|----------|:-:|:-:|:-:| -| No editing | 150K | N/A | 0% | -| Hide only | 60K | 0 (hidden = lost) | 60% | -| Compact at 85% | 10K summary + recent | 0 (summarized) | 80% but lossy | -| **Externalize** | 30K inline + 5K refs | 115K via deref | 77% and **lossless** | -| **Merkle compact** | 3K tree + 20K recent | 127K via deref | 85% and **lossless** | - -The externalize strategy gives comparable token savings to compaction while keeping everything recoverable. - ---- - -## Implementation Estimate - -| Component | Lines | Phase | -|-----------|:-----:|-------| -| CAS store (file-based + SQLite index) | ~150 | Phase 1 | -| `thread_externalize` tool | ~80 | Phase 1 | -| `thread_deref` tool | ~60 | Phase 1 | -| `thread_tree` tool | ~100 | Phase 1 | -| Transform hook (replace inline with ref) | ~40 | Phase 1 | -| Auto-externalize in curator | ~50 | Phase 2 | -| Merkle tree construction | ~120 | Phase 2 | -| Merkle compaction strategy | ~150 | Phase 3 | -| Cross-session CAS (handoff integration) | ~80 | Phase 3 | -| File read dedup | ~40 | Phase 3 | -| Session sharing with CAS | ~100 | Phase 4 | -| GC and size management | ~60 | Phase 4 | -| **Total** | **~1,030** | | - -Phase 1 alone (~430 LOC) gives you the core: store, externalize, deref, tree. This is self-contained and valuable without any other editable context mode. diff --git a/docs/research/EDITABLE_CONTEXT_MODES.md b/docs/research/EDITABLE_CONTEXT_MODES.md deleted file mode 100644 index 110540f6e..000000000 --- a/docs/research/EDITABLE_CONTEXT_MODES.md +++ /dev/null @@ -1,702 +0,0 @@ -# Editable Context — Modes - -Expanding the base `thread_edit` tool into a system of intelligent context management modes. - ---- - -## Mode Overview - -| Mode | Trigger | Who Edits | User Interaction | Persistence | -|------|---------|-----------|-----------------|-------------| -| **Manual** | Agent decides, or user instructs | Active agent | None (tool call in stream) | Session-scoped | -| **Background Curator** | Between every turn | Hidden `curator` agent | Optional: can ask questions | Session-scoped | -| **Threshold Refocus** | At configurable % (default 50%) | Hidden `refocus` agent | Optional: confirms objective | Session-scoped | -| **Handoff** | Session end, manual `/handoff`, or threshold | Hidden `handoff` agent | Optional: reviews artifact | **Cross-session** (project-level) | -| **Pin & Decay** | Continuous (per-turn scoring) | Automatic (no LLM) | User pins via command | Session-scoped | -| **Objective Tracker** | Every N turns or on drift detection | Active agent or curator | Can ask "is this still the goal?" | **Cross-session** | - ---- - -## Mode 1: Manual (Base) - -Already designed in `EDITABLE_CONTEXT.md`. The active agent calls `thread_edit` when it recognizes a need. No automation, no background process. - ---- - -## Mode 2: Background Curator - -### Concept - -A lightweight hidden agent that runs **between turns** (after the main agent finishes, before the user's next input). It reads the current thread, identifies noise, and makes surgical edits. Think of it as a copy editor working in the margins while the author takes a break. - -### When It Runs - -``` -User message → Main agent responds → [Curator runs] → User sees clean thread → Next input -``` - -The curator fires as a **post-step hook** — after `processor.process()` returns `"continue"` or `"stop"`, before control returns to the TUI prompt. It does NOT run during the agent's tool-call loop (that would create interference). - -### What It Does - -The curator receives the full thread and a focused prompt: - -``` -You are a context curator. Your job is to keep the conversation thread clean and focused. - -Review the conversation and apply thread_edit operations for: -1. Tool results that are now stale (file was edited since the read, grep from before refactor) -2. Exploration dead-ends (the agent tried something, it didn't work, moved on) -3. Redundant information (same file read twice, same error shown multiple times) -4. Verbose tool output that can be summarized (a 200-line grep can become "found 12 matches in auth/") - -Do NOT hide: -- The user's messages (you can't anyway — ownership rules) -- The most recent 2 turns -- Errors the user should know about -- The current approach/strategy the agent is following - -Current objective: {objective_tracker.current} -``` - -### Implementation - -```typescript -// In processor.ts, after the main process() returns: -if (config.editableContext?.curator?.enabled) { - const curator = await Agent.get("curator") // new hidden agent - const messages = await Session.messages({ sessionID }) - const filtered = MessageV2.filterCompacted(MessageV2.filterEdited(messages)) - - // Only run if enough content to curate (>= 6 turns) - if (filtered.length >= 6) { - const curatorProcessor = SessionProcessor.create({ - assistantMessage: /* hidden, not shown to user */, - sessionID, model: curator.model, abort - }) - await curatorProcessor.process({ - user: /* synthetic curator prompt */, - agent: curator, - tools: { thread_edit: ThreadEditTool }, // only editing tools - messages: MessageV2.toModelMessages(filtered, model), - ... - }) - } -} -``` - -### Curator Agent Definition - -```typescript -// New hidden agent in the agent system -{ - name: "curator", - hidden: true, // not user-selectable - tools: ["thread_edit"], // only edit tools, no bash/read/write - maxSteps: 5, // hard cap — curator should be fast - model: "small", // use the small/fast model, not the primary - temperature: 0, // deterministic curation -} -``` - -### Configuration - -```jsonc -// opencode.json -{ - "editableContext": { - "curator": { - "enabled": true, - "frequency": "every_turn", // "every_turn" | "every_n_turns" | "on_idle" - "n": 3, // for "every_n_turns" - "minTurns": 6, // don't curate short conversations - "model": "small", // override model - "maxEditsPerRun": 5, // curator budget per invocation - "askUser": false // see "User Interaction" below - } - } -} -``` - -### User Interaction: Curator Questions - -When `askUser: true`, the curator can use a modified `question` tool to ask the user before making significant edits: - -``` -Curator: I'm about to hide 8 tool results from your early file exploration -(turns 3-7) since you've since refactored those files. Keep or hide? -[Keep] [Hide] [Hide and summarize] -``` - -This uses the existing `Question` tool infrastructure (`packages/opencode/src/question/`). The curator's question appears as a lightweight prompt in the TUI, distinct from the main agent's questions (styled with the curator's agent color, prefixed with "Context Curator:"). - -If the user doesn't respond within 30 seconds (configurable), the curator proceeds with default action (hide, since it's non-destructive and reversible). - -### Cost Control - -The curator uses the `small_model` (configured in `opencode.json`). On a cheap model like Haiku, curating a 50-turn conversation costs ~$0.01. The curator's own messages are marked `hidden: true` in the agent system, so they don't appear in the thread or consume context. - ---- - -## Mode 3: Threshold Refocus - -### Concept - -When context usage hits a configurable threshold (default 50%), a `refocus` agent activates and rewrites the thread around the current objective. Unlike compaction (which summarizes everything at ~85%), refocus is **opinionated** — it keeps what matters for the current goal and aggressively compresses everything else. - -### How It Differs From Compaction - -| Aspect | Compaction | Threshold Refocus | -|--------|-----------|-------------------| -| Trigger | ~85% context (panic mode) | 50% context (proactive) | -| Strategy | Summarize everything uniformly | Keep goal-relevant details, compress the rest | -| Granularity | All-or-nothing boundary | Part-by-part, preserving structure | -| Output | Single summary block | Thread with some parts hidden, some summarized, some untouched | -| Audit trail | Original gone from LLM view | All edits reversible | -| Objective awareness | No | Yes — uses the objective tracker | - -### When It Runs - -```typescript -// In processor.ts, at finish-step (where isOverflow is already checked): -case "finish-step": - // ... existing token tracking ... - const usage = computeUsageRatio(tokens, model) - - if (usage >= config.editableContext.refocus.threshold) { - // Refocus, not compaction - return "refocus" - } - if (await SessionCompaction.isOverflow({ tokens, model })) { - needsCompaction = true // existing compaction as fallback - } -``` - -The processor returns a new `"refocus"` result, and the caller runs the refocus agent instead of compaction. - -### Refocus Agent Prompt - -``` -You are a context refocus agent. The conversation is at {usage}% of context capacity. - -Current objective: {objective_tracker.current} - -Your task: make the thread precise and focused on the current objective. - -Strategy: -1. KEEP: Everything directly relevant to the current objective -2. KEEP: Key discoveries, decisions, and the current approach -3. SUMMARIZE: Long explorations that produced a useful conclusion (use summarize_range) -4. HIDE: Dead-end explorations, superseded approaches, stale tool output -5. HIDE: Verbose tool results where only 1-2 lines were actually useful - -After editing, the thread should read like a focused narrative: -- What we're doing (objective) -- What we tried and learned (discoveries) -- Where we are now (current state) -- What to do next (plan) - -Do NOT hide the user's instructions, even if they seem tangential — the user decides relevance. -``` - -### Refocus Output: Structured State - -After refocusing, the agent writes a **state summary** as a synthetic TextPart at the refocus boundary: - -``` ---- Context refocused at 52% usage --- - -**Objective:** Implement editable context for OpenCode agents -**Current approach:** Plugin-based with part.metadata.edit persistence -**Key files:** session/message-v2.ts, tool/thread-edit.ts, plugin/index.ts -**Blocked on:** Need to verify experimental.chat.messages.transform fires in SDK mode -**Next steps:** 1) Write transform hook, 2) Test with non-interactive mode -**Hidden:** 12 parts (3 dead-end explorations, 5 stale tool results, 4 verbose outputs) -``` - -### Configuration - -```jsonc -{ - "editableContext": { - "refocus": { - "enabled": true, - "threshold": 0.5, // 50% of context capacity - "model": "small", // use fast model - "maxEditsPerRun": 20, // more aggressive than curator - "askUser": true, // confirm objective before refocusing - "preserveRecentTurns": 4 // never touch last 4 turns - } - } -} -``` - -### User Interaction: Objective Confirmation - -When `askUser: true` and refocus triggers: - -``` -Context Refocus: Thread is at 52% capacity. I'd like to refocus around your current goal. - -Current objective: "Implement editable context plugin for OpenCode" -Is this still what we're working on? (Press Enter to confirm, or type a new objective) -> _ -``` - -If the user provides a new objective, the refocus agent uses that instead. If they press Enter, it proceeds with the tracked objective. - ---- - -## Mode 4: Handoff (Cross-Session Persistence) - -### Concept - -When a session ends (or at threshold), distill the curated thread into a structured **handoff artifact** that persists at the project level and loads into the next session. This is the bridge between session-scoped editable context and project-level memory. - -### The Handoff Artifact - -Stored at project level (not session level): - -**Path:** `.opencode/handoff/{workspaceID|projectID}.json` and `.opencode/handoff/{id}.md` - -```typescript -interface HandoffArtifact { - id: string - sessionID: string // source session - projectID: string - workspaceID?: string - createdAt: number - objective: string - status: "in_progress" | "completed" | "blocked" | "abandoned" - summary: { - goal: string - approach: string - discoveries: string[] // key findings - accomplished: string[] // completed work - remaining: string[] // work left to do - blockers: string[] // what's stuck and why - files: string[] // relevant files - decisions: Array<{ // important decisions made - decision: string - reason: string - alternatives: string[] - }> - } - context: { - pinnedParts: Array<{ // parts the user/agent pinned - content: string - reason: string - }> - keyExchanges: Array<{ // important user-agent exchanges - user: string - agent: string - }> - } -} -``` - -### When It Runs - -Three triggers: - -1. **Session end:** User closes the TUI, types `/quit`, or session is archived -2. **Manual:** User types `/handoff` or tells the agent "create a handoff" -3. **Threshold:** When refocus triggers, it also updates the handoff artifact - -### Loading Into New Sessions - -When a new session starts, the system checks for handoff artifacts: - -```typescript -// In session creation flow: -const handoffs = await HandoffStore.list({ projectID, workspaceID }) -const recent = handoffs.filter(h => h.status === "in_progress" && isRecent(h)) - -if (recent.length > 0) { - // Inject as a synthetic user message at the start of the session - const handoff = recent[0] - await Session.updatePart({ - type: "text", - synthetic: true, - text: `[Continuing from previous session]\n\n${formatHandoff(handoff)}`, - metadata: { handoff: { id: handoff.id, sessionID: handoff.sessionID } } - }) -} -``` - -The agent sees the handoff as context at the start of its thread. It knows what was done, what's remaining, and what the objective is — without replaying the entire previous session. - -### Handoff Agent - -```typescript -{ - name: "handoff", - hidden: true, - tools: [], // no tools — pure analysis - model: "small", // fast model - prompt: `Analyze the conversation and create a handoff artifact for the next session. - - Focus on: - 1. What is the user trying to accomplish? (objective) - 2. What approach was taken? What worked, what didn't? - 3. What key discoveries were made? - 4. What work is completed? What remains? - 5. What important decisions were made and why? - 6. What specific files/functions are relevant? - 7. Are there any blockers? - - Be precise and specific. Include file paths, function names, error messages. - Do NOT include verbose tool output — summarize to the essential finding.` -} -``` - -### Configuration - -```jsonc -{ - "editableContext": { - "handoff": { - "enabled": true, - "trigger": "session_end", // "session_end" | "manual" | "threshold" | "all" - "autoLoad": true, // load handoffs into new sessions - "maxAge": "7d", // ignore handoffs older than 7 days - "maxHandoffs": 5, // keep last 5 per workspace - "askUser": true // confirm before loading into new session - } - } -} -``` - -### Interaction With Existing AGENTS.md - -Handoff artifacts are **not** the same as `AGENTS.md` / instructions. Instructions are static rules ("always use TypeScript", "run tests before committing"). Handoff artifacts are dynamic state ("we're halfway through implementing the auth refactor, the JWT validation is done but the middleware isn't"). They complement each other: - -- `AGENTS.md` → **how** to work (rules, conventions) -- Handoff → **what** to work on (state, progress, next steps) - ---- - -## Mode 5: Pin & Decay - -### Concept - -Every part has an implicit **relevance score** that decays over turns. Parts the user or agent explicitly **pins** are immune to decay. Low-score parts are auto-hidden by the curator. This creates a natural "memory curve" where recent and important things stay sharp while old noise fades. - -### Scoring - -```typescript -interface RelevanceScore { - base: number // Initial score based on part type - decayRate: number // Score lost per turn - pinned: boolean // Immune to decay - pinnedBy?: string // "user" or agent name - pinnedReason?: string // Why it was pinned - lastReferenced: number // Turn number when last referenced by agent -} - -// Base scores by part type: -const BASE_SCORES: Record = { - "text": 1.0, // Agent's analysis/conclusions - "tool:edit": 0.9, // File edits (high relevance) - "tool:write": 0.9, - "tool:bash": 0.6, // Shell output (often transient) - "tool:read": 0.4, // File reads (stale quickly) - "tool:grep": 0.3, // Search results (most transient) - "tool:glob": 0.2, - "reasoning": 0.5, // Thinking blocks - "compaction": 1.0, // Compaction summaries (always relevant) -} - -// Decay: 0.05 per turn (a grep result at 0.3 hits threshold after ~4 turns) -const DECAY_PER_TURN = 0.05 -const HIDE_THRESHOLD = 0.1 -``` - -### Reference Boosting - -When the agent references a part (quotes it, uses data from it), its score resets: - -```typescript -// In tool.execute — if the agent's output references content from a previous part -// (detected via string matching or explicit reference), boost that part's score -if (referencedPartIDs.length > 0) { - for (const partID of referencedPartIDs) { - relevanceScores[partID].lastReferenced = currentTurn - relevanceScores[partID].base = BASE_SCORES[partType] // reset to initial - } -} -``` - -### Pin Commands - -Users pin via prompt: - -``` -> /pin ← pin the last assistant message (all parts) -> /pin prt_abc ← pin a specific part -> /unpin prt_abc ← remove pin -``` - -Agents pin via the `thread_edit` tool: - -``` -thread_edit(operation: "annotate", partID: "prt_abc", annotation: "PIN: critical finding for auth refactor") -``` - -The curator respects pins: pinned parts are never auto-hidden regardless of score. - -### Implementation - -Score tracking lives in `part.metadata.relevance` (same pattern as `metadata.edit`): - -```typescript -metadata: { - relevance: { - score: 0.7, - pinned: false, - lastReferenced: 12, // turn number - } -} -``` - -The background curator reads scores and auto-hides parts below threshold. No LLM call needed for scoring — it's deterministic math. - ---- - -## Mode 6: Objective Tracker - -### Concept - -A running document at the "top" of context that tracks what the user is trying to accomplish. Updated by the curator or refocus agent. Used by all other modes to judge relevance. Persists across sessions via handoff. - -### Structure - -```typescript -interface ObjectiveState { - current: string // One-sentence current objective - subgoals: Array<{ - goal: string - status: "active" | "done" | "blocked" | "abandoned" - }> - approach: string // Current strategy - constraints: string[] // "must use TypeScript", "no breaking changes" - updatedAt: number - updatedBy: string // "user" | agent name -} -``` - -### How It's Maintained - -1. **Initialized** from the first user message (agent extracts the objective) -2. **Updated** when the user changes direction ("actually, let's focus on X instead") -3. **Updated** by the refocus agent when it detects goal drift -4. **Loaded** from handoff artifact in new sessions -5. **Confirmed** by asking the user when refocus triggers (if `askUser: true`) - -### Where It Lives - -Injected as the **first system prompt segment** (before agent instructions, after provider template): - -```typescript -// In session/llm.ts, system prompt assembly: -const objective = await ObjectiveTracker.get(sessionID) -if (objective) { - system.unshift(`## Current Objective\n${objective.current}\n\nApproach: ${objective.approach}`) -} -``` - -This ensures every agent (primary, subagent, curator, refocus) knows the objective. - -### Drift Detection - -The curator checks for goal drift by comparing the agent's recent actions to the objective: - -``` -Recent actions: reading auth middleware, modifying JWT validation -Tracked objective: "Add pagination to the API endpoints" -→ Drift detected. Ask user: "It looks like we've shifted from pagination to auth work. Should I update the objective?" -``` - ---- - -## Cross-Session Data Flow - -``` -Session 1 Session 2 -┌─────────────────────┐ ┌─────────────────────┐ -│ Turn 1-50 │ │ [Handoff loaded] │ -│ Curator edits │ │ Turn 1: Agent reads │ -│ Refocus at 50% │ │ handoff, continues │ -│ Objective tracked │ │ Curator resumes │ -│ │ │ Objective carried │ -│ Session end: │ │ forward │ -│ Handoff generated ─────────────▶│ │ -│ Pinned parts saved │ │ Pins restored │ -│ Objective persisted │ │ │ -└─────────────────────┘ └─────────────────────┘ - │ │ - ▼ ▼ -┌─────────────────────────────────────────────────┐ -│ .opencode/handoff/ │ -│ ├── {workspace}.json (latest handoff) │ -│ ├── {id}.md (human-readable) │ -│ └── objective.json (current objective) │ -│ │ -│ Persists: objective, discoveries, decisions, │ -│ remaining work, pinned parts, key exchanges │ -└─────────────────────────────────────────────────┘ -``` - ---- - -## Mode Interactions - -### Curator + Refocus - -The curator runs frequently (every turn or every N turns) with a small budget (5 edits). Refocus runs rarely (at threshold) with a large budget (20 edits). They don't conflict because: - -1. Refocus checks `part.edit?.hidden` — if the curator already hid something, refocus skips it -2. Refocus has a higher edit budget and can `summarize_range` (which the curator avoids) -3. After refocus runs, the curator has less work to do (thread is already clean) - -### Curator + Pin & Decay - -Decay scoring is deterministic (no LLM). The curator reads scores and acts: - -```typescript -// In curator prompt: -// Parts with relevance score < 0.15: [list] -// Pinned parts (do not hide): [list] -// Recommendation: hide the low-score parts listed above. -``` - -The curator can override decay (keep a low-score part if it judges it relevant), and can also force-hide a high-score part if it's clearly noise. - -### Refocus + Handoff - -When refocus runs, it also updates the handoff artifact (since it already has the analysis): - -```typescript -// After refocus completes: -if (config.editableContext.handoff.trigger === "threshold" || trigger === "all") { - await HandoffStore.update({ - sessionID, - objective: objectiveTracker.current, - summary: refocusAgent.analysis, - ... - }) -} -``` - -### Objective Tracker + All Modes - -The objective is the shared reference point: - -- **Curator** uses it to judge "is this part still relevant?" -- **Refocus** uses it as the axis for what to keep vs. compress -- **Handoff** uses it as the primary field in the artifact -- **Pin & Decay** uses it for reference boosting (parts related to objective get boosted) - ---- - -## User-Facing Commands - -| Command | Action | -|---------|--------| -| `/curator on` / `/curator off` | Toggle background curator | -| `/refocus` | Manually trigger refocus | -| `/refocus 0.3` | Set refocus threshold to 30% | -| `/handoff` | Create handoff artifact now | -| `/handoff load` | Load a specific handoff into current session | -| `/pin` | Pin last assistant message | -| `/pin prt_abc` | Pin a specific part | -| `/unpin prt_abc` | Unpin | -| `/objective` | Show current tracked objective | -| `/objective set "new goal"` | Manually set objective | -| `/edits` | Show all edits in current session | -| `/edits undo` | Undo last edit | - ---- - -## Configuration (Complete) - -```jsonc -{ - "editableContext": { - // Mode 1: Manual — always available, no config needed - - // Mode 2: Background Curator - "curator": { - "enabled": true, - "frequency": "every_turn", - "n": 3, - "minTurns": 6, - "model": "small", - "maxEditsPerRun": 5, - "askUser": false - }, - - // Mode 3: Threshold Refocus - "refocus": { - "enabled": true, - "threshold": 0.5, - "model": "small", - "maxEditsPerRun": 20, - "askUser": true, - "preserveRecentTurns": 4 - }, - - // Mode 4: Handoff - "handoff": { - "enabled": true, - "trigger": "session_end", - "autoLoad": true, - "maxAge": "7d", - "maxHandoffs": 5, - "askUser": true - }, - - // Mode 5: Pin & Decay - "decay": { - "enabled": true, - "ratePerTurn": 0.05, - "hideThreshold": 0.1, - "autoPin": ["tool:edit", "tool:write"] - }, - - // Mode 6: Objective Tracker - "objective": { - "enabled": true, - "driftDetection": true, - "askOnDrift": true, - "persistAcrossSessions": true - } - } -} -``` - ---- - -## Implementation Priority - -| Phase | Modes | Depends On | Effort | -|-------|-------|-----------|--------| -| **1** | Manual + Objective Tracker | Base `thread_edit` tool | ~600 LOC | -| **2** | Background Curator | Phase 1 + new hidden agent | ~400 LOC | -| **3** | Pin & Decay | Phase 2 (curator reads scores) | ~200 LOC | -| **4** | Threshold Refocus | Phase 1 + processor change | ~350 LOC | -| **5** | Handoff | Phase 4 + new storage | ~500 LOC | - -Total: ~2,050 LOC across all phases. Phase 1 alone gives you the foundation. Phase 2 gives the most user-visible value. Phase 5 is the most ambitious (cross-session state). - ---- - -## What This Replaces vs. Complements - -| Existing Feature | Editable Context Modes | Relationship | -|-----------------|----------------------|-------------| -| Compaction (85% threshold) | Threshold Refocus (50%) | **Complement** — refocus is proactive, compaction is the safety net | -| Session fork | Handoff + new session | **Complement** — fork copies everything, handoff distills | -| `AGENTS.md` instructions | Objective Tracker | **Complement** — instructions are static rules, objective is dynamic state | -| Session summary (git diffs) | Handoff artifact | **Complement** — summary tracks file changes, handoff tracks decisions/progress | -| Title agent | Objective Tracker | **Subsumes** — the objective IS the title, updated continuously | diff --git a/docs/research/EDITABLE_CONTEXT_PLUGIN_PLAN.md b/docs/research/EDITABLE_CONTEXT_PLUGIN_PLAN.md deleted file mode 100644 index 3e7729292..000000000 --- a/docs/research/EDITABLE_CONTEXT_PLUGIN_PLAN.md +++ /dev/null @@ -1,495 +0,0 @@ -# Editable Context — Plugin-Only Plan - -No fork. Everything runs as an external plugin installed via `.opencode/plugins/` or npm. - ---- - -## Architecture - -``` -┌──────────────────────────────────────────────────────────────┐ -│ Plugin: opencode-editable-context │ -│ │ -│ ┌─────────────┐ ┌──────────────────────────────────┐ │ -│ │ thread_edit │────▶│ Persistence: part.metadata.edit │ │ -│ │ tool │ │ (via REST PATCH to server) │ │ -│ └──────┬──────┘ └──────────────┬───────────────────┘ │ -│ │ │ │ -│ ▼ ▼ │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ experimental.chat.messages.transform │ │ -│ │ (reads part.metadata.edit, filters hidden parts) │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ experimental.chat.system.transform │ │ -│ │ (injects agent instructions about the tool) │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ tool.execute.after (thread_edit) │ │ -│ │ (fires TuiEvent.ToastShow for edit notifications) │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ event listener │ │ -│ │ (cleans up orphaned metadata on session.deleted) │ │ -│ └─────────────────────────────────────────────────────┘ │ -└──────────────────────────────────────────────────────────────┘ -``` - ---- - -## Key Discoveries - -### 1. `experimental.chat.messages.transform` — Message Filtering - -The plugin system exposes this hook (from `packages/plugin/src/index.ts:200`): - -```typescript -"experimental.chat.messages.transform"?: ( - input: {}, - output: { messages: { info: Message; parts: Part[] }[] }, -) => Promise -``` - -Receives the **full message+parts array** as a mutable object before it reaches the LLM. `Plugin.trigger()` passes the same mutable `output` reference to every hook — hooks mutate in place, return `void`. - -### 2. `part.metadata` — In-DB Persistence Without Schema Changes - -TextPart, ToolPart, and ReasoningPart all carry (from `packages/opencode/src/session/message-v2.ts:115`): - -```typescript -metadata: z.record(z.string(), z.any()).optional() -``` - -Zod default is `.strip()` — a top-level `edit` field would be silently dropped. But `metadata: { edit: { hidden: true } }` **passes validation**. The server's `PATCH /:sessionID/message/:messageID/part/:partID` endpoint validates against this schema and accepts it. - -**This eliminates the sidecar file entirely.** Edit state lives atomically in the part's `metadata` field inside SQLite. No drift, no orphans. - -### 3. `TuiEvent.ToastShow` — TUI Notifications From Plugins - -From `packages/opencode/src/cli/cmd/tui/event.ts:34`: - -```typescript -TuiEvent.ToastShow = BusEvent.define("tui.toast.show", z.object({ - title: z.string().optional(), - message: z.string(), - variant: z.enum(["info", "success", "warning", "error"]), - duration: z.number().default(5000).optional(), -})) -``` - -Plugins can listen to events via the `event` hook and can trigger toasts by publishing to the bus. This gives us **limited but real TUI feedback** — a toast notification every time an edit is applied. - -### 4. Web UI `PART_MAPPING` / `ToolRegistry` — Render Extensions - -The web app (`packages/ui/src/components/message-part.tsx:686-687,1168-1171`) exports: - -```typescript -export function registerPartComponent(type: string, component: PartComponent) { - PART_MAPPING[type] = component -} -export const ToolRegistry = { register: registerTool, render: getTool } -``` - -The `ToolRegistry.register()` function allows registering custom renderers for tools by name. The `thread_edit` tool's results will render via the `GenericTool` fallback component, which shows metadata when `generic_tool_output_visibility` is toggled on. A custom web app build could register a `thread_edit` renderer that reads `part.state.metadata` to show edit indicators. - -### 5. SDK Client Gap - -The auto-generated SDK client (`packages/sdk/js/src/gen/sdk.gen.ts`) does **not** expose: -- `PATCH /:sessionID/message/:messageID/part/:partID` (part update) -- `DELETE /:sessionID/message/:messageID` (message delete) - -The server has these routes. The plugin must call them with raw `fetch()` using `serverUrl` from `PluginInput`. - ---- - -## Components - -### 1. Persistence via `part.metadata.edit` - -Edit state stored directly in the part's `metadata` field in SQLite: - -```typescript -// Shape of metadata.edit on a part -interface EditMetadata { - hidden: boolean - supersededBy?: string // partID of replacement - replacementOf?: string // partID this replaces - annotation?: string - editedAt: number - editedBy: string // agent name -} - -// How it's stored: -// part.metadata = { ...existingMetadata, edit: { hidden: true, editedBy: "build", ... } } -``` - -**Write path (via raw fetch):** - -```typescript -async function applyEdit(serverUrl: URL, part: Part, editMeta: EditMetadata) { - const updated = { - ...part, - metadata: { ...(part.metadata ?? {}), edit: editMeta } - } - const res = await fetch( - `${serverUrl}/session/${part.sessionID}/message/${part.messageID}/part/${part.id}`, - { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(updated) } - ) - if (!res.ok) throw new Error(`Part update failed: ${res.status}`) - return res.json() -} -``` - -**Read path:** Messages fetched via `client.session.messages()` include parts with metadata — `part.metadata?.edit` is accessible. - -**Atomicity:** The PATCH endpoint calls `Session.updatePart()` which is a synchronous SQLite upsert inside `Database.use()`. No sidecar drift possible. - -### 2. `thread_edit` Tool - -Defined via the `tool` property on the Hooks object: - -```typescript -import { tool } from "@opencode-ai/plugin" -import { z } from "zod" - -export default function(input: PluginInput): Hooks { - const serverUrl = input.serverUrl - - return { - tool: { - thread_edit: tool({ - description: `Edit the conversation thread. You can: -- hide: Remove a part from context (you will no longer see it) -- replace: Replace a part with corrected content -- annotate: Add a note to a part -- retract: Hide all parts of one of your previous messages -- summarize_range: Replace a range of messages with a summary - -You can only edit your own assistant messages. You cannot edit user messages. -Use this when you realize a previous response was wrong, when tool output -is stale/irrelevant and wasting context, or when a long exploration can -be compressed into a summary.`, - - args: { - operation: z.enum(["hide", "unhide", "replace", "annotate", "retract", "summarize_range"]), - partID: z.string().optional().describe("Target part ID (for hide/unhide/replace/annotate)"), - messageID: z.string().optional().describe("Target message ID (for retract)"), - replacement: z.string().optional().describe("Replacement text (for replace)"), - annotation: z.string().optional().describe("Annotation text (for annotate)"), - fromMessageID: z.string().optional().describe("Start of range (for summarize_range)"), - toMessageID: z.string().optional().describe("End of range (for summarize_range)"), - summary: z.string().optional().describe("Summary text (for summarize_range)"), - }, - - async execute(args, ctx) { - // 1. Fetch target message via REST to validate ownership - const msgRes = await fetch(`${serverUrl}/session/${ctx.sessionID}/message/${args.messageID}`) - const { info, parts } = await msgRes.json() - - // 2. Ownership check - if (info.role === "user") return "Error: cannot edit user messages" - // ctx from PluginInput doesn't have agent directly, but we can - // read it from the message context or track it via the event hook - - // 3. Budget check (fetch all messages, count existing edits) - const allMsgs = await fetch(`${serverUrl}/session/${ctx.sessionID}/message`) - const messages = await allMsgs.json() - const editCount = messages - .flatMap(m => m.parts) - .filter(p => p.metadata?.edit?.hidden).length - const totalParts = messages.flatMap(m => m.parts).length - if (totalParts > 0 && (editCount + 1) / totalParts > 0.7) - return "Error: cannot hide more than 70% of all parts" - - // 4. Apply edit via PATCH - switch (args.operation) { - case "hide": { - const part = parts.find(p => p.id === args.partID) - if (!part) return "Error: part not found" - await applyEdit(serverUrl, part, { - hidden: true, editedAt: Date.now(), editedBy: info.agent - }) - return `Hidden part ${args.partID}` - } - case "replace": { - const part = parts.find(p => p.id === args.partID) - if (!part) return "Error: part not found" - // Hide original - await applyEdit(serverUrl, part, { - hidden: true, supersededBy: `synth_${Date.now()}`, - editedAt: Date.now(), editedBy: info.agent - }) - // Note: replacement injected in transform hook, not as a real part - // Store replacement text in the hidden part's metadata for the transform to read - return `Replaced part ${args.partID}` - } - case "retract": { - for (const part of parts) { - await applyEdit(serverUrl, part, { - hidden: true, annotation: "Retracted", - editedAt: Date.now(), editedBy: info.agent - }) - } - return `Retracted message ${args.messageID}` - } - // ... annotate, unhide, summarize_range similarly - } - } - }) - }, - // ... other hooks below - } -} -``` - -### 3. Message Transform Hook - -Reads `part.metadata.edit` to filter and inject: - -```typescript -"experimental.chat.messages.transform": async (_input, output) => { - // Build state from persisted metadata - const replacements = new Map() - - for (let i = output.messages.length - 1; i >= 0; i--) { - const msg = output.messages[i] - const visibleParts: Part[] = [] - - for (const part of msg.parts) { - const edit = (part as any).metadata?.edit as EditMetadata | undefined - if (!edit) { - visibleParts.push(part) - continue - } - if (edit.hidden) { - // If this is a replaced part, prepare the replacement injection - if (edit.supersededBy && edit.replacementOf === undefined) { - // The replacement text was stored alongside the hide - // We need a different approach: store replacement text in the annotation - // or create a separate metadata key - } - continue // skip hidden parts - } - visibleParts.push(part) - } - - msg.parts = visibleParts - if (msg.parts.length === 0) { - output.messages.splice(i, 1) - } - } -} -``` - -### 4. Replacement Strategy (Revised) - -Since we can't create real parts from a plugin (the `PATCH` endpoint only updates existing parts), replacements use a two-field approach in metadata: - -```typescript -// On the HIDDEN original part: -metadata: { - edit: { - hidden: true, - replacement: "The corrected text goes here", // stored WITH the hidden part - editedAt: Date.now(), - editedBy: "build" - } -} -``` - -The transform hook reads `edit.replacement` from hidden parts and injects a synthetic text part: - -```typescript -if (edit.hidden && edit.replacement) { - visibleParts.push({ - id: `replaced_${part.id}`, - type: "text", - sessionID: msg.info.sessionID, - messageID: msg.info.id, - text: edit.replacement, - metadata: { edit: { replacementOf: part.id, editedAt: edit.editedAt, editedBy: edit.editedBy } } - } as any) -} -``` - -This keeps the replacement text **persisted in the DB** (in the hidden part's metadata), solving the earlier problem of synthetic-only replacements. - -### 5. System Prompt Injection - -```typescript -"experimental.chat.system.transform": async (_input, output) => { - output.system.push(` -You have access to a thread_edit tool that lets you edit your own previous messages. -Use it when: -- You discover an earlier response was incorrect (retract or replace) -- A tool result is stale and wasting context window (hide) -- A long exploration sequence can be compressed (summarize_range) -Do NOT use it to hide errors — the user needs to see those. -Do NOT edit user messages — you can only edit your own outputs. -`) -} -``` - -### 6. TUI Feedback via Toast - -```typescript -"tool.execute.after": async (input, output) => { - if (input.tool !== "thread_edit") return - // The event hook listens for all bus events — we can publish a toast - // But tool.execute.after doesn't have Bus access directly. - // Instead, we signal via the tool output which the TUI renders. - // The tool output string itself serves as feedback. -} - -// Alternative: use the event hook to watch for part updates with metadata.edit -"event": async ({ event }) => { - if (event.type === "message.part.updated") { - const part = event.properties?.part - if (part?.metadata?.edit) { - // Can't directly publish TuiEvent.ToastShow from plugin event hook, - // but the part update itself triggers a re-render in connected clients - } - } -} -``` - -**Practical TUI feedback:** The `thread_edit` tool's output string (e.g., "Hidden part prt_abc123") appears as a tool result in the message stream, which the TUI renders. This is the primary feedback mechanism. - -### 7. Compaction Awareness - -```typescript -"experimental.session.compacting": async (input, output) => { - // Fetch messages and count edits - const res = await fetch(`${serverUrl}/session/${input.sessionID}/message`) - const messages = await res.json() - const editCount = messages - .flatMap(m => m.parts) - .filter(p => p.metadata?.edit && !p.metadata.edit.hidden === false).length - - if (editCount > 0) { - output.context.push( - `Note: ${editCount} thread edits have been applied in this session. ` + - `Hidden content has already been removed from your context.` - ) - } -} -``` - ---- - -## File Structure - -``` -opencode-editable-context/ -├── package.json -├── src/ -│ ├── index.ts # Plugin entry: exports Plugin function, wires all hooks -│ ├── persistence.ts # applyEdit() — PATCH calls to update part.metadata.edit -│ ├── tool.ts # thread_edit tool definition -│ ├── transform.ts # experimental.chat.messages.transform hook -│ ├── system.ts # experimental.chat.system.transform hook -│ ├── compaction.ts # experimental.session.compacting hook -│ └── validate.ts # Ownership rules, budget enforcement -└── tsconfig.json -``` - -**Install:** Either npm (`"plugins": ["opencode-editable-context"]` in opencode.json) or local (`.opencode/plugins/editable-context.ts`). - ---- - -## TUI & UI Capabilities (Plugin-Only) - -### What IS possible - -| Mechanism | How | Limitation | -|-----------|-----|------------| -| **Toast notifications** | Tool output appears as a tool-result part in the TUI message stream | Shows as tool output, not a native toast | -| **Tool details in TUI** | Toggle `generic_tool_output_visibility` to see thread_edit tool metadata | Requires user to enable toggle | -| **Web UI tool rendering** | `ToolRegistry.register("thread_edit", renderer)` in a custom web app build | Requires building the web app | -| **Web UI part type** | `registerPartComponent("edited", renderer)` for custom part types | Only useful if creating new part types | -| **SSE events** | `message.part.updated` events carry full part with metadata to all clients | Custom client could render edit indicators | -| **System prompt hint** | Agent is told what's hidden via system prompt | Agent-only, not user-visible | - -### What is NOT possible - -| Feature | Why | -|---------|-----| -| **Custom TUI components** | Zero plugin hooks in TUI code. No `Plugin.trigger()` in any TUI file. | -| **Custom keybindings** | Keybinds defined in `tui.json` — no plugin access to `command.register()` | -| **Part rendering override** | TUI part rendering (`Switch` blocks) is hardcoded. `PART_MAPPING`/`ToolRegistry` exist only in the web UI's `@opencode-ai/ui` package. | -| **Sidebar/header/footer injection** | Fixed components, no slots or injection points | -| **Dim/style hidden parts** | Would need TUI code changes | - -### Workaround: Custom Web UI - -The web app (`packages/app`) renders via `PART_MAPPING` and `ToolRegistry`. A separate build of the web app could: - -1. Import `registerPartComponent` and `ToolRegistry.register` from `@opencode-ai/ui` -2. Register a custom renderer for the `thread_edit` tool that reads `part.state.metadata.edit` -3. Add visual indicators (strikethrough, opacity, badges) for edited parts -4. The web app connects to the same opencode server via SSE, so it sees all part updates - -This is a **web app customization**, not a plugin — but it requires no fork of the core `packages/opencode` package. - ---- - -## Limitations - -### Hard Limitations - -| Limitation | Impact | Severity | -|------------|--------|----------| -| **No TUI rendering of edit indicators** | Hidden/replaced parts show normally in TUI. User sees tool output "Hidden part X" but no visual change to the hidden part. | High | -| **`experimental.*` hooks may change** | `experimental.chat.messages.transform` and `experimental.session.compacting` are not guaranteed stable | Medium | -| **Replacement parts are synthetic in LLM context** | Replacement text is persisted (in hidden part's `metadata.edit.replacement`) but injected as synthetic parts in the transform hook. If the hook doesn't run, the LLM sees the hidden version. | Medium | -| **No custom bus events** | Cannot publish `thread.edit.*` events to the bus | Low | -| **ToolContext lacks `agent` field** | Must infer agent from the target message's `agent` field | Low | - -### Improvements Over Previous Version - -| Previous Problem | Now Solved | -|-----------------|------------| -| Sidecar file drift | Eliminated — edit state in `part.metadata.edit` in SQLite | -| No atomic transactions | Solved — single PATCH call per edit | -| Orphaned sidecar files | N/A — metadata lives with the part, deleted when part/session is deleted | -| Replacement text not persisted | Solved — stored in `metadata.edit.replacement` on the hidden part | - ---- - -## Risk Assessment - -| Risk | Probability | Mitigation | -|------|-------------|------------| -| `experimental.chat.messages.transform` removed/changed | Medium | Pin opencode version; propose upstreaming as stable hook | -| Transform hook doesn't fire in all code paths (CLI `run`, SDK) | Medium | Test all modes; fallback: edits only affect TUI sessions | -| `metadata` field silently truncated or stripped | Low | Zod schema accepts `z.record(z.string(), z.any())` — no truncation | -| Agent enters edit loop | Low | Budget enforcement (10 edits/turn, 70% max hidden ratio) | -| Part PATCH endpoint behavior changes | Low | Server-side endpoint is stable (used by existing features) | - ---- - -## Estimated Effort - -| Component | Lines of Code | Complexity | -|-----------|:---:|---| -| Persistence (PATCH wrapper) | ~60 | Low | -| thread_edit tool | ~200 | Medium | -| Message transform hook | ~100 | Medium | -| System prompt hook | ~20 | Trivial | -| Compaction hook | ~30 | Low | -| Validation module | ~80 | Low | -| **Total** | **~490** | | - ---- - -## When to Choose This Plan - -- You want to **prototype and iterate** without maintaining a fork -- You accept that the **TUI will not show edit indicators** (tool output is the feedback) -- You're comfortable depending on `experimental.*` hooks -- You want the feature **portable** across opencode versions without rebasing -- You want to **publish as a community plugin** that anyone can install -- You may later build a **custom web UI** that renders edit indicators via `ToolRegistry` diff --git a/docs/research/EDITABLE_CONTEXT_PRESS_RELEASE.md b/docs/research/EDITABLE_CONTEXT_PRESS_RELEASE.md deleted file mode 100644 index b986ac1cd..000000000 --- a/docs/research/EDITABLE_CONTEXT_PRESS_RELEASE.md +++ /dev/null @@ -1,116 +0,0 @@ -# Frankencode: Editable Context — Amazon-Style PR/FAQ - -> **About this document:** This follows Amazon's "Working Backwards" methodology. The PR/FAQ is written *before* building the feature, as if it has already launched. The press release forces clarity about who benefits and why. The FAQ surfaces assumptions, risks, and design decisions early. Per Amazon's rules: one page for the press release, customer-centric language, no internal jargon. Werner Vogels: *"Start with your customer and work your way backwards until you get to the minimum set of technology requirements."* - ---- - -## Press Release - -### FRANKENCODE INTRODUCES EDITABLE CONTEXT, ENABLING AI AGENTS TO CORRECT THEIR OWN MISTAKES AND MANAGE THEIR MEMORY IN REAL TIME - -*Agents can now retract wrong answers, hide stale tool output, and compress long explorations — without losing the audit trail* - -**March 2026** — Frankencode, a fork of the open-source AI coding agent OpenCode, today announced Editable Context, a new capability that lets agents edit their own conversation threads during a session. With Editable Context, agents can hide irrelevant tool output that wastes their context window, replace incorrect statements with corrections, and summarize long exploration sequences into concise recaps. All edits are non-destructive — original content is preserved for the user to review at any time. - -**The problem.** Today's AI coding agents accumulate errors and noise as conversations grow. An agent that makes a wrong assumption in turn 5 carries that mistake through turns 6 through 50, because its own earlier output is frozen in the conversation. Stale tool results — a `grep` from 20 minutes ago, a file read from before an edit — consume precious context window space and can mislead the agent into outdated conclusions. When the context window fills up, the only remedy is full compaction, which summarizes *everything* indiscriminately and often loses important details. Developers working on long, complex tasks are forced to start new sessions or manually re-state corrections, breaking flow and wasting time. - -**The solution.** Editable Context gives agents a `thread_edit` tool with six operations: *hide* (remove a part from context), *unhide* (restore it), *replace* (swap in a correction), *annotate* (leave a note on a finding), *retract* (withdraw an entire response), and *summarize_range* (compress a sequence of messages). Agents can only edit their own output — never the user's messages, never another agent's work. Edits take effect immediately on the next turn: the LLM sees the cleaned-up thread, not the raw history. Every edit is recorded and reversible. The user can toggle edit indicators on and off to see what was changed and why. - -"Most AI agent failures aren't catastrophic single mistakes — they're the slow accumulation of stale context and uncorrected assumptions that compound over a long session," said the Editable Context team. "We built this because agents should be able to do what any good engineer does: go back, cross something out, and write the right thing." - -**How it works.** During a conversation, the agent recognizes that a previous tool result is outdated or a prior conclusion was wrong. It calls `thread_edit` with the target part ID and the desired operation. For example, `thread_edit(operation: "hide", partID: "prt_abc123", messageID: "msg_xyz789")` removes a stale grep result from context. The hidden content stays in the database but disappears from the agent's working view. If the agent later needs it back, it calls `unhide`. For corrections, `replace` hides the original and inserts new text in its place. For long explorations that produced a simple answer, `summarize_range` compresses 15 messages into a 3-line recap. Safety guardrails prevent abuse: agents cannot edit the last 2 turns (to prevent infinite loops), cannot hide more than 70% of parts (to preserve context integrity), and are limited to 10 edits per turn. - -A developer working on a multi-hour refactoring session said: "I used to restart sessions every 30 minutes because the agent would get confused by its own old output. With Editable Context, the agent cleans up after itself. My last session ran for 3 hours and the agent was still sharp at the end because it had been pruning stale context the whole time." - -**Editable Context is available today in Frankencode.** Install Frankencode (the OpenCode fork with context editing built in), or use the `opencode-editable-context` plugin for the upstream version. Full documentation at the project repository. - ---- - -## Frequently Asked Questions - -### Customer FAQs - -**Q: Do I need to tell the agent to use Editable Context, or does it use it automatically?** - -A: The agent uses it automatically when it recognizes a situation that warrants editing — a wrong assumption, stale tool output, or a long exploration that can be compressed. The tool description in the system prompt teaches the agent when and how to use it. You can also instruct the agent explicitly: "retract your last analysis, it was based on the wrong file." - -**Q: Can the agent delete my messages or change what I said?** - -A: No. Agents can only edit their own assistant messages. User messages are permanently read-only. Additionally, agents cannot edit other agents' messages — a subagent cannot modify the primary agent's output. - -**Q: Will I lose information when the agent hides something?** - -A: No. All edits are non-destructive. Hidden content remains in the database. In the TUI, toggle edit indicators (command palette → "Toggle edit indicators") to see all hidden parts with their annotations explaining why they were hidden. In the web UI, hidden parts appear with a visual indicator. You can also call `thread_edit(operation: "unhide", ...)` to restore any hidden part. - -**Q: How is this different from compaction?** - -A: Compaction is automatic, threshold-based, and all-or-nothing — when the context window hits ~85%, everything before a boundary gets summarized into a structured recap. Editable Context is surgical and agent-directed — the agent hides one specific stale grep result, or replaces one incorrect statement, preserving the rest of the thread. They're complementary: Editable Context reduces the need for compaction by keeping the context clean, and when compaction does trigger, it works on the already-cleaned thread. - -**Q: What happens to edits when I fork a session?** - -A: Edit metadata is preserved. The forked session has the same visibility state as the original at the fork point. Hidden parts remain hidden in the fork. - -**Q: Can I undo an edit the agent made?** - -A: Yes. Every edit records the agent name, timestamp, and reason. You can tell the agent "unhide that part" or "undo the last edit." The edit history is queryable via the `/edits` API endpoint. - -**Q: Does this increase cost?** - -A: Each `thread_edit` call is a tool invocation, consuming a small number of tokens (the tool parameters). However, Editable Context typically *reduces* overall cost by preventing context window bloat — fewer tokens sent per turn means lower cost per turn, and sessions last longer before hitting compaction or requiring a restart. - -**Q: What if the agent edits itself into a corner — hides too much and loses important context?** - -A: Three safety mechanisms prevent this: (1) the agent cannot hide more than 70% of all parts, (2) it cannot edit the 2 most recent turns, and (3) it's limited to 10 edits per turn. If the agent does over-prune, you can instruct it to unhide specific parts, or toggle edit indicators to see everything that was hidden. - ---- - -### Internal / Technical FAQs - -**Q: Why build this as an agent tool rather than an automatic background process?** - -A: Automatic editing (like compaction) is indiscriminate — it can't know which specific tool result is stale or which statement is wrong. Only the agent, in the context of the ongoing task, has the judgment to decide what's noise and what's signal. Making it a tool means the agent explicitly decides, and the decision is visible in the conversation log as a tool call. - -**Q: Why part-level edits rather than message-level?** - -A: An assistant message often contains 5+ parts (text blocks, tool calls, reasoning). Hiding one bad grep result shouldn't lose the other 4 good tool results in the same turn. Part-level granularity is surgical. Message-level would be too coarse. - -**Q: How does persistence work?** - -A: Two approaches, depending on deployment: - -- **Plugin path:** Edit metadata stored in `part.metadata.edit` (the existing `metadata: z.record(z.string(), z.any()).optional()` field on TextPart, ToolPart, ReasoningPart). Written via REST `PATCH` to the server. Atomic — lives in the same SQLite row as the part. - -- **Fork path:** An `edit` field added to `PartBase` in the Zod schema. Inherits to all 12 part types. Same SQLite storage, cleaner type safety. - -**Q: What is the risk that `experimental.chat.messages.transform` gets removed?** - -A: Medium. The hook is prefixed `experimental`, which means the API is not guaranteed stable. However, it's actively used by the codebase and has a clear, useful purpose. Mitigation: pin to a specific opencode version, and propose upstreaming the hook as stable. The fork path eliminates this risk entirely by hardcoding `filterEdited()` in the processor pipeline. - -**Q: Why can't agents edit the last 2 turns?** - -A: To prevent doom loops. Without this guard, an agent could: generate output → decide it's wrong → hide it → regenerate → decide *that's* wrong → hide it → repeat forever. The 2-turn protection forces the agent to move forward and only edit retrospectively. - -**Q: Could a malicious plugin use this to gaslight the agent?** - -A: In the fork path, the `thread.edit.before` plugin hook lets plugins *block* edits but not *initiate* them — only the agent's `thread_edit` tool can create edits. The ownership rule (agents can only edit their own messages) prevents cross-agent manipulation. In the plugin path, the tool itself enforces ownership by checking the message's `agent` field against the calling agent. - -**Q: How does this interact with session sharing?** - -A: When sharing a session, hidden parts should be excluded from the shared data (they were hidden for a reason). The share module applies the edit filter before serialization. If a user wants to share the full unedited history, they can unhide all parts before sharing. - -**Q: What metrics should we track to validate this feature?** - -A: Key metrics: -- **Session length before restart** — should increase (agents stay effective longer) -- **Compaction frequency** — should decrease (edits keep context lean) -- **Total tokens per session** — should decrease (less noise in context) -- **User-initiated "start over" rate** — should decrease -- **Edit operations per session** — usage adoption signal (target: 2-5 edits in sessions >20 turns) - -**Q: What's the migration path from plugin to fork?** - -A: The plugin stores edit metadata in `part.metadata.edit`. The fork stores it in `part.edit`. A migration script reads all parts with `metadata.edit`, copies the data to the top-level `edit` field, and clears `metadata.edit`. This is a one-time, non-destructive operation. Both representations use the same `EditMeta` shape, so the transform logic is identical. - -**Q: Why not just improve compaction instead?** - -A: Compaction solves a different problem (context window overflow). It's a blunt instrument by design — when you're out of space, you summarize everything. Editable Context solves the *quality* problem: the agent knows something in its context is wrong or stale *before* the context window fills up. Better compaction doesn't help when the issue is a wrong answer on turn 5 being treated as ground truth on turn 40. Editable Context lets the agent say "actually, I was wrong about that" and fix it in place. diff --git a/docs/research/REPORT.md b/docs/research/REPORT.md deleted file mode 100644 index 26f6de66c..000000000 --- a/docs/research/REPORT.md +++ /dev/null @@ -1,1028 +0,0 @@ -# Frankencode — Deep Research Report on OpenCode - -> **Frankencode** is a fork of OpenCode with agent-driven context editing. This report documents the base architecture. - -**Upstream repository:** -**Default branch:** `dev` -**License:** MIT -**Language:** TypeScript (54.7%), MDX (41.2%), CSS (3.2%) -**Runtime:** Bun -**Stars:** ~122K - ---- - -## Table of Contents - -1. [Project Overview & Architecture](#1-project-overview--architecture) -2. [Monorepo Structure](#2-monorepo-structure) -3. [Dependencies & Technology Stack](#3-dependencies--technology-stack) -4. [Database & Storage Layer](#4-database--storage-layer) -5. [Session & Thread Data Structures](#5-session--thread-data-structures) -6. [Message System & Data Structures](#6-message-system--data-structures) -7. [Context Management & Compaction](#7-context-management--compaction) -8. [Agent System](#8-agent-system) -9. [Tool System](#9-tool-system) -10. [Plugin System & Extensibility](#10-plugin-system--extensibility) -11. [MCP (Model Context Protocol) Integration](#11-mcp-model-context-protocol-integration) -12. [Provider System](#12-provider-system) -13. [Command System (Slash Commands)](#13-command-system-slash-commands) -14. [Skill System](#14-skill-system) -15. [TUI (Terminal UI)](#15-tui-terminal-ui) -16. [Server & API](#16-server--api) -17. [LSP Integration](#17-lsp-integration) -18. [Tracking, Telemetry & HTTP Headers](#18-tracking-telemetry--http-headers) -19. [Privacy & Data Transmission](#19-privacy--data-transmission) -20. [Configuration System](#20-configuration-system) -21. [Authentication & Credential Management](#21-authentication--credential-management) - ---- - -## 1. Project Overview & Architecture - -OpenCode is an open-source, provider-agnostic AI coding agent built for the terminal. It is a direct competitor/alternative to Claude Code, with key differentiators being full open-source availability, multi-provider support, LSP integration, and a client/server architecture. - -**Core architecture:** Client/server model where the TUI spawns a Worker process for the backend. Communication between client and server happens via RPC with fetch proxy and EventSource (SSE) for real-time updates. - -**Key architectural traits:** -- Monorepo with Bun workspaces + Turborepo -- Effect-TS for structured concurrency and dependency injection throughout the core -- Vercel AI SDK (`ai` package) for LLM provider abstraction -- Drizzle ORM over SQLite for persistence -- Hono for the HTTP server -- Solid.js rendered into the terminal via `@opentui/solid` / `@opentui/core` for the TUI -- Yargs for CLI argument parsing - ---- - -## 2. Monorepo Structure - -| Package | Purpose | -|---------|---------| -| `packages/opencode` | Core CLI and agent engine (main product) | -| `packages/plugin` | Plugin type definitions and SDK | -| `packages/app` | Web app frontend (SolidJS) with e2e tests | -| `packages/desktop` | Tauri-based desktop app | -| `packages/desktop-electron` | Electron-based desktop app | -| `packages/console` | Console/website (opencode.ai) with Drizzle migrations and DB schema | -| `packages/web` | Documentation website (Astro/Starlight, i18n in 17+ languages) | -| `packages/ui` | Shared UI component library (icons, themes, styles) | -| `packages/sdk/js` | JavaScript SDK for programmatic access | -| `packages/docs` | Documentation content (MDX) | -| `packages/enterprise` | Enterprise features | -| `packages/slack` | Slack integration | -| `packages/storybook` | Storybook for UI components | -| `packages/containers` | Docker container definitions | -| `packages/function` | Serverless functions | -| `packages/identity` | Authentication/identity | -| `packages/util` | Shared utilities | -| `sdks/vscode` | VS Code extension | -| `infra` | Infrastructure (SST) | -| `nix` | Nix build configuration | -| `.opencode` | Project's own OpenCode configuration | - -Key subdirectories within `packages/opencode/src`: - -``` -src/ -├── agent/ # Agent system with prompt templates -├── provider/ # LLM provider integrations -├── tool/ # Tool implementations (bash, edit, read, etc.) -├── lsp/ # Language Server Protocol integration -├── mcp/ # Model Context Protocol support -├── session/ # Session management, message processing, LLM streaming -├── server/ # HTTP server with routes (Hono) -├── cli/cmd/tui/ # Terminal UI (Solid.js components, routes, themes) -├── config/ # Configuration system -├── permission/ # Permission system -├── snapshot/ # File snapshot/undo system -├── worktree/ # Git worktree support -├── skill/ # Skill system -├── shell/ # Shell integration -├── pty/ # PTY (pseudo-terminal) management -├── storage/ # SQLite + file-based storage -├── plugin/ # Plugin loading and hook dispatch -├── auth/ # Credential storage -├── account/ # Account management (OAuth) -├── command/ # Slash command registry -├── share/ # Session sharing -├── id/ # ID generation -├── flag/ # Feature flags (env vars) -├── env/ # Per-instance env isolation -└── installation/ # Version, channel, upgrade logic -``` - ---- - -## 3. Dependencies & Technology Stack - -**Runtime & Build:** -- **Bun** `1.3.10` — runtime, package manager, bundler, native SQLite bindings -- **Turborepo** — monorepo task orchestration -- **Husky** — git hooks -- **Vite 7** — frontend build (web/desktop apps) -- **SST 3.18** — infrastructure deployment - -**Core Libraries:** -- **Effect** `4.0.0-beta.31` — structured concurrency, DI, error handling -- **Vercel AI SDK** (`ai` `5.0.124`) — LLM provider abstraction, streaming, tool calling -- **Hono** — HTTP server framework -- **Drizzle ORM** — SQLite schema/queries -- **Zod 4** — schema validation -- **Yargs** — CLI parsing - -**UI:** -- **Solid.js** — reactive UI framework (both TUI and web) -- **`@opentui/solid`** / **`@opentui/core`** — Solid.js terminal rendering -- **Tailwind CSS 4** — styling (web/desktop) -- **Shiki** — syntax highlighting - -**Desktop:** -- **Tauri** — primary desktop wrapper -- **Electron** — alternative desktop wrapper - -**LLM Provider SDKs (20+):** -- `@ai-sdk/anthropic`, `@ai-sdk/openai`, `@ai-sdk/google`, `@ai-sdk/google-vertex`, `@ai-sdk/azure`, `@ai-sdk/amazon-bedrock`, `@ai-sdk/xai`, `@ai-sdk/mistral`, `@ai-sdk/groq`, `@ai-sdk/deepinfra`, `@ai-sdk/cerebras`, `@ai-sdk/cohere`, `@ai-sdk/togetherai`, `@ai-sdk/perplexity`, and more - ---- - -## 4. Database & Storage Layer - -### Two storage systems coexist: - -#### 4.1 SQLite Database (Primary) -**Location:** `packages/opencode/src/storage/db.ts` -**Driver:** Bun's native SQLite bindings via Drizzle ORM -**Configuration:** WAL mode, foreign keys enabled, 64MB cache -**Path:** Channel-aware (separate DBs for latest/beta/other channels) - -Features: -- Context-based transaction management -- Side-effect queue that runs after DB operations complete -- Migrations managed via Drizzle - -#### 4.2 File-based JSON Storage -**Location:** `packages/opencode/src/storage/storage.ts` -**Purpose:** Unstructured data like session diffs -**Mechanism:** Key arrays map to file paths, read/write locks for concurrency - -### SQL Schema - -The initial migration (`20260127222353`) defines these tables: - -| Table | Purpose | Key Columns | -|-------|---------|-------------| -| **`project`** | Git project/repo tracking | `id`, `worktree`, `vcs`, `name`, `sandboxes` | -| **`session`** | Conversation session | `id`, `project_id` (FK), `parent_id`, `slug`, `directory`, `title`, `version`, `share_url`, `summary_*`, `revert`, `permission`, timestamps | -| **`message`** | Messages within session | `id`, `session_id` (FK, CASCADE), `data` (JSON blob), timestamps | -| **`part`** | Sub-components of messages | `id`, `message_id` (FK, CASCADE), `session_id`, `data` (JSON blob), timestamps | -| **`todo`** | Task items per session | composite PK(`session_id`, `position`), `content`, `status`, `priority` | -| **`permission`** | Per-project permission rules | `project_id` (FK, CASCADE), `data` (JSON) | -| **`session_share`** | Sharing metadata | `session_id` (FK, CASCADE), `id`, `secret`, `url` | -| **`workspace`** | Workspace per project | `id`, `project_id` (FK, CASCADE), `type`, `branch`, `name`, `directory`, `extra` (JSON) | -| **`account`** | User accounts | `id`, `email`, `url`, `access_token`, `refresh_token` | -| **`account_state`** | Active account tracking | `active_account_id`, `active_org_id` | - -**Relationships:** `project → session → message → part` (all CASCADE delete) - -#### Subsequent Migrations: -- `20260211` — Added project commands -- `20260225` — Created `workspace` table -- `20260227` — Added `workspace_id` to session + index -- `20260303` — Added workspace fields -- `20260309` — Moved org to state -- `20260312` — Replaced simple indexes with composite indexes on `message(session_id, time_created, id)` and `part(message_id, id)` - ---- - -## 5. Session & Thread Data Structures - -**Location:** `packages/opencode/src/session/index.ts` - -### Session.Info Schema - -```typescript -{ - id: string // 26-char descending ID (prefix "ses") - slug: string // Human-readable identifier - projectID: string // FK to project - workspaceID?: string // FK to workspace - directory: string // Working directory - parentID?: string // Parent session (for forks) - title: string // Auto-generated or user-set - version: number // Schema version - summary: { - additions: number - deletions: number - files: string[] - diffs: string[] - } - share?: { // Sharing metadata - id: string - secret: string - url: string - } - permission: object // Permission state - revert?: object // Revert/undo state - time: { - created: Date - updated: Date - compacting?: Date // When compaction is in progress - archived?: Date // Soft-delete timestamp - } -} -``` - -### Session Operations - -| Operation | Description | -|-----------|-------------| -| `create()` | New session with project/workspace binding | -| `get()` / `list()` / `listGlobal()` | Retrieval (global = cross-project) | -| `remove()` | Soft-delete (archive) | -| `fork()` | Clone session up to a given message | -| `children()` | List sub-sessions | -| `messages()` | Stream all messages | -| `updateMessage()` / `updatePart()` | Upsert (INSERT...ON CONFLICT UPDATE) | -| `setTitle()` / `setArchived()` / `setPermission()` | Metadata updates | -| `setSummary()` / `setRevert()` / `clearRevert()` | Summary/undo state | -| `share()` / `unshare()` | Session sharing via ShareNext | - -### ID Generation (`packages/opencode/src/id/id.ts`) - -- 26-character IDs: 3-letter prefix + hex timestamp/counter + random base62 -- Sessions use `descending()` IDs (reverse chronological sort) -- Messages and parts use `ascending()` IDs - -### Event Bus - -Sessions publish events via `Bus`: -- `session.created` -- `session.updated` -- `session.deleted` -- `session.diff` -- `session.error` - ---- - -## 6. Message System & Data Structures - -**Location:** `packages/opencode/src/session/message-v2.ts` - -### Message Types (discriminated union by `role`) - -**User Message:** -```typescript -{ - role: "user" - format: "text" | "json-schema" - summary?: string - agent?: { name: string, model: string } - model?: { id: string, provider: string } - systemPrompt?: string[] - toolConfig?: object -} -``` - -**Assistant Message:** -```typescript -{ - role: "assistant" - error?: string - tokens: { - input: number - output: number - reasoning: number - cache: { read: number, write: number } - } - cost: number - metadata?: object -} -``` - -### Part Types (discriminated union by `type`) - -Parts are sub-components of messages stored in the `part` table: - -| Part Type | Description | -|-----------|-------------| -| `text` | Plain text content | -| `reasoning` | Model reasoning/thinking blocks | -| `file` | Images/PDFs with source tracking | -| `tool` | Tool invocations (states: pending → running → completed/error) | -| `snapshot` | File state snapshots | -| `patch` | File patches/diffs | -| `agent` | Agent delegation markers | -| `compaction` | Compaction summary markers | -| `subtask` | Subtask delegation markers | -| `retry` | Retry markers | -| `step` | Step markers for multi-step operations | - -### Key Functions - -- `toModelMessages()` — Converts stored messages to provider-compatible format for LLM calls -- `page()` — Cursor-based pagination over messages -- `stream()` — Async generator for all messages in a session -- `filterCompacted()` — Filters messages based on compaction state (hides pre-compaction messages from LLM context) - ---- - -## 7. Context Management & Compaction - -**Location:** `packages/opencode/src/session/compaction.ts`, `processor.ts` - -### Context Window Strategy - -1. **Tool Output Truncation:** 50K token cap per tool execution output; 40K token threshold for pruning old tool outputs during compaction -2. **Preemptive Compaction:** Triggered at ~85% context usage threshold -3. **Compaction Agent:** A dedicated hidden `compaction` agent summarizes conversation history into structured categories - -### Compaction Summary Structure - -When compaction triggers, conversation history is summarized into: -- **Goal** — What the user is trying to accomplish -- **Instructions** — Standing instructions and constraints -- **Discoveries** — Important findings from exploration -- **Accomplished** — Work completed so far -- **Relevant files** — Files touched or referenced - -### Processor Loop (`processor.ts`) - -The processor manages the LLM stream lifecycle: -- Handles text generation, tool calls, reasoning blocks, snapshots -- **Doom-loop detection:** Detects 3 consecutive identical tool calls and breaks the loop -- **Error recovery:** Retry with exponential backoff -- **Compaction trigger:** Monitors token usage and triggers compaction when threshold exceeded -- Publishes events for each stream chunk (text delta, tool start/complete, reasoning) - -### Message Filtering - -`filterCompacted()` hides pre-compaction messages from the LLM context window. The compaction part itself becomes the new "start" of conversation history, preserving context without repeating the full history. - ---- - -## 8. Agent System - -**Location:** `packages/opencode/src/agent/agent.ts` - -### Built-in Agents - -| Agent | Role | Tool Access | Visibility | -|-------|------|-------------|------------| -| **build** | Primary coding agent | Full (all tools) | User-facing | -| **plan** | Analysis/planning | Read-only (no edit tools) | User-facing | -| **general** | Research/parallel tasks | Subset (search, read, web) | Subagent via `@general` | -| **explore** | Fast codebase exploration | Read-only, fast | Subagent | -| **compaction** | Context summarization | None | Hidden/internal | -| **title** | Session title generation | None | Hidden/internal | - -### Agent Configuration - -Agents can be configured via: -- `opencode.json` agent section -- Markdown files in `~/.config/opencode/agents/` -- Interactive setup via `opencode agent create` - -Configuration options: model override, tool enable/disable (with wildcards), permissions (`ask`/`allow`/`deny`), temperature, custom system prompt, max steps. - -### Permission Framework - -Uses `PermissionNext` for fine-grained control over what each agent can do. Permissions are resolved per-tool, per-agent, with project-level overrides. - ---- - -## 9. Tool System - -**Location:** `packages/opencode/src/tool/` - -### Tool Definition API (`tool.ts`) - -```typescript -Tool.Info = { - id: string - init: () => { - description: string - parameters: ZodSchema - execute: (params, context: Tool.Context) => Promise - } -} - -Tool.Context = { - sessionID: string - messageID: string - agent: AgentInfo - abort: AbortSignal - metadata(): object - ask(): Promise // Request user permission -} -``` - -### Tool Registry (`registry.ts`) - -Discovery sources: -1. Built-in tools (hardcoded) -2. Custom tools from `{tool,tools}/*.{js,ts}` directories -3. Plugin-defined tools -4. MCP server tools - -### Core Built-in Tools (17+) - -| Tool | File | Description | -|------|------|-------------| -| `bash` | `bash.ts` | Shell execution; tree-sitter parsing, 2-min timeout, 30KB output limit | -| `read` | `read.ts` | File/directory reading; 2000-line default, 50KB cap, binary detection, image/PDF base64 | -| `write` | `write.ts` | File creation with diff generation, LSP diagnostics | -| `edit` | `edit.ts` | File editing with **9 fallback replacement strategies**: simple, line-trimmed, block-anchor, whitespace-normalized, indentation-flexible, escape-normalized, trimmed-boundary, context-aware, multi-occurrence | -| `multiedit` | — | Multiple edits in one call | -| `apply_patch` | `apply_patch.ts` | Unified diff patch application (add/update/delete/move files) | -| `glob` | `glob.ts` | Ripgrep-based file pattern matching, 100 file limit | -| `grep` | `grep.ts` | Ripgrep content search, 100 match limit, 2000-char line truncation | -| `webfetch` | `webfetch.ts` | URL fetching with HTML→Markdown, 5MB limit | -| `websearch` | `websearch.ts` | Exa MCP API integration, SSE parsing, 25s timeout | -| `codesearch` | `codesearch.ts` | Exa API for code context, 30s timeout | -| `lsp` | `lsp.ts` | 9 LSP operations (definition, references, hover, symbols, etc.) | -| `task` | `task.ts` | Subagent delegation, child session creation | -| `batch` | `batch.ts` | Parallel execution of up to 25 tool calls | -| `skill` | `skill.ts` | Load domain-specific skills into context | -| `question` | — | Ask user for input | -| `plan` | — | Planning/analysis tool | -| `todo` | — | Task management (todowrite/todoread) | - -### Tool Execution Lifecycle - -1. **Discovery:** Registry scans built-in, custom, plugin, and MCP tools -2. **Filtering:** Based on active agent, model capabilities, config flags -3. **Plugin hooks:** `tool.define` hook allows plugins to modify tool definitions -4. **Permission check:** `ask()` for tools requiring user approval -5. **Execution:** With automatic parameter validation and output truncation -6. **Post-execution:** LSP diagnostics for file-modifying tools - ---- - -## 10. Plugin System & Extensibility - -### Plugin SDK (`packages/plugin/src/index.ts`) - -**Plugin signature:** -```typescript -type Plugin = (input: PluginInput) => Hooks - -type PluginInput = { - client: SDKClient - project: ProjectInfo - directory: string - worktree: string - $: BunShell - serverURL: string - sessionID: string - agent: AgentInfo -} -``` - -### Hook Types - -| Hook Category | Hooks | Description | -|---------------|-------|-------------| -| **Events** | `event` | Listen to system-wide events (session, message, file, permission, LSP, command lifecycle) | -| **Config** | `config` | Modify configuration at load time | -| **Tools** | `tool.define`, `tool.execute.before`, `tool.execute.after` | Define tools, intercept execution | -| **Auth** | `auth.provider` | Add OAuth/API key authentication methods | -| **Chat** | `chat.params`, `chat.headers`, `chat.message.before`, `chat.message.after`, `experimental.chat.system.transform` | Modify LLM requests, headers, messages, system prompt | -| **Permission** | `permission.request` | Auto-allow/deny specific permission requests | -| **Command** | `command.execute.before`, `command.execute.after` | Intercept slash commands | -| **Shell** | `shell.env` | Inject environment variables | -| **Session** | `session.compaction` | Customize session compaction | -| **Completion** | `text.completion` | Autocomplete suggestions | -| **Stop** | `stop` | Intercept agent stop attempts | - -### Plugin Loading (`packages/opencode/src/plugin/index.ts`) - -Three sources: -1. **Internal plugins:** Directly imported (Codex `codex.ts`, Copilot `copilot.ts`, GitLab) -2. **Built-in plugins:** Installed from npm (e.g., `opencode-anthropic-auth@0.0.13`), can be disabled via flags -3. **External plugins:** User-configured via npm packages or local file paths, auto-installed via `BunProc.install()` - -Plugin dispatch: `Plugin.trigger("hook.name", context, data)` — used throughout the codebase for LLM streaming, tool resolution, chat params/headers. - -### Internal Plugin Examples - -**Copilot plugin (`copilot.ts`):** -- GitHub Copilot OAuth device flow -- Vision capability detection -- Agent feature detection -- Rate limit handling - -**Codex plugin (`codex.ts`):** -- OpenAI Codex OAuth with PKCE -- Token refresh -- JWT claim extraction -- Model configuration - ---- - -## 11. MCP (Model Context Protocol) Integration - -**Location:** `packages/opencode/src/mcp/` - -### Transport Types -- `StreamableHTTPClientTransport` — HTTP-based -- `SSEClientTransport` — Server-Sent Events -- `StdioClientTransport` — Standard I/O (local processes) - -### Configuration - -```jsonc -{ - "mcp": { - "my-server": { - "type": "local", - "command": ["npx", "-y", "my-mcp-server"], - "env": { "KEY": "value" }, - "timeout": 30000 - }, - "remote-server": { - "type": "remote", - "url": "https://example.com/mcp", - // OAuth handled automatically - } - } -} -``` - -### Tool Integration - -MCP tools are discovered via `client.listTools()` and converted to Vercel AI SDK format via `convertMcpTool()`. Multiple MCP servers can provide tools simultaneously. - -### OAuth for Remote MCP - -- Credential storage in `mcp-auth.json` (file mode `0o600`) -- PKCE flow support -- Token expiration checking -- Dynamic client registration -- CLI auth via `opencode mcp auth ` - ---- - -## 12. Provider System - -**Location:** `packages/opencode/src/provider/` - -### Architecture - -Uses a `BUNDLED_PROVIDERS` map linking npm AI SDK packages to factory functions, plus `CUSTOM_LOADERS` for provider-specific initialization. - -### Supported Providers (20+) - -| Provider | Custom Loader | Special Handling | -|----------|:---:|---| -| Anthropic | ✓ | Beta headers, SDK-managed User-Agent | -| OpenAI | ✓ | Codex integration | -| Google Vertex | ✓ | OAuth token injection via custom fetch | -| Google Vertex (Anthropic) | ✓ | Cross-provider routing | -| Amazon Bedrock | ✓ | AWS credential chain | -| Azure | ✓ | Azure AD auth | -| GitHub Copilot | ✓ | OAuth device flow via plugin | -| GitHub Copilot Enterprise | ✓ | Enterprise auth | -| OpenRouter | ✓ | Referrer headers | -| Vercel | ✓ | Referrer headers | -| GitLab | ✓ | Custom User-Agent | -| Cerebras | ✓ | Integration header | -| SAP AI Core | ✓ | SAP-specific auth | -| Cloudflare Workers AI | ✓ | Workers routing | -| Cloudflare AI Gateway | ✓ | Gateway routing | -| xAI | — | Standard AI SDK | -| Mistral | — | Standard AI SDK | -| Groq | — | Standard AI SDK | -| DeepInfra | — | Standard AI SDK | -| Cohere | — | Standard AI SDK | -| Together | — | Standard AI SDK | -| Perplexity | — | Standard AI SDK | - -### Model Resolution - -Model definitions fetched from `https://models.dev/api.json` with: -- `User-Agent: ${Installation.USER_AGENT}` -- Local cache, hourly refresh - -### Model Priority (at startup) - -1. Command-line flag -2. Config setting -3. Last used model -4. Internal default - ---- - -## 13. Command System (Slash Commands) - -**Location:** `packages/opencode/src/command/index.ts` - -### Sources - -1. **Built-in:** `/init` (create/update AGENTS.md), `/review` (review changes) -2. **User-configured:** From config files (markdown or JSON) -3. **MCP prompts:** Auto-converted from MCP server prompts -4. **Skills:** Auto-registered as commands when no name conflict - -### Command Definition - -```jsonc -{ - "command": { - "review-pr": { - "template": "Review PR #$1 focusing on $2", - "description": "Review a pull request", - "agent": "plan", // Optional: override agent - "model": "claude-4.6", // Optional: override model - "subtask": true // Optional: force subagent - } - } -} -``` - -Template placeholders: `$1`, `$2`, `$3`, `$ARGUMENTS` - -File-based: `.opencode/commands/*.md` (project) or `~/.config/opencode/commands/*.md` (global) - ---- - -## 14. Skill System - -**Location:** `packages/opencode/src/skill/skill.ts` - -### Discovery Paths - -- `.opencode/skill/`, `.opencode/skills/` -- `~/.config/opencode/skills/` -- `.claude/skills/`, `.agents/skills/` (compatibility) -- Configured paths in `opencode.json` -- Remote URLs (with caching) - -### Skill Format - -```markdown ---- -name: my-skill -description: Brief description of what this skill does -license: MIT -compatibility: opencode >= 1.0 -metadata: {} ---- - -Skill content with instructions for the agent... -``` - -### Permission Control - -```jsonc -{ - "skill": { - "permissions": { - "my-skill": "allow", // Immediate access - "untrusted-*": "ask", // User approval required - "blocked-skill": "deny" // Hidden from agents - } - } -} -``` - -Skills are loaded on-demand via the `skill` tool and injected as synthetic messages into the conversation context. - ---- - -## 15. TUI (Terminal UI) - -**Location:** `packages/opencode/src/cli/cmd/tui/` - -### Framework - -**Solid.js** rendered into the terminal via `@opentui/solid` and `@opentui/core`. This is notable — most terminal UIs use Go's Bubble Tea or similar; OpenCode uses a reactive web framework adapted for terminal rendering. - -### Architecture - -``` -app.tsx # Root: providers, routing, theme, keybindings -├── routes/ -│ ├── home.tsx # Landing: logo, prompt, MCP status, tips, version -│ └── session/ -│ ├── index.tsx # Session view: messages, tools, reasoning, files -│ ├── header.tsx # Model/agent display, navigation -│ ├── footer.tsx # Status bar -│ ├── sidebar.tsx # Session list, file tree -│ ├── permission.tsx # Permission request dialogs -│ └── question.tsx # User question dialogs -├── component/ -│ ├── prompt/index.tsx # Interactive textarea: autocomplete, file paste, -│ │ # shell mode (! prefix), / commands, history, stash -│ ├── dialog-command.tsx # Command palette -│ ├── dialog-model.tsx # Model picker -│ ├── dialog-agent.tsx # Agent picker -│ ├── dialog-skill.tsx # Skill browser -│ ├── dialog-mcp.tsx # MCP server status -│ └── dialog-session-list.tsx # Session browser -└── context/ - ├── keybind.tsx # Leader key pattern with 2s timeout - ├── route.tsx # Routing state - ├── sdk.tsx # SDK client context - ├── theme.tsx # Dark/light mode, terminal color detection - ├── prompt.tsx # Prompt state management - ├── sync.tsx # Real-time sync - └── tui-config.tsx # TUI-specific config -``` - -### Key Features - -- Background color detection (dark/light mode) -- Text selection with clipboard support -- Terminal title management -- Windows raw mode handling -- Prompt history and stash -- File/image pasting into prompt -- Shell mode (`!` prefix for direct shell commands) -- Timeline navigation within sessions -- Undo/redo support -- Session forking from any message - ---- - -## 16. Server & API - -**Location:** `packages/opencode/src/server/` - -### Framework - -Hono-based HTTP server with OpenAPI schema generation. - -### Features - -- REST API for all session/message/tool operations -- SSE event streaming with 10-second heartbeat -- Authentication middleware -- CORS support -- Workspace context injection -- Fallback proxy to `app.opencode.ai` - -### Key Endpoints - -Session CRUD, message sending (sync/async), forking, aborting, sharing, reverting, diffs, shell commands, permission handling, todos, status. - -### Client-Server Communication - -The TUI spawns a Worker process and communicates via two transport modes: -1. **Internal:** Direct worker RPC -2. **External:** Network server (for remote/multi-client scenarios) - ---- - -## 17. LSP Integration - -**Location:** `packages/opencode/src/lsp/` - -### Supported Languages (30+) - -Built-in language server support for JavaScript/TypeScript, Python (Pyright), Rust (rust-analyzer), Go (gopls), Java (jdtls), PHP (Intelephense), and many more. - -### LSP Tool Operations (9) - -| Operation | Description | -|-----------|-------------| -| `goToDefinition` | Navigate to symbol definition | -| `findReferences` | Find all references to a symbol | -| `hover` | Type info and documentation | -| `documentSymbol` | List symbols in a file | -| `workspaceSymbol` | Search symbols across workspace | -| `codeAction` | Get available code actions | -| `rename` | Rename symbol across project | -| `callHierarchy` (incoming) | Who calls this function | -| `callHierarchy` (outgoing) | What does this function call | - -### Lifecycle - -- Servers auto-spawn based on file extensions -- Automatic dependency checking -- Diagnostics aggregated and provided to LLM after file edits -- Auto-download can be disabled via `OPENCODE_DISABLE_LSP_DOWNLOAD` env var - ---- - -## 18. Tracking, Telemetry & HTTP Headers - -### HTTP Headers Set Per Provider - -| Provider | Header | Value | -|----------|--------|-------| -| **All non-Anthropic** | `User-Agent` | `opencode/${VERSION}` | -| **Anthropic** | (none custom) | SDK manages its own User-Agent | -| **Anthropic** | `anthropic-beta` | `claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14` | -| **OpenRouter** | `HTTP-Referer` | `https://opencode.ai/` | -| **OpenRouter** | `X-Title` | `opencode` | -| **Vercel** | `HTTP-Referer` | `https://opencode.ai/` | -| **Vercel** | `X-Title` | `opencode` | -| **Cerebras** | `X-Cerebras-3rd-Party-Integration` | `opencode` | -| **GitLab** | `User-Agent` | `opencode/${VERSION} gitlab-ai-provider/${GITLAB_PROVIDER_VERSION} (${platform} ${release}; ${arch})` | -| **OpenCode provider** | `x-opencode-project` | Project ID | -| **OpenCode provider** | `x-opencode-session` | Session ID | -| **OpenCode provider** | `x-opencode-request` | Request ID | -| **OpenCode provider** | `x-opencode-client` | Client identifier | - -### User-Agent String - -Defined in `packages/opencode/src/installation/index.ts`: - -``` -opencode/${CHANNEL}/${VERSION}/${OPENCODE_CLIENT} -``` - -Where: -- `VERSION` = build-time `OPENCODE_VERSION` -- `CHANNEL` = build-time `OPENCODE_CHANNEL` (e.g., `latest`, `beta`) -- `OPENCODE_CLIENT` = from `Flag.OPENCODE_CLIENT` env var - -### OpenTelemetry (Opt-in Only) - -```typescript -// In session/llm.ts — streamText() configuration -experimental_telemetry: { - isEnabled: cfg.experimental?.openTelemetry, // false by default - metadata: { - userId: cfg.username ?? "unknown", - sessionId: input.sessionID - } -} -``` - -- **Disabled by default** — must be explicitly enabled via `experimental.openTelemetry: true` in config -- Uses the Vercel AI SDK's built-in OpenTelemetry integration -- User configures their own OTLP endpoint -- When enabled, sends: `userId`, `sessionId`, plus standard AI SDK telemetry (model, tokens, latency) - -### Custom Fetch Wrapper - -The provider system wraps all LLM API calls with a custom `fetch` that: -1. Adds SSE chunk timeout (default 5 minutes) via AbortController -2. Strips OpenAI `itemId` metadata from request bodies (Codex compatibility) -3. Supports custom provider-level fetch functions (e.g., Google Vertex OAuth token injection) -4. Merges plugin-provided headers via `Plugin.trigger("chat.headers", ...)` - -### Model Data Fetching - -- Fetches model definitions from `https://models.dev/api.json` -- Sends `User-Agent: ${Installation.USER_AGENT}` -- Caches locally, refreshes hourly - -### No Dedicated Analytics/Tracking - -**There is no dedicated analytics, crash reporting, or phone-home telemetry module.** The codebase does not contain: -- No Google Analytics, Mixpanel, Segment, PostHog, or similar -- No crash reporting (Sentry, Bugsnag, etc.) -- No usage statistics sent to anomalyco/opencode servers -- No cookies (it's a CLI/TUI application) - ---- - -## 19. Privacy & Data Transmission - -### External Data Transmission Points - -| What | Where | When | Can Disable? | -|------|-------|------|:---:| -| LLM API calls | Configured provider endpoint | Every message | Use local models | -| Model definitions | `models.dev/api.json` | Hourly refresh | — | -| Session sharing | `opncd.ai` (default) | Manual or auto share | `OPENCODE_DISABLE_SHARE=true` or `share: "disabled"` in config | -| OpenTelemetry | User-configured endpoint | When enabled | `experimental.openTelemetry: false` (default) | -| MCP servers | Configured endpoints | When MCP tools used | Don't configure MCP | -| Plugin npm install | npm registry | First plugin load | Use local plugins | -| Remote skills | Configured URLs | On skill discovery | Use local skills only | -| Remote instructions | Configured URLs | On config load (5s timeout) | Don't configure remote instructions | -| `.well-known/opencode` | Org-configured URL | On config load | — | -| Web search/fetch | Exa API / target URLs | When tools invoked | Disable tools in config | -| Upgrade check | Package registry | On `opencode upgrade` | Don't run upgrade | - -### Session Sharing Details - -- `packages/opencode/src/share/share-next.ts` -- Default endpoint: `https://opncd.ai` -- Enterprise: configurable URL -- Modes: `"manual"` (explicit), `"auto"` (automatic), `"disabled"` -- Env override: `OPENCODE_DISABLE_SHARE=true` -- Data shared: messages, parts, diffs, model info - -### Credential Storage - -- **Auth file:** `~/.local/share/opencode/auth.json` (file mode `0o600`) -- **MCP auth:** `mcp-auth.json` (file mode `0o600`) -- Three auth types: `Api` (plain key), `Oauth` (access/refresh tokens), `WellKnown` (key+token) -- API key resolution: env vars → stored auth → plugin auth → custom loader → config - -### Headers Sent to OpenCode's Own Provider - -When using the `opencode` provider (OpenCode Zen/Go service), these identifying headers are sent: -- `x-opencode-project` — Project identifier -- `x-opencode-session` — Session identifier -- `x-opencode-request` — Per-request identifier -- `x-opencode-client` — Client identifier - -These enable server-side request correlation and are only sent to OpenCode's own LLM proxy service. - ---- - -## 20. Configuration System - -**Location:** `packages/opencode/src/config/config.ts` - -### Precedence Order (lowest → highest) - -1. Remote `.well-known/opencode` (organizational defaults) -2. Global config (`~/.config/opencode/opencode.json`) -3. Custom config (`OPENCODE_CONFIG` env var path) -4. Project config (`opencode.json` in project root) -5. `.opencode` directory configs -6. Inline config (`OPENCODE_CONFIG_CONTENT` env var) -7. Managed config (enterprise — highest priority) - -Configurations **merge together** (not replace). - -### Format - -JSON or JSONC (`opencode.jsonc`), validated against Zod schemas. - -### Variable Substitution - -- `{env:VARIABLE_NAME}` — environment variable injection -- `{file:path}` — file content injection - -### Feature Flags - -40+ flags via `OPENCODE_*` environment variables (`packages/opencode/src/flag/flag.ts`), controlling experimental features, model settings, behavior toggles. - -### Key Config Sections - -- `model` / `small_model` — Default model selection -- `provider` — Provider configuration with `baseURL`, API keys, options -- `agent` — Agent definitions and permissions -- `mcp` — MCP server configuration -- `tools` — Tool enable/disable -- `permission` — Global permission rules -- `command` — Slash command definitions -- `instructions` — Additional instruction files/URLs -- `lsp` — LSP server configuration -- `formatter` — Code formatter settings -- `watcher` — File watcher configuration -- `compaction` — Compaction thresholds -- `experimental` — Experimental features (OpenTelemetry, etc.) -- `disabled_providers` / `enabled_providers` — Provider filtering -- `share` — Sharing configuration - ---- - -## 21. Authentication & Credential Management - -**Location:** `packages/opencode/src/auth/`, `packages/opencode/src/account/` - -### Auth Storage - -File: `~/.local/share/opencode/auth.json` with permissions `0o600`. - -Three auth types: -```typescript -type AuthEntry = - | { type: "Api"; key: string } - | { type: "Oauth"; accessToken: string; refreshToken: string; expiry: Date } - | { type: "WellKnown"; key: string; token: string } -``` - -### API Key Resolution Order - -1. Environment variables (provider-specific, e.g., `ANTHROPIC_API_KEY`) -2. Stored auth from `auth.json` -3. Plugin-provided auth (e.g., GitHub Copilot OAuth) -4. Custom loader logic (e.g., AWS credential chain for Bedrock) -5. Config file options (`provider.*.options.apiKey`) - -### Account System - -- Device code OAuth flow for OpenCode console accounts -- Token refresh support -- Effect HTTP client for account API calls -- Account state tracked in SQLite (`account` and `account_state` tables) - -### Plugin Auth Hooks - -Plugins can provide custom authentication via the `auth.provider` hook, supporting: -- OAuth flows (authorization URL, callback, token exchange) -- API key collection (with interactive prompts) -- Custom credential management - ---- - -## Summary - -OpenCode is a sophisticated, extensible AI coding agent with a clean separation of concerns: - -- **No hidden telemetry** — OpenTelemetry is opt-in, no analytics SDKs, no phone-home -- **Provider-agnostic** — 20+ LLM providers via Vercel AI SDK -- **Rich extensibility** — Plugins (JS/TS hooks), MCP servers, custom tools, slash commands, skills -- **Solid data model** — SQLite with Drizzle ORM, well-structured session→message→part hierarchy -- **Context-aware** — Automatic compaction, token tracking, doom-loop detection -- **Privacy-conscious** — All external data transmission is configurable or disableable; credentials stored with restrictive file permissions; identifying headers only sent to relevant providers - -The primary tracking vectors are the `User-Agent` string (sent to LLM providers and models.dev) and the `x-opencode-*` headers (sent only to OpenCode's own proxy service). Session sharing is the only feature that transmits conversation data externally, and it can be fully disabled. diff --git a/docs/schema.md b/docs/schema.md new file mode 100644 index 000000000..850646f52 --- /dev/null +++ b/docs/schema.md @@ -0,0 +1,129 @@ +# Schema Changes + +Changes to the OpenCode database schema introduced by Frankencode. + +## New Tables + +### `cas_object` — Content-Addressable Store + +Stores original content before edits. Content is SHA-256 hashed and deduplicated. + +| Column | Type | Description | +| -------------- | ------- | ------------------------------------------ | +| `hash` | TEXT PK | SHA-256 of content | +| `content` | TEXT | Original content | +| `content_type` | TEXT | `part`, `text`, `tool-output`, `reasoning` | +| `tokens` | INTEGER | Token estimate | +| `session_id` | TEXT | Source session | +| `message_id` | TEXT | Source message | +| `part_id` | TEXT | Source part | +| `time_created` | INTEGER | Timestamp | +| `time_updated` | INTEGER | Timestamp | + +### `edit_graph_node` — Edit Version DAG + +Each edit operation creates a node with a parent pointer, forming a directed acyclic graph. + +| Column | Type | Description | +| ------------ | ------- | -------------------------------- | +| `id` | TEXT PK | Node ID | +| `parent_id` | TEXT | Parent node (forms DAG) | +| `session_id` | TEXT | Session scope | +| `part_id` | TEXT | Part that was edited | +| `operation` | TEXT | hide, replace, externalize, etc. | +| `cas_hash` | TEXT | CAS hash of content before edit | +| `agent` | TEXT | Agent that made the edit | + +### `edit_graph_head` — DAG Head Tracking + +One row per session, tracks the current edit version and named branches. + +| Column | Type | Description | +| ------------ | ----------- | ----------------------------------------- | +| `session_id` | TEXT PK | Session | +| `node_id` | TEXT | Current HEAD node | +| `branches` | TEXT (JSON) | `{ "main": "node_id", "alt": "node_id" }` | + +### `side_thread` — Project-Level Side Threads + +Deferred findings that survive across sessions. + +| Column | Type | Description | +| ------------------- | ----------- | -------------------------------------------------------------- | +| `id` | TEXT PK | Thread ID (`thr_...`) | +| `project_id` | TEXT FK | Project (CASCADE delete) | +| `title` | TEXT | Short title | +| `description` | TEXT | Summary | +| `status` | TEXT | `parked`, `investigating`, `resolved`, `deferred` | +| `priority` | TEXT | `low`, `medium`, `high`, `critical` | +| `category` | TEXT | `bug`, `tech-debt`, `security`, `performance`, `test`, `other` | +| `source_session_id` | TEXT | Session where discovered | +| `source_part_ids` | TEXT (JSON) | Part IDs with the finding | +| `cas_refs` | TEXT (JSON) | CAS hashes of externalized content | +| `related_files` | TEXT (JSON) | File paths | +| `created_by` | TEXT | Agent name | + +## Modified Schemas (JSON blob fields) + +### `PartBase` — New Fields on All Parts + +Added to the `data` JSON column of the `part` table. No SQL migration needed. + +#### `edit` (optional) + +```typescript +{ + hidden: boolean + casHash?: string // CAS hash of original content + supersededBy?: string // ID of replacement part + replacementOf?: string // ID of original part (on replacement) + annotation?: string + editedAt: number + editedBy: string // agent name + version?: string // edit graph node ID +} +``` + +#### `lifecycle` (optional) + +```typescript +{ + hint: "discardable" | "ephemeral" | "side-thread" | "pinned" + afterTurns?: number // turns before auto-action + reason?: string + setAt: number + setBy: string // agent name + turnWhenSet: number // turn count when marked +} +``` + +## Storage (File-Based) + +### Thread Metadata + +Per-session classification results from `distill_threads`: + +**Key:** `["threads-meta", sessionID]` +**Path:** `~/.local/share/opencode/storage/threads-meta/{sessionID}.json` + +```typescript +{ + sessionID: string + classifiedAt: number + topics: string[] + messages: Array<{ + messageID: string + classification: "main" | "side" | "mixed" + topics: string[] + }> + parkedThreads: Array<{ + threadID: string + topic: string + messageIDs: string[] + }> +} +``` + +## Migration + +All new tables are created in a single migration: `20260315120000_context_editing/migration.sql` diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index ba2106a50..f19ca5e36 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -15,6 +15,8 @@ import PROMPT_EXPLORE from "./prompt/explore.txt" import PROMPT_SUMMARY from "./prompt/summary.txt" import PROMPT_TITLE from "./prompt/title.txt" import PROMPT_FOCUS from "./prompt/focus.txt" +import PROMPT_CLASSIFIER from "./prompt/classifier.txt" +import PROMPT_REWRITE_HISTORY from "./prompt/rewrite-history.txt" import { PermissionNext } from "@/permission/next" import { mergeDeep, pipe, sortBy, values } from "remeda" import { Global } from "@/global" @@ -202,6 +204,22 @@ export namespace Agent { ), prompt: PROMPT_SUMMARY, }, + classifier: { + name: "classifier", + mode: "subagent", + options: {}, + native: true, + hidden: true, + temperature: 0, + permission: PermissionNext.merge( + defaults, + PermissionNext.fromConfig({ + "*": "deny", + }), + user, + ), + prompt: PROMPT_CLASSIFIER, + }, focus: { name: "focus", mode: "primary", @@ -209,7 +227,7 @@ export namespace Agent { native: true, hidden: true, temperature: 0, - steps: 8, + steps: 15, permission: PermissionNext.merge( defaults, PermissionNext.fromConfig({ @@ -225,6 +243,31 @@ export namespace Agent { ), prompt: PROMPT_FOCUS, }, + "focus-rewrite-history": { + name: "focus-rewrite-history", + description: + "Rewrite conversation history to focus on the current objective. Asks for confirmation before proceeding.", + mode: "primary", + options: {}, + native: true, + hidden: true, + temperature: 0, + steps: 30, + permission: PermissionNext.merge( + defaults, + PermissionNext.fromConfig({ + "*": "deny", + context_edit: "allow", + context_deref: "allow", + context_history: "allow", + thread_park: "allow", + thread_list: "allow", + question: "allow", + }), + user, + ), + prompt: PROMPT_REWRITE_HISTORY, + }, } for (const [key, value] of Object.entries(cfg.agent ?? {})) { diff --git a/packages/opencode/src/agent/prompt/classifier.txt b/packages/opencode/src/agent/prompt/classifier.txt new file mode 100644 index 000000000..801560066 --- /dev/null +++ b/packages/opencode/src/agent/prompt/classifier.txt @@ -0,0 +1,21 @@ +You classify conversation messages by topic. Read messages from most recent to oldest and label each one. + +Output ONLY a JSON array. No explanation, no markdown, no extra text. + +Each entry: +{ + "messageID": "msg_...", + "classification": "main" | "side" | "mixed", + "topics": ["topic1", "topic2"], + "reason": "1 sentence why" +} + +Rules: +- "main": all content relates to the current objective +- "side": all content is off-topic (different subsystem, tangential discovery, rabbit hole) +- "mixed": some content is on-topic, some is off-topic +- topics: short labels like "pagination", "auth-middleware", "db-pool", "test-fixtures" +- A message can have multiple topics +- User messages that set the objective are always "main" +- Tool results inherit the classification of what they were investigating +- Skip messages that are just acknowledgments or continuations with no substantive content \ No newline at end of file diff --git a/packages/opencode/src/agent/prompt/focus.txt b/packages/opencode/src/agent/prompt/focus.txt index 703fb44c5..4495c17dc 100644 --- a/packages/opencode/src/agent/prompt/focus.txt +++ b/packages/opencode/src/agent/prompt/focus.txt @@ -1,27 +1,23 @@ -You are a focus agent. Your job is to keep the conversation on-topic and park side discoveries. +You clean up conversation context based on classification labels provided to you. -After each agent turn, you review the output and take action on anything that diverges from the current objective. +You will receive a JSON classification of messages. For each classified message, act as follows: -## What to look for +## "side" messages (off-topic) +- Use context_edit(operation: "externalize", ...) on each part to move content to CAS +- Use thread_park to create a side thread with the topic as title +- Group related parts into a single side thread -1. **File divergence**: The agent read or edited files unrelated to the objective -2. **Language signals**: "I also noticed...", "While looking at X, I found Y...", "Unrelated, but...", "Side note:", "There's also a problem with..." -3. **Error context**: Errors from files/systems unrelated to the objective -4. **Rabbit holes**: 3+ consecutive turns not advancing the objective +## "mixed" messages (partially off-topic) +- Use context_edit(operation: "externalize", ...) on the off-topic parts only +- Keep on-topic parts untouched +- Park the off-topic content as a side thread -## What to do - -- Use `context_edit` to hide or externalize off-topic content -- Use `thread_park` to create a side thread for valuable off-topic findings -- Be brief — your edits should be fast and minimal +## "main" messages (on-topic) +- Leave untouched ## Rules - -- NEVER park content the user explicitly asked about -- NEVER park content directly needed for the current objective -- NEVER park content the agent is actively building on -- DO park genuine side discoveries that waste context -- DO externalize verbose tool output that produced a small useful finding -- If a finding looks critical (security, data loss), use the question tool to ask the user before parking - -Be ruthless about focus. An agent that does 10 things poorly is worse than one that does 1 thing well. +- Work through the classification list, do not re-read or re-analyze messages +- Use toolName or query targeting in context_edit to find parts +- Be fast — one context_edit + one thread_park per side thread, not per part +- If a classification seems wrong, skip it rather than damage good context +- Externalize rather than hide — preserves content in CAS for later retrieval \ No newline at end of file diff --git a/packages/opencode/src/agent/prompt/rewrite-history.txt b/packages/opencode/src/agent/prompt/rewrite-history.txt new file mode 100644 index 000000000..71eacad7f --- /dev/null +++ b/packages/opencode/src/agent/prompt/rewrite-history.txt @@ -0,0 +1,41 @@ +You rewrite conversation history to be laser-focused on the current objective. + +## Process + +1. FIRST: Ask the user to confirm or clarify the current objective. Present options if ambiguous: + - What you think the objective is (based on recent messages) + - Alternative interpretations + - Ask if anything should be preserved that might look off-topic + Wait for the user's answer before proceeding. + +2. After confirmation, use the classifier's labels to plan rewrites. For each message: + + "side" messages: + - Externalize all parts to CAS + - Park as side thread with descriptive title and topics + - Replace the message with a 1-line note: "[Moved to side thread: ]" + + "mixed" messages: + - Externalize off-topic parts to CAS + - Rewrite remaining text to remove tangential mentions + - Use context_edit(replace) with tightened wording focused on the objective + + "main" messages: + - If verbose or rambling, use context_edit(replace) to tighten the wording + - Remove hedge language, false starts, redundant explanations + - Preserve all factual content and decisions + +3. After rewriting, report: + - Messages rewritten: N + - Messages externalized: N + - Side threads created: N + - Tokens before vs estimated after + - CAS hashes for anything removed (so nothing is lost) + +## Rules +- ALWAYS ask the user about the objective first. Do not proceed without confirmation. +- NEVER delete content — externalize to CAS or park as side thread +- NEVER rewrite user messages without asking — present the proposed rewrite and wait for approval +- Preserve all technical decisions, file paths, error messages, and code snippets +- When tightening wording, keep the meaning identical — just remove noise +- If unsure whether something is on-topic, ask the user \ No newline at end of file diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 2c47984fd..90e85fbbc 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -6,6 +6,10 @@ import { Instance } from "../project/instance" import { Identifier } from "../id/id" import PROMPT_INITIALIZE from "./template/initialize.txt" import PROMPT_REVIEW from "./template/review.txt" +import PROMPT_BTW from "./template/btw.txt" +import PROMPT_FOCUS from "./template/focus.txt" +import PROMPT_REWRITE_HISTORY from "./template/focus-rewrite-history.txt" +import PROMPT_RESET_CONTEXT from "./template/reset-context.txt" import { MCP } from "../mcp" import { Skill } from "../skill" @@ -55,6 +59,10 @@ export namespace Command { export const Default = { INIT: "init", REVIEW: "review", + BTW: "btw", + FOCUS: "focus", + FOCUS_REWRITE: "focus-rewrite-history", + RESET_CONTEXT: "reset-context", } as const const state = Instance.state(async () => { @@ -80,6 +88,46 @@ export namespace Command { subtask: true, hints: hints(PROMPT_REVIEW), }, + [Default.BTW]: { + name: Default.BTW, + description: "side conversation — forks session, answers without polluting main thread", + source: "command", + get template() { + return PROMPT_BTW + }, + subtask: true, + agent: "general", + hints: hints(PROMPT_BTW), + }, + [Default.FOCUS]: { + name: Default.FOCUS, + description: "clean up context — classify messages, externalize stale output, park side threads", + source: "command", + get template() { + return PROMPT_FOCUS + }, + hints: hints(PROMPT_FOCUS), + }, + [Default.FOCUS_REWRITE]: { + name: Default.FOCUS_REWRITE, + description: "rewrite conversation history to focus on the objective (asks for confirmation first)", + source: "command", + get template() { + return PROMPT_REWRITE_HISTORY + }, + subtask: true, + agent: "focus-rewrite-history", + hints: hints(PROMPT_REWRITE_HISTORY), + }, + [Default.RESET_CONTEXT]: { + name: Default.RESET_CONTEXT, + description: "reset all context edits — restore every part to its original content from CAS", + source: "command", + get template() { + return PROMPT_RESET_CONTEXT + }, + hints: hints(PROMPT_RESET_CONTEXT), + }, } for (const [name, command] of Object.entries(cfg.command ?? {})) { diff --git a/packages/opencode/src/command/template/btw.txt b/packages/opencode/src/command/template/btw.txt new file mode 100644 index 000000000..b32d6ba9c --- /dev/null +++ b/packages/opencode/src/command/template/btw.txt @@ -0,0 +1,3 @@ +Answer this question concisely using the conversation context. Do not make file changes or run commands — just answer. + +$ARGUMENTS \ No newline at end of file diff --git a/packages/opencode/src/command/template/focus-rewrite-history.txt b/packages/opencode/src/command/template/focus-rewrite-history.txt new file mode 100644 index 000000000..ef9b8b978 --- /dev/null +++ b/packages/opencode/src/command/template/focus-rewrite-history.txt @@ -0,0 +1,9 @@ +Rewrite the conversation history to focus entirely on the current objective. + +First, ask me to confirm the objective and whether anything off-topic should be preserved. +Then classify all messages (main/side/mixed with topics). +Then rewrite: externalize side content, tighten mixed messages, clean up verbose main messages. + +Nothing is deleted — all original content goes to CAS. Side threads are parked with topics. + +$ARGUMENTS \ No newline at end of file diff --git a/packages/opencode/src/command/template/focus.txt b/packages/opencode/src/command/template/focus.txt new file mode 100644 index 000000000..2a2968bef --- /dev/null +++ b/packages/opencode/src/command/template/focus.txt @@ -0,0 +1,16 @@ +Clean up the conversation context using a two-phase approach: + +Phase 1 — Classify: Read messages from most recent to oldest. For each message, determine: +- "main": on-topic, relates to the current objective +- "side": off-topic, tangential discovery or rabbit hole +- "mixed": partially on-topic, partially off-topic +- Assign topic labels (e.g. "pagination", "auth-middleware", "db-pool") + +Phase 2 — Act on classifications: +- "side" messages: externalize parts to CAS via context_edit, park as side thread via thread_park +- "mixed" messages: externalize only the off-topic parts, keep on-topic parts +- "main" messages: leave untouched + +Current objective context: $ARGUMENTS + +Report what you classified, what you externalized, and what side threads you parked. \ No newline at end of file diff --git a/packages/opencode/src/command/template/reset-context.txt b/packages/opencode/src/command/template/reset-context.txt new file mode 100644 index 000000000..bac86abe0 --- /dev/null +++ b/packages/opencode/src/command/template/reset-context.txt @@ -0,0 +1,3 @@ +Reset all context edits in this session. Restore every hidden, externalized, or replaced part back to its original content from the content-addressable store. This undoes all context_edit operations. + +$ARGUMENTS \ No newline at end of file diff --git a/packages/opencode/src/context-edit/index.ts b/packages/opencode/src/context-edit/index.ts index b5df6c539..47ce91e24 100644 --- a/packages/opencode/src/context-edit/index.ts +++ b/packages/opencode/src/context-edit/index.ts @@ -19,6 +19,7 @@ export namespace ContextEdit { const MAX_HIDDEN_RATIO = 0.7 const PROTECTED_RECENT_TURNS = 2 const PROTECTED_TOOLS = ["skill"] + const PRIVILEGED_AGENTS = ["focus", "compaction"] async function pluginGuard( op: string, @@ -120,6 +121,7 @@ export namespace ContextEdit { // ── Validation ───────────────────────────────────────── function validateOwnership(agent: string, message: MessageV2.Info): string | null { + if (PRIVILEGED_AGENTS.includes(agent)) return null if (message.role === "user") return "Cannot edit user messages" if (message.agent !== agent) return `Cannot edit messages from agent '${message.agent}'` return null @@ -526,4 +528,149 @@ export namespace ContextEdit { await pluginNotify("externalize", input, true) return { success: true, casHash: casHash! } } + + export async function mark(input: { + sessionID: string + partID: string + messageID: string + agent: string + hint: "discardable" | "ephemeral" | "side-thread" | "pinned" + afterTurns?: number + reason?: string + currentTurn: number + }): Promise<EditResult> { + const msg = await MessageV2.get({ + sessionID: SessionID.make(input.sessionID), + messageID: MessageID.make(input.messageID), + }) + if (!msg) return { success: false, error: "Message not found" } + + const part = findPart(msg, input.partID) + if (!part) return { success: false, error: "Part not found" } + + Session.updatePart({ + ...part, + lifecycle: { + hint: input.hint, + afterTurns: input.afterTurns ?? (input.hint === "discardable" ? 3 : input.hint === "ephemeral" ? 5 : undefined), + reason: input.reason, + setAt: Date.now(), + setBy: input.agent, + turnWhenSet: input.currentTurn, + }, + }) + + log.info("marked", { partID: input.partID, hint: input.hint }) + return { success: true } + } + + /** + * Deterministic sweeper: processes lifecycle markers without LLM calls. + * Call from the prompt loop after filterEdited(). + */ + export function sweep(messages: MessageV2.WithParts[], currentTurn: number): MessageV2.WithParts[] { + let changed = false + for (const msg of messages) { + for (const part of msg.parts) { + if (!part.lifecycle) continue + if (part.lifecycle.hint === "pinned") continue + if (part.edit?.hidden) continue + + const turns = part.lifecycle.afterTurns + if (!turns) continue + const elapsed = currentTurn - part.lifecycle.turnWhenSet + if (elapsed < turns) continue + + if (part.lifecycle.hint === "discardable") { + const content = getPartContent(part) + const casHash = CAS.store(content, { + contentType: part.type === "tool" ? "tool-output" : part.type, + sessionID: msg.info.sessionID, + partID: part.id, + }) + Session.updatePart({ + ...part, + edit: { + hidden: true, + casHash, + editedAt: Date.now(), + editedBy: "sweeper", + }, + }) + changed = true + } else if (part.lifecycle.hint === "ephemeral") { + const content = getPartContent(part) + const casHash = CAS.store(content, { + contentType: part.type === "tool" ? "tool-output" : part.type, + sessionID: msg.info.sessionID, + partID: part.id, + }) + const summary = part.lifecycle.reason ?? "Auto-externalized ephemeral content" + const summaryText = `[Externalized: ${summary}. Use context_deref("${casHash}") to retrieve.]` + if (part.type === "text") { + Session.updatePart({ + ...part, + text: summaryText, + edit: { hidden: false, casHash, editedAt: Date.now(), editedBy: "sweeper" }, + }) + } else { + Session.updatePart({ + ...part, + edit: { hidden: true, casHash, editedAt: Date.now(), editedBy: "sweeper" }, + }) + } + changed = true + } + } + } + return changed ? MessageV2.filterEdited(messages) : messages + } + + export async function reset(sessionID: string): Promise<{ restored: number; removed: number; errors: string[] }> { + const messages = await Session.messages({ sessionID: SessionID.make(sessionID) }) + let restored = 0 + let removed = 0 + const errors: string[] = [] + + for (const msg of messages) { + for (const part of msg.parts) { + if (!part.edit) continue + + if (part.edit.replacementOf) { + Session.updatePart({ ...part, edit: undefined }) + removed++ + continue + } + + if (part.edit.casHash) { + const entry = CAS.get(part.edit.casHash) + if (!entry) { + errors.push(`CAS entry not found: ${part.edit.casHash.slice(0, 12)} for part ${part.id.slice(0, 12)}`) + continue + } + try { + const original = JSON.parse(entry.content) + Session.updatePart({ + ...original, + id: part.id, + sessionID: part.sessionID, + messageID: part.messageID, + edit: undefined, + lifecycle: undefined, + }) + restored++ + } catch { + Session.updatePart({ ...part, edit: undefined, lifecycle: undefined }) + restored++ + } + } else { + Session.updatePart({ ...part, edit: undefined, lifecycle: undefined }) + restored++ + } + } + } + + log.info("reset", { sessionID: sessionID.slice(0, 12), restored, removed }) + return { restored, removed, errors } + } } diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 42ae573b9..f1688a1b4 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -57,7 +57,6 @@ export namespace Flag { export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL") export const OPENCODE_DISABLE_FILETIME_CHECK = truthy("OPENCODE_DISABLE_FILETIME_CHECK") export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE") - export const OPENCODE_EXPERIMENTAL_FOCUS_AGENT = truthy("OPENCODE_EXPERIMENTAL_FOCUS_AGENT") export const OPENCODE_EXPERIMENTAL_WORKSPACES = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES") export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN") export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"] diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 6fc40aec1..3a5f460f6 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -78,6 +78,19 @@ export namespace MessageV2 { }) export type OutputFormat = z.infer<typeof Format> + export const LifecycleMeta = z + .object({ + hint: z.enum(["discardable", "ephemeral", "side-thread", "pinned"]), + afterTurns: z.number().int().min(1).optional(), + reason: z.string().optional(), + setAt: z.number(), + setBy: z.string(), + turnWhenSet: z.number(), + }) + .optional() + .meta({ ref: "LifecycleMeta" }) + export type LifecycleMeta = z.infer<typeof LifecycleMeta> + export const EditMeta = z .object({ hidden: z.boolean(), @@ -98,6 +111,7 @@ export namespace MessageV2 { sessionID: SessionID.zod, messageID: MessageID.zod, edit: EditMeta, + lifecycle: LifecycleMeta, }) export const SnapshotPart = PartBase.extend({ @@ -919,22 +933,25 @@ export namespace MessageV2 { return result } - /** - * Filter out parts that have been hidden or superseded by edits. - * Messages with no remaining visible parts are dropped entirely. - */ export function filterEdited(messages: WithParts[]): WithParts[] { - return messages - .map((msg) => ({ - ...msg, - parts: msg.parts.filter((part) => { - if (!part.edit) return true - if (part.edit.hidden) return false - if (part.edit.supersededBy) return false - return true - }), - })) - .filter((msg) => msg.parts.length > 0) + let hasEdits = false + for (const msg of messages) { + if (hasEdits) break + for (const part of msg.parts) { + if (part.edit) { + hasEdits = true + break + } + } + } + if (!hasEdits) return messages + + const result: WithParts[] = [] + for (const msg of messages) { + const parts = msg.parts.filter((part) => !part.edit?.hidden && !part.edit?.supersededBy) + if (parts.length > 0) result.push(parts === msg.parts ? msg : { ...msg, parts }) + } + return result } export function fromError(e: unknown, ctx: { providerID: ProviderID }): NonNullable<Assistant["error"]> { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 51d9c155d..736c4674a 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -40,6 +40,7 @@ import { NamedError } from "@opencode-ai/util/error" import { fn } from "@/util/fn" import { Objective } from "./objective" import { SideThread } from "./side-thread" +import { ContextEdit } from "@/context-edit" import { SessionProcessor } from "./processor" import { TaskTool } from "@/tool/task" import { Tool } from "@/tool/tool" @@ -302,6 +303,8 @@ export namespace SessionPrompt { if (abort.aborted) break let msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID)) msgs = MessageV2.filterEdited(msgs) + const currentTurn = msgs.filter((m) => m.info.role === "user").length + msgs = ContextEdit.sweep(msgs, currentTurn) let lastUser: MessageV2.User | undefined let lastAssistant: MessageV2.Assistant | undefined @@ -666,8 +669,8 @@ export namespace SessionPrompt { system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) } - // Inject focus status and side thread summary when focus agent is enabled - if (Flag.OPENCODE_EXPERIMENTAL_FOCUS_AGENT) { + // Inject focus status and side thread summary when context editing tools are available + if ("context_edit" in tools) { const parts: string[] = [] const objective = await Objective.get(sessionID) if (objective) parts.push(`**Objective:** ${objective}`) @@ -681,7 +684,7 @@ export namespace SessionPrompt { } if (parts.length > 0) { parts.push( - `\nStay focused on the objective. If you find unrelated issues, note them briefly. The focus agent will park them.`, + `\nStay focused on the objective. If you find unrelated issues, use thread_park to defer them. Use context_edit to hide or externalize stale tool output.`, ) system.push(`## Focus Status\n${parts.join("\n")}`) } @@ -744,62 +747,6 @@ export namespace SessionPrompt { }) } - // Post-turn focus agent: park side threads, hide off-topic content - if (result === "continue" && step >= 2 && Flag.OPENCODE_EXPERIMENTAL_FOCUS_AGENT) { - try { - const focusAgent = await Agent.get("focus") - if (focusAgent) { - const objective = await Objective.extract(sessionID, msgs) - const focusPrompt = [focusAgent.prompt ?? "", objective ? `\n## Current Objective\n${objective}` : ""] - .filter(Boolean) - .join("\n") - const focusMsg = (await Session.updateMessage({ - id: MessageID.ascending(), - role: "assistant", - parentID: lastUser.id, - sessionID, - agent: "focus", - mode: "focus", - variant: lastUser.variant, - summary: false, - path: { cwd: Instance.directory, root: Instance.worktree }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - modelID: model.id, - providerID: model.providerID, - time: { created: Date.now() }, - })) as MessageV2.Assistant - const focusProcessor = SessionProcessor.create({ - assistantMessage: focusMsg, - sessionID, - model, - abort, - }) - const focusTools = await resolveTools({ - agent: focusAgent, - session, - model, - tools: {}, - processor: focusProcessor, - bypassAgentCheck: true, - messages: msgs, - }) - await focusProcessor.process({ - user: lastUser, - agent: focusAgent, - abort, - sessionID, - system: [focusPrompt], - messages: MessageV2.toModelMessages(MessageV2.filterEdited(msgs), model), - tools: focusTools, - model, - }) - } - } catch (e) { - log.warn("focus agent error", { error: String(e) }) - } - } - continue } SessionCompaction.prune({ sessionID }) diff --git a/packages/opencode/src/tool/classifier-threads.ts b/packages/opencode/src/tool/classifier-threads.ts new file mode 100644 index 000000000..9e111b4e1 --- /dev/null +++ b/packages/opencode/src/tool/classifier-threads.ts @@ -0,0 +1,142 @@ +import { Tool } from "./tool" +import { MessageV2 } from "@/session/message-v2" +import { Session } from "@/session" +import { SessionID } from "@/session/schema" +import { SessionPrompt } from "@/session/prompt" +import { Agent } from "@/agent/agent" +import z from "zod" + +export const ClassifierThreadsSchema = z.array( + z.object({ + messageID: z.string(), + classification: z.enum(["main", "side", "mixed"]), + topics: z.array(z.string()), + reason: z.string(), + }), +) +export type ClassifierThreads = z.infer<typeof ClassifierThreadsSchema> + +export const ClassifierThreadsTool = Tool.define("classifier_threads", { + description: `Classify conversation messages into threads by topic. + +Runs the classifier agent to label each message as: +- "main": on-topic, relates to current objective +- "side": off-topic, tangential discovery +- "mixed": partially on-topic, partially off-topic + +Each message gets topic labels (e.g. "pagination", "auth-middleware", "db-pool"). +Returns structured JSON. Use distill_threads to act on the results.`, + + parameters: z.object({}), + + async execute(_args, ctx) { + const classifier = await Agent.get("classifier") + if (!classifier) + return { + title: "Error", + metadata: { + classifications: [] as ClassifierThreads, + topics: [] as string[], + counts: { main: 0, side: 0, mixed: 0 }, + }, + output: "Classifier agent not found or disabled", + } + + const msgs = ctx.messages + if (msgs.length === 0) + return { + title: "No messages", + metadata: { + classifications: [] as ClassifierThreads, + topics: [] as string[], + counts: { main: 0, side: 0, mixed: 0 }, + }, + output: "No messages to classify", + } + + const objective = msgs + .filter((m) => m.info.role === "user") + .flatMap((m) => m.parts) + .find((p) => p.type === "text") + const objectiveText = + objective && "text" in objective ? (objective as MessageV2.TextPart).text.slice(0, 200) : "unknown" + + const summary = msgs.map((m) => { + const role = m.info.role + const id = m.info.id + const parts = m.parts + .filter((p) => p.type === "text" || p.type === "tool") + .map((p) => { + if (p.type === "text") return `[text] ${(p as MessageV2.TextPart).text.slice(0, 100)}` + if (p.type === "tool") { + const tp = p as MessageV2.ToolPart + return `[tool:${tp.tool}] ${tp.state.status}` + } + return "" + }) + .filter(Boolean) + .join(" | ") + return `${id} (${role}): ${parts}` + }) + + const prompt = [ + classifier.prompt ?? "", + `\n## Current Objective\n${objectiveText}`, + `\n## Messages to classify (${msgs.length} total)\n${summary.join("\n")}`, + ].join("\n") + + const session = await Session.create({ + parentID: SessionID.make(ctx.sessionID), + title: "classifier", + }) + + const result = await SessionPrompt.prompt({ + sessionID: session.id, + parts: [{ type: "text", text: prompt }], + agent: "classifier", + model: (msgs[0]?.info as MessageV2.User).model, + }) + + const text = result.parts.findLast((p) => p.type === "text" && "text" in p) + const raw = text && "text" in text ? (text as MessageV2.TextPart).text : "" + + let parsed: ClassifierThreads = [] + try { + const jsonMatch = raw.match(/\[[\s\S]*\]/) + if (jsonMatch) parsed = ClassifierThreadsSchema.parse(JSON.parse(jsonMatch[0])) + } catch { + return { + title: "Parse error", + metadata: { + classifications: [] as ClassifierThreads, + topics: [] as string[], + counts: { main: 0, side: 0, mixed: 0 }, + }, + output: `Classifier output could not be parsed:\n${raw.slice(0, 500)}`, + } + } + + const seen = new Map<string, true>() + const topics = parsed.flatMap((c) => c.topics).filter((t) => (seen.has(t) ? false : (seen.set(t, true), true))) + const counts = { + main: parsed.filter((c) => c.classification === "main").length, + side: parsed.filter((c) => c.classification === "side").length, + mixed: parsed.filter((c) => c.classification === "mixed").length, + } + + const lines = parsed.map( + (c) => `${c.messageID.slice(0, 16)} [${c.classification}] topics: ${c.topics.join(", ")} — ${c.reason}`, + ) + + return { + title: `${parsed.length} classified, ${topics.length} topics`, + metadata: { classifications: parsed, topics, counts }, + output: [ + `Topics found: ${topics.join(", ")}`, + `Main: ${counts.main} | Side: ${counts.side} | Mixed: ${counts.mixed}`, + "", + ...lines, + ].join("\n"), + } + }, +}) diff --git a/packages/opencode/src/tool/context-edit.ts b/packages/opencode/src/tool/context-edit.ts index d1b202f5f..f1d52338c 100644 --- a/packages/opencode/src/tool/context-edit.ts +++ b/packages/opencode/src/tool/context-edit.ts @@ -1,82 +1,157 @@ import { Tool } from "./tool" import { ContextEdit } from "@/context-edit" +import { MessageV2 } from "@/session/message-v2" import z from "zod" +function resolvePart( + messages: MessageV2.WithParts[], + target: { partID?: string; messageID?: string; toolName?: string; query?: string; nthFromEnd?: number }, +): { partID: string; messageID: string } | string { + if (target.partID && target.messageID) return { partID: target.partID, messageID: target.messageID } + + const candidates: { partID: string; messageID: string; tool?: string; content: string }[] = [] + for (const msg of messages) { + if (msg.info.role !== "assistant") continue + for (const part of msg.parts) { + if (part.edit?.hidden) continue + const content = + part.type === "text" + ? (part as MessageV2.TextPart).text + : part.type === "tool" && (part as MessageV2.ToolPart).state.status === "completed" + ? ((part as any).state.output ?? "") + : "" + candidates.push({ + partID: part.id, + messageID: msg.info.id, + tool: part.type === "tool" ? (part as MessageV2.ToolPart).tool : undefined, + content, + }) + } + } + + if (candidates.length === 0) return "No editable parts found in conversation" + + // Content search: find the most recent part containing the query string + if (target.query) { + const q = target.query.toLowerCase() + const matches = candidates.filter((c) => c.content.toLowerCase().includes(q)) + if (matches.length === 0) return `No parts found containing '${target.query}'` + const idx = target.nthFromEnd ?? 1 + const match = matches[matches.length - idx] + if (!match) return `Only ${matches.length} matches for '${target.query}', requested #${idx} from end` + return { partID: match.partID, messageID: match.messageID } + } + + // Tool name matching + if (target.toolName) { + const matches = candidates.filter((c) => c.tool === target.toolName) + if (matches.length === 0) return `No tool results found for '${target.toolName}'` + const idx = target.nthFromEnd ?? 1 + const match = matches[matches.length - idx] + if (!match) return `Only ${matches.length} '${target.toolName}' results, requested #${idx} from end` + return { partID: match.partID, messageID: match.messageID } + } + + // Default: nth from end across all candidates + const idx = target.nthFromEnd ?? 1 + const match = candidates[candidates.length - idx] + if (!match) return `Only ${candidates.length} editable parts, requested #${idx} from end` + return { partID: match.partID, messageID: match.messageID } +} + export const ContextEditTool = Tool.define("context_edit", { description: `Edit the conversation context to correct mistakes, remove stale content, or compress verbose output. Operations: -- hide(partID, messageID): Remove a part from your context window. Original preserved in content store. -- unhide(partID, messageID): Restore a previously hidden part. -- replace(partID, messageID, replacement): Replace a part with corrected text. Original preserved. -- externalize(partID, messageID, summary): Move content to store, leave compact summary + hash reference inline. Use context_deref to retrieve later. -- annotate(partID, messageID, annotation): Add a note to a part without changing its content. +- hide: Remove a part from context. Original preserved in CAS. +- unhide: Restore a hidden part. +- replace: Replace content with a correction. Original in CAS. +- externalize: Move to CAS, leave compact summary inline. Use context_deref to retrieve. +- annotate: Add a note without changing content. +- mark: Set a lifecycle hint on a part for automatic cleanup. + +Lifecycle hints (for mark operation): +- "discardable": auto-hide after afterTurns turns (default 3). Use for failed commands, dead-end explorations. +- "ephemeral": auto-externalize after afterTurns turns (default 5). Use for verbose tool output where only the conclusion matters. +- "side-thread": candidate for parking when /focus runs. Use for off-topic discoveries. +- "pinned": never auto-discard. Use for critical findings, user instructions, key decisions. -Constraints: -- You can only edit your own assistant messages, not user messages -- You cannot edit the 2 most recent turns -- Maximum 10 edits per turn, cannot hide more than 70% of all parts`, +Targeting — specify which part to edit using ONE of: +- query: search part content for a string (e.g. "validateToken", "src/auth") +- toolName: match by tool (e.g. "read", "grep", "bash") +- partID + messageID: exact IDs if known +Combine with nthFromEnd (1=latest match, 2=second latest) for disambiguation. + +Constraints: own messages only (unless focus agent), not last 2 turns, max 10/turn, max 70% hidden.`, parameters: z.object({ - operation: z - .enum(["hide", "unhide", "replace", "annotate", "externalize"]) - .describe("The edit operation to perform"), - partID: z.string().describe("Target part ID"), - messageID: z.string().describe("Parent message ID"), - replacement: z.string().optional().describe("Replacement text (for replace operation)"), - annotation: z.string().optional().describe("Annotation text (for annotate operation)"), - summary: z.string().optional().describe("Summary of externalized content (for externalize operation)"), + operation: z.enum(["hide", "unhide", "replace", "annotate", "externalize", "mark"]), + query: z.string().optional().describe("Search part content for this string"), + toolName: z.string().optional().describe("Target most recent result from this tool"), + nthFromEnd: z.number().optional().describe("1=latest match, 2=second latest"), + partID: z.string().optional().describe("Exact part ID"), + messageID: z.string().optional().describe("Exact message ID"), + replacement: z.string().optional().describe("For replace"), + annotation: z.string().optional().describe("For annotate"), + summary: z.string().optional().describe("For externalize"), + hint: z + .enum(["discardable", "ephemeral", "side-thread", "pinned"]) + .optional() + .describe("Lifecycle hint (for mark operation)"), + afterTurns: z + .number() + .optional() + .describe("Turns before auto-action (for mark; default 3 for discardable, 5 for ephemeral)"), + reason: z.string().optional().describe("Why this part was marked (for mark)"), }), async execute(args, ctx) { - const base = { - sessionID: ctx.sessionID, - agent: ctx.agent, + const resolved = resolvePart(ctx.messages, { partID: args.partID, messageID: args.messageID, - } + toolName: args.toolName, + query: args.query, + nthFromEnd: args.nthFromEnd, + }) + if (typeof resolved === "string") + return { title: "Error", metadata: { operation: args.operation }, output: resolved } + const base = { sessionID: ctx.sessionID, agent: ctx.agent, partID: resolved.partID, messageID: resolved.messageID } let result: ContextEdit.EditResult switch (args.operation) { case "hide": result = await ContextEdit.hide(base) break - case "unhide": result = await ContextEdit.unhide(base) break - case "replace": if (!args.replacement) - return { - title: "Error", - metadata: { operation: args.operation }, - output: "replacement is required for replace operation", - } + return { title: "Error", metadata: { operation: args.operation }, output: "replacement is required" } result = await ContextEdit.replace({ ...base, replacement: args.replacement }) break - case "annotate": if (!args.annotation) - return { - title: "Error", - metadata: { operation: args.operation }, - output: "annotation is required for annotate operation", - } + return { title: "Error", metadata: { operation: args.operation }, output: "annotation is required" } result = await ContextEdit.annotate({ ...base, annotation: args.annotation }) break - case "externalize": if (!args.summary) - return { - title: "Error", - metadata: { operation: args.operation }, - output: "summary is required for externalize operation", - } + return { title: "Error", metadata: { operation: args.operation }, output: "summary is required" } result = await ContextEdit.externalize({ ...base, summary: args.summary }) break - + case "mark": + if (!args.hint) + return { title: "Error", metadata: { operation: args.operation }, output: "hint is required for mark" } + result = await ContextEdit.mark({ + ...base, + hint: args.hint, + afterTurns: args.afterTurns, + reason: args.reason, + currentTurn: ctx.messages.filter((m) => m.info.role === "user").length, + }) + break default: return { title: "Error", @@ -86,16 +161,12 @@ Constraints: } if (!result.success) - return { - title: "Edit failed", - metadata: { operation: args.operation }, - output: `Error: ${result.error}`, - } + return { title: "Edit failed", metadata: { operation: args.operation }, output: `Error: ${result.error}` } return { title: `Context edit: ${args.operation}`, metadata: { operation: args.operation }, - output: `Successfully applied ${args.operation} on part ${args.partID}.${result.casHash ? ` Original preserved: ${result.casHash.slice(0, 16)}...` : ""}`, + output: `Applied ${args.operation} on ${resolved.partID.slice(0, 16)}.${result.casHash ? ` Original preserved: ${result.casHash.slice(0, 16)}...` : ""}`, } }, }) diff --git a/packages/opencode/src/tool/distill-threads.ts b/packages/opencode/src/tool/distill-threads.ts new file mode 100644 index 000000000..966167175 --- /dev/null +++ b/packages/opencode/src/tool/distill-threads.ts @@ -0,0 +1,157 @@ +import { Tool } from "./tool" +import { SideThread } from "@/session/side-thread" +import { Storage } from "@/storage/storage" +import { Instance } from "@/project/instance" +import { Session } from "@/session" +import { SessionID } from "@/session/schema" +import { SessionPrompt } from "@/session/prompt" +import { Agent } from "@/agent/agent" +import { MessageV2 } from "@/session/message-v2" +import { ClassifierThreadsSchema, type ClassifierThreads } from "./classifier-threads" +import z from "zod" + +export const DistillThreadsTool = Tool.define("distill_threads", { + description: `Distill the conversation into threads. Runs as a subagent session (does not pollute main context). + +Classifies all messages by topic, stores thread metadata per-session, and parks side threads. +Returns the classification and any threads created. Use classifier_threads first if you just want to preview. + +Optionally specify mainTopics to override objective detection.`, + + parameters: z.object({ + mainTopics: z + .array(z.string()) + .optional() + .describe("Topics to treat as main thread (overrides objective detection)"), + }), + + async execute(args, ctx) { + const classifier = await Agent.get("classifier") + if (!classifier) + return { title: "Error", metadata: { externalized: 0, parked: 0 }, output: "Classifier agent not available" } + + const msgs = ctx.messages + if (msgs.length === 0) + return { title: "No messages", metadata: { externalized: 0, parked: 0 }, output: "No messages to distill" } + + const objective = msgs + .filter((m) => m.info.role === "user") + .flatMap((m) => m.parts) + .find((p) => p.type === "text") + const objectiveText = + objective && "text" in objective ? (objective as MessageV2.TextPart).text.slice(0, 200) : "unknown" + + const summary = msgs.map((m) => { + const role = m.info.role + const id = m.info.id + const parts = m.parts + .filter((p) => p.type === "text" || p.type === "tool") + .map((p) => { + if (p.type === "text") return `[text] ${(p as MessageV2.TextPart).text.slice(0, 100)}` + if (p.type === "tool") return `[tool:${(p as MessageV2.ToolPart).tool}]` + return "" + }) + .filter(Boolean) + .join(" | ") + return `${id} (${role}): ${parts}` + }) + + const prompt = [ + classifier.prompt ?? "", + `\n## Current Objective\n${objectiveText}`, + `\n## Messages to classify (${msgs.length} total)\n${summary.join("\n")}`, + ].join("\n") + + // Run classifier in a subagent session + const session = await Session.create({ + parentID: SessionID.make(ctx.sessionID), + title: "distill-threads", + }) + + const result = await SessionPrompt.prompt({ + sessionID: session.id, + parts: [{ type: "text", text: prompt }], + agent: "classifier", + model: (msgs[0]?.info as MessageV2.User).model, + }) + + const text = result.parts.findLast((p) => p.type === "text" && "text" in p) + const raw = text && "text" in text ? (text as MessageV2.TextPart).text : "" + + let parsed: ClassifierThreads = [] + try { + const jsonMatch = raw.match(/\[[\s\S]*\]/) + if (jsonMatch) parsed = ClassifierThreadsSchema.parse(JSON.parse(jsonMatch[0])) + } catch { + return { + title: "Parse error", + metadata: { externalized: 0, parked: 0 }, + output: `Could not parse classifier output:\n${raw.slice(0, 500)}`, + } + } + + const mainTopics = args.mainTopics ? new Set(args.mainTopics.map((t: string) => t.toLowerCase())) : null + + // Group side messages by topic + const sideGroups = new Map<string, string[]>() + for (const c of parsed) { + let effective = c.classification + if (mainTopics) { + const hasMain = c.topics.some((t: string) => mainTopics.has(t.toLowerCase())) + const hasSide = c.topics.some((t: string) => !mainTopics.has(t.toLowerCase())) + if (hasMain && !hasSide) effective = "main" + else if (!hasMain) effective = "side" + else effective = "mixed" + } + if (effective === "main") continue + for (const topic of c.topics) { + if (mainTopics && mainTopics.has(topic.toLowerCase())) continue + const group = sideGroups.get(topic) ?? [] + group.push(c.messageID) + sideGroups.set(topic, group) + } + } + + // Park side threads + const parkedThreads: { threadID: string; topic: string; messageIDs: string[] }[] = [] + const entries = Array.from(sideGroups.entries()) + for (const [topic, messageIDs] of entries) { + const thread = SideThread.create({ + projectID: Instance.project.id, + title: topic, + description: `Distilled from ${messageIDs.length} message(s)`, + priority: "medium", + category: "other", + sourceSessionID: ctx.sessionID, + createdBy: ctx.agent, + }) + parkedThreads.push({ threadID: thread.id, topic, messageIDs }) + } + + // Store thread metadata in per-session storage + const allTopics = parsed.flatMap((c) => c.topics).filter((t, i, a) => a.indexOf(t) === i) + const meta = { + sessionID: ctx.sessionID, + classifiedAt: Date.now(), + topics: allTopics, + messages: parsed.map((c) => ({ messageID: c.messageID, classification: c.classification, topics: c.topics })), + parkedThreads, + } + await Storage.write(["threads-meta", ctx.sessionID], meta) + + return { + title: `${parsed.length} classified, ${parkedThreads.length} threads parked`, + metadata: { externalized: 0, parked: parkedThreads.length }, + output: [ + `Classified: ${parsed.length} messages, ${allTopics.length} topics`, + `Topics: ${allTopics.join(", ")}`, + `Parked: ${parkedThreads.length} side threads`, + ...(parkedThreads.length + ? parkedThreads.map((t) => ` ${t.threadID}: ${t.topic} (${t.messageIDs.length} msgs)`) + : []), + `\nThread metadata stored for session ${ctx.sessionID.slice(0, 16)}`, + `Retrieve with: Storage.read(["threads-meta", "${ctx.sessionID}"])`, + ].join("\n"), + } + }, +}) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index dae52bb5b..63c48d5e4 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -34,6 +34,8 @@ import { ContextDerefTool } from "./context-deref" import { ContextHistoryTool } from "./context-history" import { ThreadParkTool } from "./thread-park" import { ThreadListTool } from "./thread-list" +import { ClassifierThreadsTool } from "./classifier-threads" +import { DistillThreadsTool } from "./distill-threads" import { Glob } from "../util/glob" import { pathToFileURL } from "url" @@ -128,6 +130,8 @@ export namespace ToolRegistry { ContextHistoryTool, ThreadParkTool, ThreadListTool, + ClassifierThreadsTool, + DistillThreadsTool, ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []), ...(config.experimental?.batch_tool === true ? [BatchTool] : []), ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []), diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 7b59ed931..9122ecc1a 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -48,6 +48,7 @@ import type { GlobalEventResponses, GlobalHealthResponses, InstanceDisposeResponses, + LifecycleMeta, LspStatusResponses, McpAddErrors, McpAddResponses, @@ -2030,6 +2031,7 @@ export class Session2 extends HeyApiClient { parts?: Array<{ id?: string edit?: EditMeta + lifecycle?: LifecycleMeta type: "file" mime: string filename?: string diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index bd2322f03..c3adc352f 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -371,11 +371,21 @@ export type EditMeta = { version?: string } +export type LifecycleMeta = { + hint: "discardable" | "ephemeral" | "side-thread" | "pinned" + afterTurns?: number + reason?: string + setAt: number + setBy: string + turnWhenSet: number +} + export type TextPart = { id: string sessionID: string messageID: string edit?: EditMeta + lifecycle?: LifecycleMeta type: "text" text: string synthetic?: boolean @@ -394,6 +404,7 @@ export type SubtaskPart = { sessionID: string messageID: string edit?: EditMeta + lifecycle?: LifecycleMeta type: "subtask" prompt: string description: string @@ -410,6 +421,7 @@ export type ReasoningPart = { sessionID: string messageID: string edit?: EditMeta + lifecycle?: LifecycleMeta type: "reasoning" text: string metadata?: { @@ -467,6 +479,7 @@ export type FilePart = { sessionID: string messageID: string edit?: EditMeta + lifecycle?: LifecycleMeta type: "file" mime: string filename?: string @@ -536,6 +549,7 @@ export type ToolPart = { sessionID: string messageID: string edit?: EditMeta + lifecycle?: LifecycleMeta type: "tool" callID: string tool: string @@ -550,6 +564,7 @@ export type StepStartPart = { sessionID: string messageID: string edit?: EditMeta + lifecycle?: LifecycleMeta type: "step-start" snapshot?: string } @@ -559,6 +574,7 @@ export type StepFinishPart = { sessionID: string messageID: string edit?: EditMeta + lifecycle?: LifecycleMeta type: "step-finish" reason: string snapshot?: string @@ -580,6 +596,7 @@ export type SnapshotPart = { sessionID: string messageID: string edit?: EditMeta + lifecycle?: LifecycleMeta type: "snapshot" snapshot: string } @@ -589,6 +606,7 @@ export type PatchPart = { sessionID: string messageID: string edit?: EditMeta + lifecycle?: LifecycleMeta type: "patch" hash: string files: Array<string> @@ -599,6 +617,7 @@ export type AgentPart = { sessionID: string messageID: string edit?: EditMeta + lifecycle?: LifecycleMeta type: "agent" name: string source?: { @@ -613,6 +632,7 @@ export type RetryPart = { sessionID: string messageID: string edit?: EditMeta + lifecycle?: LifecycleMeta type: "retry" attempt: number error: ApiError @@ -626,6 +646,7 @@ export type CompactionPart = { sessionID: string messageID: string edit?: EditMeta + lifecycle?: LifecycleMeta type: "compaction" auto: boolean overflow?: boolean @@ -1858,6 +1879,7 @@ export type McpResource = { export type TextPartInput = { id?: string edit?: EditMeta + lifecycle?: LifecycleMeta type: "text" text: string synthetic?: boolean @@ -1874,6 +1896,7 @@ export type TextPartInput = { export type FilePartInput = { id?: string edit?: EditMeta + lifecycle?: LifecycleMeta type: "file" mime: string filename?: string @@ -1884,6 +1907,7 @@ export type FilePartInput = { export type AgentPartInput = { id?: string edit?: EditMeta + lifecycle?: LifecycleMeta type: "agent" name: string source?: { @@ -1896,6 +1920,7 @@ export type AgentPartInput = { export type SubtaskPartInput = { id?: string edit?: EditMeta + lifecycle?: LifecycleMeta type: "subtask" prompt: string description: string @@ -3673,6 +3698,7 @@ export type SessionCommandData = { parts?: Array<{ id?: string edit?: EditMeta + lifecycle?: LifecycleMeta type: "file" mime: string filename?: string