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.
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Crop planning (DESIGN.md §6.5): which tool results / turns *can* be cropped,
3
+ * which are protected, the `--auto` rules, and how a selection becomes the
4
+ * `crop.applied` journal payload that core/crop.ts applies.
5
+ *
6
+ * Pure, no OpenCode/opentui/solid-js imports — see test/core-purity.test.ts.
7
+ */
8
+ import type { CropAppliedData, CropTarget } from "./journal.js"
9
+ import { estimateTokens } from "./tokens.js"
10
+ import type { StepPart, Transcript, TranscriptMessage } from "./transcript.js"
11
+
12
+ /** FNV-1a 32-bit, hex — a stable 8-char handle for "this exact output". */
13
+ export function sha8(text: string): string {
14
+ let h = 0x811c9dc5
15
+ for (let i = 0; i < text.length; i++) {
16
+ h ^= text.charCodeAt(i)
17
+ h = Math.imul(h, 0x01000193) >>> 0
18
+ }
19
+ return h.toString(16).padStart(8, "0")
20
+ }
21
+
22
+ export type Protection = "latest-per-tool" | "current-turn" | "decision" | "keep-glob" | "already-cropped" | "too-small"
23
+
24
+ export type ResultCandidate = {
25
+ kind: "result"
26
+ messageID: string
27
+ partID: string
28
+ callID?: string
29
+ tool: string
30
+ arg: string
31
+ estTokens: number
32
+ sha8: string
33
+ /** user-turn index (1-based) this result belongs to */
34
+ turn: number
35
+ turnsAgo: number
36
+ protections: Protection[]
37
+ }
38
+
39
+ export type TurnCandidate = {
40
+ kind: "turn"
41
+ anchorMessageID: string
42
+ turn: number
43
+ turnsAgo: number
44
+ steps: number
45
+ estTokens: number
46
+ sha8: string
47
+ targets: CropTarget[]
48
+ protections: Protection[]
49
+ }
50
+
51
+ export type CropRules = {
52
+ minTokens: number
53
+ olderThanTurns: number
54
+ keep: string[]
55
+ }
56
+
57
+ export const DEFAULT_RULES: CropRules = { minTokens: 10_000, olderThanTurns: 2, keep: [] }
58
+
59
+ function primaryArg(part: StepPart): string {
60
+ const input = part.state?.input
61
+ if (!input || typeof input !== "object") return ""
62
+ const rec = input as Record<string, unknown>
63
+ const v = rec["command"] ?? rec["filePath"] ?? rec["pattern"] ?? rec["url"] ?? rec["path"]
64
+ return typeof v === "string" ? v : ""
65
+ }
66
+
67
+ function globToRegExp(glob: string): RegExp {
68
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".")
69
+ return new RegExp(`^${escaped}$`, "i")
70
+ }
71
+
72
+ function keepMatches(keep: string[], tool: string, arg: string): boolean {
73
+ return keep.some((g) => {
74
+ const re = globToRegExp(g)
75
+ return re.test(tool) || re.test(`${tool} ${arg}`) || re.test(arg)
76
+ })
77
+ }
78
+
79
+ function isCtreeKind(message: TranscriptMessage, kind: string): boolean {
80
+ return message.parts.some((p) => (p.metadata?.["ctree"] as { kind?: string } | undefined)?.kind === kind)
81
+ }
82
+
83
+ type Turn = { index: number; user?: TranscriptMessage; assistants: TranscriptMessage[] }
84
+
85
+ function turnsOf(messages: TranscriptMessage[]): Turn[] {
86
+ const turns: Turn[] = []
87
+ let index = 0
88
+ for (const m of messages) {
89
+ if (m.role === "user") turns.push({ index: ++index, user: m, assistants: [] })
90
+ else {
91
+ // after a compaction the transcript opens with an assistant summary: it gets turn 0
92
+ if (turns.length === 0) turns.push({ index: 0, assistants: [] })
93
+ turns[turns.length - 1]!.assistants.push(m)
94
+ }
95
+ }
96
+ return turns
97
+ }
98
+
99
+ /** Index of the turn in progress (the last one), for the "current turn" protection. */
100
+ function lastTurnIndex(turns: Turn[]): number {
101
+ return turns.at(-1)?.index ?? 0
102
+ }
103
+
104
+ /** Every completed tool result in the transcript, newest last, with its protections. */
105
+ export function resultCandidates(transcript: Transcript, opts: { alreadyCropped?: Set<string>; keep?: string[]; minTokens?: number } = {}): ResultCandidate[] {
106
+ const turns = turnsOf(transcript.messages)
107
+ const total = lastTurnIndex(turns)
108
+ const out: ResultCandidate[] = []
109
+ for (const turn of turns) {
110
+ for (const m of turn.assistants) {
111
+ for (const p of m.parts) {
112
+ if (p.type !== "tool" || p.state?.status !== "completed") continue
113
+ const output = p.state.output ?? ""
114
+ const tool = p.tool ?? "tool"
115
+ const arg = primaryArg(p)
116
+ const protections: Protection[] = []
117
+ if (turn.index === total) protections.push("current-turn")
118
+ if (opts.alreadyCropped?.has(p.id)) protections.push("already-cropped")
119
+ if (opts.keep && keepMatches(opts.keep, tool, arg)) protections.push("keep-glob")
120
+ const estTokens = estimateTokens(output)
121
+ if (opts.minTokens !== undefined && estTokens < opts.minTokens) protections.push("too-small")
122
+ out.push({ kind: "result", messageID: m.id, partID: p.id, callID: p.callID, tool, arg, estTokens, sha8: sha8(output), turn: turn.index, turnsAgo: total - turn.index, protections })
123
+ }
124
+ }
125
+ }
126
+ // latest result per tool is protected (needs an explicit double-mark)
127
+ const seen = new Set<string>()
128
+ for (let i = out.length - 1; i >= 0; i--) {
129
+ const c = out[i]!
130
+ if (!seen.has(c.tool)) {
131
+ seen.add(c.tool)
132
+ c.protections.push("latest-per-tool")
133
+ }
134
+ }
135
+ return out
136
+ }
137
+
138
+ /** Every user turn with its answers, as a droppable unit. */
139
+ export function turnCandidates(transcript: Transcript, opts: { alreadyDropped?: Set<string> } = {}): TurnCandidate[] {
140
+ const turns = turnsOf(transcript.messages)
141
+ const total = lastTurnIndex(turns)
142
+ const out: TurnCandidate[] = []
143
+ for (const turn of turns) {
144
+ if (!turn.user) continue // a compaction-opened turn has no user message to splice from
145
+ const protections: Protection[] = []
146
+ if (turn.index === total) protections.push("current-turn")
147
+ if (isCtreeKind(turn.user, "decision") || turn.assistants.some((m) => isCtreeKind(m, "decision"))) protections.push("decision")
148
+ if (opts.alreadyDropped?.has(turn.user.id)) protections.push("already-cropped")
149
+ const targets: CropTarget[] = []
150
+ let text = ""
151
+ const userText = turn.user.parts.map((p) => p.text ?? "").join("\n")
152
+ text += userText
153
+ targets.push({ messageID: turn.user.id, estTokens: estimateTokens(userText), sha8: "" })
154
+ for (const m of turn.assistants) {
155
+ const t = m.parts.map((p) => (p.type === "tool" ? (p.state?.output ?? "") : (p.text ?? ""))).join("\n")
156
+ text += `\n${t}`
157
+ targets.push({ messageID: m.id, estTokens: estimateTokens(t), sha8: sha8(t) })
158
+ }
159
+ // the anchor target carries the whole-turn handle: it is what crop.ts shows as `recoverable:`
160
+ const handle = sha8(text)
161
+ targets[0]!.sha8 = handle
162
+ const estTokens = targets.reduce((s, t) => s + t.estTokens, 0)
163
+ out.push({ kind: "turn", anchorMessageID: turn.user.id, turn: turn.index, turnsAgo: total - turn.index, steps: 1 + turn.assistants.length, estTokens, sha8: handle, targets, protections })
164
+ }
165
+ return out
166
+ }
167
+
168
+ /** `--auto`: pre-mark results that are big enough, old enough, and unprotected. */
169
+ export function autoMark(candidates: ResultCandidate[], rules: CropRules = DEFAULT_RULES): ResultCandidate[] {
170
+ return candidates.filter((c) => c.estTokens >= rules.minTokens && c.turnsAgo >= rules.olderThanTurns && c.protections.length === 0)
171
+ }
172
+
173
+ /** The single biggest unprotected result (`/crop --top`). With `force`, the "latest per
174
+ * tool" protection is waived (the current turn and decision records never are). */
175
+ export function topCandidate(candidates: ResultCandidate[], force = false): ResultCandidate | undefined {
176
+ const waived: Protection[] = force ? ["too-small", "latest-per-tool"] : ["too-small"]
177
+ return candidates
178
+ .filter((c) => !c.protections.some((p) => !waived.includes(p)))
179
+ .sort((a, b) => b.estTokens - a.estTokens)[0]
180
+ }
181
+
182
+ /** Turn a result-mode selection into one `crop.applied` payload. */
183
+ export function planResultCrop(sessionID: string, selected: ResultCandidate[]): CropAppliedData | undefined {
184
+ if (selected.length === 0) return undefined
185
+ const targets: CropTarget[] = selected.map((c) => ({ messageID: c.messageID, partID: c.partID, callID: c.callID, tool: c.tool, estTokens: c.estTokens, sha8: c.sha8 }))
186
+ // the anchor is informational for result crops: the earliest touched message
187
+ const anchorMessageID = selected.slice().sort((a, b) => a.turn - b.turn)[0]!.messageID
188
+ return { sessionID, mode: "result", targets, anchorMessageID }
189
+ }
190
+
191
+ /** One `crop.applied` payload per dropped turn (turn crops splice, so each is its own line).
192
+ * The current turn is never planned — the model must keep seeing the last user message. */
193
+ export function planTurnCrops(sessionID: string, selected: TurnCandidate[]): CropAppliedData[] {
194
+ return selected
195
+ .filter((t) => !t.protections.includes("current-turn"))
196
+ .map((t) => ({ sessionID, mode: "turn", targets: t.targets, anchorMessageID: t.anchorMessageID }))
197
+ }
198
+
199
+ export function reclaimed(selected: (ResultCandidate | TurnCandidate)[]): number {
200
+ return selected.reduce((s, c) => s + c.estTokens, 0)
201
+ }
@@ -0,0 +1,89 @@
1
+ /** Argument parsing for the headless `/ctree …` server command (DESIGN.md §5). Pure. */
2
+ export type CtreeCommand =
3
+ | { kind: "status" }
4
+ | { kind: "branch"; name: string; model?: string }
5
+ | { kind: "merge-discard"; note?: string }
6
+ | { kind: "crop-top"; apply: boolean; force: boolean }
7
+ | { kind: "crop-auto"; apply: boolean; minTokens?: number; olderThan?: number; keep: string[] }
8
+ | { kind: "undo" }
9
+ | { kind: "decisions"; export?: string }
10
+ | { kind: "help"; error?: string }
11
+
12
+ /** Relative to the project directory, as promised by `CTREE_HELP` and docs/USAGE.md. */
13
+ export const DEFAULT_DECISIONS_EXPORT = "ctree-decisions.md"
14
+
15
+ export function parseCtreeArgs(raw: string): CtreeCommand {
16
+ const args = raw.trim().split(/\s+/).filter(Boolean)
17
+ const [sub, ...rest] = args
18
+ switch (sub) {
19
+ case undefined:
20
+ case "help":
21
+ return { kind: "help" }
22
+ case "status":
23
+ return { kind: "status" }
24
+ case "branch": {
25
+ // only a `provider/model`-shaped last token is a model, so `branch fix flaky test` keeps its name
26
+ const model = rest.length > 1 && rest[rest.length - 1]!.includes("/") ? rest.pop() : undefined
27
+ const name = rest.join(" ")
28
+ if (!name) return { kind: "help", error: "branch needs a name: /ctree branch <name> [provider/model]" }
29
+ return { kind: "branch", name, model }
30
+ }
31
+ case "merge": {
32
+ if (rest[0] !== "--discard") return { kind: "help", error: "headless merge supports --discard only (squash needs the TUI's editor gate): /ctree merge --discard [note]" }
33
+ const note = rest.slice(1).join(" ").trim() || undefined
34
+ return { kind: "merge-discard", note }
35
+ }
36
+ case "crop": {
37
+ const apply = rest.includes("--apply")
38
+ if (rest.includes("--top")) return { kind: "crop-top", apply, force: rest.includes("--force") }
39
+ if (rest.includes("--auto")) {
40
+ // a `--`-prefixed token is the next flag, never this flag's value
41
+ const value = (flag: string): string | undefined => {
42
+ const i = rest.indexOf(flag)
43
+ if (i < 0) return undefined
44
+ const v = rest[i + 1]
45
+ return v && !v.startsWith("--") ? v : ""
46
+ }
47
+ const num = (flag: string): number | undefined | null => {
48
+ const raw = value(flag)
49
+ if (raw === undefined) return undefined
50
+ const n = Number(raw)
51
+ return raw !== "" && Number.isFinite(n) && n >= 0 ? n : null
52
+ }
53
+ const minTokens = num("--min-tokens")
54
+ if (minTokens === null) return { kind: "help", error: "--min-tokens needs a number, e.g. --min-tokens 10000" }
55
+ const olderThan = num("--older-than")
56
+ if (olderThan === null) return { kind: "help", error: "--older-than needs a number of turns, e.g. --older-than 2" }
57
+ const keep: string[] = []
58
+ for (let i = 0; i < rest.length; i++) {
59
+ if (rest[i] !== "--keep") continue
60
+ const glob = rest[i + 1]
61
+ if (!glob || glob.startsWith("--")) return { kind: "help", error: "--keep needs a glob, e.g. --keep chrome.*" }
62
+ keep.push(glob)
63
+ }
64
+ return { kind: "crop-auto", apply, minTokens, olderThan, keep }
65
+ }
66
+ return { kind: "help", error: "crop needs --top or --auto (add --apply to write; without it, dry run)" }
67
+ }
68
+ case "undo":
69
+ return { kind: "undo" }
70
+ case "decisions": {
71
+ const i = rest.indexOf("--export")
72
+ if (i < 0) return { kind: "decisions" }
73
+ const target = rest[i + 1] && !rest[i + 1]!.startsWith("--") ? rest[i + 1]! : DEFAULT_DECISIONS_EXPORT
74
+ return { kind: "decisions", export: target }
75
+ }
76
+ default:
77
+ return { kind: "help", error: `unknown subcommand "${sub}"` }
78
+ }
79
+ }
80
+
81
+ export const CTREE_HELP = `context tree — headless commands
82
+ /ctree status tree, branch, crops of this session
83
+ /ctree branch <name> [provider/model] fork here into a named branch
84
+ /ctree merge --discard [note] close this branch as rejected, back to the parent
85
+ /ctree crop --top [--apply] [--force] biggest unprotected tool result (dry run unless --apply; --force ignores "latest per tool")
86
+ /ctree crop --auto [--apply] [--min-tokens N] [--older-than N] [--keep glob]
87
+ /ctree undo revert the last crop / branch / merge on this path
88
+ /ctree decisions [--export [path]] list ◆ decision records (default export: ./ctree-decisions.md)
89
+ The TUI has the full experience: /tree (ctrl+q), /branch, /merge, /decisions.`
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Decision records (DESIGN.md §6.4, template from pi-context-tree spec §6).
3
+ * Pure: prompt/template builders and transcript serialisation for the merge draft.
4
+ */
5
+ import type { TreeState } from "./journal.js"
6
+ import type { Transcript, TranscriptMessage } from "./transcript.js"
7
+
8
+ export const DECISION_SYSTEM =
9
+ "You write concise engineering decision records. You are given the transcript of a side branch of a coding session. Do NOT continue the conversation. Output ONLY the record in the exact markdown template requested, nothing else."
10
+
11
+ export function decisionTemplate(branchName: string, model?: string, date = new Date()): string {
12
+ return `## Decision: ${branchName}
13
+ **Date:** ${date.toISOString().slice(0, 10)} · **Model:** ${model ?? "unknown"} · **Branch:** ${branchName}
14
+ **Outcome:** <1–3 sentences: what was concluded / built>
15
+ **Why:**
16
+ - <≤5 bullets>
17
+ **Assumptions:** <taken as true but not verified — the trunk must know these>
18
+ **Changes:** <files touched, or "none">
19
+ **Gotchas:** <traps found on the way>
20
+ **Open questions:** <what is still unknown>
21
+ **Confidence / revisit-if:** <high|medium|low; what would change the decision>
22
+
23
+ ### Rejected alternatives
24
+ - **<name>:** <one-line reason>
25
+ `
26
+ }
27
+
28
+ /**
29
+ * Serialise the branch's own messages (after its anchor) for the drafting model.
30
+ * The anchor id lives in the *parent* (fork copies the shared prefix with fresh ids), so
31
+ * it is resolved against `parentMessageIDs` and applied positionally. Omitting
32
+ * `messageID` asks for the whole transcript; an id the parent no longer has is an error,
33
+ * never a silent "include the shared prefix too".
34
+ */
35
+ export function branchTranscriptText(transcript: Transcript, anchor: { messageID?: string; parentMessageIDs: string[] }, toolChars = 2000): string {
36
+ let anchorIndex = -1
37
+ if (anchor.messageID) {
38
+ anchorIndex = anchor.parentMessageIDs.indexOf(anchor.messageID)
39
+ if (anchorIndex === -1) throw new Error(`anchor message ${anchor.messageID} is no longer in the parent session — cannot tell this branch's own turns from the shared prefix`)
40
+ }
41
+ const msgs = transcript.messages.slice(anchorIndex + 1)
42
+ return msgs
43
+ .map((m) => messageText(m, toolChars))
44
+ .filter(Boolean)
45
+ .join("\n\n")
46
+ }
47
+
48
+ function messageText(m: TranscriptMessage, toolChars: number): string {
49
+ const role = m.role === "user" ? "[User]" : "[Assistant]"
50
+ const body = m.parts
51
+ .map((p) => {
52
+ if (p.type === "text") return p.text ?? ""
53
+ if (p.type === "tool") {
54
+ const input = JSON.stringify(p.state?.input ?? {}).slice(0, 300)
55
+ const output = String(p.state?.output ?? "")
56
+ return `(tool ${p.tool ?? "?"} ${input} → ${output.length > toolChars ? `${output.slice(0, toolChars)}… [${output.length - toolChars} more chars]` : output})`
57
+ }
58
+ return ""
59
+ })
60
+ .filter(Boolean)
61
+ .join("\n")
62
+ return body ? `${role}: ${body}` : ""
63
+ }
64
+
65
+ export function buildDecisionDraftPrompt(input: { branchName: string; model?: string; transcript: string; siblings?: { name: string; transcript: string }[] }): string {
66
+ const siblings = input.siblings?.length
67
+ ? `\n\nThe following sibling branches explored alternatives that LOST to this one. Add one line each under "Rejected alternatives" — an epitaph that stops the trunk model from proposing them again:\n${input.siblings.map((s) => `\n<sibling name="${s.name}">\n${s.transcript}\n</sibling>`).join("\n")}`
68
+ : ""
69
+ return `<branch name="${input.branchName}">\n${input.transcript}\n</branch>${siblings}\n\nFill in this template exactly (keep the headings, replace every <placeholder>, drop bullets you cannot fill, target 300–800 words, preserve exact file paths, function names and error messages):\n\n${decisionTemplate(input.branchName, input.model)}`
70
+ }
71
+
72
+ /** The text that lands in the trunk: a ◆ header the user can scan plus the record body. */
73
+ export function decisionMessageText(record: string, branchName: string): string {
74
+ const body = record.trim()
75
+ return body.startsWith("## Decision:") ? `◆ ${body}` : `◆ ## Decision: ${branchName}\n${body}`
76
+ }
77
+
78
+ /** Open sibling branches forked from the same point (tournament, DESIGN.md §6.4). */
79
+ export function openSiblings(state: TreeState, sessionID: string): string[] {
80
+ const me = state.sessions[sessionID]
81
+ if (!me) return []
82
+ return Object.values(state.sessions)
83
+ .filter((b) => b.sessionID !== sessionID && b.status === "open" && b.parentSessionID === me.parentSessionID && b.anchorMessageID === me.anchorMessageID)
84
+ .map((b) => b.sessionID)
85
+ }
86
+
87
+ /** Markdown export of every decision on a session's path (`/decisions --export`). */
88
+ export function exportDecisions(records: { branchName: string; text: string; sessionID: string; at?: number }[]): string {
89
+ if (records.length === 0) return "# Decisions\n\n_(none yet)_\n"
90
+ const one = (r: { branchName: string; text: string; sessionID: string; at?: number }) => {
91
+ const when = r.at ? new Date(r.at).toISOString() : "date unknown"
92
+ // the heading already names the branch, so the record's own "## Decision: <name>" goes
93
+ const body = r.text.replace(/^◆ /, "").replace(/^## Decision:[^\n]*\n?/, "").trim()
94
+ return `## ⎇ ${r.branchName} · ${when}\n_session ${r.sessionID}_\n\n${body}`
95
+ }
96
+ return `# Decisions\n\n${records.map(one).join("\n\n---\n\n")}\n`
97
+ }