opencode-context-tree 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/USAGE.md ADDED
@@ -0,0 +1,155 @@
1
+ # opencode-context-tree — usage
2
+
3
+ ## Install
4
+
5
+ ```jsonc
6
+ // opencode.json (server half: crops, branch model, headless /ctree commands)
7
+ { "plugin": [["opencode-context-tree", { "storage": "local" }]] }
8
+ // tui.json (TUI half: /tree, /branch, /merge, /decisions, gauge, sidebar card)
9
+ { "plugin": [["opencode-context-tree", { "storage": "local", "jumpSummary": "ask" }]] }
10
+ ```
11
+
12
+ Each half only sees the options of its own file, and both halves read `storage` — so if you
13
+ change it, **set the same `storage` in both files**, or the TUI and the server end up with
14
+ two different journals (crops written by one would never reach the other). The remaining
15
+ options only matter to the half that implements them (`storage` both; `jumpSummary`,
16
+ `hardCrop`, `keybinds` TUI-only). Plain `{ "plugin": ["opencode-context-tree"] }` in both
17
+ files is fine and uses the defaults.
18
+
19
+ From a checkout: `bun install && bun run build`, then list `/abs/path/dist/server.js` and
20
+ `/abs/path/dist/tui.js` instead of the package name.
21
+
22
+ Options: `storage` `"local"` (default, `.opencode/context-tree/` in the worktree, gitignored)
23
+ or `"global"` (OpenCode's state dir); `jumpSummary` `"ask"` (default, Pi behaviour) or `"never"`;
24
+ `hardCrop` `true` also sets OpenCode's own "compacted" flag on cropped tool parts so the
25
+ transcript shows `[Old tool result content cleared]` (reversible by undo, but it touches
26
+ OpenCode storage — off by default); `keybinds` overrides any route key by command name,
27
+ e.g. `{ "keybinds": { "open": "ctrl+t", "up": "k,up", "copy": "none" } }` — names are
28
+ `open up down jump_up jump_down first last prev_branch next_branch fold unfold toggle go
29
+ branch label filter search back crop crop_toggle_mode mark auto undo merge inspector
30
+ consumers copy mode_duration mode_turns mode_calls decisions export help`.
31
+
32
+ ## The loop
33
+
34
+ ```
35
+ /branch name it → you are on ⎇ name, a real OpenCode session (b in the tree
36
+ also asks "Model for this branch", Enter keeps the current one)
37
+ …side quest…
38
+ /merge Squash → the branch model drafts a ◆ decision record → your $EDITOR →
39
+ save to confirm → the record lands in the trunk as one message; the
40
+ noisy turns stay on the branch
41
+ /tree see where you are, what it costs, and jump anywhere
42
+ c … space … ⏎ crop a fat tool result (double space for protected ones) → the model
43
+ sees "[cropped: bash …]" from the next turn; the transcript keeps the text
44
+ x undo the last crop / branch / merge on this path
45
+ ```
46
+
47
+ `/merge` asks how to close the branch:
48
+
49
+ | option | what it does |
50
+ |---|---|
51
+ | **Squash** | drafts a ◆ decision record you confirm — one model call, then your `$EDITOR` (or an in-app confirm when none is set); it lands in the trunk as one message |
52
+ | **Squash without LLM** | you write the record yourself, from the empty template |
53
+ | **Discard** | rejected; nothing lands in the trunk |
54
+ | **Tournament** | compare sibling branches and keep one — only offered when the branch has open siblings |
55
+
56
+ Every confirmation repeats the promise: *your transcript is never rewritten; the record is
57
+ appended to the trunk as a normal message.*
58
+
59
+ ## `/tree` keys
60
+
61
+ | key | action |
62
+ |---|---|
63
+ | `↑↓` `j k` · `J K` (20) · `g G` | move |
64
+ | `[` `]` | previous / next branch row |
65
+ | `← →` `h l` `e` | fold / unfold a branch inline |
66
+ | `⏎` | go here: switch to a branch tip, or fork (a user turn = redo with the text pre-filled; a step = continue from there). Asks "Summarize the branch you are leaving?" (Pi) |
67
+ | `b` | branch here: name it, then "Model for this branch" (Enter keeps the current one) |
68
+ | `m` | merge: Squash / Squash without LLM / Discard / Tournament (siblings only) |
69
+ | `c` `space` `a` `t` `⏎` | crop mode: mark, auto-mark (≥10k tokens, older than 2 turns), result⇄turn, apply |
70
+ | `x` | undo |
71
+ | `D` `E` | decisions panel, export `ctree-decisions.md` |
72
+ | `u` | consumers: what is filling the context |
73
+ | `i` | inspector pane on/off (auto-hidden under 110 columns) |
74
+ | `1 2 3` | minimap lanes by duration / turns / tool calls |
75
+ | `L` | label the selected message |
76
+ | `f` `/` | filter cycle (default → no-tools → user-only → labeled → all), search |
77
+ | `y` | save the selected text to `.opencode/context-tree/last-copy.txt` |
78
+ | `?` | help overlay: how to read the screen + every key (`?` or `esc` closes) |
79
+ | `q` `esc` | back (esc leaves crop mode / panels first) |
80
+
81
+ The footer only lists the six you need — `⏎ go b branch m merge c crop x undo ? help
82
+ q back`; the rest live behind `?`.
83
+
84
+ Palette: **Context tree**, **Branch here**, **Merge branch**, **Decisions**, **Label this point**.
85
+ `ctrl+q` opens the tree.
86
+
87
+ ## Reading the screen
88
+
89
+ ```
90
+ ┌ Context tree · Fix flaky test · trunk ctx ~46k/200k · filling
91
+ │ filter: default 24 rows
92
+ │ ● user: build yourself a tool that reads the context window… ~1.2k
93
+ │ ○ assistant: I'll start by inspecting my environment… 0.3k
94
+ │ ⚙ [bash $ ls -la ~/Documents/] → total 744 … ~2.1k
95
+ │ ● user: decompress the session and show the structure ~0.2k
96
+ │ ╰⎇ try-redis ▸ squashed · 9 turns ~22k
97
+ │ ╰⎇ fix-flaky ▾ open · 6 turns ← here ~14k
98
+ │ │ ● user: the bun test is flaky, find the race ~0.4k
99
+ │ │ ⚙ [bash $ bun test src/foo.test.ts] ⚠ ~4.7k
100
+ │ ◆ Decision: try-redis · Outcome: switched to a write-through cache… ~0.9k
101
+ └ ⏎ go b branch m merge c crop i inspector 1·2·3 lanes x undo ? help q back
102
+ ```
103
+
104
+ - `/tree` is an outline of the *whole* tree: one content-forward row per message (`● user:` /
105
+ `○ assistant:`) and tool step (`⚙ [bash $ …]` / `[tool: arg] → out`). From anywhere you see the
106
+ whole tree — your branch open with `← here`, the rest folded to their `⎇` header (`→` opens one).
107
+ - The Input/Model/Tools lanes and the right-hand inspector (DeepSeek-Harness trajectory) are OFF by
108
+ default so the first screen is the clean outline; `1/2/3` bring in the lanes, `i` the inspector.
109
+ - The header's context string is the same one the prompt gauge shows, character for
110
+ character. The lanes only appear once there are three turns to plot; a session with no
111
+ messages says so instead of drawing an empty frame.
112
+ - `⎇` rows hang off the message they were forked from. Colours: open green, squashed blue,
113
+ rejected red, abandoned/deleted grey.
114
+ - From inside a branch, its own `⎇` row is drawn at the fork point with `← here`; the rows
115
+ below it are the branch's own turns.
116
+ - `┆⎇` rows at the bottom are branches you cannot reach on the active path (siblings, or the
117
+ trunk continuing past your fork point); `⏎` switches to them, `→` expands them.
118
+ - Sessions made with OpenCode's own `/fork` are adopted into the tree automatically (matched
119
+ to their parent by the copied message prefix; they show under the session's title).
120
+ - Tokens: a leading `~` means estimated (chars/4); assistant steps use the model's own counts.
121
+ Step durations and lane heights are read from the same data — estimated wherever the `~` is.
122
+ - `⚠` ≥10k tokens, `✂` cropped, `✗` tool error, `◆` decision record, `◇` branch summary.
123
+ - A branch you just made says `just branched, nothing here yet` — there is nothing to unfold.
124
+ - The gauge on the prompt line: `⎇ fix-flaky · ctx ~46k/200k · filling ▲+24% (bash)` —
125
+ absolute bands (<8k low · 8–32k healthy · 32–64k filling · ≥64k red), the jump since the
126
+ last look and what caused it. One toast when you enter red; one when OpenCode's
127
+ auto-compaction is near.
128
+ - The sidebar card, under a **Context tree** heading: `⎇ <branch>` and, on its own line,
129
+ `open · from "<the session you forked>"` — or `trunk · 2 branches` when you are on the
130
+ trunk. Active crops add `✂ 2 crops · ~31k hidden`; last line is the `/tree · ctrl+q` hint.
131
+
132
+ ## Headless (desktop / web / scripts)
133
+
134
+ `/ctree status` · `/ctree branch <name> [provider/model]` · `/ctree merge --discard [note]` ·
135
+ `/ctree crop --top [--apply]` ·
136
+ `/ctree crop --auto [--apply] [--min-tokens N] [--older-than N] [--keep glob]` · `/ctree undo` ·
137
+ `/ctree decisions [--export [path]]` (no path → `./ctree-decisions.md`, relative to the
138
+ project directory). These run as OpenCode commands, so the model answers with a one-line
139
+ acknowledgement. (Squash merges need the TUI's `$EDITOR` gate.)
140
+
141
+ `/ctree branch` takes the whole rest of the line as the name unless the last word looks like
142
+ a model (`provider/model`): `/ctree branch fix flaky test` names the branch "fix flaky test",
143
+ `/ctree branch fix anthropic/claude-haiku-4-5` names it "fix" and runs it on that model.
144
+
145
+ ## What is (and is not) touched
146
+
147
+ - Branch = OpenCode session (`session.fork`). The plugin remembers `(parent, anchor)` in
148
+ `.opencode/context-tree/<tree>.jsonl` (append-only) and mirrors it into `session.metadata`.
149
+ - Sessions you fork with OpenCode's own `/fork` are adopted into the tree automatically (the
150
+ copied message prefix identifies the parent); they show up under it with their session
151
+ title. Adoption only appends a journal line — the sessions themselves are untouched.
152
+ - Crops and hidden records are applied per request in the server plugin; OpenCode's own
153
+ storage is never rewritten. `/undo` appends, never deletes.
154
+ - Decision records are ordinary user messages (`noReply`) tagged in part metadata; they are
155
+ re-injected verbatim when OpenCode compacts.
package/package.json ADDED
@@ -0,0 +1,93 @@
1
+ {
2
+ "name": "opencode-context-tree",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "Pi-style context tree for OpenCode: branch, merge (human-confirmed decision records), crop, undo, plus a DeepSeek-Harness-style trajectory view",
7
+ "license": "MIT",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/server/index.ts",
11
+ "import": "./dist/server.js"
12
+ },
13
+ "./server": {
14
+ "types": "./src/server/index.ts",
15
+ "import": "./dist/server.js"
16
+ },
17
+ "./tui": {
18
+ "types": "./src/tui/index.tsx",
19
+ "import": "./dist/tui.js"
20
+ }
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "src",
25
+ "README.md",
26
+ "LICENSE",
27
+ "docs/USAGE.md",
28
+ "CHANGELOG.md"
29
+ ],
30
+ "scripts": {
31
+ "build": "bun run scripts/build.ts",
32
+ "prepack": "bun run build",
33
+ "typecheck": "bunx tsc --noEmit -p tsconfig.json",
34
+ "test": "bun test",
35
+ "test:e2e": "CTREE_E2E=1 bun test --timeout 120000 test/e2e"
36
+ },
37
+ "engines": {
38
+ "opencode": ">=1.18.0"
39
+ },
40
+ "dependencies": {
41
+ "@opencode-ai/plugin": "1.18.26",
42
+ "@opencode-ai/sdk": "1.18.26",
43
+ "zod": "^4.0.0"
44
+ },
45
+ "devDependencies": {
46
+ "@opentui/core": "^0.5.10",
47
+ "@opentui/keymap": "^0.5.10",
48
+ "@opentui/solid": "^0.5.10",
49
+ "@types/bun": "latest",
50
+ "solid-js": "^1.9.12",
51
+ "typescript": "^5.8.2"
52
+ },
53
+ "peerDependencies": {
54
+ "@opentui/core": ">=0.5.10",
55
+ "@opentui/keymap": ">=0.5.10",
56
+ "@opentui/solid": ">=0.5.10",
57
+ "solid-js": ">=1.9.12"
58
+ },
59
+ "peerDependenciesMeta": {
60
+ "@opentui/core": {
61
+ "optional": true
62
+ },
63
+ "@opentui/keymap": {
64
+ "optional": true
65
+ },
66
+ "@opentui/solid": {
67
+ "optional": true
68
+ },
69
+ "solid-js": {
70
+ "optional": true
71
+ }
72
+ },
73
+ "author": "Naveen (navbytes)",
74
+ "repository": {
75
+ "type": "git",
76
+ "url": "git+https://github.com/navbytes/opencode-tree.git"
77
+ },
78
+ "homepage": "https://github.com/navbytes/opencode-tree#readme",
79
+ "keywords": [
80
+ "opencode",
81
+ "opencode-plugin",
82
+ "context-tree",
83
+ "context-engineering",
84
+ "branch",
85
+ "merge",
86
+ "crop",
87
+ "trajectory",
88
+ "tui"
89
+ ],
90
+ "publishConfig": {
91
+ "access": "public"
92
+ }
93
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Pure planning of the "go here" jump action (DESIGN.md §6.2), given a selected
3
+ * `Row` and the `TreeView` it came from. Planning never performs the jump itself —
4
+ * callers (the server/TUI halves) execute the plan against OpenCode and the journal.
5
+ *
6
+ * Pure, no OpenCode/opentui/solid-js imports — see test/core-purity.test.ts.
7
+ */
8
+ import type { Row } from "./tree.js"
9
+ import type { Transcript } from "./transcript.js"
10
+
11
+ export type JumpPlan =
12
+ | { kind: "noop"; reason: string }
13
+ | { kind: "switch"; sessionID: string }
14
+ | {
15
+ kind: "fork"
16
+ sessionID: string
17
+ /** The message to fork AT (exclusive — `session.fork` copies everything before it). */
18
+ messageID: string
19
+ prefill?: string
20
+ mode: "redo" | "continue"
21
+ }
22
+
23
+ /**
24
+ * Plan the jump for a selected row (DESIGN.md §6.2), against the *unfiltered*
25
+ * transcripts so search/filters never change what a jump does:
26
+ * - a **branch row** → switch to that branch's session (same as its tip), or a noop on the
27
+ * marker row of the session you are already in;
28
+ * - a **turn/step row on the last message of a non-current session** → switch to it
29
+ * (no fork) — Pi's "move the leaf to an existing leaf";
30
+ * - a **turn row** elsewhere → fork at that user message, prefilled with its text;
31
+ * - a **step row** elsewhere → fork at the *next* message after its assistant message;
32
+ * - the **last message of the current session** → noop, "already here".
33
+ */
34
+ export function planJump(row: Row, ctx: { transcripts: Record<string, Transcript>; currentSessionID: string }): JumpPlan {
35
+ if (row.kind === "branch") return row.sessionID === ctx.currentSessionID ? { kind: "noop", reason: "you are here" } : { kind: "switch", sessionID: row.sessionID }
36
+ const tr = ctx.transcripts[row.sessionID]
37
+ if (!tr) return { kind: "noop", reason: "that session is not loaded" }
38
+ const idx = tr.messages.findIndex((m) => m.id === row.messageID)
39
+ if (idx === -1) return { kind: "noop", reason: "message not found" }
40
+ const last = idx === tr.messages.length - 1
41
+ if (row.sessionID === ctx.currentSessionID) {
42
+ if (last) return { kind: "noop", reason: "already here" }
43
+ } else if (last) {
44
+ return { kind: "switch", sessionID: row.sessionID }
45
+ }
46
+ if (row.kind === "turn") {
47
+ const text = tr.messages[idx]!.parts.filter((p) => p.type === "text").map((p) => p.text ?? "").join("\n")
48
+ return { kind: "fork", sessionID: row.sessionID, messageID: row.messageID, prefill: text || row.preview, mode: "redo" }
49
+ }
50
+ const next = tr.messages[idx + 1]
51
+ if (!next) return { kind: "switch", sessionID: row.sessionID }
52
+ return { kind: "fork", sessionID: row.sessionID, messageID: next.id, mode: "continue" }
53
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Matching OpenCode's *native* forks (`/fork`, `session.fork`) back to the session
3
+ * they came from, so the tree can adopt them as `branch.opened { kind: "native" }`.
4
+ *
5
+ * `session.fork` sets no `parentID` and copies the prefix with fresh message ids but
6
+ * identical `time.created`, so the only proof of a fork is that prefix; the
7
+ * `"… (fork #n)"` title is a hint used for ranking, never for matching.
8
+ *
9
+ * Pure, no OpenCode/opentui/solid-js imports — see test/core-purity.test.ts. The IO
10
+ * half lives in ../shared/adopt.ts.
11
+ */
12
+
13
+ export type ForkMessage = { id: string; role: string; created: number }
14
+
15
+ /** OpenCode's `Session`, reduced to the fields adoption needs. */
16
+ export type SessionInfo = {
17
+ id: string
18
+ title: string
19
+ created: number
20
+ /** Set only on subagent children — never on a fork, and never adoptable. */
21
+ parentID?: string
22
+ directory?: string
23
+ }
24
+
25
+ export type ForkCandidate = SessionInfo & { messages: ForkMessage[] }
26
+
27
+ export type ForkParent = { parentID: string; anchorMessageID: string }
28
+
29
+ const FORK_TITLE = /^(.+) \(fork #(\d+)\)$/
30
+
31
+ /** `"Fix the bug (fork #2)"` → `{ base: "Fix the bug", n: 2 }`. */
32
+ export function parseForkTitle(title: string): { base: string; n: number } | undefined {
33
+ const match = FORK_TITLE.exec(title)
34
+ if (!match) return undefined
35
+ const n = Number(match[2])
36
+ if (!Number.isInteger(n) || n < 1) return undefined
37
+ return { base: match[1]!, n }
38
+ }
39
+
40
+ /** The title `getForkedTitle` must have read to produce `title`, if it still follows the pattern. */
41
+ export function expectedParentTitle(title: string): string | undefined {
42
+ const parsed = parseForkTitle(title)
43
+ if (!parsed) return undefined
44
+ return parsed.n === 1 ? parsed.base : `${parsed.base} (fork #${parsed.n - 1})`
45
+ }
46
+
47
+ /** How much of `fork` is a copy of `candidate`, by (role, created) from index 0. */
48
+ function sharedPrefixLength(fork: ForkMessage[], candidate: ForkMessage[]): number {
49
+ const max = Math.min(fork.length, candidate.length)
50
+ let n = 0
51
+ while (n < max && fork[n]!.role === candidate[n]!.role && fork[n]!.created === candidate[n]!.created) n++
52
+ return n
53
+ }
54
+
55
+ /**
56
+ * The session `fork` was forked from, with the journal's anchor (the last *shared*
57
+ * message in the parent, inclusive). `candidates` should be scoped to the same directory;
58
+ * the fork's own age and `parentID` are re-checked here.
59
+ *
60
+ * Only the *copied* head of the fork can match: everything it said after the fork has a
61
+ * `created` of its own, so a fork is adoptable long after it has moved on. Matching on the
62
+ * longest common prefix (≥1 message, starting at index 0) is what makes that work.
63
+ */
64
+ export function findForkParent(fork: ForkCandidate, candidates: ForkCandidate[]): ForkParent | undefined {
65
+ if (fork.messages.length === 0) return undefined
66
+ const expected = expectedParentTitle(fork.title)
67
+ const matches: { candidate: ForkCandidate; shared: number }[] = []
68
+ for (const candidate of candidates) {
69
+ if (candidate.id === fork.id || candidate.parentID || candidate.created > fork.created) continue
70
+ const shared = sharedPrefixLength(fork.messages, candidate.messages)
71
+ if (shared > 0) matches.push({ candidate, shared })
72
+ }
73
+ if (matches.length === 0) return undefined
74
+ const titled = (m: { candidate: ForkCandidate }) => (expected !== undefined && m.candidate.title === expected ? 1 : 0)
75
+ // a fork of a fork copies its grandparent's messages too, but only through its parent —
76
+ // so the longest shared prefix is the closest ancestor; two forks of one session share a
77
+ // title, so the title expectation and then creation time break the ties
78
+ matches.sort((a, b) => b.shared - a.shared || titled(b) - titled(a) || b.candidate.created - a.candidate.created)
79
+ const best = matches[0]!
80
+ return { parentID: best.candidate.id, anchorMessageID: best.candidate.messages[best.shared - 1]!.id }
81
+ }
82
+
83
+ /**
84
+ * Sessions worth testing for adoption, oldest first so a parent that is itself an
85
+ * un-adopted fork joins the tree before its children. Only `"… (fork #n)"` titles
86
+ * qualify: matching every untitled session costs one round-trip each and mis-adopts
87
+ * strangers, so a fork the user renamed is simply not adopted automatically.
88
+ */
89
+ export function pickAdoptables(sessions: SessionInfo[], registered: Set<string>): SessionInfo[] {
90
+ return sessions
91
+ .filter((s) => !s.parentID && !registered.has(s.id) && parseForkTitle(s.title) !== undefined)
92
+ .sort((a, b) => a.created - b.created)
93
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * "What is filling the window" (DESIGN.md §7.4): tokens by source over the
3
+ * current session's context. Pure.
4
+ */
5
+ import { estimateTokens } from "./tokens.js"
6
+ import type { Transcript } from "./transcript.js"
7
+
8
+ export type Consumer = { source: string; kind: "tool" | "assistant" | "user" | "decision" | "summary" | "reasoning"; tokens: number; count: number; share: number }
9
+
10
+ export function consumers(transcript: Transcript, opts: { cropped?: Set<string> } = {}): Consumer[] {
11
+ const acc = new Map<string, Consumer>()
12
+ const add = (source: string, kind: Consumer["kind"], tokens: number) => {
13
+ const c = acc.get(source) ?? { source, kind, tokens: 0, count: 0, share: 0 }
14
+ c.tokens += tokens
15
+ c.count += 1
16
+ acc.set(source, c)
17
+ }
18
+ for (const m of transcript.messages) {
19
+ for (const p of m.parts) {
20
+ if (opts.cropped?.has(p.id)) continue
21
+ if (p.type === "tool") add(p.tool ?? "tool", "tool", estimateTokens(p.state?.output ?? "") + estimateTokens(JSON.stringify(p.state?.input ?? "")))
22
+ else if (p.type === "text") {
23
+ const kind = (p.metadata?.["ctree"] as { kind?: string } | undefined)?.kind
24
+ if (kind === "decision") add("◆ decisions", "decision", estimateTokens(p.text ?? ""))
25
+ else if (kind === "summary") add("◇ branch summaries", "summary", estimateTokens(p.text ?? ""))
26
+ else if (m.role === "user") add("● user prompts", "user", estimateTokens(p.text ?? ""))
27
+ else if (m.summary) add("◇ compaction summaries", "summary", estimateTokens(p.text ?? ""))
28
+ else add("○ assistant text", "assistant", estimateTokens(p.text ?? ""))
29
+ } else if (p.type === "reasoning") add("(thinking)", "reasoning", estimateTokens(p.text ?? ""))
30
+ }
31
+ }
32
+ const total = [...acc.values()].reduce((s, c) => s + c.tokens, 0) || 1
33
+ return [...acc.values()].map((c) => ({ ...c, share: c.tokens / total })).sort((a, b) => b.tokens - a.tokens)
34
+ }
35
+
36
+ export function bar(share: number, width: number): string {
37
+ const n = Math.round(share * width)
38
+ return "▰".repeat(n) + "▱".repeat(Math.max(0, width - n))
39
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Crop application (DESIGN.md §6.5, invariants in §8.1).
3
+ *
4
+ * Operates on a minimal structural type that mirrors OpenCode's
5
+ * `{ info: Message, parts: Part[] }` shape closely enough to be driven by
6
+ * `experimental.chat.messages.transform`, without importing anything from
7
+ * `@opencode-ai/*`. Pure, no OpenCode/opentui imports — see
8
+ * test/core-purity.test.ts.
9
+ */
10
+
11
+ export type MinimalToolState = {
12
+ status?: string
13
+ input?: unknown
14
+ output?: string
15
+ title?: string
16
+ time?: { start?: number; end?: number; compacted?: number }
17
+ }
18
+
19
+ export type MinimalPart = {
20
+ id: string
21
+ type: string
22
+ tool?: string
23
+ callID?: string
24
+ state?: MinimalToolState
25
+ text?: string
26
+ metadata?: Record<string, unknown>
27
+ }
28
+
29
+ export type MinimalMessageInfo = {
30
+ id: string
31
+ role: "user" | "assistant" | "system"
32
+ sessionID: string
33
+ }
34
+
35
+ export type MinimalMessage = {
36
+ info: MinimalMessageInfo
37
+ parts: MinimalPart[]
38
+ }
39
+
40
+ /** One crop target, as recorded on a `crop.applied` journal line (see core/journal.ts). */
41
+ export type CropTargetRef = {
42
+ messageID: string
43
+ partID?: string
44
+ callID?: string
45
+ tool?: string
46
+ estTokens: number
47
+ sha8: string
48
+ }
49
+
50
+ export type CropSpec = {
51
+ mode: "result" | "turn"
52
+ targets: CropTargetRef[]
53
+ anchorMessageID: string
54
+ }
55
+
56
+ const CROPPED_PREFIX = "[cropped:"
57
+ const DROPPED_PREFIX = "[dropped turn"
58
+
59
+ function formatK(tokens: number): string {
60
+ if (tokens < 1000) return String(tokens)
61
+ const k = (tokens / 1000).toFixed(1)
62
+ return `${k.endsWith(".0") ? k.slice(0, -2) : k}k`
63
+ }
64
+
65
+ function isDecisionMessage(message: MinimalMessage): boolean {
66
+ return message.parts.some((p) => {
67
+ const ctree = p.metadata?.["ctree"] as { kind?: string } | undefined
68
+ return ctree?.kind === "decision"
69
+ })
70
+ }
71
+
72
+ function findLastUserIndex(messages: MinimalMessage[]): number {
73
+ for (let i = messages.length - 1; i >= 0; i--) {
74
+ if (messages[i]!.info.role === "user") return i
75
+ }
76
+ return -1
77
+ }
78
+
79
+ function matchesTarget(part: MinimalPart, target: CropTargetRef): boolean {
80
+ if (target.partID) return part.id === target.partID
81
+ if (target.callID) return part.callID === target.callID
82
+ if (target.tool) return part.type === "tool" && part.tool === target.tool
83
+ return part.type === "tool"
84
+ }
85
+
86
+ function shortArg(state: MinimalToolState | undefined): string {
87
+ if (!state?.input || typeof state.input !== "object") return ""
88
+ const input = state.input as Record<string, unknown>
89
+ const candidate = input["command"] ?? input["filePath"] ?? input["pattern"] ?? input["url"]
90
+ if (typeof candidate !== "string") return ""
91
+ const trimmed = candidate.length > 40 ? `${candidate.slice(0, 40)}…` : candidate
92
+ return ` "${trimmed}"`
93
+ }
94
+
95
+ /** result mode: rewrite `state.output` to a stub; input/callID/status are left untouched. */
96
+ function applyResultCrop(messages: MinimalMessage[], crop: CropSpec, lastUserIndex: number): void {
97
+ for (const target of crop.targets) {
98
+ const index = messages.findIndex((m) => m.info.id === target.messageID)
99
+ if (index === -1) continue
100
+ if (index === lastUserIndex) continue // never touch the last user message
101
+ const message = messages[index]!
102
+ if (isDecisionMessage(message)) continue // decision records are never cropped
103
+
104
+ for (const part of message.parts) {
105
+ if (part.type !== "tool") continue
106
+ if (!matchesTarget(part, target)) continue
107
+ if (!part.state || part.state.status !== "completed") continue
108
+ if (typeof part.state.output === "string" && part.state.output.startsWith(CROPPED_PREFIX)) continue // idempotent
109
+
110
+ const arg = shortArg(part.state)
111
+ part.state.output = `${CROPPED_PREFIX} ${target.tool ?? part.tool ?? "tool"}${arg}, ~${formatK(target.estTokens)} tokens, sha8 ${target.sha8}]`
112
+ }
113
+ }
114
+ }
115
+
116
+ /**
117
+ * turn mode: splice out the user message at `anchorMessageID` and every
118
+ * assistant/tool message up to (excluding) the next user message, replacing them
119
+ * with exactly one synthetic user message so role alternation is preserved.
120
+ */
121
+ function applyTurnCrop(messages: MinimalMessage[], crop: CropSpec, lastUserIndex: number): void {
122
+ const startIndex = messages.findIndex((m) => m.info.id === crop.anchorMessageID)
123
+ if (startIndex === -1) return // already dropped (idempotent) or not in this array
124
+ if (startIndex >= lastUserIndex) return // nothing at or after the last user message is ever dropped
125
+ const anchor = messages[startIndex]!
126
+ if (anchor.info.role !== "user") return
127
+ if (isDecisionMessage(anchor)) return
128
+ if (anchor.parts.some((p) => p.type === "text" && typeof p.text === "string" && p.text.startsWith(DROPPED_PREFIX))) return // idempotent
129
+
130
+ let endIndex = startIndex + 1
131
+ while (endIndex < messages.length && messages[endIndex]!.info.role !== "user") endIndex++
132
+
133
+ const removed = endIndex - startIndex
134
+ const target = crop.targets[0]
135
+ const estTokens = crop.targets.reduce((sum, t) => sum + t.estTokens, 0)
136
+ const sha8 = target?.sha8 ?? "00000000"
137
+
138
+ const synthetic: MinimalMessage = {
139
+ info: { id: anchor.info.id, role: "user", sessionID: anchor.info.sessionID },
140
+ parts: [
141
+ {
142
+ id: `${anchor.info.id}-dropped`,
143
+ type: "text",
144
+ text: `${DROPPED_PREFIX} — ${removed} steps, ~${formatK(estTokens)} tokens, recoverable: ${sha8}]`,
145
+ },
146
+ ],
147
+ }
148
+
149
+ messages.splice(startIndex, removed, synthetic)
150
+ }
151
+
152
+ /**
153
+ * Apply a set of crops to `messages` in place, mirroring how the server plugin
154
+ * mutates OpenCode's `output.messages` by reference in
155
+ * `experimental.chat.messages.transform`. Returns the same array for convenience.
156
+ *
157
+ * Invariants (DESIGN.md §8.1), all enforced here:
158
+ * 1. Never remove or alter the last user message.
159
+ * 2. A tool part is stubbed by rewriting `state.output` only.
160
+ * 3. A dropped turn removes user + assistant/tool up to the next user message and
161
+ * inserts exactly one synthetic user message, so roles still alternate.
162
+ * 5. Decision records are never cropped.
163
+ * 6. Idempotent: running this twice on the same array yields the same result.
164
+ */
165
+ export function applyCrops(messages: MinimalMessage[], crops: CropSpec[]): MinimalMessage[] {
166
+ if (messages.length === 0 || crops.length === 0) return messages
167
+
168
+ for (const crop of crops) {
169
+ // Recomputed per crop: turn crops splice the array, so indices shift.
170
+ const lastUserIndex = findLastUserIndex(messages)
171
+ if (crop.mode === "result") applyResultCrop(messages, crop, lastUserIndex)
172
+ else applyTurnCrop(messages, crop, lastUserIndex)
173
+ }
174
+
175
+ return messages
176
+ }