opencode-blackhole 0.0.0-stage → 0.1.1
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/LICENSE +35 -0
- package/LICENSE.vendor +21 -0
- package/README.md +377 -2
- package/dist/server.js +5025 -0
- package/index.ts +1 -0
- package/package.json +57 -4
- package/src/adapter/opencode-messages.ts +270 -0
- package/src/compaction/build.ts +264 -0
- package/src/compaction/handoff.ts +421 -0
- package/src/config.ts +316 -0
- package/src/index.ts +714 -0
- package/src/memory/ledger.ts +254 -0
- package/src/memory/manager.ts +659 -0
- package/src/memory/models.ts +172 -0
- package/src/memory/prompts.ts +145 -0
- package/src/recall/recall.ts +327 -0
- package/src/state/paths.ts +42 -0
- package/src/store/capture.ts +84 -0
- package/src/store/transcript.ts +279 -0
- package/src/vcc/README.md +51 -0
- package/src/vcc/core/brief.ts +407 -0
- package/src/vcc/core/build-sections.ts +81 -0
- package/src/vcc/core/compact-args.ts +45 -0
- package/src/vcc/core/content.ts +156 -0
- package/src/vcc/core/drill-down.ts +302 -0
- package/src/vcc/core/filter-noise.ts +41 -0
- package/src/vcc/core/format-recall.ts +100 -0
- package/src/vcc/core/format.ts +104 -0
- package/src/vcc/core/lineage.ts +26 -0
- package/src/vcc/core/normalize.ts +76 -0
- package/src/vcc/core/rank.ts +283 -0
- package/src/vcc/core/recall-request.ts +160 -0
- package/src/vcc/core/recall-scope.ts +30 -0
- package/src/vcc/core/render-entries.ts +55 -0
- package/src/vcc/core/sanitize.ts +5 -0
- package/src/vcc/core/search-entries.ts +594 -0
- package/src/vcc/core/skill-collapse.ts +35 -0
- package/src/vcc/core/summarize.ts +289 -0
- package/src/vcc/core/token-estimate.ts +100 -0
- package/src/vcc/core/tool-args.ts +16 -0
- package/src/vcc/details.ts +11 -0
- package/src/vcc/extract/commits.ts +96 -0
- package/src/vcc/extract/files.ts +87 -0
- package/src/vcc/extract/goals.ts +79 -0
- package/src/vcc/extract/preferences.ts +55 -0
- package/src/vcc/extract/tracked-commands.ts +232 -0
- package/src/vcc/load-messages.ts +42 -0
- package/src/vcc/pi-types.ts +53 -0
- package/src/vcc/sections.ts +12 -0
- 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
|
+
})
|