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/CHANGELOG.md +84 -0
- package/LICENSE +21 -0
- package/README.md +122 -0
- package/dist/server.js +19792 -0
- package/dist/tui.js +22742 -0
- package/docs/USAGE.md +155 -0
- package/package.json +93 -0
- package/src/core/actions.ts +53 -0
- package/src/core/adopt.ts +93 -0
- package/src/core/consumers.ts +39 -0
- package/src/core/crop.ts +176 -0
- package/src/core/cropplan.ts +201 -0
- package/src/core/ctree-args.ts +89 -0
- package/src/core/decision.ts +97 -0
- package/src/core/journal.ts +343 -0
- package/src/core/lanes.ts +149 -0
- package/src/core/navigation.ts +95 -0
- package/src/core/tokens.ts +105 -0
- package/src/core/transcript.ts +124 -0
- package/src/core/tree.ts +738 -0
- package/src/core/undo.ts +52 -0
- package/src/server/index.ts +278 -0
- package/src/shared/adopt.ts +82 -0
- package/src/shared/debug.ts +9 -0
- package/src/shared/store.ts +286 -0
- package/src/tui/actions.ts +447 -0
- package/src/tui/editor.ts +45 -0
- package/src/tui/index.tsx +421 -0
- package/src/tui/route.tsx +1058 -0
- package/src/tui/transcripts.ts +55 -0
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
|
+
}
|
package/src/core/crop.ts
ADDED
|
@@ -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
|
+
}
|