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
package/src/config.ts
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration for opencode-blackhole.
|
|
3
|
+
*
|
|
4
|
+
* Merge order (last wins):
|
|
5
|
+
* defaults → global (~/.config/opencode/blackhole.json)
|
|
6
|
+
* → project (<project>/.opencode/blackhole.json)
|
|
7
|
+
* → plugin options (opencode.json → plugins[].options)
|
|
8
|
+
*
|
|
9
|
+
* The project file is read on every compaction so edits apply without a
|
|
10
|
+
* server restart.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
13
|
+
import { homedir } from "node:os"
|
|
14
|
+
import { join } from "node:path"
|
|
15
|
+
|
|
16
|
+
export interface ModelRef {
|
|
17
|
+
providerID: string
|
|
18
|
+
id: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The free OpenRouter router — the default worker model. */
|
|
22
|
+
export const DEFAULT_WORKER_MODEL: ModelRef = { providerID: "openrouter", id: "openrouter/free" }
|
|
23
|
+
|
|
24
|
+
export interface BlackholeConfig {
|
|
25
|
+
/** Master switch. When false the plugin does nothing at all. */
|
|
26
|
+
enabled: boolean
|
|
27
|
+
/** Deterministic compaction: replace the model's own summarizer. */
|
|
28
|
+
compaction: {
|
|
29
|
+
enabled: boolean
|
|
30
|
+
/**
|
|
31
|
+
* Keep the vcc brief transcript (the ranked tail of the conversation).
|
|
32
|
+
*
|
|
33
|
+
* Off by default: OpenCode keeps the newest exchange verbatim either way,
|
|
34
|
+
* and the brief duplicates it at a real token cost. The handoff block is
|
|
35
|
+
* the better use of the budget.
|
|
36
|
+
*/
|
|
37
|
+
brief: boolean
|
|
38
|
+
/** Token budget for the brief transcript. */
|
|
39
|
+
briefTokens: number
|
|
40
|
+
/** Hard cap for the whole summary. */
|
|
41
|
+
maxSummaryTokens: number
|
|
42
|
+
/** Extra instruction appended to the summary, e.g. project conventions. */
|
|
43
|
+
extraNote: string
|
|
44
|
+
/**
|
|
45
|
+
* Spend one model call on the handoff block: decisions and why, work
|
|
46
|
+
* state, next move, and the context that is expensive to rediscover.
|
|
47
|
+
*
|
|
48
|
+
* The deterministic sections are an inventory; they cannot say what was
|
|
49
|
+
* decided or what happens next. This block is additive - when the call
|
|
50
|
+
* fails, the deterministic summary is used unchanged.
|
|
51
|
+
*/
|
|
52
|
+
handoff: boolean
|
|
53
|
+
/** The model that writes the handoff block. */
|
|
54
|
+
handoffModel: ModelRef
|
|
55
|
+
/** Extra models to try when the primary handoff model fails. */
|
|
56
|
+
handoffFallbacks: ModelRef[]
|
|
57
|
+
/** Token budget for the handoff block itself. */
|
|
58
|
+
handoffMaxTokens: number
|
|
59
|
+
/** How much conversation the handoff prompt may carry. */
|
|
60
|
+
handoffInputMaxTokens: number
|
|
61
|
+
/** Give up on the handoff after this long and keep the deterministic summary. */
|
|
62
|
+
handoffTimeoutSeconds: number
|
|
63
|
+
}
|
|
64
|
+
memory: {
|
|
65
|
+
enabled: boolean
|
|
66
|
+
observerModel: ModelRef
|
|
67
|
+
reflectorModel: ModelRef
|
|
68
|
+
dropperModel: ModelRef
|
|
69
|
+
/** Extra models to try when the primary worker model fails. */
|
|
70
|
+
observerFallbacks: ModelRef[]
|
|
71
|
+
reflectorFallbacks: ModelRef[]
|
|
72
|
+
dropperFallbacks: ModelRef[]
|
|
73
|
+
/** Allow a worker to fall back to the session's model. Off by default so
|
|
74
|
+
* background work never silently bills the main model. */
|
|
75
|
+
sessionFallback: boolean
|
|
76
|
+
/** Run the observer once the session has grown by this many tokens. */
|
|
77
|
+
observeAfterTokens: number
|
|
78
|
+
/**
|
|
79
|
+
* Run the reflector once this many observation tokens are pending.
|
|
80
|
+
*
|
|
81
|
+
* Upstream uses 25,000, but its observation cap bounds the *projection*
|
|
82
|
+
* rather than the store. This port's cap is a hard delete, so the trigger
|
|
83
|
+
* has to sit below it or the reflector could never fire.
|
|
84
|
+
*/
|
|
85
|
+
reflectAfterTokens: number
|
|
86
|
+
/**
|
|
87
|
+
* The most the observer is shown in one round.
|
|
88
|
+
*
|
|
89
|
+
* Upstream pi-blackhole chunks the observer input at the same limit: a
|
|
90
|
+
* window that does not fit would overflow the model's context and stall
|
|
91
|
+
* the memory. The chunk that is sent is covered; the rest stays pending.
|
|
92
|
+
*/
|
|
93
|
+
observerChunkMaxTokens: number
|
|
94
|
+
/**
|
|
95
|
+
* Run a second pass over the observer's draft.
|
|
96
|
+
*
|
|
97
|
+
* Upstream's observer works in an agent loop: it records a batch, reads
|
|
98
|
+
* the result back, and corrects itself. OpenCode's plugin API has no
|
|
99
|
+
* tool loop, so the correction happens as a second call. It costs one
|
|
100
|
+
* more request per chunk and catches the facts a single pass misses.
|
|
101
|
+
*/
|
|
102
|
+
observerRefine: boolean
|
|
103
|
+
/** Drop low-value observations above this pool size. */
|
|
104
|
+
observationsPoolMaxTokens: number
|
|
105
|
+
/** Minimum seconds between two attempts of the same worker. */
|
|
106
|
+
retryGateSeconds: number
|
|
107
|
+
/** How long a failed model stays in cooldown. Upstream cools for an hour. */
|
|
108
|
+
cooldownSeconds: number
|
|
109
|
+
}
|
|
110
|
+
recall: {
|
|
111
|
+
enabled: boolean
|
|
112
|
+
/**
|
|
113
|
+
* Maximum characters in a single recall response.
|
|
114
|
+
*
|
|
115
|
+
* Bounds search pages, expanded entries and related observations, so one
|
|
116
|
+
* huge stored message cannot flood the context. The budget is shared
|
|
117
|
+
* across the entries of a response, and each entry's lines share their
|
|
118
|
+
* slice of it. 0 disables the cap.
|
|
119
|
+
*/
|
|
120
|
+
responseMaxChars: number
|
|
121
|
+
}
|
|
122
|
+
/** How long the plugin keeps its own state on disk. */
|
|
123
|
+
retention: {
|
|
124
|
+
/** Days to keep a session transcript. 0 keeps them forever. */
|
|
125
|
+
transcriptDays: number
|
|
126
|
+
/** Days to keep a session's memory ledger. 0 keeps them forever. */
|
|
127
|
+
memoryDays: number
|
|
128
|
+
/** Days to keep the trace log. 0 keeps it forever. */
|
|
129
|
+
logDays: number
|
|
130
|
+
}
|
|
131
|
+
/** Runtime limits that keep a long-lived server healthy. */
|
|
132
|
+
limits: {
|
|
133
|
+
/** How many parsed sessions stay in memory at once. */
|
|
134
|
+
maxCachedSessions: number
|
|
135
|
+
/** Hard cap for the content of a single stored message. */
|
|
136
|
+
maxMessageChars: number
|
|
137
|
+
/** Compact a ledger file once it grows past this size. */
|
|
138
|
+
maxLedgerBytes: number
|
|
139
|
+
}
|
|
140
|
+
/** "debug" writes a JSONL trace next to the plugin state. */
|
|
141
|
+
logLevel: "off" | "info" | "debug"
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export const DEFAULTS: BlackholeConfig = {
|
|
145
|
+
enabled: true,
|
|
146
|
+
compaction: {
|
|
147
|
+
enabled: true,
|
|
148
|
+
brief: false,
|
|
149
|
+
briefTokens: 2_000,
|
|
150
|
+
maxSummaryTokens: 4_000,
|
|
151
|
+
extraNote: "",
|
|
152
|
+
handoff: true,
|
|
153
|
+
handoffModel: DEFAULT_WORKER_MODEL,
|
|
154
|
+
handoffFallbacks: [],
|
|
155
|
+
handoffMaxTokens: 900,
|
|
156
|
+
handoffInputMaxTokens: 12_000,
|
|
157
|
+
handoffTimeoutSeconds: 120,
|
|
158
|
+
},
|
|
159
|
+
memory: {
|
|
160
|
+
enabled: true,
|
|
161
|
+
observerModel: DEFAULT_WORKER_MODEL,
|
|
162
|
+
reflectorModel: DEFAULT_WORKER_MODEL,
|
|
163
|
+
dropperModel: DEFAULT_WORKER_MODEL,
|
|
164
|
+
observerFallbacks: [],
|
|
165
|
+
reflectorFallbacks: [],
|
|
166
|
+
dropperFallbacks: [],
|
|
167
|
+
/**
|
|
168
|
+
* Allow a worker to fall back to the session's model when its own model
|
|
169
|
+
* chain is exhausted. Upstream pi-blackhole defaults this to true, so
|
|
170
|
+
* memory works out of the box; set it to false to guarantee that
|
|
171
|
+
* background work never bills the session's model.
|
|
172
|
+
*/
|
|
173
|
+
sessionFallback: true,
|
|
174
|
+
observeAfterTokens: 15_000,
|
|
175
|
+
reflectAfterTokens: 15_000,
|
|
176
|
+
observerChunkMaxTokens: 40_000,
|
|
177
|
+
observerRefine: true,
|
|
178
|
+
observationsPoolMaxTokens: 20_000,
|
|
179
|
+
retryGateSeconds: 30,
|
|
180
|
+
cooldownSeconds: 3_600,
|
|
181
|
+
},
|
|
182
|
+
recall: { enabled: true, responseMaxChars: 48_000 },
|
|
183
|
+
retention: {
|
|
184
|
+
transcriptDays: 7,
|
|
185
|
+
memoryDays: 90,
|
|
186
|
+
logDays: 7,
|
|
187
|
+
},
|
|
188
|
+
limits: {
|
|
189
|
+
maxCachedSessions: 8,
|
|
190
|
+
maxMessageChars: 262_144,
|
|
191
|
+
maxLedgerBytes: 65_536,
|
|
192
|
+
},
|
|
193
|
+
logLevel: "off",
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const isObject = (value: unknown): value is Record<string, unknown> =>
|
|
197
|
+
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
198
|
+
|
|
199
|
+
const readJson = (path: string): Record<string, unknown> | undefined => {
|
|
200
|
+
try {
|
|
201
|
+
if (!existsSync(path)) return undefined
|
|
202
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"))
|
|
203
|
+
return isObject(parsed) ? parsed : undefined
|
|
204
|
+
} catch {
|
|
205
|
+
return undefined
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const asModelRef = (value: unknown): ModelRef | undefined => {
|
|
210
|
+
if (!isObject(value)) return undefined
|
|
211
|
+
const providerID = value.providerID ?? value.provider
|
|
212
|
+
const id = value.id ?? value.model
|
|
213
|
+
if (typeof providerID === "string" && typeof id === "string") return { providerID, id }
|
|
214
|
+
return undefined
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const asModelRefs = (value: unknown): ModelRef[] =>
|
|
218
|
+
Array.isArray(value) ? value.map(asModelRef).filter((m): m is ModelRef => m !== undefined) : []
|
|
219
|
+
|
|
220
|
+
const asNumber = (value: unknown, fallback: number): number =>
|
|
221
|
+
typeof value === "number" && Number.isFinite(value) ? value : fallback
|
|
222
|
+
|
|
223
|
+
const asBoolean = (value: unknown, fallback: boolean): boolean =>
|
|
224
|
+
typeof value === "boolean" ? value : fallback
|
|
225
|
+
|
|
226
|
+
const asString = (value: unknown, fallback: string): string =>
|
|
227
|
+
typeof value === "string" ? value : fallback
|
|
228
|
+
|
|
229
|
+
const merge = (base: BlackholeConfig, patch: Record<string, unknown>): BlackholeConfig => {
|
|
230
|
+
const compaction = isObject(patch.compaction) ? patch.compaction : {}
|
|
231
|
+
const memory = isObject(patch.memory) ? patch.memory : {}
|
|
232
|
+
const recall = isObject(patch.recall) ? patch.recall : {}
|
|
233
|
+
const retention = isObject(patch.retention) ? patch.retention : {}
|
|
234
|
+
const limits = isObject(patch.limits) ? patch.limits : {}
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
enabled: asBoolean(patch.enabled, base.enabled),
|
|
238
|
+
compaction: {
|
|
239
|
+
enabled: asBoolean(compaction.enabled, base.compaction.enabled),
|
|
240
|
+
brief: asBoolean(compaction.brief, base.compaction.brief),
|
|
241
|
+
briefTokens: asNumber(compaction.briefTokens, base.compaction.briefTokens),
|
|
242
|
+
maxSummaryTokens: asNumber(compaction.maxSummaryTokens, base.compaction.maxSummaryTokens),
|
|
243
|
+
extraNote: asString(compaction.extraNote, base.compaction.extraNote),
|
|
244
|
+
handoff: asBoolean(compaction.handoff, base.compaction.handoff),
|
|
245
|
+
handoffModel: asModelRef(compaction.handoffModel) ?? base.compaction.handoffModel,
|
|
246
|
+
handoffFallbacks: compaction.handoffFallbacks
|
|
247
|
+
? asModelRefs(compaction.handoffFallbacks)
|
|
248
|
+
: base.compaction.handoffFallbacks,
|
|
249
|
+
handoffMaxTokens: asNumber(compaction.handoffMaxTokens, base.compaction.handoffMaxTokens),
|
|
250
|
+
handoffInputMaxTokens: asNumber(compaction.handoffInputMaxTokens, base.compaction.handoffInputMaxTokens),
|
|
251
|
+
handoffTimeoutSeconds: asNumber(compaction.handoffTimeoutSeconds, base.compaction.handoffTimeoutSeconds),
|
|
252
|
+
},
|
|
253
|
+
memory: {
|
|
254
|
+
enabled: asBoolean(memory.enabled, base.memory.enabled),
|
|
255
|
+
observerModel: asModelRef(memory.observerModel) ?? base.memory.observerModel,
|
|
256
|
+
reflectorModel: asModelRef(memory.reflectorModel) ?? base.memory.reflectorModel,
|
|
257
|
+
dropperModel: asModelRef(memory.dropperModel) ?? base.memory.dropperModel,
|
|
258
|
+
observerFallbacks: memory.observerFallbacks
|
|
259
|
+
? asModelRefs(memory.observerFallbacks)
|
|
260
|
+
: base.memory.observerFallbacks,
|
|
261
|
+
reflectorFallbacks: memory.reflectorFallbacks
|
|
262
|
+
? asModelRefs(memory.reflectorFallbacks)
|
|
263
|
+
: base.memory.reflectorFallbacks,
|
|
264
|
+
dropperFallbacks: memory.dropperFallbacks
|
|
265
|
+
? asModelRefs(memory.dropperFallbacks)
|
|
266
|
+
: base.memory.dropperFallbacks,
|
|
267
|
+
sessionFallback: asBoolean(memory.sessionFallback, base.memory.sessionFallback),
|
|
268
|
+
observeAfterTokens: asNumber(memory.observeAfterTokens, base.memory.observeAfterTokens),
|
|
269
|
+
reflectAfterTokens: asNumber(memory.reflectAfterTokens, base.memory.reflectAfterTokens),
|
|
270
|
+
observerChunkMaxTokens: asNumber(memory.observerChunkMaxTokens, base.memory.observerChunkMaxTokens),
|
|
271
|
+
observerRefine: asBoolean(memory.observerRefine, base.memory.observerRefine),
|
|
272
|
+
observationsPoolMaxTokens: asNumber(
|
|
273
|
+
memory.observationsPoolMaxTokens,
|
|
274
|
+
base.memory.observationsPoolMaxTokens,
|
|
275
|
+
),
|
|
276
|
+
retryGateSeconds: asNumber(memory.retryGateSeconds, base.memory.retryGateSeconds),
|
|
277
|
+
cooldownSeconds: asNumber(memory.cooldownSeconds, base.memory.cooldownSeconds),
|
|
278
|
+
},
|
|
279
|
+
recall: {
|
|
280
|
+
enabled: asBoolean(recall.enabled, base.recall.enabled),
|
|
281
|
+
responseMaxChars: asNumber(recall.responseMaxChars, base.recall.responseMaxChars),
|
|
282
|
+
},
|
|
283
|
+
retention: {
|
|
284
|
+
transcriptDays: asNumber(retention.transcriptDays, base.retention.transcriptDays),
|
|
285
|
+
memoryDays: asNumber(retention.memoryDays, base.retention.memoryDays),
|
|
286
|
+
logDays: asNumber(retention.logDays, base.retention.logDays),
|
|
287
|
+
},
|
|
288
|
+
limits: {
|
|
289
|
+
maxCachedSessions: asNumber(limits.maxCachedSessions, base.limits.maxCachedSessions),
|
|
290
|
+
maxMessageChars: asNumber(limits.maxMessageChars, base.limits.maxMessageChars),
|
|
291
|
+
maxLedgerBytes: asNumber(limits.maxLedgerBytes, base.limits.maxLedgerBytes),
|
|
292
|
+
},
|
|
293
|
+
logLevel: (["off", "info", "debug"] as const).includes(patch.logLevel as any)
|
|
294
|
+
? (patch.logLevel as BlackholeConfig["logLevel"])
|
|
295
|
+
: base.logLevel,
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const globalConfigPath = () => join(homedir(), ".config", "opencode", "blackhole.json")
|
|
300
|
+
|
|
301
|
+
export interface LoadOptions {
|
|
302
|
+
/** Project directory (the plugin's location directory). */
|
|
303
|
+
directory: string
|
|
304
|
+
/** Plugin options from opencode.json. */
|
|
305
|
+
options?: unknown
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export const loadConfig = ({ directory, options }: LoadOptions): BlackholeConfig => {
|
|
309
|
+
let config = DEFAULTS
|
|
310
|
+
const global = readJson(globalConfigPath())
|
|
311
|
+
if (global) config = merge(config, global)
|
|
312
|
+
const project = readJson(join(directory, ".opencode", "blackhole.json"))
|
|
313
|
+
if (project) config = merge(config, project)
|
|
314
|
+
if (isObject(options)) config = merge(config, options)
|
|
315
|
+
return config
|
|
316
|
+
}
|