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/LICENSE ADDED
@@ -0,0 +1,35 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 opencode-blackhole contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ This package contains code ported from:
26
+
27
+ pi-vcc - https://github.com/sting8k/pi-vcc (MIT) - Copyright (c) 2026 sting8k
28
+ pi-blackhole - https://github.com/k0valik/pi-blackhole (MIT) - Copyright (c) 2026 k0valik
29
+
30
+ The ported files live in `src/vcc/` - see `src/vcc/README.md` for exactly what
31
+ changed. Their licence text is in `LICENSE.vendor`.
32
+
33
+ The memory workers (observer, reflector, dropper) are an independent
34
+ implementation of pi-blackhole's design, written for this package; no code was
35
+ copied from it.
package/LICENSE.vendor ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 sting8k
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,378 @@
1
- # Temporary Holding Version
1
+ # opencode-blackhole
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Deterministic compaction + observational memory for **OpenCode**.
4
+
5
+ A port of [pi-blackhole](https://github.com/k0valik/pi-blackhole) (for the Pi agent)
6
+ to OpenCode V2, built on the [pi-vcc](https://github.com/sting8k/pi-vcc) compaction
7
+ engine (both MIT).
8
+
9
+ **What it does**
10
+
11
+ 1. **Replaces OpenCode's LLM summarizer with a structured checkpoint.**
12
+ OpenCode's compaction calls the model to write a prose summary, then compacts
13
+ that summary, and the next one — details erode over a long session. This plugin
14
+ answers OpenCode's `compaction` hook itself. The inventory is extracted
15
+ deterministically — goal, files, commits, preferences, outstanding context,
16
+ with exact paths, commands and identifiers — and one small model call writes
17
+ the part no extractor can: `[Decisions]` (and why), `[Work State]`,
18
+ `[Next Move]` and `[Important Context]`. If that call fails, the deterministic
19
+ summary is used unchanged.
20
+
21
+ 2. **Keeps durable memory across compactions.** A background observer records the
22
+ facts worth keeping (decisions, preferences, constraints, completed work); a
23
+ reflector distils them into durable insights. The memory is injected into the
24
+ system prompt on every request, and both the reflections and the observations
25
+ they rest on ride along with every compaction summary.
26
+
27
+ 3. **Cuts token cost.** One bounded call replaces the summarizer's, the summary
28
+ has a hard token cap, and the workers run on cheap or free models.
29
+
30
+ ---
31
+
32
+ ## Install
33
+
34
+ ### One command
35
+
36
+ ```sh
37
+ opencode plugin add opencode-blackhole
38
+ ```
39
+
40
+ That is the whole install. OpenCode fetches the package from the npm registry,
41
+ caches it in its own store (`~/.cache/opencode/npm/`) and adds it to your
42
+ global configuration — no `pnpm add`, no manual config editing.
43
+
44
+ ```sh
45
+ opencode plugin list # see it
46
+ opencode plugin check # check for updates
47
+ opencode plugin update # update it
48
+ opencode plugin remove opencode-blackhole
49
+ ```
50
+
51
+ Restart OpenCode (or `/reload`) afterwards.
52
+
53
+ ### The same thing by hand
54
+
55
+ If you prefer to edit the config yourself, add it to your **global** config
56
+ (`~/.config/opencode/opencode.json`) to use it everywhere, or to a project's
57
+ `opencode.json` for just that project:
58
+
59
+ ```jsonc
60
+ {
61
+ "$schema": "https://opencode.ai/config.json",
62
+ "plugins": ["opencode-blackhole"]
63
+ }
64
+ ```
65
+
66
+ To remove it, prefix the name with `-` (it is applied last, so it also
67
+ cancels an auto-discovered copy):
68
+
69
+ ```jsonc
70
+ { "plugins": ["-opencode-blackhole"] }
71
+ ```
72
+
73
+ ### From a local checkout
74
+
75
+ ```jsonc
76
+ { "plugins": ["/absolute/path/to/opencode-blackhole"] }
77
+ ```
78
+
79
+ or, as a plugin file anywhere OpenCode looks for one:
80
+
81
+ ```ts
82
+ // ~/.config/opencode/plugins/blackhole.ts
83
+ export { default } from "/absolute/path/to/opencode-blackhole/index.ts"
84
+ ```
85
+
86
+ A checkout must be built first — the plugin loads a bundled `dist/server.js`:
87
+
88
+ ```sh
89
+ bun install && bun run build
90
+ ```
91
+
92
+ > Requires OpenCode V2 (`@opencode/plugin` ≥ 2.0.28) and Node ≥ 22.13.
93
+
94
+ ### What works without any setup
95
+
96
+ The compaction half works immediately with whatever model you already use: it
97
+ replaces the summarizer call with a deterministic extraction, so it needs no
98
+ extra provider, no key and no cost.
99
+
100
+ The **memory** workers default to `openrouter/openrouter/free`, which needs an
101
+ OpenRouter account and key. Without one they fall back to **your session's
102
+ model** — so memory still works out of the box, at the cost of a background
103
+ call on the model you are already paying for. To keep the workers on a free
104
+ tier, give OpenRouter a key; to keep them off your main model entirely, set
105
+ `"sessionFallback": false` and point them at a model you choose:
106
+
107
+ ```jsonc
108
+ {
109
+ "plugins": [
110
+ {
111
+ "package": "opencode-blackhole",
112
+ "options": {
113
+ "memory": {
114
+ "observerModel": { "providerID": "openai", "id": "gpt-4.1-mini" },
115
+ "reflectorModel": { "providerID": "openai", "id": "gpt-4.1-mini" }
116
+ }
117
+ }
118
+ }
119
+ ]
120
+ }
121
+ ```
122
+
123
+ The workers run through OpenCode's own generation API, so they use the
124
+ credentials you have connected in OpenCode — there is no second key to
125
+ configure. Two caveats:
126
+
127
+ - OpenCode's **free-tier models cannot be called from a plugin** ("OpenCode's
128
+ free tier can only be used from within OpenCode"), so if your session model is
129
+ one of those, it is not usable as the fallback. Use a provider model.
130
+ - A worker model that is not in the catalog is skipped without a cooldown. A
131
+ transient failure (rate limit, timeout, an upstream provider error behind a
132
+ router) is retried once on the same model, then cooled down for three minutes
133
+ — a router like `openrouter/free` picks a provider per request, so the retry
134
+ usually lands somewhere else. A deterministic failure (bad key, rejected
135
+ payload) is cooled for the full `cooldownSeconds` and the chain moves on to
136
+ the next candidate.
137
+ - When a stage ends with no model at all, the session gets one notice with the
138
+ last error, so a busy upstream is not mistaken for a missing key.
139
+
140
+ ---
141
+
142
+ ## Configuration
143
+
144
+ Plugin options in `opencode.json` win over a project file
145
+ (`.opencode/blackhole.json`), which wins over the global file
146
+ (`~/.config/opencode/blackhole.json`), which wins over the defaults.
147
+
148
+ ```jsonc
149
+ {
150
+ "plugins": [
151
+ {
152
+ "package": "opencode-blackhole",
153
+ "options": {
154
+ "enabled": true,
155
+ "compaction": {
156
+ "enabled": true,
157
+ "brief": false, // keep the ranked transcript tail (duplicates OpenCode's kept tail)
158
+ "briefTokens": 2000, // token budget for the brief, when enabled
159
+ "maxSummaryTokens": 4000, // hard cap for the whole summary
160
+ "extraNote": "", // extra text appended to every summary
161
+ "handoff": true, // one model call for the narrative block
162
+ "handoffModel": { "providerID": "openrouter", "id": "openrouter/free" },
163
+ "handoffFallbacks": [],
164
+ "handoffMaxTokens": 900, // budget for the block itself
165
+ "handoffInputMaxTokens": 12000, // how much conversation the prompt carries
166
+ "handoffTimeoutSeconds": 120 // past this, keep the deterministic summary
167
+ },
168
+ "memory": {
169
+ "enabled": true,
170
+ "observerModel": { "providerID": "openrouter", "id": "openrouter/free" },
171
+ "reflectorModel": { "providerID": "openrouter", "id": "openrouter/free" },
172
+ "dropperModel": { "providerID": "openrouter", "id": "openrouter/free" },
173
+ "observerFallbacks": [],
174
+ "reflectorFallbacks": [],
175
+ "dropperFallbacks": [], "sessionFallback": true, // the session's model is the last resort
176
+ "observeAfterTokens": 15000,
177
+ "reflectAfterTokens": 15000, // upstream uses 25000; see the notes below
178
+ "observerChunkMaxTokens": 40000, // the most the observer sees at once
179
+ "observerRefine": true, // a second pass reviews the observer's draft
180
+ "observationsPoolMaxTokens": 20000,
181
+ "retryGateSeconds": 30,
182
+ "cooldownSeconds": 3600
183
+ },
184
+ "recall": {
185
+ "enabled": true,
186
+ "responseMaxChars": 48000 // 0 = unbounded
187
+ },
188
+ "retention": {
189
+ "transcriptDays": 7, // 0 = keep forever
190
+ "memoryDays": 90, // the ledger holds the session's memory
191
+ "logDays": 7
192
+ },
193
+ "limits": {
194
+ "maxCachedSessions": 8, // parsed sessions kept in memory
195
+ "maxMessageChars": 262144, // cap on a single stored message
196
+ "maxLedgerBytes": 65536 // a ledger is compacted past this
197
+ },
198
+ "logLevel": "off" // "info" | "debug" writes a JSONL trace
199
+ }
200
+ }
201
+ ]
202
+ }
203
+ ```
204
+
205
+ **Worker model resolution:** `observerModel` → `observerFallbacks[]` → (the
206
+ session's model, because `sessionFallback` defaults to true, as upstream does).
207
+ A model that fails gets a persisted cooldown and is skipped on later attempts; a
208
+ stage that cannot run is skipped, and the coverage cursor never advances past
209
+ what a worker actually saw — so nothing is lost, it is just observed later.
210
+
211
+ The observer is shown at most `observerChunkMaxTokens` of the pending window at
212
+ a time (upstream's limit). The chunk it reads is covered; the rest stays
213
+ pending for the next round, and an answer the plugin cannot parse leaves the
214
+ window pending rather than discarding it. The reflector keeps its own cursor:
215
+ each observation is crystallised once, so repeated runs cannot pile up
216
+ duplicate reflections.
217
+
218
+ **The observer runs two passes** (`observerRefine`, on by default). The first
219
+ extracts a draft; the second sees that draft next to the same transcript and
220
+ returns the corrected list — the facts the first pass missed, the ones it got
221
+ wrong, the ones that were never durable. Upstream gets the same effect from
222
+ its agent loop, which reads its own first batch back; OpenCode's plugin API
223
+ has no tool loop, so the review is a second call instead. A review that fails
224
+ or comes back empty keeps the draft, so the second pass can only improve
225
+ things. It costs one more request per chunk against your worker model's
226
+ limits — turn it off with `"observerRefine": false` to go back to one call.
227
+
228
+ `reflectAfterTokens` is 15,000 rather than upstream's 25,000 because this
229
+ port's observation pool is a hard delete at 20,000, while upstream's is a
230
+ budget on the projection: at 25,000 the reflector could never fire here.
231
+
232
+ Set `"sessionFallback": false` if you would rather the workers stop than run on
233
+ your session's model: then a worker with no reachable model is skipped and the
234
+ session says so once.
235
+
236
+ ---
237
+
238
+ ## Commands and tools
239
+
240
+ | Command | Description |
241
+ | --- | --- |
242
+ | `/blackhole` | Compact now with blackhole's checkpoint (no summarizer call) |
243
+ | `/blackhole-recall <query>` | Search this session's history (`page:N`, `scope:all`, `#N:path`) |
244
+ | `/blackhole-memory` | Show the memory status: observations, reflections, pending tokens |
245
+ | `/blackhole-export [out:<path>.md]` | Export the session's memory as markdown |
246
+
247
+ The agent also gets a `blackhole_recall` tool: keyword/regex search, entry
248
+ ranges, full-text expansion of a `#N` reference, and the list of files worked on.
249
+ `#N` is the same index that appears in the summary refs (`(#12)`).
250
+
251
+ A recall response is bounded by `recall.responseMaxChars` (48,000 by default):
252
+ the budget is shared across the entries of a response, and each entry's lines
253
+ share their slice of it, so one huge stored message cannot flood the context it
254
+ was meant to help with. Set it to `0` for unbounded output.
255
+
256
+ ---
257
+
258
+ ## How the compaction is replaced
259
+
260
+ OpenCode's compaction hook fires for automatic compaction, overflow recovery and
261
+ `/compact`. The plugin answers it with a summary, and OpenCode skips its own
262
+ summarizer call — the compaction message then carries a zero-usage summary plus
263
+ OpenCode's own verbatim tail.
264
+
265
+ The summary has two halves. The **sections** are extracted from the transcript
266
+ without a model: session goal, files read/modified/created, commits, user
267
+ preferences, outstanding context. They hold exact paths and identifiers but say
268
+ nothing about *why* a thing was done. The **handoff block** is one model call for
269
+ exactly that gap — `[Decisions]`, `[Work State]`, `[Next Move]`,
270
+ `[Important Context]` — placed after the sections and before the recall note.
271
+
272
+ The block is additive: a failed, slow (past `handoffTimeoutSeconds`) or
273
+ unparseable response leaves the deterministic summary untouched. When the model
274
+ is unreachable, a smaller block is derived without one: the work state from the
275
+ commits, the modified-file count and the last command, plus the question the
276
+ session ended on. It never guesses a decision. Turn the whole thing off with
277
+ `"handoff": false` to get the purely deterministic summary.
278
+
279
+ The brief transcript (`"brief": true`) is off by default: OpenCode already keeps
280
+ the newest exchange verbatim, so the brief mostly duplicates it. It remains
281
+ useful if you compact very long sessions on a model with a small kept tail.
282
+
283
+ If the extraction produces nothing usable, the plugin leaves the hook result
284
+ unset and OpenCode falls back to its native summarizer. A compaction never
285
+ happens without a summary.
286
+
287
+ ---
288
+
289
+ ## Known differences from pi-blackhole
290
+
291
+ * **Tail policy** is OpenCode's: the newest exchange is kept verbatim by the
292
+ core (`compaction.keep.tokens`), and the plugin's summary covers the rest.
293
+ * **Provider-native compaction** (Claude/GPT server-side context management)
294
+ bypasses the plugin hook entirely. Every other model — including everything
295
+ served through OpenRouter — goes through the deterministic path.
296
+ * **The dropper proposes, a deterministic rule decides.** Upstream's dropper
297
+ asks a model which observations are safe to remove, then ranks whatever it
298
+ proposed and caps the count. This port does the same: one call proposes the
299
+ ids, and the deterministic rule still decides which of them actually go —
300
+ least supported first, oldest within a tier. When the model cannot be
301
+ reached, the rule alone prunes the pool, so the pool always falls under its
302
+ cap.
303
+ * **Workers use one-shot structured calls.** OpenCode's plugin API has no
304
+ tool-calling loop, so the observer cannot refine its output over several
305
+ turns. It gets a second pass instead: the draft comes back for review
306
+ against the same transcript, and the corrected list replaces it
307
+ (`observerRefine`).
308
+ * **No interactive settings overlay** — the CLI plugin surface is out of scope.
309
+
310
+ ---
311
+
312
+ ## State on disk
313
+
314
+ The state lives **next to OpenCode's own data, in your home directory — not
315
+ inside your project**:
316
+
317
+ ```
318
+ ~/.local/share/opencode/blackhole/
319
+ <projectID>/
320
+ transcripts/<sessionID>.jsonl # the session transcript (for recall)
321
+ memory/<sessionID>.jsonl # observations, reflections, coverage cursor
322
+ cooldowns.json # worker model cooldowns
323
+ blackhole.log # trace (logLevel: debug)
324
+ ```
325
+
326
+ A project's folder is keyed by OpenCode's `projectID`, not by its path, so
327
+ moving or renaming the project keeps its history. Your repository is never
328
+ written to — there is nothing to add to `.gitignore`, and no conversation
329
+ content is synced to a cloud drive with your code.
330
+
331
+ **Nothing grows without bound.** Transcripts expire after `retention.transcriptDays`
332
+ (7 by default) — they duplicate what OpenCode already stores in the session
333
+ record, so only the searchable index is lost. Ledgers hold the session's actual
334
+ memory, so they are kept much longer (`retention.memoryDays`, 90 by default)
335
+ and are compacted in place once they pass `limits.maxLedgerBytes`: the live
336
+ observations, the reflections and the cursor are rewritten, and the dropped
337
+ markers are folded into a counter. At most `limits.maxCachedSessions` sessions
338
+ are held in memory at once, and a single stored message is clipped to
339
+ `limits.maxMessageChars`.
340
+
341
+ **Privacy:** the transcript stores the full text of your tool output on disk,
342
+ locally, in your home directory. If you paste secrets into a session, they end
343
+ up in that file until it expires. Use `retention.transcriptDays: 0` to keep
344
+ them forever, or a short window to keep them less.
345
+
346
+ ---
347
+
348
+ ## Development
349
+
350
+ ```sh
351
+ bun install
352
+ bun run check # typecheck + tests + build
353
+ bun run dev # rebuild the bundle on every source change
354
+ bun run bench # where the time goes in the capture path
355
+ ```
356
+
357
+ **The plugin loads the bundle, not the sources.** A change under `src/` only
358
+ reaches a running OpenCode after `bun run build` (or `bun run dev`, which does
359
+ it for you); OpenCode then reloads the plugin by itself — no restart needed.
360
+ Changing the root `index.ts` takes effect immediately, since it is part of the
361
+ plugin's own file graph.
362
+
363
+ To try a local build in OpenCode, point a plugin file at it:
364
+
365
+ ```ts
366
+ // ~/.config/opencode/plugins/blackhole.ts
367
+ export { default } from "/path/to/opencode-blackhole/index.ts"
368
+ ```
369
+
370
+ The compaction engine lives in `src/vcc/` — a port of pi-vcc with the Pi
371
+ couplings removed, kept as a separate module so upstream fixes can be merged
372
+ rather than re-implemented. `LICENSE.vendor` carries its MIT license, and
373
+ `src/vcc/README.md` records exactly what diverges from upstream.
374
+
375
+ ## License
376
+
377
+ MIT. Ported from [pi-vcc](https://github.com/sting8k/pi-vcc) and
378
+ [pi-blackhole](https://github.com/k0valik/pi-blackhole) (both MIT).