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
package/src/index.ts ADDED
@@ -0,0 +1,714 @@
1
+ import { Plugin } from "@opencode/plugin"
2
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"
3
+ import { dirname, join } from "node:path"
4
+ import { buildSummary } from "./compaction/build"
5
+ import {
6
+ buildHandoffPrompt,
7
+ cleanHandoff,
8
+ fallbackHandoff,
9
+ insertBlock,
10
+ renderWindow,
11
+ withTimeout,
12
+ } from "./compaction/handoff"
13
+ import { loadConfig, type BlackholeConfig, type ModelRef } from "./config"
14
+ import { captureRequest } from "./store/capture"
15
+ import { TranscriptStore } from "./store/transcript"
16
+ import { normalize } from "./vcc/core/normalize"
17
+ import { DEFAULT_CHARS_PER_TOKEN, estimateTokensFromChars } from "./vcc/core/token-estimate"
18
+ import { extractFiles } from "./vcc/extract/files"
19
+ import { runRecallCommand, runRecallTool } from "./recall/recall"
20
+ import { CooldownStore, runWithFallbacks } from "./memory/models"
21
+ import { MemoryLedger } from "./memory/ledger"
22
+ import { MemoryManager } from "./memory/manager"
23
+ import { directoryKey, listProjectDirs, projectStateDir, stateBase } from "./state/paths"
24
+
25
+ const DAY_MS = 24 * 60 * 60 * 1000
26
+ /** The trace log is trimmed once it grows past this. */
27
+ const MAX_LOG_BYTES = 5 * 1024 * 1024
28
+
29
+ /** Remove a file that has not been touched for `olderThanMs`. */
30
+ const sweepFile = (path: string, olderThanMs: number, now = Date.now()): number => {
31
+ try {
32
+ const stats = statSync(path)
33
+ if (now - stats.mtimeMs < olderThanMs) return 0
34
+ rmSync(path, { force: true })
35
+ return 1
36
+ } catch {
37
+ return 0
38
+ }
39
+ }
40
+
41
+ /**
42
+ * opencode-blackhole — deterministic compaction (and, later, observational
43
+ * memory) for OpenCode.
44
+ *
45
+ * The compaction hook replaces OpenCode's LLM summarizer with the ported vcc
46
+ * pipeline: same input, same output, no model call. If the extraction yields
47
+ * nothing useful we leave the hook result unset, and OpenCode falls back to
48
+ * its own summarizer.
49
+ */
50
+ export default Plugin.define({
51
+ id: "opencode-blackhole",
52
+ async setup(ctx) {
53
+ const directory = ctx.location.directory
54
+ const config = (): BlackholeConfig => loadConfig({ directory, options: ctx.options })
55
+
56
+ // The state lives in the user's home directory, next to OpenCode's own
57
+ // data, grouped by project id — not inside the project.
58
+ const base = stateBase()
59
+ const fallbackKey = directoryKey(directory)
60
+ const logFile = join(base, "blackhole.log")
61
+ mkdirSync(base, { recursive: true })
62
+
63
+ /** session id → project key. */
64
+ const sessionKeys = new Map<string, string>()
65
+ /** project key → its store and ledger. */
66
+ const projectStates = new Map<string, { store: TranscriptStore; ledger: MemoryLedger }>()
67
+
68
+ const resolveKey = async (sessionID: string): Promise<string> => {
69
+ const cached = sessionKeys.get(sessionID)
70
+ if (cached) return cached
71
+ let key = fallbackKey
72
+ try {
73
+ const info: any = await ctx.session.get({ sessionID })
74
+ // The project id survives moving or renaming the project; the
75
+ // directory path does not.
76
+ if (info?.projectID) key = info.projectID
77
+ } catch {
78
+ // Fall back to the directory key.
79
+ }
80
+ sessionKeys.set(sessionID, key)
81
+ return key
82
+ }
83
+
84
+ const stateFor = (key: string) => {
85
+ let entry = projectStates.get(key)
86
+ if (!entry) {
87
+ const dir = projectStateDir(base, key)
88
+ entry = {
89
+ store: new TranscriptStore(join(dir, "transcripts"), {
90
+ maxCachedSessions: config().limits.maxCachedSessions,
91
+ maxMessageChars: config().limits.maxMessageChars,
92
+ }),
93
+ ledger: new MemoryLedger(join(dir, "memory"), {
94
+ maxCachedSessions: config().limits.maxCachedSessions,
95
+ maxFileBytes: config().limits.maxLedgerBytes,
96
+ }),
97
+ }
98
+ projectStates.set(key, entry)
99
+ }
100
+ return entry
101
+ }
102
+
103
+ /** The state of a session, resolved synchronously. */
104
+ const state = (sessionID: string) => stateFor(sessionKeys.get(sessionID) ?? fallbackKey)
105
+
106
+ /** Resolve a session's project, then return its state. */
107
+ const ensure = async (sessionID: string) => stateFor(await resolveKey(sessionID))
108
+
109
+ /**
110
+ * Write one trace line. Failures here must never break a compaction.
111
+ */
112
+ const writeLog = (kind: string, data: Record<string, unknown>) => {
113
+ try {
114
+ // Keep the trace bounded: a long session would otherwise grow the log
115
+ // without limit.
116
+ if (existsSync(logFile) && statSync(logFile).size > MAX_LOG_BYTES) {
117
+ const lines = readFileSync(logFile, "utf8").split("\n")
118
+ writeFileSync(logFile, lines.slice(Math.floor(lines.length / 2)).join("\n"))
119
+ }
120
+ appendFileSync(logFile, JSON.stringify({ ts: new Date().toISOString(), kind, ...data }) + "\n")
121
+ } catch {
122
+ // Logging must never break a compaction.
123
+ }
124
+ }
125
+
126
+ const log = (kind: string, data: Record<string, unknown>) => {
127
+ if (config().logLevel === "off") return
128
+ writeLog(kind, data)
129
+ }
130
+
131
+ /**
132
+ * The outcome of a compaction, per session.
133
+ *
134
+ * A compaction that OpenCode wrote with its own model looks exactly like a
135
+ * normal compaction from the inside the session, so the decision is kept
136
+ * where it can be asked for.
137
+ */
138
+ const outcomes = new Map<
139
+ string,
140
+ { outcome: string; reason?: string; tokens?: number; at: string }
141
+ >()
142
+ const recordOutcome = (sessionID: string, outcome: string, reason?: string, tokens?: number) => {
143
+ outcomes.set(sessionID, { outcome, reason, tokens, at: new Date().toISOString() })
144
+ }
145
+
146
+ /**
147
+ * Compaction decisions are always written, whatever the log level is.
148
+ *
149
+ * They are rare (one per compaction) and they are the only trace of a
150
+ * compaction that handed itself back to OpenCode's summarizer - the one
151
+ * failure mode a user cannot otherwise see.
152
+ */
153
+ const logOutcome = (kind: string, data: Record<string, unknown>) => {
154
+ writeLog(kind, data)
155
+ }
156
+
157
+ const cooldowns = new CooldownStore(base)
158
+
159
+ // Move any state written by an older version (which kept it inside the
160
+ // project) into the global store, keyed by the session's project.
161
+ try {
162
+ const legacy = join(directory, ".opencode", "blackhole")
163
+ if (existsSync(legacy)) {
164
+ let moved = 0
165
+ for (const kind of ["transcripts", "memory"]) {
166
+ const source = join(legacy, kind)
167
+ if (!existsSync(source)) continue
168
+ for (const file of readdirSync(source)) {
169
+ if (!file.endsWith(".jsonl")) continue
170
+ const sessionID = file.replace(/\.jsonl$/, "")
171
+ const key = await resolveKey(sessionID)
172
+ const dest = join(projectStateDir(base, key), kind)
173
+ mkdirSync(dest, { recursive: true })
174
+ const target = join(dest, file)
175
+ if (existsSync(target)) continue
176
+ try {
177
+ renameSync(join(source, file), target)
178
+ moved++
179
+ } catch {
180
+ // Leave a file we cannot move where it is.
181
+ }
182
+ }
183
+ }
184
+ if (moved > 0) log("state-migrated", { moved, from: legacy, to: base })
185
+ // Drop what is left of the old directory.
186
+ try {
187
+ rmSync(legacy, { recursive: true, force: true })
188
+ } catch {
189
+ // Not fatal: the old files are inert.
190
+ }
191
+ }
192
+ } catch (err) {
193
+ log("state-migration-failed", { error: String(err) })
194
+ }
195
+
196
+ // Expire old state once, when the plugin starts. Transcripts duplicate
197
+ // what OpenCode already stores in the session record, so they expire
198
+ // quickly; ledgers hold the session's memory and are kept much longer.
199
+ try {
200
+ const settings = config().retention
201
+ for (const dir of listProjectDirs(base)) {
202
+ if (settings.transcriptDays > 0) {
203
+ const removed = TranscriptStore.sweepDir(join(dir, "transcripts"), settings.transcriptDays * DAY_MS)
204
+ if (removed > 0) log("retention", { kind: "transcript", removed })
205
+ }
206
+ if (settings.memoryDays > 0) {
207
+ const removed = MemoryLedger.sweepDir(join(dir, "memory"), settings.memoryDays * DAY_MS)
208
+ if (removed > 0) log("retention", { kind: "memory", removed })
209
+ }
210
+ }
211
+ if (settings.logDays > 0) {
212
+ const removed = sweepFile(logFile, settings.logDays * DAY_MS)
213
+ if (removed > 0) log("retention", { kind: "log", removed })
214
+ }
215
+ } catch (err) {
216
+ log("retention-failed", { error: String(err) })
217
+ }
218
+
219
+ /**
220
+ * The model each session is currently using.
221
+ *
222
+ * `ctx.session.get()` does not carry the model, so it is taken from the
223
+ * request itself: the context hook and the compaction hook both see the
224
+ * model the session is running with. Without this the session-model
225
+ * fallback could never fire.
226
+ */
227
+ const sessionModels = new Map<string, { providerID: string; id: string }>()
228
+ const rememberModel = (sessionID: string, model: unknown) => {
229
+ const ref = model as { providerID?: string; id?: string } | undefined
230
+ if (ref?.providerID && ref?.id) sessionModels.set(sessionID, { providerID: ref.providerID, id: ref.id })
231
+ }
232
+
233
+ const sessionModel = async (sessionID: string) => {
234
+ const known = sessionModels.get(sessionID)
235
+ if (known) return known
236
+ try {
237
+ const info: any = await ctx.session.get({ sessionID })
238
+ const model = info?.model
239
+ if (model?.providerID && model?.id) {
240
+ const ref = { providerID: model.providerID, id: model.id }
241
+ sessionModels.set(sessionID, ref)
242
+ return ref
243
+ }
244
+ } catch {
245
+ // Fall through: the session model is optional.
246
+ }
247
+ return undefined
248
+ }
249
+
250
+ /** The one way this plugin calls a model. */
251
+ const generate = async (model: ModelRef, prompt: string) => {
252
+ const result = await ctx.generate.text({ model, prompt })
253
+ return result.text
254
+ }
255
+
256
+ /**
257
+ * The handoff block: one model call for the narrative half of the
258
+ * checkpoint - decisions and why, where the work stands, what happens
259
+ * next, and the facts that are expensive to rediscover.
260
+ *
261
+ * Returns undefined when the call fails or times out; the deterministic
262
+ * summary is a valid checkpoint on its own. When the model is unreachable
263
+ * a smaller block is derived without one, so a checkpoint still says
264
+ * where the work stands.
265
+ */
266
+ const runHandoff = async (options: {
267
+ sessionID: string
268
+ messages: readonly unknown[]
269
+ sections: string
270
+ previousSummary?: string
271
+ }): Promise<string | undefined> => {
272
+ const settings = config()
273
+ const inputChars = Math.max(4_000, Math.round(settings.compaction.handoffInputMaxTokens * 4))
274
+ const previousChars = Math.min(options.previousSummary?.length ?? 0, Math.floor(inputChars * 0.4))
275
+ const sectionChars = Math.min(options.sections.length, Math.floor(inputChars * 0.25))
276
+ const windowChars = Math.max(2_000, inputChars - previousChars - sectionChars)
277
+ const prompt = buildHandoffPrompt({
278
+ previousSummary: options.previousSummary?.slice(0, previousChars),
279
+ sections: options.sections.slice(0, sectionChars),
280
+ window: renderWindow(options.messages, windowChars),
281
+ })
282
+
283
+ let model: string | undefined
284
+ // The block shares the summary budget with the deterministic sections,
285
+ // so it can never push the checkpoint past the hard cap on its own.
286
+ const blockBudget =
287
+ Math.max(
288
+ 400,
289
+ Math.min(settings.compaction.handoffMaxTokens, Math.floor(settings.compaction.maxSummaryTokens / 2)),
290
+ ) * 4
291
+
292
+ // One deadline for every attempt: a model that is slow must not hold
293
+ // compaction open for the timeout once per retry.
294
+ const deadline = Date.now() + settings.compaction.handoffTimeoutSeconds * 1_000
295
+ const attemptOnce = async () => {
296
+ const remaining = deadline - Date.now()
297
+ if (remaining < 1_000) return undefined
298
+ const run = await withTimeout(
299
+ runWithFallbacks({
300
+ models: [settings.compaction.handoffModel, ...settings.compaction.handoffFallbacks],
301
+ prompt,
302
+ call: generate,
303
+ sessionModel: await sessionModel(options.sessionID),
304
+ sessionFallback: settings.memory.sessionFallback,
305
+ cooldowns,
306
+ cooldownSeconds: settings.memory.cooldownSeconds,
307
+ onFailure: (failed, error, retryable) =>
308
+ log("handoff-model-failed", {
309
+ sessionID: options.sessionID,
310
+ model: `${failed.providerID}/${failed.id}`,
311
+ retryable,
312
+ error: String(error).slice(0, 200),
313
+ }),
314
+ }),
315
+ remaining,
316
+ )
317
+ if (run) model = `${run.model.providerID}/${run.model.id}`
318
+ return run
319
+ }
320
+
321
+ let run = await attemptOnce()
322
+ let block = run ? cleanHandoff(run.text, blockBudget) : undefined
323
+ // A reasoning model can spend the whole output budget thinking and
324
+ // return no text at all. The free router picks a provider per request,
325
+ // so one retry normally lands somewhere else; the deadline still wins.
326
+ if (!block && run && deadline - Date.now() > 5_000) {
327
+ log("handoff-retry", { sessionID: options.sessionID, model })
328
+ run = await attemptOnce()
329
+ block = run ? cleanHandoff(run.text, blockBudget) : undefined
330
+ }
331
+ if (block) {
332
+ log("handoff", { sessionID: options.sessionID, model, chars: block.length })
333
+ return block
334
+ }
335
+
336
+ const fallback = fallbackHandoff({
337
+ summary: options.sections,
338
+ messages: options.messages,
339
+ previousSummary: options.previousSummary,
340
+ })
341
+ log("handoff-fallback", {
342
+ sessionID: options.sessionID,
343
+ model,
344
+ chars: fallback?.length ?? 0,
345
+ reason: run ? "unusable response" : "no model answered",
346
+ })
347
+ return fallback
348
+ }
349
+
350
+ const memory = new MemoryManager({
351
+ state,
352
+ cooldowns,
353
+ config,
354
+ generate,
355
+ sessionModel,
356
+ notify: (sessionID, text) => {
357
+ void ctx.session.synthetic({ sessionID, text })
358
+ },
359
+ log,
360
+ })
361
+
362
+ // Record every model request so the session has a full transcript for
363
+ // recall and for session-global `#N` references, and inject the durable
364
+ // memory into the system prompt.
365
+ await ctx.session.hook("context", async (event) => {
366
+ if (!config().enabled) return
367
+ rememberModel(event.sessionID, (event as any).model)
368
+ try {
369
+ const { store } = await ensure(event.sessionID)
370
+ captureRequest(store, event.sessionID, event.messages as unknown[])
371
+ } catch (err) {
372
+ log("capture-failed", { sessionID: event.sessionID, error: String(err) })
373
+ }
374
+ if (!config().memory.enabled) return
375
+ try {
376
+ const block = memory.memoryBlock(event.sessionID)
377
+ if (block) {
378
+ event.system.push({ type: "text", text: block })
379
+ log("memory-injected", { sessionID: event.sessionID, chars: block.length })
380
+ }
381
+ } catch (err) {
382
+ log("memory-inject-failed", { sessionID: event.sessionID, error: String(err) })
383
+ }
384
+ })
385
+
386
+ // Turn end: run the memory workers when their thresholds are met.
387
+ const controller = new AbortController()
388
+ const seenEventTypes = new Set<string>()
389
+ void (async () => {
390
+ try {
391
+ for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
392
+ const type = (event as any)?.type
393
+ if (!seenEventTypes.has(type)) {
394
+ seenEventTypes.add(type)
395
+ log("event-type", { type })
396
+ }
397
+ if (type !== "session.execution.succeeded") continue
398
+ const sessionID = (event as any)?.data?.sessionID ?? (event as any)?.sessionID
399
+ log("turn-end", { sessionID })
400
+ if (!sessionID) continue
401
+ void (async () => {
402
+ try {
403
+ await ensure(sessionID)
404
+ await memory.onTurnEnd(sessionID)
405
+ } catch (err) {
406
+ log("memory-error", { sessionID, error: String(err) })
407
+ }
408
+ })()
409
+ }
410
+ } catch (err) {
411
+ log("event-stream-error", { error: String(err) })
412
+ }
413
+ })()
414
+
415
+ await ctx.session.hook("compaction", async (event) => {
416
+ // Another plugin already produced a summary — leave it alone, but say
417
+ // so: a compaction we did not write is the one thing a user cannot see
418
+ // from the session itself.
419
+ if (event.result) {
420
+ recordOutcome(event.sessionID, "skipped", "another plugin produced a summary")
421
+ logOutcome("compaction-skipped", { sessionID: event.sessionID, reason: "another plugin produced a summary" })
422
+ return
423
+ }
424
+
425
+ try {
426
+ const settings = config()
427
+ if (!settings.enabled || !settings.compaction.enabled) {
428
+ recordOutcome(event.sessionID, "disabled", "blackhole is disabled")
429
+ return
430
+ }
431
+
432
+ rememberModel(event.sessionID, (event as any).model)
433
+
434
+ const { store } = await ensure(event.sessionID)
435
+
436
+ // The compaction request carries the newest exchange OpenCode kept
437
+ // verbatim, which we have not seen in a request yet. Capturing it keeps
438
+ // the transcript complete at the point where it matters most.
439
+ try {
440
+ captureRequest(store, event.sessionID, event.messages as unknown[])
441
+ } catch (err) {
442
+ log("capture-failed", { sessionID: event.sessionID, error: String(err) })
443
+ }
444
+
445
+ const transcript = store.read(event.sessionID)
446
+ const resolveIndex = (id: string) => transcript.indexById.get(id)
447
+
448
+ // Authoritative file activity across the whole session, including the
449
+ // newest exchange OpenCode retained verbatim (it is not part of the
450
+ // summarization request).
451
+ let fileOps
452
+ try {
453
+ const blocks = normalize(transcript.messages.map((entry) => entry.message))
454
+ const files = extractFiles(blocks)
455
+ fileOps = {
456
+ readFiles: [...files.read],
457
+ modifiedFiles: [...files.modified],
458
+ createdFiles: [...files.created],
459
+ }
460
+ } catch {
461
+ fileOps = undefined
462
+ }
463
+
464
+ const result = buildSummary({
465
+ messages: event.messages as unknown[],
466
+ resolveIndex,
467
+ fileOps,
468
+ briefTokens: settings.compaction.brief ? settings.compaction.briefTokens : 0,
469
+ maxSummaryTokens: settings.compaction.maxSummaryTokens,
470
+ extraNote: settings.compaction.extraNote,
471
+ })
472
+
473
+ // The handoff block: the narrative the deterministic sections cannot
474
+ // carry. Additive - a failed call leaves the deterministic summary
475
+ // untouched - and a carried checkpoint has nothing new to narrate.
476
+ let handoff: string | undefined
477
+ if (settings.compaction.handoff && !result.carried) {
478
+ try {
479
+ handoff = await runHandoff({
480
+ sessionID: event.sessionID,
481
+ messages: event.messages as unknown[],
482
+ sections: result.summary ?? "",
483
+ previousSummary: result.previousSummary,
484
+ })
485
+ } catch (err) {
486
+ // Never a reason to fail a compaction.
487
+ log("handoff-error", { sessionID: event.sessionID, error: String(err) })
488
+ }
489
+ }
490
+
491
+ let summary = result.summary
492
+ if (handoff) summary = summary ? insertBlock(summary, handoff) : handoff
493
+
494
+ if (!summary) {
495
+ // The safety valve: OpenCode's own summarizer takes over. Record it,
496
+ // because from the session this is invisible - it just looks like a
497
+ // slow, model-written summary.
498
+ recordOutcome(event.sessionID, "fallback", result.rejected ?? "no usable content")
499
+ logOutcome("compaction-fallback", {
500
+ sessionID: event.sessionID,
501
+ reason: result.rejected,
502
+ messageCount: event.messages?.length ?? 0,
503
+ handoff: Boolean(handoff),
504
+ })
505
+ return
506
+ }
507
+
508
+ // Durable memory rides along with the summary, so a compaction never
509
+ // loses what the session learned.
510
+ if (settings.memory.enabled) {
511
+ try {
512
+ const block = memory.summaryMemoryBlock(event.sessionID)
513
+ if (block) summary = `${summary}\n\n${block}`
514
+ } catch {
515
+ // Reflections are an addition, never a reason to fail a compaction.
516
+ }
517
+ }
518
+
519
+ const tokens = estimateTokensFromChars(summary.length, DEFAULT_CHARS_PER_TOKEN)
520
+ event.result = { summary }
521
+ recordOutcome(
522
+ event.sessionID,
523
+ result.summary ? "deterministic" : "handoff-only",
524
+ handoff ? "with handoff block" : undefined,
525
+ tokens,
526
+ )
527
+ logOutcome("compaction", {
528
+ sessionID: event.sessionID,
529
+ model: event.model,
530
+ messageCount: event.messages?.length ?? 0,
531
+ summaryTokens: tokens,
532
+ handoff: Boolean(handoff),
533
+ handoffOnly: !result.summary,
534
+ hadPreviousSummary: Boolean(result.previousSummary),
535
+ })
536
+ } catch (err) {
537
+ // A bug here must never fail a compaction: leave the result unset and
538
+ // let OpenCode fall back to its own summarizer. Say what happened.
539
+ recordOutcome(event.sessionID, "error", String(err))
540
+ logOutcome("compaction-error", { sessionID: event.sessionID, error: String(err) })
541
+ }
542
+ })
543
+
544
+ // The recall tool: search the session transcript, including anything a
545
+ // compaction dropped.
546
+ await ctx.tool.transform((editor) => {
547
+ editor.add({
548
+ name: "blackhole_recall",
549
+ description: [
550
+ "Recall earlier parts of this session, including anything dropped by compaction.",
551
+ "Use it before saying the context is gone.",
552
+ "",
553
+ "Pick one per call:",
554
+ "- query: keyword search (regex works), 5 results per page.",
555
+ "- range: [from, to]: entries #from..#to in order, 20 per page.",
556
+ "- expand: [N, ...]: full untruncated text of those entries.",
557
+ "- mode: 'touched': files worked on, with entry indices.",
558
+ "- query '#N:path': file content from entry N ('#N:path:full' for all lines).",
559
+ "",
560
+ "Add to any of them:",
561
+ "- page: next page of query, range or touched results.",
562
+ "- scope: 'all' reads the whole session.",
563
+ "",
564
+ "#N is the number shown in summary refs like (#12) and in recall results.",
565
+ "Only the current session is searchable.",
566
+ ].join("\n"),
567
+ input: {
568
+ type: "object",
569
+ properties: {
570
+ query: {
571
+ type: "string",
572
+ description:
573
+ "What to recall, in plain keywords (e.g. 'redis cache decision'). Multi-word queries are ranked by relevance. A regex pattern also works. '#N:path' instead shows a file's content from entry N.",
574
+ },
575
+ range: {
576
+ type: "array",
577
+ items: { type: "number" },
578
+ minItems: 2,
579
+ maxItems: 2,
580
+ description:
581
+ "[from, to]: every entry from #from to #to (inclusive) in order, 20 per page. #N is the number in summary refs like (#12) and in recall results.",
582
+ },
583
+ expand: {
584
+ type: "array",
585
+ items: { type: "number" },
586
+ description:
587
+ "#N indices to return full untruncated content for (from summary refs like (#12) or recall results).",
588
+ },
589
+ page: {
590
+ type: "number",
591
+ description: "Page number (1-based) for query, range or touched results. Default: 1.",
592
+ },
593
+ scope: {
594
+ type: "string",
595
+ enum: ["lineage", "all"],
596
+ description:
597
+ "Default 'lineage' covers the active conversation path. Use 'all' to also reach messages from other branches.",
598
+ },
599
+ mode: {
600
+ type: "string",
601
+ enum: ["hybrid", "touched"],
602
+ description:
603
+ "hybrid (default) = normal recall; touched = files worked on, by path, with entry indices.",
604
+ },
605
+ },
606
+ additionalProperties: false,
607
+ },
608
+ execute: async (input: any, context: any) => {
609
+ const { store } = await ensure(context.sessionID)
610
+ const text = runRecallTool(store, context.sessionID, input ?? {}, {
611
+ maxChars: config().recall.responseMaxChars,
612
+ })
613
+ return { content: text }
614
+ },
615
+ })
616
+ })
617
+
618
+ // Commands. OpenCode's command API cannot return text directly, so the
619
+ // output is delivered into the session as a synthetic message.
620
+ await ctx.command.transform((editor) => {
621
+ editor.add({
622
+ name: "blackhole",
623
+ description: "Compact this session now with blackhole's checkpoint (no summarizer call)",
624
+ execute: async ({ sessionID }) => {
625
+ await ctx.session.compact({ sessionID })
626
+ },
627
+ })
628
+ editor.add({
629
+ name: "blackhole-recall",
630
+ description: "Search this session's history: /blackhole-recall <query> [page:N] [scope:all]",
631
+ execute: async ({ sessionID, prompt }) => {
632
+ const { store } = await ensure(sessionID)
633
+ const text = runRecallCommand(store, sessionID, prompt?.text ?? "", {
634
+ maxChars: config().recall.responseMaxChars,
635
+ })
636
+ await ctx.session.synthetic({ sessionID, text })
637
+ },
638
+ })
639
+ editor.add({
640
+ name: "blackhole-memory",
641
+ description: "Show blackhole's memory status for this session",
642
+ execute: async ({ sessionID }) => {
643
+ const { ledger } = await ensure(sessionID)
644
+ const status = memory.status(sessionID)
645
+ const state = ledger.read(sessionID)
646
+ const lines = [
647
+ "# Blackhole memory",
648
+ "",
649
+ `- observations: ${status.observations}`,
650
+ `- reflections: ${status.reflections}`,
651
+ `- dropped: ${status.dropped}`,
652
+ `- observed through entry: #${status.observedThrough}`,
653
+ `- pending: ~${status.pendingTokens} tokens`,
654
+ `- state: ${projectStateDir(base, sessionKeys.get(sessionID) ?? fallbackKey)}`,
655
+ ]
656
+ const last = outcomes.get(sessionID)
657
+ if (last) {
658
+ // Whether the last compaction was ours or OpenCode's own model is
659
+ // invisible from inside the session, so it is reported here.
660
+ const detail = last.reason ? ` (${last.reason})` : ""
661
+ const size = last.tokens ? `, ${last.tokens} tokens` : ""
662
+ lines.push(`- last compaction: ${last.outcome}${detail}${size} at ${last.at}`)
663
+ }
664
+ if (state.observations.length > 0) {
665
+ lines.push("", "## Observations")
666
+ for (const observation of state.observations) lines.push(`- ${observation.text}`)
667
+ }
668
+ if (state.reflections.length > 0) {
669
+ lines.push("", "## Reflections")
670
+ for (const reflection of state.reflections) lines.push(`- ${reflection.text}`)
671
+ }
672
+ await ctx.session.synthetic({ sessionID, text: lines.join("\n") })
673
+ },
674
+ })
675
+ editor.add({
676
+ name: "blackhole-export",
677
+ description: "Export this session's memory as markdown: /blackhole-export [out:<path>.md]",
678
+ execute: async ({ sessionID, prompt }) => {
679
+ const { ledger } = await ensure(sessionID)
680
+ const state = ledger.read(sessionID)
681
+ const args = prompt?.text ?? ""
682
+ const outMatch = /out:(\S+)/.exec(args)
683
+ const body = [
684
+ "# Blackhole memory export",
685
+ "",
686
+ "## Reflections",
687
+ ...(state.reflections.length > 0
688
+ ? state.reflections.map((reflection) => `- ${reflection.text}`)
689
+ : ["- (none)"]),
690
+ "",
691
+ "## Observations",
692
+ ...(state.observations.length > 0
693
+ ? state.observations.map((observation) => `- ${observation.text}`)
694
+ : ["- (none)"]),
695
+ "",
696
+ ].join("\n")
697
+ if (outMatch) {
698
+ const path = outMatch[1]
699
+ try {
700
+ mkdirSync(dirname(path), { recursive: true })
701
+ writeFileSync(path, body)
702
+ await ctx.session.synthetic({ sessionID, text: `Memory exported to ${path}` })
703
+ return
704
+ } catch (error) {
705
+ await ctx.session.synthetic({ sessionID, text: `Export failed: ${String(error)}` })
706
+ return
707
+ }
708
+ }
709
+ await ctx.session.synthetic({ sessionID, text: body })
710
+ },
711
+ })
712
+ })
713
+ },
714
+ })