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,659 @@
1
+ /**
2
+ * The memory manager: decides when the workers run, runs them with the
3
+ * configured model chain, and renders the memory that gets injected back into
4
+ * the session.
5
+ *
6
+ * Flow (mirrors pi-blackhole):
7
+ * turn ends → pending transcript since the coverage cursor
8
+ * → observer (when the pending slice is big enough)
9
+ * → reflector (when enough observations accumulated)
10
+ * → dropper (when the observation pool is over budget)
11
+ */
12
+ import type { BlackholeConfig, ModelRef } from "../config"
13
+ import type { StoredMessage, TranscriptStore } from "../store/transcript"
14
+ import type { Message } from "../vcc/pi-types"
15
+ import { CooldownStore, runWithFallbacks } from "./models"
16
+ import { MemoryLedger, type Observation, type Reflection } from "./ledger"
17
+ import { DROPPER_PROMPT, OBSERVER_PROMPT, OBSERVER_REFINE_PROMPT, parseJson, REFLECTOR_PROMPT } from "./prompts"
18
+
19
+ const CHARS_PER_TOKEN = 4
20
+ const tokens = (text: string) => Math.ceil(text.length / CHARS_PER_TOKEN)
21
+
22
+ /** Render a message for the observer: compact, but enough to see the work. */
23
+ const renderForObserver = (message: Message): string | undefined => {
24
+ const clip = (text: string, max: number) =>
25
+ text.length > max ? `${text.slice(0, max)}…(+${text.length - max} chars)` : text
26
+
27
+ if (message.role === "user") {
28
+ const text = typeof message.content === "string" ? message.content : ""
29
+ const joined = Array.isArray(message.content)
30
+ ? message.content
31
+ .map((part) => (part.type === "text" ? part.text : ""))
32
+ .filter(Boolean)
33
+ .join("\n")
34
+ : text
35
+ return joined ? `[user] ${clip(joined, 800)}` : undefined
36
+ }
37
+
38
+ if (message.role === "assistant") {
39
+ const parts = Array.isArray(message.content) ? message.content : []
40
+ const text = parts
41
+ .filter((part) => part.type === "text")
42
+ .map((part) => (part as { text: string }).text)
43
+ .join("\n")
44
+ const calls = parts
45
+ .filter((part) => part.type === "toolCall")
46
+ .map((part) => {
47
+ const call = part as { name: string; arguments: Record<string, unknown> }
48
+ const args = call.arguments ?? {}
49
+ const detail =
50
+ typeof args.command === "string"
51
+ ? args.command
52
+ : typeof args.path === "string"
53
+ ? args.path
54
+ : Object.keys(args).join(", ")
55
+ return `[tool ${call.name}] ${clip(String(detail), 200)}`
56
+ })
57
+ const lines = [...calls, text ? clip(text, 800) : ""].filter(Boolean)
58
+ return lines.length > 0 ? `[assistant] ${lines.join(" | ")}` : undefined
59
+ }
60
+
61
+ if (message.role === "bashExecution") {
62
+ const output = clip(message.output ?? "", 400)
63
+ return `[shell] ${clip(message.command ?? "", 200)} → ${output}`
64
+ }
65
+
66
+ if (message.role === "toolResult") {
67
+ const text = typeof message.content === "string" ? message.content : ""
68
+ return `[result ${message.toolName ?? "tool"}] ${clip(text, 400)}`
69
+ }
70
+
71
+ return undefined
72
+ }
73
+
74
+ export interface MemoryManagerDeps {
75
+ /**
76
+ * The store and ledger of a session's project.
77
+ *
78
+ * The state lives per project, so the manager resolves it per session.
79
+ */
80
+ state: (sessionID: string) => { store: TranscriptStore; ledger: MemoryLedger }
81
+ cooldowns: CooldownStore
82
+ config: () => BlackholeConfig
83
+ /** Run a one-shot generation with a specific model. */
84
+ generate: (model: ModelRef, prompt: string) => Promise<string>
85
+ /** The session's current model, used only when `sessionFallback` is on. */
86
+ sessionModel: (sessionID: string) => ModelRef | undefined | Promise<ModelRef | undefined>
87
+ /** Tell the user something, in the session itself. */
88
+ notify?: (sessionID: string, text: string) => void
89
+ log: (kind: string, data: Record<string, unknown>) => void
90
+ }
91
+
92
+ export interface MemoryStatus {
93
+ observations: number
94
+ reflections: number
95
+ dropped: number
96
+ observedThrough: number
97
+ pendingTokens: number
98
+ }
99
+
100
+ export class MemoryManager {
101
+ private readonly deps: MemoryManagerDeps
102
+ /** Last attempt per session+stage, for the retry gate. */
103
+ private readonly lastAttempt = new Map<string, number>()
104
+ private readonly running = new Set<string>()
105
+ /** Stages we have already complained about, so we only say it once. */
106
+ private readonly notified = new Set<string>()
107
+
108
+ constructor(deps: MemoryManagerDeps) {
109
+ this.deps = deps
110
+ }
111
+
112
+ /**
113
+ * Tell the user, once, that a worker has no model to run on.
114
+ *
115
+ * A worker that quietly does nothing is the worst failure mode of a
116
+ * background feature: the session looks fine and the memory is simply
117
+ * never written. One line in the session says what is missing and how to
118
+ * fix it.
119
+ */
120
+ private noticeNoModel(sessionID: string, stage: string, model: ModelRef, reason?: string) {
121
+ const key = `${sessionID}:${stage}`
122
+ if (this.notified.has(key)) return
123
+ this.notified.add(key)
124
+ this.deps.log(`${stage}-no-model`, { sessionID, model })
125
+ // Some catalogs already carry the provider in the id ("openrouter/free").
126
+ const full = model.id.startsWith(`${model.providerID}/`) ? model.id : `${model.providerID}/${model.id}`
127
+ // A router can fail because an upstream provider did, which is a very
128
+ // different problem from a missing key. Say which one it was.
129
+ const detail = reason ? `: ${reason}` : ""
130
+ this.deps.notify?.(
131
+ sessionID,
132
+ [
133
+ `**Blackhole: the ${stage} has no model to run on** (\`${full}\` failed${detail}).`,
134
+ "",
135
+ "Compaction is unaffected — summaries are still free and deterministic.",
136
+ "Memory needs a model of its own. Either:",
137
+ "",
138
+ `- use a model you already have: set \`memory.${stage}Model\` in \`~/.config/opencode/blackhole.json\``,
139
+ `- or give OpenRouter a key so the default free router (\`openrouter/openrouter/free\`) works`,
140
+ "",
141
+ `The session's own model is tried last, so this usually means it failed too.`,
142
+ ].join("\n"),
143
+ )
144
+ }
145
+
146
+ /** The ledger of the session's project. */
147
+ private ledgerFor(sessionID: string): MemoryLedger {
148
+ return this.deps.state(sessionID).ledger
149
+ }
150
+
151
+ /** The transcript store of the session's project. */
152
+ private storeFor(sessionID: string): TranscriptStore {
153
+ return this.deps.state(sessionID).store
154
+ }
155
+
156
+ private gate(key: string, seconds: number, now = Date.now()): boolean {
157
+ const last = this.lastAttempt.get(key)
158
+ if (last !== undefined && now - last < seconds * 1000) return false
159
+ this.lastAttempt.set(key, now)
160
+ return true
161
+ }
162
+
163
+ /**
164
+ * The transcript slice the observer has not read yet.
165
+ *
166
+ * The token cost of each message is computed once, when it is captured, so
167
+ * this stays a cheap scan instead of re-serialising the whole pending
168
+ * window on every turn.
169
+ */
170
+ private pending(sessionID: string): StoredMessage[] {
171
+ const state = this.ledgerFor(sessionID).read(sessionID)
172
+ const transcript = this.storeFor(sessionID).read(sessionID)
173
+ return transcript.messages.filter((entry) => entry.index > state.observedThrough)
174
+ }
175
+
176
+ private pendingTokens(sessionID: string, pending: StoredMessage[]): number {
177
+ let total = 0
178
+ const store = this.storeFor(sessionID)
179
+ for (const entry of pending) total += entry.tokens ?? store.estimateTokens(entry.message)
180
+ return total
181
+ }
182
+
183
+ status(sessionID: string): MemoryStatus {
184
+ const state = this.ledgerFor(sessionID).read(sessionID)
185
+ const pending = this.pending(sessionID)
186
+ return {
187
+ observations: state.observations.length,
188
+ reflections: state.reflections.length,
189
+ dropped: state.droppedCount,
190
+ observedThrough: state.observedThrough,
191
+ pendingTokens: this.pendingTokens(sessionID, pending),
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Run the workers if their thresholds are met. Safe to call on every turn
197
+ * end: the thresholds and the retry gate keep it from running too often.
198
+ */
199
+ async onTurnEnd(sessionID: string): Promise<void> {
200
+ const config = this.deps.config()
201
+ if (!config.enabled || !config.memory.enabled) return
202
+ if (this.running.has(sessionID)) return
203
+ this.running.add(sessionID)
204
+ try {
205
+ const pending = this.pending(sessionID)
206
+ const pendingTokens = this.pendingTokens(sessionID, pending)
207
+ this.deps.log("memory-turn", {
208
+ sessionID,
209
+ pending: pending.length,
210
+ pendingTokens,
211
+ threshold: config.memory.observeAfterTokens,
212
+ })
213
+ await this.observe(sessionID, config)
214
+ await this.reflect(sessionID, config)
215
+ await this.drop(sessionID, config)
216
+ } catch (error) {
217
+ this.deps.log("memory-error", { sessionID, error: String(error) })
218
+ } finally {
219
+ this.running.delete(sessionID)
220
+ }
221
+ }
222
+
223
+ private async observe(sessionID: string, config: BlackholeConfig) {
224
+ const pending = this.pending(sessionID)
225
+ if (pending.length === 0) return
226
+ const pendingTokens = this.pendingTokens(sessionID, pending)
227
+ if (pendingTokens < config.memory.observeAfterTokens) return
228
+ if (!this.gate(`observer:${sessionID}`, config.memory.retryGateSeconds)) return
229
+
230
+ // Only send what fits. Upstream chunks the observer input and covers the
231
+ // chunk it managed to read; sending an unbounded window would exceed the
232
+ // model's context and stall the memory for good.
233
+ const chunk = this.chunkForObserver(sessionID, pending, config.memory.observerChunkMaxTokens)
234
+ const rendered = chunk
235
+ .map((entry) => renderForObserver(entry.message))
236
+ .filter((line): line is string => Boolean(line))
237
+ .join("\n")
238
+ if (!rendered.trim()) return
239
+
240
+ const prompt = `${OBSERVER_PROMPT}\n\n---\nTRANSCRIPT\n${rendered}\n---`
241
+ let lastError: string | undefined
242
+ const result = await runWithFallbacks({
243
+ models: [config.memory.observerModel, ...config.memory.observerFallbacks],
244
+ prompt,
245
+ call: (model, text) => this.deps.generate(model, text),
246
+ sessionModel: await this.deps.sessionModel(sessionID),
247
+ sessionFallback: config.memory.sessionFallback,
248
+ cooldowns: this.deps.cooldowns,
249
+ cooldownSeconds: config.memory.cooldownSeconds,
250
+ onFailure: (model, error) => {
251
+ const message = String(error)
252
+ if (!message.includes("in cooldown:")) lastError = `${model.providerID}/${model.id}: ${message.slice(0, 140)}`
253
+ this.deps.log("observer-failed", { sessionID, model, error: message.slice(0, 200) })
254
+ },
255
+ })
256
+ if (!result) {
257
+ this.deps.log("observer-skipped", { sessionID, pendingTokens })
258
+ this.noticeNoModel(sessionID, "observer", config.memory.observerModel, lastError)
259
+ return
260
+ }
261
+
262
+ const parsed = parseJson<{ observations?: string[] }>(result.text)
263
+ if (parsed === undefined) {
264
+ // The model answered, but not with something we can read. Upstream calls
265
+ // this a half-observed chunk and refuses to advance coverage over it, so
266
+ // the window is offered again instead of being lost.
267
+ this.deps.log("observer-unreadable", {
268
+ sessionID,
269
+ model: result.model,
270
+ outputChars: result.text.length,
271
+ })
272
+ return
273
+ }
274
+
275
+ const lines = (parsed.observations ?? []).map((line) => line.trim()).filter(Boolean)
276
+ if (lines.length === 0) {
277
+ // The model ran and reported nothing durable — that is an answer, so the
278
+ // chunk is covered.
279
+ this.advance(sessionID, chunk)
280
+ return
281
+ }
282
+
283
+ // The second pass: upstream's observer reads its own first batch back and
284
+ // corrects it inside the agent loop. There is no tool loop here, so the
285
+ // correction is a second call. A failed refinement keeps the draft.
286
+ const refined = config.memory.observerRefine
287
+ ? await this.refineObservations(sessionID, config, lines, rendered)
288
+ : undefined
289
+ const finalLines = refined?.length ? refined : lines
290
+
291
+ const now = new Date().toISOString()
292
+ const observations: Observation[] = finalLines.map((text, i) => ({
293
+ id: `obs_${Date.now().toString(36)}_${i}`,
294
+ ts: now,
295
+ text,
296
+ tokens: tokens(text),
297
+ }))
298
+ this.ledgerFor(sessionID).append(sessionID, observations.map((observation) => ({ kind: "observation", ...observation })))
299
+ this.advance(sessionID, chunk)
300
+ this.deps.log("observer", {
301
+ sessionID,
302
+ count: observations.length,
303
+ draft: lines.length,
304
+ model: result.model,
305
+ skipped: result.skipped.length,
306
+ covered: chunk.length,
307
+ })
308
+ }
309
+
310
+ /**
311
+ * The observer's second pass: check the draft against the transcript.
312
+ *
313
+ * Returns the corrected list, or undefined when the pass could not be used
314
+ * — the caller then keeps the draft, so a failed refinement never loses
315
+ * what the first pass found.
316
+ */
317
+ private async refineObservations(
318
+ sessionID: string,
319
+ config: BlackholeConfig,
320
+ draft: string[],
321
+ transcript: string,
322
+ ): Promise<string[] | undefined> {
323
+ const prompt = `${OBSERVER_REFINE_PROMPT}\n\n---\nDRAFT\n${draft.map((line) => `- ${line}`).join("\n")}\n---\nTRANSCRIPT\n${transcript}\n---`
324
+ const result = await runWithFallbacks({
325
+ models: [config.memory.observerModel, ...config.memory.observerFallbacks],
326
+ prompt,
327
+ call: (model, text) => this.deps.generate(model, text),
328
+ sessionModel: await this.deps.sessionModel(sessionID),
329
+ sessionFallback: config.memory.sessionFallback,
330
+ cooldowns: this.deps.cooldowns,
331
+ cooldownSeconds: config.memory.cooldownSeconds,
332
+ onFailure: (model, error) =>
333
+ this.deps.log("observer-refine-failed", { sessionID, model, error: String(error).slice(0, 200) }),
334
+ })
335
+ if (!result) {
336
+ this.deps.log("observer-refine-skipped", { sessionID, draft: draft.length })
337
+ return undefined
338
+ }
339
+ const parsed = parseJson<{ observations?: string[] }>(result.text)
340
+ if (parsed === undefined) {
341
+ this.deps.log("observer-refine-unreadable", { sessionID, model: result.model })
342
+ return undefined
343
+ }
344
+ const lines = (parsed.observations ?? []).map((line) => line.trim()).filter(Boolean)
345
+ if (lines.length === 0) {
346
+ // An empty correction would erase the draft, which is a worse outcome
347
+ // than an unreviewed draft.
348
+ this.deps.log("observer-refine-empty", { sessionID, model: result.model })
349
+ return undefined
350
+ }
351
+ return lines
352
+ }
353
+
354
+ /**
355
+ * The oldest prefix of the pending window that fits the observer's budget.
356
+ *
357
+ * The prefix keeps the coverage cursor contiguous: everything sent is
358
+ * covered, everything unsent stays pending for the next round. At least one
359
+ * message is always sent, so a single huge message cannot stall the worker.
360
+ */
361
+ private chunkForObserver(
362
+ sessionID: string,
363
+ pending: StoredMessage[],
364
+ maxTokens: number,
365
+ ): StoredMessage[] {
366
+ if (maxTokens <= 0) return pending
367
+ const store = this.storeFor(sessionID)
368
+ const chunk: StoredMessage[] = []
369
+ let used = 0
370
+ for (const entry of pending) {
371
+ const cost = entry.tokens ?? store.estimateTokens(entry.message)
372
+ if (chunk.length > 0 && used + cost > maxTokens) break
373
+ chunk.push(entry)
374
+ used += cost
375
+ }
376
+ return chunk.length > 0 ? chunk : pending.slice(0, 1)
377
+ }
378
+
379
+ /** Move the coverage cursor to the last message the observer was shown. */
380
+ private advance(sessionID: string, pending: StoredMessage[]) {
381
+ let observedThrough = this.ledgerFor(sessionID).read(sessionID).observedThrough
382
+ for (const entry of pending) {
383
+ if (entry.index > observedThrough) observedThrough = entry.index
384
+ }
385
+ this.ledgerFor(sessionID).append(sessionID, [
386
+ { kind: "cursor", observedThrough, ts: new Date().toISOString() },
387
+ ])
388
+ }
389
+
390
+ private async reflect(sessionID: string, config: BlackholeConfig) {
391
+ const state = this.ledgerFor(sessionID).read(sessionID)
392
+ if (state.observations.length === 0) return
393
+ if (!this.gate(`reflector:${sessionID}`, config.memory.retryGateSeconds)) return
394
+
395
+ // Upstream keeps a reflector cursor: each observation is reviewed once.
396
+ // Without it the reflector re-reads the same window on every run and
397
+ // re-crystallises it, so duplicates pile up and crowd out new insights.
398
+ const unreviewed = state.observations.filter((observation) => !state.reflectedIds.has(observation.id))
399
+ if (unreviewed.length === 0) return
400
+
401
+ // The trigger counts the observations still waiting to be crystallised,
402
+ // not the whole pool - otherwise a pool that is mostly already
403
+ // crystallised would keep re-triggering the reflector.
404
+ const pendingTokens = unreviewed.reduce((sum, observation) => sum + observation.tokens, 0)
405
+ if (pendingTokens < config.memory.reflectAfterTokens) return
406
+
407
+ const review = unreviewed.slice(-60)
408
+
409
+ const listed = review.map((observation) => `- [${observation.id}] ${observation.text}`).join("\n")
410
+ const prompt = `${REFLECTOR_PROMPT}\n\n---\nOBSERVATIONS\n${listed}\n---`
411
+
412
+ let lastError: string | undefined
413
+ const result = await runWithFallbacks({
414
+ models: [config.memory.reflectorModel, ...config.memory.reflectorFallbacks],
415
+ prompt,
416
+ call: (model, text) => this.deps.generate(model, text),
417
+ sessionModel: await this.deps.sessionModel(sessionID),
418
+ sessionFallback: config.memory.sessionFallback,
419
+ cooldowns: this.deps.cooldowns,
420
+ cooldownSeconds: config.memory.cooldownSeconds,
421
+ onFailure: (model, error) => {
422
+ const message = String(error)
423
+ if (!message.includes("in cooldown:")) lastError = `${model.providerID}/${model.id}: ${message.slice(0, 140)}`
424
+ this.deps.log("reflector-failed", { sessionID, model, error: message.slice(0, 200) })
425
+ },
426
+ })
427
+ if (!result) {
428
+ this.deps.log("reflector-skipped", { sessionID })
429
+ this.noticeNoModel(sessionID, "reflector", config.memory.reflectorModel, lastError)
430
+ return
431
+ }
432
+
433
+ const parsed = parseJson<{ reflections?: string[] }>(result.text)
434
+ if (parsed === undefined) {
435
+ // Unreadable output: the review did not happen, so the cursor must not
436
+ // move - the same rule the observer follows.
437
+ this.deps.log("reflector-unreadable", { sessionID, model: result.model })
438
+ return
439
+ }
440
+
441
+ // The review is done either way: the model saw these observations. The
442
+ // cursor is written after the reflections, so a failed write cannot lose
443
+ // the review.
444
+ const known = new Set(state.reflections.map((reflection) => reflection.text.trim().toLowerCase()))
445
+ const lines = (parsed.reflections ?? [])
446
+ .map((line) => line.trim())
447
+ .filter((line) => {
448
+ if (!line) return false
449
+ const key = line.toLowerCase()
450
+ if (known.has(key)) return false
451
+ known.add(key)
452
+ return true
453
+ })
454
+
455
+ const now = new Date().toISOString()
456
+ const evidence = review.slice(-10).map((observation) => observation.id)
457
+ const reflections: Reflection[] = lines.map((text, i) => ({
458
+ id: `ref_${Date.now().toString(36)}_${i}`,
459
+ ts: now,
460
+ text,
461
+ evidence,
462
+ }))
463
+ const ledger = this.ledgerFor(sessionID)
464
+ if (reflections.length > 0) {
465
+ ledger.append(sessionID, reflections.map((reflection) => ({ kind: "reflection", ...reflection })))
466
+ }
467
+ ledger.append(sessionID, [{ kind: "reflected", ids: review.map((observation) => observation.id), ts: now }])
468
+ this.deps.log("reflector", {
469
+ sessionID,
470
+ count: reflections.length,
471
+ model: result.model,
472
+ reviewed: review.length,
473
+ })
474
+ }
475
+
476
+ /**
477
+ * Drop the least valuable observations when the pool is over budget.
478
+ *
479
+ * Upstream pi-blackhole asks a model to choose. This port drops
480
+ * deterministically instead, but it keeps upstream's ordering: an
481
+ * observation is dropped by how much support it has — none first, then one
482
+ * supporting reflection, then several — and within a tier the oldest goes
483
+ * first. Without that last step the pool could never fall under its cap:
484
+ * reflection evidence protects observations forever, so a pool of protected
485
+ * observations would grow without bound.
486
+ */
487
+ private async drop(sessionID: string, config: BlackholeConfig) {
488
+ const state = this.ledgerFor(sessionID).read(sessionID)
489
+ if (state.observations.length === 0) return
490
+ const poolTokens = state.observations.reduce((sum, o) => sum + o.tokens, 0)
491
+ if (poolTokens <= config.memory.observationsPoolMaxTokens) return
492
+
493
+ // How many reflections support each observation.
494
+ const support = new Map<string, number>()
495
+ for (const reflection of state.reflections) {
496
+ for (const id of reflection.evidence) support.set(id, (support.get(id) ?? 0) + 1)
497
+ }
498
+
499
+ // Upstream's dropper asks a model which observations are safe to remove
500
+ // and then ranks whatever it proposed. The model supplies the semantic
501
+ // judgement - duplicates, superseded facts, low-signal noise - that the
502
+ // deterministic rule cannot. When it cannot be reached, the rule below
503
+ // still prunes the pool.
504
+ const proposed = await this.proposeDrops(sessionID, config, state.observations, support)
505
+
506
+ // Least supported first; oldest first within a tier.
507
+ const rank = (a: Observation, b: Observation) => {
508
+ const bySupport = (support.get(a.id) ?? 0) - (support.get(b.id) ?? 0)
509
+ if (bySupport !== 0) return bySupport
510
+ return a.ts === b.ts ? a.id.localeCompare(b.id) : a.ts < b.ts ? -1 : 1
511
+ }
512
+
513
+ let candidates: Observation[]
514
+ if (proposed && proposed.length > 0) {
515
+ const wanted = new Set(proposed)
516
+ candidates = state.observations.filter((observation) => wanted.has(observation.id)).sort(rank)
517
+ } else {
518
+ candidates = [...state.observations].sort(rank)
519
+ }
520
+
521
+ const drop: string[] = []
522
+ let tokensLeft = poolTokens
523
+ for (const observation of candidates) {
524
+ if (tokensLeft <= config.memory.observationsPoolMaxTokens) break
525
+ drop.push(observation.id)
526
+ tokensLeft -= observation.tokens
527
+ }
528
+ if (drop.length === 0) return
529
+ this.ledgerFor(sessionID).append(
530
+ sessionID,
531
+ drop.map((id) => ({ kind: "dropped" as const, id, ts: new Date().toISOString() })),
532
+ )
533
+ this.deps.log("dropper", {
534
+ sessionID,
535
+ dropped: drop.length,
536
+ poolTokens,
537
+ tokensLeft,
538
+ proposed: proposed?.length,
539
+ selected: proposed && proposed.length > 0 ? "model" : "deterministic",
540
+ })
541
+ }
542
+
543
+ /**
544
+ * Ask the model which observations are safe to drop.
545
+ *
546
+ * Returns the ids it proposed, or undefined when the call could not be
547
+ * made or read - the caller then falls back to the deterministic rule.
548
+ */
549
+ private async proposeDrops(
550
+ sessionID: string,
551
+ config: BlackholeConfig,
552
+ observations: Observation[],
553
+ support: Map<string, number>,
554
+ ): Promise<string[] | undefined> {
555
+ const pool = observations
556
+ .map((observation) => {
557
+ const cited = support.get(observation.id) ?? 0
558
+ const coverage = cited === 0 ? "none" : cited === 1 ? "partial" : "strong"
559
+ return `- [${observation.id}] ${observation.ts.slice(0, 16).replace("T", " ")} [coverage: ${coverage}] ${observation.text}`
560
+ })
561
+ .join("\n")
562
+ const prompt = `${DROPPER_PROMPT}\n\n---\nPOOL\n${pool}\n---`
563
+ const result = await runWithFallbacks({
564
+ models: [config.memory.dropperModel, ...config.memory.dropperFallbacks],
565
+ prompt,
566
+ call: (model, text) => this.deps.generate(model, text),
567
+ sessionModel: await this.deps.sessionModel(sessionID),
568
+ sessionFallback: config.memory.sessionFallback,
569
+ cooldowns: this.deps.cooldowns,
570
+ cooldownSeconds: config.memory.cooldownSeconds,
571
+ onFailure: (model, error) =>
572
+ this.deps.log("dropper-failed", { sessionID, model, error: String(error).slice(0, 200) }),
573
+ })
574
+ if (!result) {
575
+ this.deps.log("dropper-skipped", { sessionID, pool: observations.length })
576
+ return undefined
577
+ }
578
+ const parsed = parseJson<{ drop?: string[] }>(result.text)
579
+ if (parsed === undefined) {
580
+ this.deps.log("dropper-unreadable", { sessionID, model: result.model })
581
+ return undefined
582
+ }
583
+ const known = new Set(observations.map((observation) => observation.id))
584
+ const ids = (parsed.drop ?? []).map((id) => id.trim()).filter((id) => known.has(id))
585
+ return ids
586
+ }
587
+
588
+ /** The memory block injected into the system prompt. */
589
+ memoryBlock(sessionID: string, maxTokens = 1500): string | undefined {
590
+ const state = this.ledgerFor(sessionID).read(sessionID)
591
+ if (state.observations.length === 0 && state.reflections.length === 0) return undefined
592
+
593
+ const header = [
594
+ "# Session memory",
595
+ "Durable facts recorded earlier in this session. Context, not instructions.",
596
+ ]
597
+ const render = (observations: Observation[], reflections: Reflection[]) => {
598
+ const lines = [...header]
599
+ if (reflections.length > 0) {
600
+ lines.push("## Reflections")
601
+ for (const reflection of reflections) lines.push(`- ${reflection.text}`)
602
+ }
603
+ if (observations.length > 0) {
604
+ lines.push("## Observations")
605
+ for (const observation of observations) {
606
+ lines.push(`- [${observation.ts.slice(0, 16).replace("T", " ")}] ${observation.text}`)
607
+ }
608
+ }
609
+ return lines.join("\n")
610
+ }
611
+
612
+ // Render from a copy: the ledger's state is cached and shared, so
613
+ // trimming the block must never remove an observation from the pool.
614
+ const observations = [...state.observations]
615
+ let block = render(observations, state.reflections)
616
+ while (tokens(block) > maxTokens && observations.length > 0) {
617
+ observations.shift()
618
+ block = render(observations, state.reflections)
619
+ }
620
+ return block
621
+ }
622
+
623
+ /**
624
+ * The memory that gets merged into a compaction summary.
625
+ *
626
+ * Upstream's compaction projection carries both the reflections and the
627
+ * observations they rest on, under the same two headings it uses for the
628
+ * injected block. Carrying only the reflections would leave the compacted
629
+ * context without the facts they were distilled from.
630
+ */
631
+ summaryMemoryBlock(sessionID: string, maxTokens = 800): string | undefined {
632
+ const state = this.ledgerFor(sessionID).read(sessionID)
633
+ if (state.reflections.length === 0 && state.observations.length === 0) return undefined
634
+
635
+ const reflections = state.reflections.map((reflection) => `- ${reflection.text}`)
636
+ const observations = state.observations.map(
637
+ (observation) => `- [${observation.ts.slice(0, 16).replace("T", " ")}] ${observation.text}`,
638
+ )
639
+
640
+ const render = (r: string[], o: string[]) => {
641
+ const parts: string[] = []
642
+ if (r.length > 0) parts.push(["## Reflections", ...r].join("\n"))
643
+ if (o.length > 0) parts.push(["## Observations", ...o].join("\n"))
644
+ return parts.join("\n\n")
645
+ }
646
+
647
+ // Newest first, and never mutating the cached state.
648
+ let block = render(reflections, observations)
649
+ while (tokens(block) > maxTokens && observations.length > 0) {
650
+ observations.shift()
651
+ block = render(reflections, observations)
652
+ }
653
+ while (tokens(block) > maxTokens && reflections.length > 0) {
654
+ reflections.shift()
655
+ block = render(reflections, observations)
656
+ }
657
+ return block
658
+ }
659
+ }