opencode-blackhole 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/LICENSE +35 -0
  2. package/LICENSE.vendor +21 -0
  3. package/README.md +377 -2
  4. package/dist/server.js +5025 -0
  5. package/index.ts +1 -0
  6. package/package.json +57 -4
  7. package/src/adapter/opencode-messages.ts +270 -0
  8. package/src/compaction/build.ts +264 -0
  9. package/src/compaction/handoff.ts +421 -0
  10. package/src/config.ts +316 -0
  11. package/src/index.ts +714 -0
  12. package/src/memory/ledger.ts +254 -0
  13. package/src/memory/manager.ts +659 -0
  14. package/src/memory/models.ts +172 -0
  15. package/src/memory/prompts.ts +145 -0
  16. package/src/recall/recall.ts +327 -0
  17. package/src/state/paths.ts +42 -0
  18. package/src/store/capture.ts +84 -0
  19. package/src/store/transcript.ts +279 -0
  20. package/src/vcc/README.md +51 -0
  21. package/src/vcc/core/brief.ts +407 -0
  22. package/src/vcc/core/build-sections.ts +81 -0
  23. package/src/vcc/core/compact-args.ts +45 -0
  24. package/src/vcc/core/content.ts +156 -0
  25. package/src/vcc/core/drill-down.ts +302 -0
  26. package/src/vcc/core/filter-noise.ts +41 -0
  27. package/src/vcc/core/format-recall.ts +100 -0
  28. package/src/vcc/core/format.ts +104 -0
  29. package/src/vcc/core/lineage.ts +26 -0
  30. package/src/vcc/core/normalize.ts +76 -0
  31. package/src/vcc/core/rank.ts +283 -0
  32. package/src/vcc/core/recall-request.ts +160 -0
  33. package/src/vcc/core/recall-scope.ts +30 -0
  34. package/src/vcc/core/render-entries.ts +55 -0
  35. package/src/vcc/core/sanitize.ts +5 -0
  36. package/src/vcc/core/search-entries.ts +594 -0
  37. package/src/vcc/core/skill-collapse.ts +35 -0
  38. package/src/vcc/core/summarize.ts +289 -0
  39. package/src/vcc/core/token-estimate.ts +100 -0
  40. package/src/vcc/core/tool-args.ts +16 -0
  41. package/src/vcc/details.ts +11 -0
  42. package/src/vcc/extract/commits.ts +96 -0
  43. package/src/vcc/extract/files.ts +87 -0
  44. package/src/vcc/extract/goals.ts +79 -0
  45. package/src/vcc/extract/preferences.ts +55 -0
  46. package/src/vcc/extract/tracked-commands.ts +232 -0
  47. package/src/vcc/load-messages.ts +42 -0
  48. package/src/vcc/pi-types.ts +53 -0
  49. package/src/vcc/sections.ts +12 -0
  50. package/src/vcc/types.ts +16 -0
package/src/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
+ }