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
|
@@ -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
|
+
}
|