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/src/core/undo.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/undo` planning (DESIGN.md §6.6): find the most recent mutation that is still
|
|
3
|
+
* active on the current session's path and describe how to revert it. Pure.
|
|
4
|
+
*/
|
|
5
|
+
import type { JournalEntry, TreeState } from "./journal.js"
|
|
6
|
+
|
|
7
|
+
export type UndoPlan =
|
|
8
|
+
| { kind: "restore-crop"; cropID: string; mode: "result" | "turn"; estTokens: number }
|
|
9
|
+
| { kind: "abandon-branch"; sessionID: string; parentSessionID: string; name?: string }
|
|
10
|
+
| { kind: "reopen-branch"; sessionID: string; decisionMessageID?: string; status: string }
|
|
11
|
+
| { kind: "nothing" }
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Walk the journal newest→oldest and return the first entry that (a) concerns the
|
|
15
|
+
* current session or the branch it lives on and (b) has not itself been undone.
|
|
16
|
+
* `entries` must be the raw journal in file order; `state` its fold.
|
|
17
|
+
*/
|
|
18
|
+
export function planUndo(entries: JournalEntry[], state: TreeState, sessionID: string): UndoPlan {
|
|
19
|
+
const undone = new Set<string>()
|
|
20
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
21
|
+
const e = entries[i]!
|
|
22
|
+
switch (e.type) {
|
|
23
|
+
case "crop.restored":
|
|
24
|
+
undone.add(e.data.cropID)
|
|
25
|
+
continue
|
|
26
|
+
case "crop.applied": {
|
|
27
|
+
if (e.data.sessionID !== sessionID || undone.has(e.id)) continue
|
|
28
|
+
const crop = state.crops[e.id]
|
|
29
|
+
if (!crop || crop.restored) continue
|
|
30
|
+
return { kind: "restore-crop", cropID: e.id, mode: e.data.mode, estTokens: e.data.targets.reduce((s, t) => s + t.estTokens, 0) }
|
|
31
|
+
}
|
|
32
|
+
case "branch.closed": {
|
|
33
|
+
// a squash/discard whose branch we are standing in (trunk side) can be re-opened
|
|
34
|
+
const branch = state.sessions[e.data.sessionID]
|
|
35
|
+
if (!branch) continue
|
|
36
|
+
if (branch.parentSessionID !== sessionID && e.data.sessionID !== sessionID) continue
|
|
37
|
+
if (branch.status === "open") continue // already re-opened
|
|
38
|
+
if (e.data.status === "abandoned") continue // an undone jump is not itself undoable
|
|
39
|
+
return { kind: "reopen-branch", sessionID: e.data.sessionID, decisionMessageID: e.data.decisionMessageID, status: e.data.status }
|
|
40
|
+
}
|
|
41
|
+
case "branch.opened": {
|
|
42
|
+
if (e.data.sessionID !== sessionID) continue
|
|
43
|
+
const branch = state.sessions[sessionID]
|
|
44
|
+
if (!branch || branch.status !== "open") continue
|
|
45
|
+
return { kind: "abandon-branch", sessionID, parentSessionID: e.data.parentSessionID, name: e.data.name }
|
|
46
|
+
}
|
|
47
|
+
default:
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { kind: "nothing" }
|
|
52
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server plugin half (DESIGN.md §3.1, §8).
|
|
3
|
+
*
|
|
4
|
+
* Runs inside the OpenCode server. Owns the headless `/ctree` commands (status,
|
|
5
|
+
* branch, merge --discard, crop, undo, decisions) and applies crops to the
|
|
6
|
+
* messages OpenCode sends to the model, in place. Tree-shaping entries it does
|
|
7
|
+
* not create itself (squash merges, labels, summaries) come from the TUI half.
|
|
8
|
+
*/
|
|
9
|
+
import fs from "node:fs"
|
|
10
|
+
import path from "node:path"
|
|
11
|
+
import type { Plugin } from "@opencode-ai/plugin"
|
|
12
|
+
import { activeCrops } from "../core/journal.js"
|
|
13
|
+
import { applyCrops, type CropSpec, type MinimalMessage } from "../core/crop.js"
|
|
14
|
+
import { JournalStore, type StorageMode } from "../shared/store.js"
|
|
15
|
+
import { CTREE_HELP, parseCtreeArgs } from "../core/ctree-args.js"
|
|
16
|
+
import { autoMark, planResultCrop, resultCandidates, topCandidate, type CropRules, DEFAULT_RULES } from "../core/cropplan.js"
|
|
17
|
+
import { planUndo } from "../core/undo.js"
|
|
18
|
+
import { exportDecisions } from "../core/decision.js"
|
|
19
|
+
import type { Transcript, TranscriptMessage } from "../core/transcript.js"
|
|
20
|
+
import { parseForkTitle } from "../core/adopt.js"
|
|
21
|
+
import { adoptNativeForks } from "../shared/adopt.js"
|
|
22
|
+
|
|
23
|
+
export const server: Plugin = async ({ worktree, client, directory }, options) => {
|
|
24
|
+
// same option parsing as the TUI half, so both write to the same place (docs/USAGE.md)
|
|
25
|
+
const mode: StorageMode = options?.["storage"] === "global" ? "global" : "local"
|
|
26
|
+
// awaiting an SDK call in the plugin factory deadlocks the server (plugin init blocks
|
|
27
|
+
// request handling), so the state dir is resolved off the critical path
|
|
28
|
+
const stateDir = mode === "global" ? client.path.get({ query: { directory } }).then((res) => res.data?.state).catch(() => undefined) : Promise.resolve(undefined)
|
|
29
|
+
const journal = stateDir.then((dir) => new JournalStore({ worktree, stateDir: dir, mode }))
|
|
30
|
+
|
|
31
|
+
const say = (output: { parts: any[] }, text: string) => {
|
|
32
|
+
output.parts.length = 0
|
|
33
|
+
output.parts.push({ id: `prt_ctree_${Date.now().toString(36)}`, type: "text", text: `[context tree]\n${text}\n\n(Acknowledge in one short line; do not act on this.)` })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Mirror tree linkage into `session.metadata.ctree` (DESIGN.md §4.2), merging with what is there. Best effort. */
|
|
37
|
+
async function mirrorMetadata(sessionID: string, ctree: Record<string, unknown>): Promise<void> {
|
|
38
|
+
const existing = await client.session.get({ path: { id: sessionID }, query: { directory } }).catch(() => undefined)
|
|
39
|
+
const info = existing?.data as { metadata?: { ctree?: Record<string, unknown> } } | undefined
|
|
40
|
+
if (!info) return // the PATCH replaces `metadata` wholesale: without the current value we would wipe other plugins' keys
|
|
41
|
+
const meta = (info.metadata ?? {}) as { ctree?: Record<string, unknown> }
|
|
42
|
+
await client.session
|
|
43
|
+
.update({ path: { id: sessionID }, query: { directory }, body: { metadata: { ...meta, ctree: { ...(meta.ctree ?? {}), ...ctree } } } as unknown as { title?: string } })
|
|
44
|
+
.catch(() => undefined)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** One request, no paging: `before` is an opaque cursor the response never exposes, and
|
|
48
|
+
* omitting `limit` returns the whole session ascending (a `limit` would silently truncate). */
|
|
49
|
+
async function transcriptOf(sessionID: string): Promise<Transcript> {
|
|
50
|
+
const res = await client.session.messages({ path: { id: sessionID }, query: { directory } })
|
|
51
|
+
if (res.error || !Array.isArray(res.data)) throw new Error(`could not read the messages of ${sessionID}: ${res.error ? JSON.stringify(res.error) : `unexpected response (${typeof res.data})`}`)
|
|
52
|
+
const messages: TranscriptMessage[] = (res.data as any[]).map((m) => ({
|
|
53
|
+
id: m.info.id as string,
|
|
54
|
+
role: (m.info.role === "user" ? "user" : "assistant") as "user" | "assistant",
|
|
55
|
+
time: m.info.time,
|
|
56
|
+
tokens: m.info.tokens,
|
|
57
|
+
summary: m.info.summary === true ? true : undefined,
|
|
58
|
+
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 })),
|
|
59
|
+
}))
|
|
60
|
+
return { sessionID, title: sessionID, status: "available", messages }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Journal native `/fork` sessions the plugin did not create itself (DESIGN.md §4.1). */
|
|
64
|
+
async function adopt() {
|
|
65
|
+
return adoptNativeForks({
|
|
66
|
+
store: await journal,
|
|
67
|
+
directory,
|
|
68
|
+
actor: "server",
|
|
69
|
+
listSessions: async () => {
|
|
70
|
+
const res = await client.session.list({ query: { directory } })
|
|
71
|
+
return ((res.data as any[]) ?? []).map((s) => ({ id: s.id as string, title: (s.title as string) ?? "", created: (s.time?.created as number) ?? 0, parentID: s.parentID as string | undefined, directory: s.directory as string | undefined }))
|
|
72
|
+
},
|
|
73
|
+
messagesOf: async (sessionID) => (await transcriptOf(sessionID)).messages.map((m) => ({ id: m.id, role: m.role, created: m.time.created })),
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The `session.created` event fires before the fork's messages are copied, so wait, then retry. */
|
|
78
|
+
async function adoptSoon(): Promise<void> {
|
|
79
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
80
|
+
await new Promise((r) => setTimeout(r, 1000))
|
|
81
|
+
if ((await adopt()).length > 0) return
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
config: async (cfg) => {
|
|
87
|
+
const c = cfg as { command?: Record<string, unknown> }
|
|
88
|
+
if (c.command?.["ctree"]) return // a user-defined /ctree command wins
|
|
89
|
+
c.command = { ...(c.command ?? {}), ctree: { template: "$ARGUMENTS", description: "Context tree (headless): status | branch <name> | merge --discard | crop --top | crop --auto | undo | decisions" } }
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
"command.execute.before": async (input, output) => {
|
|
93
|
+
if (input.command !== "ctree") return
|
|
94
|
+
const sessionID = input.sessionID
|
|
95
|
+
try {
|
|
96
|
+
const store = await journal
|
|
97
|
+
const cmd = parseCtreeArgs(input.arguments)
|
|
98
|
+
switch (cmd.kind) {
|
|
99
|
+
case "help":
|
|
100
|
+
return say(output, `${cmd.error ? `error: ${cmd.error}\n\n` : ""}${CTREE_HELP}`)
|
|
101
|
+
case "status": {
|
|
102
|
+
await adopt() // headless clients have no TUI half to do it for them
|
|
103
|
+
const state = store.stateForSession(sessionID)
|
|
104
|
+
if (!state) return say(output, "this session is not in a tree yet (nothing branched, cropped or labelled).")
|
|
105
|
+
const me = state.sessions[sessionID]
|
|
106
|
+
const crops = state.activeCrops(sessionID)
|
|
107
|
+
const hidden = crops.reduce((s, c) => s + c.targets.reduce((x, y) => x + y.estTokens, 0), 0)
|
|
108
|
+
const branches = Object.values(state.sessions).filter((b) => b.parentSessionID === sessionID)
|
|
109
|
+
// an adopted native fork carries no journal name: fall back to the session's own title
|
|
110
|
+
const title = me && !me.name ? ((await client.session.get({ path: { id: sessionID }, query: { directory } }).catch(() => undefined))?.data as { title?: string } | undefined)?.title : undefined
|
|
111
|
+
return say(output, [`tree ${state.treeId}`, me ? `this session is ⎇ ${me.name ?? title ?? "branch"} (${me.status}) of ${me.parentSessionID}${me.note ? ` — ${me.note}` : ""}` : "this session is the trunk", `${branches.length} branch(es) from here: ${branches.map((b) => `${b.name ?? b.sessionID} [${b.status}]`).join(", ") || "none"}`, `${crops.length} active crop(s), ~${hidden} tokens hidden`, `${Object.values(state.decisions).filter((d) => d.sessionID === sessionID && !d.hidden).length} decision record(s) here`].join("\n"))
|
|
112
|
+
}
|
|
113
|
+
case "branch": {
|
|
114
|
+
const tr = await transcriptOf(sessionID)
|
|
115
|
+
const last = tr.messages.at(-1)
|
|
116
|
+
if (!last) return say(output, "nothing to branch from yet.")
|
|
117
|
+
const treeId = store.ensureTree(sessionID, "server")
|
|
118
|
+
const forked = await client.session.fork({ path: { id: sessionID }, query: { directory } })
|
|
119
|
+
const forkedID = (forked.data as any)?.id as string | undefined
|
|
120
|
+
if (!forkedID) return say(output, "fork failed.")
|
|
121
|
+
store.registerSession(forkedID, treeId)
|
|
122
|
+
store.record(treeId, "branch.opened", { sessionID: forkedID, parentSessionID: sessionID, anchorMessageID: last.id, name: cmd.name, kind: "explicit", branchModel: cmd.model }, "server")
|
|
123
|
+
store.record(treeId, "label.set", { sessionID, messageID: last.id, label: `⎇ ${cmd.name}` }, "server")
|
|
124
|
+
await client.session.update({ path: { id: forkedID }, query: { directory }, body: { title: `⎇ ${cmd.name}` } }).catch(() => undefined)
|
|
125
|
+
await mirrorMetadata(forkedID, { treeId, parentSessionID: sessionID, anchorMessageID: last.id, name: cmd.name, status: "open" })
|
|
126
|
+
await mirrorMetadata(sessionID, { treeId })
|
|
127
|
+
await client.tui.publish({ query: { directory }, body: { type: "tui.session.select", properties: { sessionID: forkedID } } as any }).catch(() => undefined)
|
|
128
|
+
return say(output, `⎇ ${cmd.name} opened as session ${forkedID}${cmd.model ? ` on ${cmd.model}` : ""}. Switch to it with /sessions if the TUI did not follow.`)
|
|
129
|
+
}
|
|
130
|
+
case "merge-discard": {
|
|
131
|
+
const state = store.stateForSession(sessionID)
|
|
132
|
+
const branch = state?.sessions[sessionID]
|
|
133
|
+
if (!state || !branch || branch.status !== "open") return say(output, "this session is not an open branch — /ctree branch first.")
|
|
134
|
+
store.record(state.treeId, "branch.closed", { sessionID, status: "rejected", note: cmd.note }, "server")
|
|
135
|
+
await mirrorMetadata(sessionID, { status: "rejected" })
|
|
136
|
+
await client.tui.publish({ query: { directory }, body: { type: "tui.session.select", properties: { sessionID: branch.parentSessionID } } as any }).catch(() => undefined)
|
|
137
|
+
return say(output, `⎇ ${branch.name ?? "branch"} discarded${cmd.note ? ` (${cmd.note})` : ""} — back on the trunk (${branch.parentSessionID}); /ctree undo from there re-opens it.`)
|
|
138
|
+
}
|
|
139
|
+
case "crop-top":
|
|
140
|
+
case "crop-auto": {
|
|
141
|
+
const tr = await transcriptOf(sessionID)
|
|
142
|
+
const state = store.stateForSession(sessionID)
|
|
143
|
+
const already = new Set<string>()
|
|
144
|
+
if (state) for (const c of state.activeCrops(sessionID)) for (const t of c.targets) already.add(t.partID ?? t.messageID)
|
|
145
|
+
const rules: CropRules = cmd.kind === "crop-auto" ? { minTokens: cmd.minTokens ?? DEFAULT_RULES.minTokens, olderThanTurns: cmd.olderThan ?? DEFAULT_RULES.olderThanTurns, keep: cmd.keep } : DEFAULT_RULES
|
|
146
|
+
const cands = resultCandidates(tr, { alreadyCropped: already, keep: rules.keep })
|
|
147
|
+
const picks = cmd.kind === "crop-top" ? [topCandidate(cands, cmd.force)].filter((c): c is NonNullable<typeof c> => Boolean(c)) : autoMark(cands, rules)
|
|
148
|
+
if (picks.length === 0) {
|
|
149
|
+
const blocked = cands.filter((c) => !c.protections.includes("already-cropped")).sort((a, b) => b.estTokens - a.estTokens)[0]
|
|
150
|
+
return say(output, blocked ? `nothing unprotected to crop. Biggest candidate: ${blocked.tool} "${blocked.arg.slice(0, 40)}" ~${blocked.estTokens} tokens is protected (${blocked.protections.join(", ")}); use --force to waive "latest-per-tool".` : "nothing to crop: no completed tool results.")
|
|
151
|
+
}
|
|
152
|
+
const total = picks.reduce((s, c) => s + c.estTokens, 0)
|
|
153
|
+
const listing = picks.map((c) => ` ✂ ${c.tool} "${c.arg.slice(0, 40)}" ~${c.estTokens} tokens (turn ${c.turn})`).join("\n")
|
|
154
|
+
if (!cmd.apply) return say(output, `dry run — would crop ${picks.length} result(s), ~${total} tokens:\n${listing}\nRe-run with --apply to write it.`)
|
|
155
|
+
const plan = planResultCrop(sessionID, picks)!
|
|
156
|
+
const treeId = store.ensureTree(sessionID, "server")
|
|
157
|
+
store.record(treeId, "crop.applied", plan, "server")
|
|
158
|
+
return say(output, `cropped ${picks.length} result(s), ~${total} tokens leave the context from the next turn:\n${listing}\n/ctree undo restores.`)
|
|
159
|
+
}
|
|
160
|
+
case "undo": {
|
|
161
|
+
const state = store.stateForSession(sessionID)
|
|
162
|
+
if (!state) return say(output, "nothing to undo.")
|
|
163
|
+
const plan = planUndo(store.entriesFor(state.treeId), state, sessionID)
|
|
164
|
+
if (plan.kind === "restore-crop") {
|
|
165
|
+
store.record(state.treeId, "crop.restored", { cropID: plan.cropID }, "server")
|
|
166
|
+
return say(output, `restored the ${plan.mode === "turn" ? "dropped turn" : "cropped result"} (~${plan.estTokens} tokens back).`)
|
|
167
|
+
}
|
|
168
|
+
if (plan.kind === "abandon-branch") {
|
|
169
|
+
store.record(state.treeId, "branch.closed", { sessionID: plan.sessionID, status: "abandoned" }, "server")
|
|
170
|
+
await client.tui.publish({ query: { directory }, body: { type: "tui.session.select", properties: { sessionID: plan.parentSessionID } } as any }).catch(() => undefined)
|
|
171
|
+
return say(output, `left ⎇ ${plan.name ?? "branch"}; parent session is ${plan.parentSessionID}.`)
|
|
172
|
+
}
|
|
173
|
+
if (plan.kind === "reopen-branch") {
|
|
174
|
+
const b = state.sessions[plan.sessionID]!
|
|
175
|
+
store.record(state.treeId, "branch.opened", { sessionID: b.sessionID, parentSessionID: b.parentSessionID, anchorMessageID: b.anchorMessageID, name: b.name, kind: b.kind, branchModel: b.branchModel, trunkModel: b.trunkModel }, "server")
|
|
176
|
+
return say(output, `re-opened ⎇ ${b.name ?? "branch"} (${plan.sessionID}); its decision record is hidden from the model.`)
|
|
177
|
+
}
|
|
178
|
+
return say(output, "nothing to undo on this path.")
|
|
179
|
+
}
|
|
180
|
+
case "decisions": {
|
|
181
|
+
const state = store.stateForSession(sessionID)
|
|
182
|
+
const records = state ? Object.values(state.decisions).filter((d) => d.sessionID === sessionID).sort((a, b) => a.recordedAt - b.recordedAt) : []
|
|
183
|
+
if (cmd.export !== undefined) {
|
|
184
|
+
const file = path.resolve(directory, cmd.export)
|
|
185
|
+
// a hidden record belongs to a re-opened branch: it is not a decision yet
|
|
186
|
+
const written = records.filter((d) => !d.hidden && d.text)
|
|
187
|
+
fs.writeFileSync(file, exportDecisions(written.map((d) => ({ branchName: d.branchName, text: d.text!, sessionID: d.sessionID, at: d.recordedAt }))))
|
|
188
|
+
return say(output, `wrote ${written.length} record(s) → ${file}`)
|
|
189
|
+
}
|
|
190
|
+
if (records.length === 0) return say(output, "no decision records in this session.")
|
|
191
|
+
return say(output, records.map((d) => `${d.hidden ? "◇ (hidden)" : "◆"} ${d.branchName} · ${new Date(d.recordedAt).toISOString().slice(0, 16)}\n${(d.text ?? "").split("\n").slice(1, 6).join("\n")}`).join("\n\n"))
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
} catch (e) {
|
|
195
|
+
say(output, `error: ${e instanceof Error ? e.message : String(e)}`)
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
"experimental.chat.messages.transform": async (_input, output) => {
|
|
200
|
+
const sessionID = output.messages[0]?.info.sessionID
|
|
201
|
+
if (!sessionID) return
|
|
202
|
+
|
|
203
|
+
const state = (await journal).stateForSession(sessionID)
|
|
204
|
+
if (!state) return // not a session the plugin knows about (DESIGN.md §3.1)
|
|
205
|
+
|
|
206
|
+
// decision records of re-opened branches stay on screen but leave the context
|
|
207
|
+
const hidden = Object.values(state.decisions).filter((d) => d.hidden && d.sessionID === sessionID).map((d) => d.messageID)
|
|
208
|
+
if (hidden.length) {
|
|
209
|
+
const lastUser = [...output.messages].reverse().find((m) => m.info.role === "user")
|
|
210
|
+
for (let i = output.messages.length - 1; i >= 0; i--) {
|
|
211
|
+
const m = output.messages[i]!
|
|
212
|
+
if (hidden.includes(m.info.id) && m !== lastUser) output.messages.splice(i, 1)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const crops: CropSpec[] = activeCrops(state, sessionID).map((crop) => ({
|
|
217
|
+
mode: crop.mode,
|
|
218
|
+
targets: crop.targets,
|
|
219
|
+
anchorMessageID: crop.anchorMessageID,
|
|
220
|
+
}))
|
|
221
|
+
if (crops.length === 0) return
|
|
222
|
+
|
|
223
|
+
applyCrops(output.messages as unknown as MinimalMessage[], crops)
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
// DESIGN.md §6.8: decision records survive compaction verbatim
|
|
227
|
+
"experimental.session.compacting": async ({ sessionID }, output) => {
|
|
228
|
+
const state = (await journal).stateForSession(sessionID)
|
|
229
|
+
if (!state) return
|
|
230
|
+
const records = Object.values(state.decisions)
|
|
231
|
+
.filter((d) => d.sessionID === sessionID && !d.hidden && d.text)
|
|
232
|
+
.sort((a, b) => a.recordedAt - b.recordedAt)
|
|
233
|
+
if (records.length === 0) return
|
|
234
|
+
output.context.push(
|
|
235
|
+
`The conversation contains human-confirmed decision records (marked ◆). Reproduce each of them VERBATIM in the summary under a "## Decisions" heading; never paraphrase them:\n\n${records.map((r) => r.text).join("\n\n")}`,
|
|
236
|
+
)
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
// DESIGN.md §6.8: a system note so the model reads ◆ / ✂ markers correctly
|
|
240
|
+
"experimental.chat.system.transform": async ({ sessionID }, output) => {
|
|
241
|
+
if (!sessionID || !(await journal).stateForSession(sessionID)) return
|
|
242
|
+
output.system.push(
|
|
243
|
+
"Context notes: messages starting with ◆ are decision records confirmed by the user — treat them as settled facts. Tool results reading [cropped: …] or turns reading [dropped turn …] were removed from your context on purpose to save space; if you need one back, ask the user to restore it (they can with /undo in the context tree).",
|
|
244
|
+
)
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
"chat.message": async (input, output) => {
|
|
248
|
+
const state = (await journal).stateForSession(input.sessionID)
|
|
249
|
+
if (!state) return
|
|
250
|
+
|
|
251
|
+
const branch = state.sessions[input.sessionID]
|
|
252
|
+
if (!branch?.model || branch.status !== "open") return
|
|
253
|
+
|
|
254
|
+
const providerID = branch.model.split("/")[0]
|
|
255
|
+
const modelID = branch.model.split("/").slice(1).join("/")
|
|
256
|
+
if (!providerID || !modelID) return
|
|
257
|
+
|
|
258
|
+
output.message.model = { providerID, modelID }
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
event: async ({ event }) => {
|
|
262
|
+
if (event.type === "session.created") {
|
|
263
|
+
const info = event.properties.info
|
|
264
|
+
if (!info.parentID && parseForkTitle(info.title ?? "")) void adoptSoon()
|
|
265
|
+
return
|
|
266
|
+
}
|
|
267
|
+
if (event.type !== "session.deleted") return
|
|
268
|
+
const sessionID = event.properties.info.id
|
|
269
|
+
const store = await journal
|
|
270
|
+
const treeId = store.treeIdFor(sessionID)
|
|
271
|
+
if (!treeId) return
|
|
272
|
+
|
|
273
|
+
store.record(treeId, "session.forgotten", { sessionID }, "server")
|
|
274
|
+
},
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export default { id: "opencode-context-tree", server }
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adopting OpenCode's native forks into the journal: the IO half of core/adopt.ts,
|
|
3
|
+
* shared by both plugin halves. Idempotent, and never throws — adoption is a nicety,
|
|
4
|
+
* it must not take a hook or a route down.
|
|
5
|
+
*/
|
|
6
|
+
import { expectedParentTitle, findForkParent, pickAdoptables, type ForkCandidate, type ForkMessage, type ForkParent, type SessionInfo } from "../core/adopt.js"
|
|
7
|
+
import type { JournalActor } from "../core/journal.js"
|
|
8
|
+
import { debug } from "./debug.js"
|
|
9
|
+
import type { JournalStore } from "./store.js"
|
|
10
|
+
|
|
11
|
+
export type AdoptOptions = {
|
|
12
|
+
store: JournalStore
|
|
13
|
+
directory: string
|
|
14
|
+
actor: JournalActor
|
|
15
|
+
listSessions: () => Promise<SessionInfo[]>
|
|
16
|
+
messagesOf: (sessionID: string) => Promise<ForkMessage[]>
|
|
17
|
+
log?: (event: string, data?: Record<string, unknown>) => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type Adopted = { sessionID: string; parentSessionID: string }
|
|
21
|
+
|
|
22
|
+
/** Each candidate costs one `session.messages` round-trip, and an old session is not a
|
|
23
|
+
* plausible fork parent — so the blind fallback only looks at the recent past. */
|
|
24
|
+
const MAX_CANDIDATES = 40
|
|
25
|
+
|
|
26
|
+
type Fetch = (sessionID: string) => Promise<ForkMessage[]>
|
|
27
|
+
|
|
28
|
+
async function matchAgainst(fork: ForkCandidate, pool: SessionInfo[], messagesOf: Fetch): Promise<ForkParent | undefined> {
|
|
29
|
+
if (pool.length === 0) return undefined
|
|
30
|
+
const candidates: ForkCandidate[] = []
|
|
31
|
+
for (const session of pool) candidates.push({ ...session, messages: await messagesOf(session.id) })
|
|
32
|
+
return findForkParent(fork, candidates)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Journal every native fork of `directory` that is not in a tree yet. Returns what it adopted. */
|
|
36
|
+
export async function adoptNativeForks(opts: AdoptOptions): Promise<Adopted[]> {
|
|
37
|
+
const { store, actor } = opts
|
|
38
|
+
const log = opts.log ?? debug
|
|
39
|
+
const adopted: Adopted[] = []
|
|
40
|
+
try {
|
|
41
|
+
const sessions = (await opts.listSessions()).filter((s) => !s.parentID && (s.directory === undefined || s.directory === opts.directory))
|
|
42
|
+
const registered = new Set(sessions.filter((s) => store.treeIdFor(s.id)).map((s) => s.id))
|
|
43
|
+
const adoptables = pickAdoptables(sessions, registered)
|
|
44
|
+
if (adoptables.length === 0) return adopted
|
|
45
|
+
|
|
46
|
+
const cache = new Map<string, ForkMessage[]>()
|
|
47
|
+
const messagesOf: Fetch = async (sessionID) => {
|
|
48
|
+
const hit = cache.get(sessionID)
|
|
49
|
+
if (hit) return hit
|
|
50
|
+
const messages = await opts.messagesOf(sessionID)
|
|
51
|
+
cache.set(sessionID, messages)
|
|
52
|
+
return messages
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
for (const session of adoptables) {
|
|
56
|
+
const messages = await messagesOf(session.id)
|
|
57
|
+
// forked messages are copies and keep their original `time.created`, so they all
|
|
58
|
+
// predate the fork's session row; a session that wrote its own first message is
|
|
59
|
+
// not a fork and needs no candidate round-trips at all (equal times are common —
|
|
60
|
+
// a row created and its first message sent inside one millisecond)
|
|
61
|
+
if (messages.length === 0 || messages[0]!.created >= session.created) continue
|
|
62
|
+
|
|
63
|
+
const fork: ForkCandidate = { ...session, messages }
|
|
64
|
+
const expected = expectedParentTitle(session.title)
|
|
65
|
+
const pool = sessions.filter((s) => s.id !== session.id && s.created <= session.created).sort((a, b) => b.created - a.created)
|
|
66
|
+
const titled = expected === undefined ? [] : pool.filter((s) => s.title === expected)
|
|
67
|
+
const rest = pool.filter((s) => !titled.includes(s)).slice(0, MAX_CANDIDATES)
|
|
68
|
+
const parent = (await matchAgainst(fork, titled, messagesOf)) ?? (await matchAgainst(fork, rest, messagesOf))
|
|
69
|
+
if (!parent) continue
|
|
70
|
+
|
|
71
|
+
const treeId = store.ensureTree(parent.parentID, actor)
|
|
72
|
+
if (store.treeIdFor(session.id)) continue // the other half adopted it while we matched
|
|
73
|
+
store.registerSession(session.id, treeId)
|
|
74
|
+
store.record(treeId, "branch.opened", { sessionID: session.id, parentSessionID: parent.parentID, anchorMessageID: parent.anchorMessageID, kind: "native" }, actor)
|
|
75
|
+
adopted.push({ sessionID: session.id, parentSessionID: parent.parentID })
|
|
76
|
+
log("adopt.native", { treeId, sessionID: session.id, parentSessionID: parent.parentID, anchorMessageID: parent.anchorMessageID })
|
|
77
|
+
}
|
|
78
|
+
} catch (e) {
|
|
79
|
+
log("adopt.failed", { directory: opts.directory, error: e instanceof Error ? e.message : String(e) })
|
|
80
|
+
}
|
|
81
|
+
return adopted
|
|
82
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Opt-in file logging: set CTREE_DEBUG=/path/to/log. No-op otherwise. */
|
|
2
|
+
import fs from "node:fs"
|
|
3
|
+
const target = process.env["CTREE_DEBUG"]
|
|
4
|
+
export function debug(event: string, data?: Record<string, unknown>): void {
|
|
5
|
+
if (!target) return
|
|
6
|
+
try {
|
|
7
|
+
fs.appendFileSync(target, `${JSON.stringify({ ts: Date.now(), event, ...data })}\n`)
|
|
8
|
+
} catch {}
|
|
9
|
+
}
|