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.
- 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
|
@@ -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
|
+
}
|