opencode-blackhole 0.0.0-stage → 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.
Files changed (50) hide show
  1. package/LICENSE +35 -0
  2. package/LICENSE.vendor +21 -0
  3. package/README.md +377 -2
  4. package/dist/server.js +5025 -0
  5. package/index.ts +1 -0
  6. package/package.json +57 -4
  7. package/src/adapter/opencode-messages.ts +270 -0
  8. package/src/compaction/build.ts +264 -0
  9. package/src/compaction/handoff.ts +421 -0
  10. package/src/config.ts +316 -0
  11. package/src/index.ts +714 -0
  12. package/src/memory/ledger.ts +254 -0
  13. package/src/memory/manager.ts +659 -0
  14. package/src/memory/models.ts +172 -0
  15. package/src/memory/prompts.ts +145 -0
  16. package/src/recall/recall.ts +327 -0
  17. package/src/state/paths.ts +42 -0
  18. package/src/store/capture.ts +84 -0
  19. package/src/store/transcript.ts +279 -0
  20. package/src/vcc/README.md +51 -0
  21. package/src/vcc/core/brief.ts +407 -0
  22. package/src/vcc/core/build-sections.ts +81 -0
  23. package/src/vcc/core/compact-args.ts +45 -0
  24. package/src/vcc/core/content.ts +156 -0
  25. package/src/vcc/core/drill-down.ts +302 -0
  26. package/src/vcc/core/filter-noise.ts +41 -0
  27. package/src/vcc/core/format-recall.ts +100 -0
  28. package/src/vcc/core/format.ts +104 -0
  29. package/src/vcc/core/lineage.ts +26 -0
  30. package/src/vcc/core/normalize.ts +76 -0
  31. package/src/vcc/core/rank.ts +283 -0
  32. package/src/vcc/core/recall-request.ts +160 -0
  33. package/src/vcc/core/recall-scope.ts +30 -0
  34. package/src/vcc/core/render-entries.ts +55 -0
  35. package/src/vcc/core/sanitize.ts +5 -0
  36. package/src/vcc/core/search-entries.ts +594 -0
  37. package/src/vcc/core/skill-collapse.ts +35 -0
  38. package/src/vcc/core/summarize.ts +289 -0
  39. package/src/vcc/core/token-estimate.ts +100 -0
  40. package/src/vcc/core/tool-args.ts +16 -0
  41. package/src/vcc/details.ts +11 -0
  42. package/src/vcc/extract/commits.ts +96 -0
  43. package/src/vcc/extract/files.ts +87 -0
  44. package/src/vcc/extract/goals.ts +79 -0
  45. package/src/vcc/extract/preferences.ts +55 -0
  46. package/src/vcc/extract/tracked-commands.ts +232 -0
  47. package/src/vcc/load-messages.ts +42 -0
  48. package/src/vcc/pi-types.ts +53 -0
  49. package/src/vcc/sections.ts +12 -0
  50. package/src/vcc/types.ts +16 -0
@@ -0,0 +1,421 @@
1
+ /**
2
+ * The handoff block: the narrative half of a checkpoint.
3
+ *
4
+ * vcc's sections are an inventory - goals, files, commits. OpenCode's own
5
+ * summarizer is asked for something else: decisions and why, what is done,
6
+ * what is blocked, what happens next. That is the half a resumed session
7
+ * misses, and no deterministic extractor can invent it.
8
+ *
9
+ * So compaction spends one model call on exactly that block. The
10
+ * deterministic sections are still built and still win when the call fails:
11
+ * the block is additive, never a dependency. When the model is unreachable,
12
+ * `fallbackHandoff` derives the little that can be derived without one - the
13
+ * work state, and the question the session ended on.
14
+ */
15
+ import type { ModelRef } from "../config"
16
+
17
+ export const HANDOFF_HEADINGS = ["Decisions", "Work State", "Next Move", "Important Context"] as const
18
+ export type HandoffHeading = (typeof HANDOFF_HEADINGS)[number]
19
+
20
+ /** The separator vcc uses between sections, the brief and the recall note. */
21
+ const SEPARATOR = "\n\n---\n\n"
22
+
23
+ export interface HandoffPromptInput {
24
+ /** The checkpoint this window follows, when the request started with one. */
25
+ previousSummary?: string
26
+ /** The deterministic sections already extracted from this window. */
27
+ sections: string
28
+ /** The rendered conversation. */
29
+ window: string
30
+ }
31
+
32
+ /**
33
+ * The prompt.
34
+ *
35
+ * Deliberately close to OpenCode's own summary template: the point is to
36
+ * recover the sections its summarizer produces, not to invent a new shape.
37
+ * The "never invent" and "carry forward" rules matter more than the wording:
38
+ * a handoff that states a decision the session never made is worse than none.
39
+ */
40
+ export const HANDOFF_PROMPT = `You are writing the part of a session checkpoint that a deterministic extractor cannot write.
41
+
42
+ The checkpoint replaces the conversation. A future assistant continues from it and cannot see the transcript, so anything you leave out is lost. Write the working knowledge: what was decided and why, where the work stands, what happens next, and the facts that are expensive to rediscover.
43
+
44
+ Reply with these sections and nothing else:
45
+
46
+ [Decisions]
47
+ - <what was decided> — <why, in a few words>
48
+
49
+ [Work State]
50
+ - Done: <finished work>
51
+ - Active: <work in progress>
52
+ - Blocked: <what is blocking, and why>
53
+
54
+ [Next Move]
55
+ 1. <the next concrete action>
56
+
57
+ [Important Context]
58
+ - <a fact the next assistant cannot continue without>
59
+
60
+ Rules:
61
+ - Terse, single-line bullets. No prose paragraphs, no nested lists.
62
+ - Only what the conversation supports. Never invent a decision, a reason or a result.
63
+ - Keep exact file paths, symbols, commands, error strings, URLs and identifiers.
64
+ - Carry forward anything unresolved from the previous checkpoint, and keep the user's exact wording for user constraints.
65
+ - If a section has nothing, write "(none)".
66
+ - At most 6 bullets per section.`
67
+
68
+ interface Part {
69
+ type?: string
70
+ text?: string
71
+ id?: string
72
+ name?: string
73
+ input?: unknown
74
+ output?: unknown
75
+ state?: { input?: unknown; output?: unknown; content?: unknown; status?: string }
76
+ }
77
+
78
+ interface Message {
79
+ role?: string
80
+ content?: unknown
81
+ }
82
+
83
+ const partsOf = (message: Message): Part[] => {
84
+ if (typeof message.content === "string") return [{ type: "text", text: message.content }]
85
+ return Array.isArray(message.content) ? (message.content as Part[]) : []
86
+ }
87
+
88
+ const clip = (text: string, max: number): string => {
89
+ const flat = text.replace(/\s+/g, " ").trim()
90
+ return flat.length <= max ? flat : `${flat.slice(0, max)}…`
91
+ }
92
+
93
+ const outputText = (part: Part): string => {
94
+ const output = part.output ?? part.state?.output ?? part.state?.content
95
+ if (typeof output === "string") return output
96
+ if (output && typeof output === "object") {
97
+ const content = (output as { content?: unknown }).content
98
+ if (typeof content === "string") return content
99
+ if (Array.isArray(content)) {
100
+ return content
101
+ .map((item) =>
102
+ typeof item === "string" ? item : typeof (item as { text?: unknown })?.text === "string" ? (item as { text: string }).text : "",
103
+ )
104
+ .filter(Boolean)
105
+ .join("\n")
106
+ }
107
+ try {
108
+ return JSON.stringify(output)
109
+ } catch {
110
+ return ""
111
+ }
112
+ }
113
+ return ""
114
+ }
115
+
116
+ const inputText = (part: Part): string => {
117
+ const input = part.input ?? part.state?.input
118
+ if (typeof input === "string") return clip(input, 200)
119
+ if (input && typeof input === "object") {
120
+ const command = (input as { command?: unknown; cmd?: unknown }).command ?? (input as { cmd?: unknown }).cmd
121
+ if (typeof command === "string") return clip(command, 200)
122
+ try {
123
+ return clip(JSON.stringify(input), 200)
124
+ } catch {
125
+ return ""
126
+ }
127
+ }
128
+ return ""
129
+ }
130
+
131
+ /**
132
+ * Render a conversation for the handoff prompt.
133
+ *
134
+ * Text only, with tool traffic clipped: the model needs to see what was
135
+ * decided, not the contents of every file it read. When the window does not
136
+ * fit, the middle is dropped rather than the end - the opening states the
137
+ * task, the tail states where it stands.
138
+ */
139
+ export const renderWindow = (messages: readonly unknown[], maxChars: number): string => {
140
+ const lines: string[] = []
141
+ for (const raw of messages) {
142
+ const message = raw as Message
143
+ if (!message || typeof message !== "object") continue
144
+ const role = message.role ?? "user"
145
+ const rendered: string[] = []
146
+ for (const part of partsOf(message)) {
147
+ const type = part?.type ?? ""
148
+ if (type === "text" && typeof part.text === "string") {
149
+ const text = part.text.trim()
150
+ if (text.length > 0) rendered.push(role === "user" ? `[User]: ${clip(text, 1_200)}` : `[Assistant]: ${clip(text, 800)}`)
151
+ continue
152
+ }
153
+ if (type === "reasoning") continue
154
+ if (type === "tool-call" || type === "toolCall") {
155
+ rendered.push(`[Tool call]: ${part.name ?? "tool"}(${inputText(part)})`)
156
+ continue
157
+ }
158
+ if (type === "tool-result" || type === "toolResult") {
159
+ const text = outputText(part)
160
+ if (text.length > 0) rendered.push(`[Tool result]: ${clip(text, 300)}`)
161
+ }
162
+ }
163
+ if (rendered.length > 0) lines.push(rendered.join("\n"))
164
+ }
165
+
166
+ const text = lines.join("\n")
167
+ if (text.length <= maxChars) return text
168
+ const head = Math.floor(maxChars * 0.35)
169
+ const tail = Math.max(0, maxChars - head)
170
+ return `${text.slice(0, head)}\n[... middle of the conversation omitted ...]\n${text.slice(text.length - tail)}`
171
+ }
172
+
173
+ export const buildHandoffPrompt = (input: HandoffPromptInput): string => {
174
+ const parts = [HANDOFF_PROMPT]
175
+ if (input.previousSummary && input.previousSummary.trim().length > 0) {
176
+ parts.push(`PREVIOUS CHECKPOINT\n${input.previousSummary.trim()}`)
177
+ }
178
+ if (input.sections.trim().length > 0) {
179
+ parts.push(`SECTIONS ALREADY EXTRACTED (reuse their facts; do not restate the file list)\n${input.sections.trim()}`)
180
+ }
181
+ parts.push(`CONVERSATION\n${input.window}`)
182
+ return parts.join("\n\n")
183
+ }
184
+
185
+ /**
186
+ * Keep only the four sections, drop the ones the model left empty, and trim
187
+ * to the budget. Returns undefined when nothing usable came back, so the
188
+ * caller can fall back instead of writing an empty block.
189
+ */
190
+ export const cleanHandoff = (text: string, maxChars: number): string | undefined => {
191
+ const body = text.replace(/```[a-z]*\n?/gi, "").replace(/```/g, "")
192
+ const lines = body.split("\n")
193
+
194
+ const sections = new Map<HandoffHeading, string[]>()
195
+ let current: HandoffHeading | undefined
196
+ for (const line of lines) {
197
+ const header = /^\s*\[([^\]]+)\]/.exec(line)
198
+ if (header) {
199
+ const name = HANDOFF_HEADINGS.find((heading) => heading.toLowerCase() === header[1].trim().toLowerCase())
200
+ if (name) {
201
+ current = name
202
+ sections.set(name, [])
203
+ } else if (current) {
204
+ // Some other heading ends the block; do not swallow the rest.
205
+ current = undefined
206
+ }
207
+ continue
208
+ }
209
+ if (!current) continue
210
+ const trimmed = line.trim()
211
+ if (trimmed.length === 0) continue
212
+ if (/^(?:[-*]\s*)?\(none\)$/i.test(trimmed)) continue
213
+ sections.get(current)!.push(trimmed)
214
+ }
215
+
216
+ const kept = HANDOFF_HEADINGS.filter((heading) => (sections.get(heading)?.length ?? 0) > 0)
217
+ if (kept.length === 0) return undefined
218
+
219
+ const render = (headings: readonly HandoffHeading[]) =>
220
+ headings.map((heading) => `[${heading}]\n${(sections.get(heading) ?? []).join("\n")}`).join("\n\n")
221
+
222
+ let selected = [...kept]
223
+ let block = render(selected)
224
+ // Trim from the last section backwards, one bullet at a time.
225
+ while (block.length > maxChars && selected.length > 0) {
226
+ const last = selected[selected.length - 1]
227
+ const bullets = sections.get(last)!
228
+ bullets.pop()
229
+ if (bullets.length === 0) selected = selected.slice(0, -1)
230
+ block = render(selected)
231
+ }
232
+ return block.length > 0 ? block : undefined
233
+ }
234
+
235
+ /** The four headings the handoff block owns. */
236
+ const HANDOFF_START = /(?:^|\n)\[(?:Decisions|Work State|Next Move|Important Context)\][ \t]*(?=\n|$)/
237
+
238
+ /**
239
+ * Remove a handoff block from a checkpoint.
240
+ *
241
+ * The block is not one of the sections the vcc merge knows, and `insertBlock`
242
+ * puts it before the `---`. Without this it is swallowed by whichever section
243
+ * comes last (usually `[User Preferences]`), and the next merge carries that
244
+ * section forward verbatim while a fresh block is appended - one block per
245
+ * compaction, each repeating the one before it.
246
+ */
247
+ export const stripHandoff = (summary: string): string => {
248
+ const index = summary.indexOf(SEPARATOR)
249
+ const head = index < 0 ? summary : summary.slice(0, index)
250
+ const rest = index < 0 ? "" : summary.slice(index)
251
+ const match = HANDOFF_START.exec(head)
252
+ if (!match) return summary
253
+ return `${head.slice(0, match.index).trimEnd()}${rest}`
254
+ }
255
+
256
+ /**
257
+ * Put the block at the end of the deterministic sections, before the brief
258
+ * and the recall note, so a reader meets the inventory first and the
259
+ * narrative second.
260
+ *
261
+ * A block left by an earlier checkpoint is replaced, never stacked: the new
262
+ * block is the whole narrative, and the model has the old one as input.
263
+ */
264
+ export const insertBlock = (summary: string, block: string): string => {
265
+ if (!block.trim()) return summary
266
+ const base = stripHandoff(summary)
267
+ const index = base.indexOf(SEPARATOR)
268
+ if (index < 0) return `${base}\n\n${block}`
269
+ return `${base.slice(0, index)}\n\n${block}${base.slice(index)}`
270
+ }
271
+
272
+ /**
273
+ * Resolve with the promise, or with undefined once the deadline passes.
274
+ *
275
+ * Compaction blocks the session, so a model that never answers must not hold
276
+ * it hostage: the deterministic summary is always a valid checkpoint.
277
+ */
278
+ export const withTimeout = async <T>(promise: Promise<T>, milliseconds: number): Promise<T | undefined> => {
279
+ if (!Number.isFinite(milliseconds) || milliseconds <= 0) return promise
280
+ let timer: ReturnType<typeof setTimeout> | undefined
281
+ try {
282
+ return await Promise.race([
283
+ promise,
284
+ new Promise<undefined>((resolve) => {
285
+ timer = setTimeout(() => resolve(undefined), milliseconds)
286
+ }),
287
+ ])
288
+ } finally {
289
+ if (timer) clearTimeout(timer)
290
+ }
291
+ }
292
+
293
+ // ------------------------------------------------------------------ fallback
294
+
295
+ export interface FallbackInput {
296
+ /** The deterministic summary, used for its commits and file list. */
297
+ summary: string
298
+ /** The conversation, used for the last command and the closing question. */
299
+ messages: readonly unknown[]
300
+ /** The previous checkpoint. Its decisions are carried, never invented. */
301
+ previousSummary?: string
302
+ }
303
+
304
+ const FALLBACK_SECTION_MAX = 900
305
+
306
+ const clampSection = (text: string, max: number): string => {
307
+ if (text.length <= max) return text
308
+ const cut = text.lastIndexOf("\n", max)
309
+ return (cut > 0 ? text.slice(0, cut) : text.slice(0, max)).trimEnd()
310
+ }
311
+
312
+ const sectionBody = (summary: string, name: string): string => {
313
+ const start = summary.indexOf(`[${name}]`)
314
+ if (start < 0) return ""
315
+ const rest = summary.slice(start + name.length + 2)
316
+ const next = rest.search(/\n\[[A-Z]/)
317
+ return (next < 0 ? rest : rest.slice(0, next)).trim()
318
+ }
319
+
320
+ const COMMAND_TOOLS = new Set(["bash", "shell", "exec", "exec_command", "run_command"])
321
+
322
+ const lastCommand = (messages: readonly unknown[]): { command: string; exit?: number } | undefined => {
323
+ let found: { command: string; exit?: number } | undefined
324
+ for (let i = 0; i < messages.length; i++) {
325
+ const message = messages[i] as Message
326
+ for (const part of partsOf(message)) {
327
+ if (part?.type !== "tool-call" && part?.type !== "toolCall") continue
328
+ if (!COMMAND_TOOLS.has(String(part.name ?? ""))) continue
329
+ const input = part.input ?? part.state?.input
330
+ const command =
331
+ typeof input === "string"
332
+ ? input
333
+ : typeof (input as { command?: unknown })?.command === "string"
334
+ ? String((input as { command: string }).command)
335
+ : typeof (input as { cmd?: unknown })?.cmd === "string"
336
+ ? String((input as { cmd: string }).cmd)
337
+ : ""
338
+ if (!command) continue
339
+ found = { command: clip(command, 120) }
340
+ // The matching result usually follows in the same or the next message.
341
+ for (let j = i + 1; j < messages.length && j <= i + 3; j++) {
342
+ for (const result of partsOf(messages[j] as Message)) {
343
+ const output = (result.output ?? result.state?.output) as { metadata?: { exit?: number } } | undefined
344
+ const exit = output?.metadata?.exit
345
+ if (typeof exit === "number") found.exit = exit
346
+ }
347
+ }
348
+ }
349
+ }
350
+ return found
351
+ }
352
+
353
+ const closingAsk = (messages: readonly unknown[]): string | undefined => {
354
+ for (let i = messages.length - 1; i >= 0; i--) {
355
+ const message = messages[i] as Message
356
+ if (message?.role !== "assistant") continue
357
+ const texts = partsOf(message)
358
+ .filter((part) => part?.type === "text" && typeof part.text === "string")
359
+ .map((part) => part.text!.trim())
360
+ .filter(Boolean)
361
+ if (texts.length === 0) continue
362
+ const last = texts[texts.length - 1].split("\n").filter(Boolean).pop() ?? ""
363
+ if (last.length < 8 || last.length > 300) continue
364
+ if (/[?]$/.test(last) || /(want me to|should i|shall i|let me know|next step|say the word)/i.test(last)) {
365
+ return clip(last, 240)
366
+ }
367
+ return undefined
368
+ }
369
+ return undefined
370
+ }
371
+
372
+ /**
373
+ * What can be said about the handoff without a model.
374
+ *
375
+ * Only two things are honest here: the work state, which is already recorded
376
+ * (commits, modified files, the last command), and the question the session
377
+ * ended on. There is no deterministic way to recover a decision or its
378
+ * reason, so this block never claims one.
379
+ */
380
+ export const fallbackHandoff = (input: FallbackInput): string | undefined => {
381
+ const parts: string[] = []
382
+
383
+ // A decision that was already recorded is not invented by repeating it.
384
+ // Without this a failed model call would drop the whole narrative, because
385
+ // the merge no longer keeps the previous block in the sections.
386
+ const prevHead = (input.previousSummary ?? "").split(SEPARATOR)[0]
387
+ const decisions = clampSection(sectionBody(prevHead, "Decisions"), FALLBACK_SECTION_MAX)
388
+ if (decisions) parts.push(`[Decisions]\n${decisions}`)
389
+
390
+ const lines: string[] = []
391
+
392
+ const files = sectionBody(input.summary, "Files And Changes")
393
+ const modified = files.split("\n").find((line) => /^-?\s*Modified:/.test(line))
394
+ if (modified) {
395
+ const count = (modified.match(/,\s/g)?.length ?? 0) + 1
396
+ const more = /\+(\d+) more/.exec(modified)
397
+ const total = count + (more ? Number(more[1]) : 0)
398
+ lines.push(`- Modified: ${total} file${total === 1 ? "" : "s"}`)
399
+ }
400
+
401
+ const commits = sectionBody(input.summary, "Commits")
402
+ .split("\n")
403
+ .filter((line) => line.trim().startsWith("-"))
404
+ .slice(-2)
405
+ if (commits.length > 0) lines.push(`- Commits: ${commits.map((line) => line.replace(/^\s*-\s*/, "").trim()).join("; ")}`)
406
+
407
+ const command = lastCommand(input.messages)
408
+ if (command) {
409
+ lines.push(`- Last command: \`${command.command}\`${typeof command.exit === "number" ? ` (exit ${command.exit})` : ""}`)
410
+ }
411
+
412
+ if (lines.length > 0) parts.push(`[Work State]\n${lines.join("\n")}`)
413
+
414
+ const ask = closingAsk(input.messages)
415
+ if (ask) parts.push(`[Next Move]\n- ${ask}`)
416
+
417
+ const important = clampSection(sectionBody(prevHead, "Important Context"), FALLBACK_SECTION_MAX)
418
+ if (important) parts.push(`[Important Context]\n${important}`)
419
+
420
+ return parts.length > 0 ? parts.join("\n\n") : undefined
421
+ }