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,447 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI-side actions (DESIGN.md §6.2, §6.3, §4.2). Everything here talks to OpenCode
|
|
3
|
+
* through `api.client` (SDK v2) and writes journal lines through the shared store.
|
|
4
|
+
* Pure planning lives in core; this file only executes plans.
|
|
5
|
+
*/
|
|
6
|
+
import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
|
|
7
|
+
import { createSignal } from "solid-js"
|
|
8
|
+
import type { JournalStore } from "../shared/store.js"
|
|
9
|
+
import type { CropAppliedData, JournalEntry } from "../core/journal.js"
|
|
10
|
+
import type { UndoPlan } from "../core/undo.js"
|
|
11
|
+
import { DECISION_SYSTEM, branchTranscriptText, buildDecisionDraftPrompt, decisionMessageText, decisionTemplate, openSiblings } from "../core/decision.js"
|
|
12
|
+
import { editInExternalEditor, hasEditor } from "./editor.js"
|
|
13
|
+
import { debug } from "../shared/debug.js"
|
|
14
|
+
import { fetchTranscript } from "./transcripts.js"
|
|
15
|
+
|
|
16
|
+
export type JumpPlan =
|
|
17
|
+
| { kind: "noop"; reason: string }
|
|
18
|
+
| { kind: "switch"; sessionID: string }
|
|
19
|
+
| { kind: "fork"; sessionID: string; messageID: string; prefill?: string; mode: "redo" | "continue" }
|
|
20
|
+
|
|
21
|
+
export type ActionContext = {
|
|
22
|
+
api: TuiPluginApi
|
|
23
|
+
store: JournalStore
|
|
24
|
+
directory: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type SummaryChoice = { kind: "none" } | { kind: "summarize"; customInstructions?: string }
|
|
28
|
+
|
|
29
|
+
const [revision, setRevision] = createSignal(0)
|
|
30
|
+
/** The journal is plain files, so views built from `store.stateFor*` subscribe to this
|
|
31
|
+
* counter to notice writes made by this TUI (the sidebar card, the route). */
|
|
32
|
+
export const journalRevision = revision
|
|
33
|
+
export function bumpJournal(): void {
|
|
34
|
+
setRevision((n) => n + 1)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Every journal write from the TUI goes through here, so none can forget the bump. */
|
|
38
|
+
function record<T extends JournalEntry["type"]>(ctx: ActionContext, treeId: string, type: T, data: Extract<JournalEntry, { type: T }>["data"]): JournalEntry {
|
|
39
|
+
// generic forwarding trips TS's intersection of all data shapes, as in JournalStore.record
|
|
40
|
+
const entry = ctx.store.record(treeId, type, data as never, "tui")
|
|
41
|
+
bumpJournal()
|
|
42
|
+
return entry
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const SUMMARY_SYSTEM =
|
|
46
|
+
"You are a context summarization assistant. Read a conversation between a user and an AI coding assistant and produce a structured summary in the exact format requested. Do NOT continue the conversation; ONLY output the summary."
|
|
47
|
+
|
|
48
|
+
const SUMMARY_INSTRUCTIONS = `Create a structured summary of this conversation branch for context when returning later.
|
|
49
|
+
|
|
50
|
+
Use this EXACT format:
|
|
51
|
+
|
|
52
|
+
## Goal
|
|
53
|
+
[What was the user trying to accomplish in this branch?]
|
|
54
|
+
|
|
55
|
+
## Constraints & Preferences
|
|
56
|
+
- [Any constraints, preferences, or requirements mentioned, or "(none)"]
|
|
57
|
+
|
|
58
|
+
## Progress
|
|
59
|
+
### Done
|
|
60
|
+
- [x] [Completed tasks/changes]
|
|
61
|
+
### In Progress
|
|
62
|
+
- [ ] [Work started but not finished]
|
|
63
|
+
### Blocked
|
|
64
|
+
- [Issues preventing progress, if any]
|
|
65
|
+
|
|
66
|
+
## Key Decisions
|
|
67
|
+
- **[Decision]**: [Brief rationale]
|
|
68
|
+
|
|
69
|
+
## Next Steps
|
|
70
|
+
1. [What should happen next]
|
|
71
|
+
|
|
72
|
+
Keep each section concise. Preserve exact file paths, function names, and error messages.`
|
|
73
|
+
|
|
74
|
+
export const SUMMARY_PREAMBLE = "The user explored a different conversation branch before returning here.\nSummary of that exploration:\n\n"
|
|
75
|
+
|
|
76
|
+
/** Mirror the tree linkage into `session.metadata.ctree` (DESIGN.md §4.2). Best effort. */
|
|
77
|
+
export async function mirrorMetadata(
|
|
78
|
+
ctx: ActionContext,
|
|
79
|
+
sessionID: string,
|
|
80
|
+
ctree: Record<string, unknown>,
|
|
81
|
+
): Promise<void> {
|
|
82
|
+
const existing = await ctx.api.client.session.get({ sessionID, directory: ctx.directory }).catch(() => undefined)
|
|
83
|
+
const metadata = { ...((existing?.data as any)?.metadata ?? {}), ctree: { ...(((existing?.data as any)?.metadata ?? {}).ctree ?? {}), ...ctree } }
|
|
84
|
+
await ctx.api.client.session.update({ sessionID, directory: ctx.directory, metadata }).catch(() => undefined)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function navigateToSession(ctx: ActionContext, sessionID: string): void {
|
|
88
|
+
ctx.api.route.navigate("session", { sessionID })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Abort a streaming session before we leave it (DESIGN.md §9, Pi #7022). */
|
|
92
|
+
async function abortIfBusy(ctx: ActionContext, sessionID: string): Promise<boolean> {
|
|
93
|
+
const status = ctx.api.state.session.status(sessionID)
|
|
94
|
+
if (!status || (status as any).type === "idle") return false
|
|
95
|
+
await ctx.api.client.session.abort({ sessionID, directory: ctx.directory }).catch(() => undefined)
|
|
96
|
+
ctx.api.ui.toast({ variant: "warning", message: "Aborted the running response before switching" })
|
|
97
|
+
return true
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Fork `sessionID` at `messageID` (exclusive, OpenCode semantics), journal it, mirror metadata. */
|
|
101
|
+
export async function forkBranch(
|
|
102
|
+
ctx: ActionContext,
|
|
103
|
+
input: { sessionID: string; messageID: string; name?: string; kind: "explicit" | "jump" | "redo"; branchModel?: string; trunkModel?: string; title?: string },
|
|
104
|
+
): Promise<string> {
|
|
105
|
+
const treeId = ctx.store.ensureTree(input.sessionID, "tui")
|
|
106
|
+
// journal anchors are the last *shared* message (inclusive); OpenCode's fork boundary is exclusive.
|
|
107
|
+
// The owning session may not be loaded in this TUI (jumping into an ancestor), so read it via the SDK.
|
|
108
|
+
const parentMsgs = (await fetchTranscript(ctx.api, input.sessionID, ctx.directory)).messages
|
|
109
|
+
const boundary = parentMsgs.findIndex((m) => m.id === input.messageID)
|
|
110
|
+
if (boundary === -1) throw new Error("fork point not found in the session")
|
|
111
|
+
const anchorMessageID = boundary > 0 ? parentMsgs[boundary - 1]!.id : ""
|
|
112
|
+
const forked = await ctx.api.client.session.fork({ sessionID: input.sessionID, messageID: input.messageID, directory: ctx.directory })
|
|
113
|
+
const forkedID = (forked.data as any)?.id as string | undefined
|
|
114
|
+
if (!forkedID) throw new Error("fork did not return a session id")
|
|
115
|
+
ctx.store.registerSession(forkedID, treeId)
|
|
116
|
+
record(ctx, treeId, "branch.opened", { sessionID: forkedID, parentSessionID: input.sessionID, anchorMessageID, name: input.name, kind: input.kind, branchModel: input.branchModel, trunkModel: input.trunkModel })
|
|
117
|
+
if (input.title) await ctx.api.client.session.update({ sessionID: forkedID, directory: ctx.directory, title: input.title }).catch(() => undefined)
|
|
118
|
+
await mirrorMetadata(ctx, forkedID, { treeId, parentSessionID: input.sessionID, anchorMessageID, name: input.name, status: "open" })
|
|
119
|
+
await mirrorMetadata(ctx, input.sessionID, { treeId })
|
|
120
|
+
return forkedID
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** `/branch <name> [model]` from the tip of the current session (DESIGN.md §6.3). */
|
|
124
|
+
export async function createNamedBranch(
|
|
125
|
+
ctx: ActionContext,
|
|
126
|
+
input: { sessionID: string; name: string; model?: string; trunkModel?: string },
|
|
127
|
+
): Promise<string> {
|
|
128
|
+
const msgs = ctx.api.state.session.messages(input.sessionID)
|
|
129
|
+
const last = msgs[msgs.length - 1]
|
|
130
|
+
debug("branch.start", { sessionID: input.sessionID, name: input.name, messages: msgs.length, status: ctx.api.state.session.status(input.sessionID) })
|
|
131
|
+
if (!last) throw new Error("nothing to branch from yet")
|
|
132
|
+
await abortIfBusy(ctx, input.sessionID)
|
|
133
|
+
debug("branch.afterAbort")
|
|
134
|
+
// Fork "after the tip": OpenCode copies messages strictly before messageID, so we pass a
|
|
135
|
+
// sentinel by forking without messageID (full copy) — the SDK accepts messageID undefined.
|
|
136
|
+
const treeId = ctx.store.ensureTree(input.sessionID, "tui")
|
|
137
|
+
debug("branch.tree", { treeId })
|
|
138
|
+
const forked = await ctx.api.client.session.fork({ sessionID: input.sessionID, directory: ctx.directory })
|
|
139
|
+
debug("branch.forked", { data: forked.data, error: forked.error })
|
|
140
|
+
const forkedID = (forked.data as any)?.id as string | undefined
|
|
141
|
+
if (!forkedID) throw new Error("fork did not return a session id")
|
|
142
|
+
ctx.store.registerSession(forkedID, treeId)
|
|
143
|
+
record(ctx, treeId, "branch.opened", { sessionID: forkedID, parentSessionID: input.sessionID, anchorMessageID: last.id, name: input.name, kind: "explicit", branchModel: input.model, trunkModel: input.trunkModel })
|
|
144
|
+
debug("branch.recorded")
|
|
145
|
+
record(ctx, treeId, "label.set", { sessionID: input.sessionID, messageID: last.id, label: `⎇ ${input.name}` })
|
|
146
|
+
await ctx.api.client.session.update({ sessionID: forkedID, directory: ctx.directory, title: `⎇ ${input.name}` }).catch(() => undefined)
|
|
147
|
+
await mirrorMetadata(ctx, forkedID, { treeId, parentSessionID: input.sessionID, anchorMessageID: last.id, name: input.name, status: "open" })
|
|
148
|
+
await mirrorMetadata(ctx, input.sessionID, { treeId })
|
|
149
|
+
debug("branch.mirrored")
|
|
150
|
+
navigateToSession(ctx, forkedID)
|
|
151
|
+
ctx.api.ui.toast({ variant: "success", message: `⎇ ${input.name} opened${input.model ? ` on ${input.model}` : ""}` })
|
|
152
|
+
debug("branch.done", { forkedID })
|
|
153
|
+
return forkedID
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Execute a jump plan (DESIGN.md §6.2). Returns the session we ended up in. */
|
|
157
|
+
export async function executeJump(
|
|
158
|
+
ctx: ActionContext,
|
|
159
|
+
plan: JumpPlan,
|
|
160
|
+
opts: { currentSessionID: string; summary: SummaryChoice },
|
|
161
|
+
): Promise<string | undefined> {
|
|
162
|
+
debug("jump.plan", { plan, current: opts.currentSessionID, summary: opts.summary.kind })
|
|
163
|
+
if (plan.kind === "noop") {
|
|
164
|
+
ctx.api.ui.toast({ message: plan.reason })
|
|
165
|
+
return undefined
|
|
166
|
+
}
|
|
167
|
+
await abortIfBusy(ctx, opts.currentSessionID)
|
|
168
|
+
const leavingTip = ctx.api.state.session.messages(opts.currentSessionID).at(-1)?.id
|
|
169
|
+
let target: string
|
|
170
|
+
if (plan.kind === "switch") {
|
|
171
|
+
target = plan.sessionID
|
|
172
|
+
} else {
|
|
173
|
+
target = await forkBranch(ctx, { sessionID: plan.sessionID, messageID: plan.messageID, kind: plan.mode === "redo" ? "redo" : "jump" })
|
|
174
|
+
}
|
|
175
|
+
if (opts.summary.kind === "summarize" && leavingTip && target !== opts.currentSessionID) {
|
|
176
|
+
// the fork already exists; a failed summary must not strand the user on the old session
|
|
177
|
+
await summarizeInto(ctx, { fromSessionID: opts.currentSessionID, fromMessageID: leavingTip, targetSessionID: target, customInstructions: opts.summary.customInstructions }).catch((e) =>
|
|
178
|
+
ctx.api.ui.toast({ variant: "error", message: `summary failed: ${e instanceof Error ? e.message : String(e)} — moved without it` }),
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
debug("jump.navigate", { target })
|
|
182
|
+
navigateToSession(ctx, target)
|
|
183
|
+
if (plan.kind === "fork" && plan.prefill) {
|
|
184
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
185
|
+
await ctx.api.client.tui.appendPrompt({ text: plan.prefill, directory: ctx.directory }).catch(() => undefined)
|
|
186
|
+
}
|
|
187
|
+
return target
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Pi-style summary of the branch we are leaving, generated in a throw-away helper session and
|
|
191
|
+
* injected into the destination with `noReply` (DESIGN.md §6.2, journal `summary.recorded`). */
|
|
192
|
+
export async function summarizeInto(
|
|
193
|
+
ctx: ActionContext,
|
|
194
|
+
input: { fromSessionID: string; fromMessageID: string; targetSessionID: string; customInstructions?: string; signal?: AbortSignal },
|
|
195
|
+
): Promise<string | undefined> {
|
|
196
|
+
const msgs = await ctx.api.client.session.messages({ sessionID: input.fromSessionID, directory: ctx.directory })
|
|
197
|
+
const transcript = ((msgs.data as any[]) ?? [])
|
|
198
|
+
.map((m) => {
|
|
199
|
+
const role = m.info.role === "user" ? "[User]" : "[Assistant]"
|
|
200
|
+
const text = (m.parts as any[])
|
|
201
|
+
.map((p) => (p.type === "text" ? p.text : p.type === "tool" ? `(tool ${p.tool}: ${JSON.stringify(p.state?.input ?? {}).slice(0, 200)} → ${String(p.state?.output ?? "").slice(0, 400)})` : ""))
|
|
202
|
+
.filter(Boolean)
|
|
203
|
+
.join("\n")
|
|
204
|
+
return text ? `${role}: ${text}` : ""
|
|
205
|
+
})
|
|
206
|
+
.filter(Boolean)
|
|
207
|
+
.join("\n\n")
|
|
208
|
+
debug("summary.start", { from: input.fromSessionID, target: input.targetSessionID, chars: transcript.length })
|
|
209
|
+
const helper = await ctx.api.client.session.create({ directory: ctx.directory, title: "Context tree: branch summary" })
|
|
210
|
+
const helperID = (helper.data as any)?.id as string | undefined
|
|
211
|
+
if (!helperID) throw new Error("could not create helper session")
|
|
212
|
+
try {
|
|
213
|
+
const instructions = input.customInstructions ? `${SUMMARY_INSTRUCTIONS}\n\nAdditional focus from the user:\n${input.customInstructions}` : SUMMARY_INSTRUCTIONS
|
|
214
|
+
const reply = await ctx.api.client.session.prompt({
|
|
215
|
+
sessionID: helperID,
|
|
216
|
+
directory: ctx.directory,
|
|
217
|
+
system: SUMMARY_SYSTEM,
|
|
218
|
+
parts: [{ type: "text", text: `<conversation>\n${transcript}\n</conversation>\n\n${instructions}` }],
|
|
219
|
+
})
|
|
220
|
+
const summary = ((reply.data as any)?.parts as any[] | undefined)
|
|
221
|
+
?.filter((p) => p.type === "text" && !p.synthetic && !p.ignored)
|
|
222
|
+
.map((p) => p.text)
|
|
223
|
+
.join("")
|
|
224
|
+
.trim()
|
|
225
|
+
debug("summary.generated", { chars: summary?.length ?? 0 })
|
|
226
|
+
if (!summary) throw new Error("summary model returned no text")
|
|
227
|
+
const injected = await ctx.api.client.session.prompt({
|
|
228
|
+
sessionID: input.targetSessionID,
|
|
229
|
+
directory: ctx.directory,
|
|
230
|
+
noReply: true,
|
|
231
|
+
parts: [{ type: "text", text: SUMMARY_PREAMBLE + summary, metadata: { ctree: { kind: "summary", fromSessionID: input.fromSessionID } } }],
|
|
232
|
+
})
|
|
233
|
+
const messageID = (injected.data as any)?.info?.id ?? (injected.data as any)?.id
|
|
234
|
+
const treeId = ctx.store.ensureTree(input.targetSessionID, "tui")
|
|
235
|
+
record(ctx, treeId, "summary.recorded", { sessionID: input.targetSessionID, messageID: String(messageID ?? ""), fromSessionID: input.fromSessionID, fromMessageID: input.fromMessageID })
|
|
236
|
+
ctx.api.ui.toast({ variant: "success", message: "Branch summary added" })
|
|
237
|
+
return summary
|
|
238
|
+
} finally {
|
|
239
|
+
await ctx.api.client.session.delete({ sessionID: helperID, directory: ctx.directory }).catch(() => undefined)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function setLabel(ctx: ActionContext, input: { sessionID: string; messageID: string; label: string | null }): void {
|
|
244
|
+
const treeId = ctx.store.ensureTree(input.sessionID, "tui")
|
|
245
|
+
record(ctx, treeId, "label.set", { sessionID: input.sessionID, messageID: input.messageID, label: input.label })
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Record a crop (the server half applies it on the next turn). With `hard`, result crops
|
|
249
|
+
* additionally set OpenCode's own `state.time.compacted` flag on the tool part, so the
|
|
250
|
+
* TUI renders "[Old tool result content cleared]" and hides the text (DESIGN.md §6.5). */
|
|
251
|
+
export async function applyCrop(ctx: ActionContext, data: CropAppliedData, opts: { hard?: boolean } = {}): Promise<string> {
|
|
252
|
+
const treeId = ctx.store.ensureTree(data.sessionID, "tui")
|
|
253
|
+
const entry = record(ctx, treeId, "crop.applied", data)
|
|
254
|
+
if (opts.hard && data.mode === "result") await setCompacted(ctx, data.sessionID, data.targets.map((t) => ({ messageID: t.messageID, partID: t.partID })), Date.now())
|
|
255
|
+
return entry.id
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function setCompacted(ctx: ActionContext, sessionID: string, targets: { messageID: string; partID?: string }[], value: number | undefined): Promise<void> {
|
|
259
|
+
for (const t of targets) {
|
|
260
|
+
if (!t.partID) continue
|
|
261
|
+
const part = (ctx.api.state.part(t.messageID) as unknown as any[]).find((p) => p.id === t.partID)
|
|
262
|
+
if (!part || part.type !== "tool" || part.state?.status !== "completed") continue
|
|
263
|
+
const next = { ...part, state: { ...part.state, time: { ...(part.state.time ?? {}), compacted: value } } }
|
|
264
|
+
if (value === undefined) delete next.state.time.compacted
|
|
265
|
+
await ctx.api.client.part.update({ sessionID, messageID: t.messageID, partID: t.partID, directory: ctx.directory, part: next } as any).catch((e: unknown) => debug("hardcrop.error", { error: String(e) }))
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Execute an undo plan (DESIGN.md §6.6). Returns the session to show afterwards. */
|
|
270
|
+
export async function executeUndo(ctx: ActionContext, sessionID: string, plan: UndoPlan): Promise<string | undefined> {
|
|
271
|
+
const treeId = ctx.store.ensureTree(sessionID, "tui")
|
|
272
|
+
debug("undo.plan", { plan })
|
|
273
|
+
switch (plan.kind) {
|
|
274
|
+
case "nothing":
|
|
275
|
+
ctx.api.ui.toast({ message: "nothing to undo on this path" })
|
|
276
|
+
return undefined
|
|
277
|
+
case "restore-crop": {
|
|
278
|
+
record(ctx, treeId, "crop.restored", { cropID: plan.cropID })
|
|
279
|
+
const crop = ctx.store.stateFor(treeId).crops[plan.cropID]
|
|
280
|
+
if (crop && crop.mode === "result") await setCompacted(ctx, sessionID, crop.targets.map((t) => ({ messageID: t.messageID, partID: t.partID })), undefined)
|
|
281
|
+
ctx.api.ui.toast({ variant: "success", message: `↶ restored ${plan.mode === "turn" ? "dropped turn" : "cropped result"} (~${Math.round(plan.estTokens / 100) / 10}k tokens back in context)` })
|
|
282
|
+
return undefined
|
|
283
|
+
}
|
|
284
|
+
case "abandon-branch": {
|
|
285
|
+
await abortIfBusy(ctx, sessionID)
|
|
286
|
+
record(ctx, treeId, "branch.closed", { sessionID: plan.sessionID, status: "abandoned" })
|
|
287
|
+
await mirrorMetadata(ctx, plan.sessionID, { status: "abandoned" })
|
|
288
|
+
navigateToSession(ctx, plan.parentSessionID)
|
|
289
|
+
ctx.api.ui.toast({ variant: "success", message: `↶ back on the trunk; ⎇ ${plan.name ?? "branch"} kept as abandoned` })
|
|
290
|
+
return plan.parentSessionID
|
|
291
|
+
}
|
|
292
|
+
case "reopen-branch": {
|
|
293
|
+
const branch = ctx.store.stateFor(treeId).sessions[plan.sessionID]
|
|
294
|
+
if (!branch) return undefined
|
|
295
|
+
record(ctx, treeId, "branch.opened", { sessionID: branch.sessionID, parentSessionID: branch.parentSessionID, anchorMessageID: branch.anchorMessageID, name: branch.name, kind: branch.kind, branchModel: branch.branchModel, trunkModel: branch.trunkModel })
|
|
296
|
+
await mirrorMetadata(ctx, plan.sessionID, { status: "open" })
|
|
297
|
+
navigateToSession(ctx, plan.sessionID)
|
|
298
|
+
ctx.api.ui.toast({ variant: "success", message: `↶ re-opened ⎇ ${branch.name ?? "branch"}${plan.decisionMessageID ? " (its decision record is hidden from the model)" : ""}` })
|
|
299
|
+
return plan.sessionID
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Run one prompt in a throw-away helper session and return the assistant text. */
|
|
305
|
+
export async function draftWithHelper(ctx: ActionContext, input: { title: string; system: string; prompt: string; model?: { providerID: string; modelID: string } }): Promise<string> {
|
|
306
|
+
const helper = await ctx.api.client.session.create({ directory: ctx.directory, title: input.title })
|
|
307
|
+
const helperID = (helper.data as any)?.id as string | undefined
|
|
308
|
+
if (!helperID) throw new Error("could not create helper session")
|
|
309
|
+
try {
|
|
310
|
+
const reply = await ctx.api.client.session.prompt({ sessionID: helperID, directory: ctx.directory, system: input.system, model: input.model, parts: [{ type: "text", text: input.prompt }] })
|
|
311
|
+
const text = ((reply.data as any)?.parts as any[] | undefined)
|
|
312
|
+
?.filter((p) => p.type === "text" && !p.synthetic && !p.ignored)
|
|
313
|
+
.map((p) => p.text)
|
|
314
|
+
.join("")
|
|
315
|
+
.trim()
|
|
316
|
+
if (!text) throw new Error("the model returned no text")
|
|
317
|
+
return text
|
|
318
|
+
} finally {
|
|
319
|
+
await ctx.api.client.session.delete({ sessionID: helperID, directory: ctx.directory }).catch(() => undefined)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export type MergeMode = "squash" | "squash-no-llm" | "discard" | "tournament"
|
|
324
|
+
|
|
325
|
+
/** The promise every merge confirmation repeats — the reason a merge is safe to try. */
|
|
326
|
+
export const MERGE_TRUST = "Your transcript is never rewritten; the record is appended to the trunk as a normal message."
|
|
327
|
+
|
|
328
|
+
/** What the $EDITOR gate opens with: the draft is a proposal, saving is the confirmation. */
|
|
329
|
+
export const MERGE_GATE_NOTICE = `Edit the ◆ decision record, then save to confirm (empty file or a non-zero exit aborts the merge).\n${MERGE_TRUST}`
|
|
330
|
+
|
|
331
|
+
/** Shared copy for the merge picker (palette and route). */
|
|
332
|
+
export function mergeDialogTitle(branchName: string, trunkTitle?: string): string {
|
|
333
|
+
return `Merge ⎇ ${branchName} → ${trunkTitle ? clip(trunkTitle, 28) : "the trunk"}`
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Tournament only exists when there is something to compare against. Descriptions render on
|
|
337
|
+
* the option's own line, which truncates past ~50 columns — keep them short; the full promise
|
|
338
|
+
* is repeated at the confirmation step (`MERGE_TRUST`). */
|
|
339
|
+
export function mergeDialogOptions(input: { siblings: number }): { title: string; value: MergeMode; description: string }[] {
|
|
340
|
+
return [
|
|
341
|
+
{ title: "Squash", value: "squash" as const, description: "drafts a ◆ decision record you confirm" },
|
|
342
|
+
{ title: "Squash without LLM", value: "squash-no-llm" as const, description: "you write the record yourself" },
|
|
343
|
+
{ title: "Discard", value: "discard" as const, description: "rejected; nothing lands in the trunk" },
|
|
344
|
+
...(input.siblings > 0 ? [{ title: "Tournament", value: "tournament" as const, description: "compare sibling branches and keep one" }] : []),
|
|
345
|
+
]
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export type MergeInput = {
|
|
349
|
+
sessionID: string
|
|
350
|
+
mode: MergeMode
|
|
351
|
+
note?: string
|
|
352
|
+
/** how to confirm the record: external editor (default) or a pre-confirmed text */
|
|
353
|
+
confirm?: (draft: string) => Promise<string | undefined>
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Close the branch the session lives on (DESIGN.md §6.4). Returns the parent session id. */
|
|
357
|
+
export async function mergeBranch(ctx: ActionContext, input: MergeInput): Promise<string | undefined> {
|
|
358
|
+
const treeId = ctx.store.ensureTree(input.sessionID, "tui")
|
|
359
|
+
const state = ctx.store.stateFor(treeId)
|
|
360
|
+
const branch = state.sessions[input.sessionID]
|
|
361
|
+
if (!branch || branch.status !== "open") throw new Error("this session is not an open branch")
|
|
362
|
+
const parentID = branch.parentSessionID
|
|
363
|
+
const name = branch.name ?? "branch"
|
|
364
|
+
debug("merge.start", { mode: input.mode, sessionID: input.sessionID, parentID })
|
|
365
|
+
await abortIfBusy(ctx, input.sessionID)
|
|
366
|
+
|
|
367
|
+
if (input.mode === "discard") {
|
|
368
|
+
record(ctx, treeId, "branch.closed", { sessionID: input.sessionID, status: "rejected", note: input.note })
|
|
369
|
+
await mirrorMetadata(ctx, input.sessionID, { status: "rejected" })
|
|
370
|
+
navigateToSession(ctx, parentID)
|
|
371
|
+
ctx.api.ui.toast({ variant: "success", message: `⎇ ${name} discarded — back on the trunk` })
|
|
372
|
+
return parentID
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// --- draft ---------------------------------------------------------------
|
|
376
|
+
const parentMsgs = await ctx.api.client.session.messages({ sessionID: parentID, directory: ctx.directory })
|
|
377
|
+
const parentMessageIDs = ((parentMsgs.data as any[]) ?? []).map((m) => String(m.info.id))
|
|
378
|
+
const own = await fetchOwnTranscript(ctx, input.sessionID)
|
|
379
|
+
const transcript = branchTranscriptText(own, { messageID: branch.anchorMessageID, parentMessageIDs })
|
|
380
|
+
const model = branch.branchModel ?? branch.trunkModel
|
|
381
|
+
const modelRef = model ? { providerID: model.split("/")[0]!, modelID: model.split("/").slice(1).join("/") } : undefined
|
|
382
|
+
const siblingIDs = input.mode === "tournament" ? openSiblings(state, input.sessionID) : []
|
|
383
|
+
const siblings = await Promise.all(
|
|
384
|
+
siblingIDs.map(async (id) => {
|
|
385
|
+
const tr = await fetchOwnTranscript(ctx, id)
|
|
386
|
+
const b = state.sessions[id]!
|
|
387
|
+
return { name: b.name ?? id, transcript: branchTranscriptText(tr, { messageID: b.anchorMessageID, parentMessageIDs }, 800) }
|
|
388
|
+
}),
|
|
389
|
+
)
|
|
390
|
+
let draft: string
|
|
391
|
+
if (input.mode === "squash-no-llm") {
|
|
392
|
+
draft = decisionTemplate(name, model)
|
|
393
|
+
} else {
|
|
394
|
+
ctx.api.ui.toast({ message: `drafting the decision record for ⎇ ${name}…` })
|
|
395
|
+
draft = await draftWithHelper(ctx, { title: `Context tree: draft for ${name}`, system: DECISION_SYSTEM, prompt: buildDecisionDraftPrompt({ branchName: name, model, transcript, siblings }), model: modelRef })
|
|
396
|
+
}
|
|
397
|
+
debug("merge.drafted", { chars: draft.length })
|
|
398
|
+
|
|
399
|
+
// --- gate ----------------------------------------------------------------
|
|
400
|
+
const confirm = input.confirm ?? ((d: string) => editInExternalEditor(ctx.api.renderer as any, d, ctx.directory, MERGE_GATE_NOTICE))
|
|
401
|
+
if (!input.confirm && !hasEditor()) throw new Error("no $EDITOR configured — set VISUAL/EDITOR, or use the in-app confirm")
|
|
402
|
+
const confirmed = await confirm(draft)
|
|
403
|
+
if (!confirmed) {
|
|
404
|
+
ctx.api.ui.toast({ variant: "warning", message: "merge aborted — nothing written" })
|
|
405
|
+
return undefined
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// --- land ----------------------------------------------------------------
|
|
409
|
+
const text = decisionMessageText(confirmed, name)
|
|
410
|
+
const landed = await ctx.api.client.session.prompt({
|
|
411
|
+
sessionID: parentID,
|
|
412
|
+
directory: ctx.directory,
|
|
413
|
+
noReply: true,
|
|
414
|
+
parts: [{ type: "text", text, metadata: { ctree: { kind: "decision", forkSessionID: input.sessionID, branchName: name } } }],
|
|
415
|
+
})
|
|
416
|
+
const messageID = String((landed.data as any)?.info?.id ?? (landed.data as any)?.id ?? "")
|
|
417
|
+
if (!messageID) throw new Error("could not write the decision record into the trunk")
|
|
418
|
+
record(ctx, treeId, "decision.recorded", { sessionID: parentID, messageID, forkSessionID: input.sessionID, branchName: name, siblings: siblings.map((s) => ({ name: s.name })), text })
|
|
419
|
+
record(ctx, treeId, "branch.closed", { sessionID: input.sessionID, status: "squashed", decisionMessageID: messageID })
|
|
420
|
+
for (const id of siblingIDs) record(ctx, treeId, "branch.closed", { sessionID: id, status: "rejected", note: `lost tournament to ${name}` })
|
|
421
|
+
await mirrorMetadata(ctx, input.sessionID, { status: "squashed", decisionMessageID: messageID })
|
|
422
|
+
for (const id of siblingIDs) await mirrorMetadata(ctx, id, { status: "rejected" })
|
|
423
|
+
debug("merge.landed", { messageID, siblings: siblingIDs.length })
|
|
424
|
+
navigateToSession(ctx, parentID)
|
|
425
|
+
ctx.api.ui.toast({ variant: "success", message: `◆ merged ⎇ ${name}${siblingIDs.length ? ` (+${siblingIDs.length} sibling${siblingIDs.length === 1 ? "" : "s"} closed)` : ""}` })
|
|
426
|
+
return parentID
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function fetchOwnTranscript(ctx: ActionContext, sessionID: string) {
|
|
430
|
+
const res = await ctx.api.client.session.messages({ sessionID, directory: ctx.directory })
|
|
431
|
+
const messages = ((res.data as any[]) ?? []).map((m) => ({
|
|
432
|
+
id: m.info.id as string,
|
|
433
|
+
role: (m.info.role === "user" ? "user" : "assistant") as "user" | "assistant",
|
|
434
|
+
time: m.info.time,
|
|
435
|
+
tokens: m.info.tokens,
|
|
436
|
+
parts: (m.parts as any[]).map((p) => ({ id: p.id, type: p.type, text: p.text, tool: p.tool, callID: p.callID, state: p.state, time: p.time, metadata: p.metadata })),
|
|
437
|
+
}))
|
|
438
|
+
return { sessionID, title: sessionID, status: "available" as const, messages }
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Shared copy for the branch-name dialog (palette and route). */
|
|
442
|
+
export const BRANCH_DIALOG = { title: "Branch here → new OpenCode session", placeholder: "name, e.g. try-redis", modelTitle: "Model for this branch (Enter keeps the current one)" }
|
|
443
|
+
|
|
444
|
+
/** Truncate to `max` columns with an ellipsis — the sidebar and dialogs are narrow. */
|
|
445
|
+
export function clip(text: string, max: number): string {
|
|
446
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text
|
|
447
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The merge editor gate (DESIGN.md §6.4): suspend the renderer, hand the draft to
|
|
3
|
+
* $VISUAL/$EDITOR on a temp file, resume. Mirrors OpenCode's own `openEditor()`.
|
|
4
|
+
* Returns `undefined` when no editor is configured or the user aborted (non-zero
|
|
5
|
+
* exit / empty file).
|
|
6
|
+
*/
|
|
7
|
+
import { spawn } from "node:child_process"
|
|
8
|
+
import fs from "node:fs"
|
|
9
|
+
import os from "node:os"
|
|
10
|
+
import path from "node:path"
|
|
11
|
+
|
|
12
|
+
type RendererLike = { suspend(): void; resume(): void; requestRender?(): void; currentRenderBuffer?: { clear?(): void } }
|
|
13
|
+
|
|
14
|
+
export function hasEditor(): boolean {
|
|
15
|
+
return Boolean(process.env["VISUAL"] || process.env["EDITOR"])
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function editInExternalEditor(renderer: RendererLike, value: string, cwd?: string, notice?: string): Promise<string | undefined> {
|
|
19
|
+
const editor = process.env["VISUAL"] || process.env["EDITOR"]
|
|
20
|
+
if (!editor) return undefined
|
|
21
|
+
// private dir + 0600: the draft can quote source, and /tmp is world-readable
|
|
22
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ctree-"))
|
|
23
|
+
const file = path.join(dir, "decision.md")
|
|
24
|
+
// the notice is a markdown comment, so it never lands in the trunk even if left in place
|
|
25
|
+
const header = notice ? `<!--\n${notice}\n-->\n\n` : ""
|
|
26
|
+
fs.writeFileSync(file, header + value, { mode: 0o600 })
|
|
27
|
+
renderer.suspend()
|
|
28
|
+
renderer.currentRenderBuffer?.clear?.()
|
|
29
|
+
try {
|
|
30
|
+
await new Promise<void>((resolve, reject) => {
|
|
31
|
+
const options = { cwd: cwd && fs.existsSync(cwd) ? cwd : process.cwd(), stdio: "inherit" as const }
|
|
32
|
+
// through the shell, like OpenCode's own openEditor(): $EDITOR may carry flags or quotes
|
|
33
|
+
const child = process.platform === "win32" ? spawn(editor, [file], { ...options, shell: true }) : spawn("sh", ["-c", `${editor} "$1"`, "sh", file], options)
|
|
34
|
+
child.on("error", reject)
|
|
35
|
+
child.on("exit", (code, signal) => (code === 0 ? resolve() : reject(new Error(`editor exited with ${signal ? `signal ${signal}` : `code ${code}`}`))))
|
|
36
|
+
})
|
|
37
|
+
const text = fs.readFileSync(file, "utf8").replace(/^<!--[\s\S]*?-->\s*/, "")
|
|
38
|
+
return text.trim() ? text : undefined
|
|
39
|
+
} finally {
|
|
40
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
41
|
+
renderer.currentRenderBuffer?.clear?.()
|
|
42
|
+
renderer.resume()
|
|
43
|
+
renderer.requestRender?.()
|
|
44
|
+
}
|
|
45
|
+
}
|