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,84 @@
1
+ /**
2
+ * Capture model requests into the session transcript.
3
+ *
4
+ * The plugin sees every request the agent loop makes, which is the cheapest
5
+ * reliable way to see the whole conversation: OpenCode's plugin API only
6
+ * exposes the compacted view of a session.
7
+ *
8
+ * Only the messages we have not seen before are adapted. A request carries
9
+ * the whole conversation, so re-adapting it every turn would re-extract every
10
+ * tool output's text again and again — quadratic in the size of the session.
11
+ * Skipping known ids keeps capture linear in the amount of *new* content.
12
+ */
13
+ import { adaptMessages } from "../adapter/opencode-messages"
14
+ import type { StoredMessage, TranscriptStore } from "./transcript"
15
+
16
+ interface RawMessage {
17
+ id?: string
18
+ role: string
19
+ content?: unknown
20
+ }
21
+
22
+ const CHECKPOINT = "<conversation-checkpoint>"
23
+
24
+ /** A compaction checkpoint is a summary, not conversation — we never store it. */
25
+ const isCheckpoint = (message: RawMessage): boolean => {
26
+ if (message.role !== "user") return false
27
+ const content = message.content
28
+ if (!Array.isArray(content)) return false
29
+ return content.some(
30
+ (part) =>
31
+ (part as { type?: string; text?: string })?.type === "text" &&
32
+ typeof (part as { text?: string }).text === "string" &&
33
+ (part as { text: string }).text.includes(CHECKPOINT),
34
+ )
35
+ }
36
+
37
+ export const captureRequest = (
38
+ store: TranscriptStore,
39
+ sessionID: string,
40
+ requestMessages: readonly unknown[],
41
+ ): number => {
42
+ if (!Array.isArray(requestMessages) || requestMessages.length === 0) return 0
43
+
44
+ const transcript = store.read(sessionID)
45
+
46
+ // Keep only what we have not seen before. A request carries the whole
47
+ // conversation, so re-adapting it every turn would re-extract every tool
48
+ // output's text again and again — quadratic in the size of the session.
49
+ const fresh: RawMessage[] = []
50
+ for (const message of requestMessages) {
51
+ const raw = message as RawMessage
52
+ if (!raw?.id) continue
53
+ if (typeof raw.role !== "string") continue
54
+ if (transcript.indexById.has(raw.id)) continue
55
+ if (transcript.ignoredIds.has(raw.id)) continue
56
+ if (isCheckpoint(raw)) {
57
+ // Remember it so the next request does not rescan the summary.
58
+ transcript.ignoredIds.add(raw.id)
59
+ continue
60
+ }
61
+ fresh.push(raw)
62
+ }
63
+ if (fresh.length === 0) return 0
64
+
65
+ // The call map persists across requests, so a tool result whose call was
66
+ // captured earlier still resolves to its tool name and arguments.
67
+ const adapted = adaptMessages(fresh, { calls: transcript.callsById })
68
+
69
+ const entries: StoredMessage[] = []
70
+ let index = transcript.messages.length + 1
71
+ for (const message of adapted.messages) {
72
+ if (!message.id) continue
73
+ if (transcript.indexById.has(message.id)) continue
74
+ const storedIndex = index++
75
+ // The index rides on the message itself: the recall loader reads it there,
76
+ // so the `#N` in a recall result is the same `#N` the summary refers to.
77
+ const stored = { ...store.clipMessage(message), index: storedIndex }
78
+ entries.push({ id: message.id, index: storedIndex, message: stored, tokens: store.estimateTokens(stored) })
79
+ }
80
+
81
+ if (entries.length === 0) return 0
82
+ store.append(sessionID, entries)
83
+ return entries.length
84
+ }
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Per-session transcript store.
3
+ *
4
+ * OpenCode's plugin API exposes the *compacted* view of a session, so the
5
+ * messages that a compaction dropped are not reachable from a plugin. To give
6
+ * recall a real history — and to keep the `#N` references in a summary
7
+ * session-global rather than request-relative — the plugin records every
8
+ * model request it sees into its own append-only store.
9
+ *
10
+ * The store lives next to the project (`.opencode/blackhole/transcripts`) and
11
+ * is a JSONL file per session: one line per message, in first-seen order.
12
+ *
13
+ * Two things keep it from growing without bound:
14
+ * - the parsed transcripts are cached in memory with an LRU cap, so a long
15
+ * lived server does not hold every session it has ever seen;
16
+ * - `sweep()` deletes transcripts that have not been touched for a while.
17
+ */
18
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs"
19
+ import { join } from "node:path"
20
+ import type { Message } from "../vcc/pi-types"
21
+
22
+ export interface StoredMessage {
23
+ /** OpenCode's message id. */
24
+ id: string
25
+ /** The session-global index, 1-based, matching the `#N` refs in summaries. */
26
+ index: number
27
+ /** vcc-shaped message content. */
28
+ message: Message
29
+ /** Rough token cost, computed once when the message is captured. */
30
+ tokens?: number
31
+ }
32
+
33
+ /** A tool call, kept so a later tool result can be paired with its call. */
34
+ export interface ToolCallInfo {
35
+ name: string
36
+ input: Record<string, unknown>
37
+ }
38
+
39
+ export interface SessionTranscript {
40
+ messages: StoredMessage[]
41
+ indexById: Map<string, number>
42
+ /** Tool calls seen in this session, by call id. */
43
+ callsById: Map<string, ToolCallInfo>
44
+ /**
45
+ * Ids of messages we deliberately do not store (the compaction checkpoint).
46
+ * They come back with every request, so remembering them keeps us from
47
+ * re-scanning a large summary each turn.
48
+ */
49
+ ignoredIds: Set<string>
50
+ /** Bumped on every append, so callers can cache derived views. */
51
+ version: number
52
+ }
53
+
54
+ export interface TranscriptStoreOptions {
55
+ /** How many parsed sessions stay in memory at once. */
56
+ maxCachedSessions?: number
57
+ /** Hard cap for the content of a single stored message. */
58
+ maxMessageChars?: number
59
+ }
60
+
61
+ /**
62
+ * Where a session's transcript lives.
63
+ *
64
+ * A plain string keeps one directory; a function resolves a directory per
65
+ * session, which is what the global per-project layout needs.
66
+ */
67
+ export type DirResolver = string | ((sessionID: string) => string)
68
+
69
+ const CHARS_PER_TOKEN = 4
70
+
71
+ /** Collect the tool calls of an assistant message, for later result pairing. */
72
+ export const collectToolCalls = (message: Message): Array<ToolCallInfo & { id: string }> => {
73
+ if (!Array.isArray(message.content)) return []
74
+ const calls: Array<ToolCallInfo & { id: string }> = []
75
+ for (const part of message.content) {
76
+ if (part.type !== "toolCall") continue
77
+ const call = part as { id?: string; name?: string; arguments?: Record<string, unknown> }
78
+ if (!call.id) continue
79
+ calls.push({ id: call.id, name: call.name ?? "tool", input: call.arguments ?? {} })
80
+ }
81
+ return calls
82
+ }
83
+
84
+ export class TranscriptStore {
85
+ private readonly dirFor: (sessionID: string) => string
86
+ private readonly maxCachedSessions: number
87
+ private readonly maxMessageChars: number
88
+ /** Insertion order is the LRU order: the first key is the oldest. */
89
+ private readonly cache = new Map<string, SessionTranscript>()
90
+
91
+ constructor(dir: DirResolver, options: TranscriptStoreOptions = {}) {
92
+ this.dirFor = typeof dir === "function" ? dir : () => dir
93
+ this.maxCachedSessions = Math.max(1, options.maxCachedSessions ?? 8)
94
+ this.maxMessageChars = Math.max(4_000, options.maxMessageChars ?? 262_144)
95
+ }
96
+
97
+ private path(sessionID: string) {
98
+ return join(this.dirFor(sessionID), `${sessionID.replace(/[^a-zA-Z0-9_-]/g, "_")}.jsonl`)
99
+ }
100
+
101
+ /** Mark a session as recently used and evict the oldest when over the cap. */
102
+ private touch(sessionID: string, transcript: SessionTranscript) {
103
+ this.cache.delete(sessionID)
104
+ this.cache.set(sessionID, transcript)
105
+ while (this.cache.size > this.maxCachedSessions) {
106
+ const oldest = this.cache.keys().next().value
107
+ if (oldest === undefined) break
108
+ this.cache.delete(oldest)
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Record the messages of a model request. Messages already known are
114
+ * ignored, so repeated requests do not duplicate history. Returns the
115
+ * transcript after the append.
116
+ */
117
+ append(sessionID: string, entries: StoredMessage[]): SessionTranscript {
118
+ const transcript = this.read(sessionID)
119
+ const fresh = entries.filter((entry) => !transcript.indexById.has(entry.id))
120
+ if (fresh.length === 0) return transcript
121
+
122
+ mkdirSync(this.dirFor(sessionID), { recursive: true })
123
+ const lines = fresh
124
+ .map((entry) => JSON.stringify(entry))
125
+ .join("\n")
126
+ appendFileSync(this.path(sessionID), lines + "\n")
127
+
128
+ for (const entry of fresh) {
129
+ transcript.messages.push(entry)
130
+ transcript.indexById.set(entry.id, entry.index)
131
+ for (const call of collectToolCalls(entry.message)) {
132
+ if (!transcript.callsById.has(call.id)) transcript.callsById.set(call.id, { name: call.name, input: call.input })
133
+ }
134
+ }
135
+ transcript.version++
136
+ this.touch(sessionID, transcript)
137
+ return transcript
138
+ }
139
+
140
+ /** Read a session's transcript from disk (cached in memory). */
141
+ read(sessionID: string): SessionTranscript {
142
+ const cached = this.cache.get(sessionID)
143
+ if (cached) {
144
+ this.touch(sessionID, cached)
145
+ return cached
146
+ }
147
+
148
+ const transcript: SessionTranscript = {
149
+ messages: [],
150
+ indexById: new Map(),
151
+ callsById: new Map(),
152
+ ignoredIds: new Set(),
153
+ version: 0,
154
+ }
155
+ const path = this.path(sessionID)
156
+ try {
157
+ if (existsSync(path)) {
158
+ const raw = readFileSync(path, "utf8")
159
+ for (const line of raw.split("\n")) {
160
+ if (!line.trim()) continue
161
+ try {
162
+ const entry = JSON.parse(line) as StoredMessage
163
+ if (!entry?.id) continue
164
+ transcript.messages.push(entry)
165
+ transcript.indexById.set(entry.id, entry.index)
166
+ for (const call of collectToolCalls(entry.message)) {
167
+ if (!transcript.callsById.has(call.id)) {
168
+ transcript.callsById.set(call.id, { name: call.name, input: call.input })
169
+ }
170
+ }
171
+ } catch {
172
+ // Skip a corrupt line rather than losing the whole transcript.
173
+ }
174
+ }
175
+ }
176
+ } catch {
177
+ // An unreadable transcript is an empty transcript.
178
+ }
179
+
180
+ this.touch(sessionID, transcript)
181
+ return transcript
182
+ }
183
+
184
+ /** The next session-global index for a session. */
185
+ nextIndex(sessionID: string): number {
186
+ const transcript = this.read(sessionID)
187
+ return transcript.messages.length + 1
188
+ }
189
+
190
+ /** Rough token cost of a message, used for the memory budgets. */
191
+ estimateTokens(message: Message): number {
192
+ return Math.ceil(JSON.stringify(message.content ?? "").length / CHARS_PER_TOKEN)
193
+ }
194
+
195
+ /**
196
+ * Clip a stored message to the per-message size cap.
197
+ *
198
+ * A single tool result can be enormous (`cat` of a large file), and one of
199
+ * those would otherwise dominate the store.
200
+ */
201
+ clipMessage(message: Message): Message {
202
+ const limit = this.maxMessageChars
203
+ const clipText = (text: string) =>
204
+ text.length <= limit ? text : `${text.slice(0, limit)}…[clipped ${text.length - limit} chars]`
205
+
206
+ // A shell execution carries its output beside the command.
207
+ if (message.role === "bashExecution") {
208
+ const output = (message as { output?: string }).output ?? ""
209
+ if (output.length <= limit) return message
210
+ return { ...message, output: clipText(output) }
211
+ }
212
+
213
+ // A tool result carries its output as the message content.
214
+ if (message.role === "toolResult") {
215
+ const text = (message as { content?: unknown }).content
216
+ if (typeof text !== "string" || text.length <= limit) return message
217
+ return { ...message, content: clipText(text) }
218
+ }
219
+
220
+ const content = message.content
221
+ if (typeof content === "string") {
222
+ if (content.length <= limit) return message
223
+ return { ...message, content: clipText(content) }
224
+ }
225
+ if (!Array.isArray(content)) return message
226
+ let size = 0
227
+ let clipped = false
228
+ const parts = content.map((part) => {
229
+ if (clipped) return part
230
+ const text = (part as { text?: unknown }).text
231
+ const length = typeof text === "string" ? text.length : 0
232
+ if (size + length <= limit) {
233
+ size += length
234
+ return part
235
+ }
236
+ clipped = true
237
+ if (length <= 0) return part
238
+ const room = Math.max(0, limit - size)
239
+ return { ...part, text: `${String(text).slice(0, room)}…[clipped ${length - room} chars]` }
240
+ })
241
+ if (!clipped) return message
242
+ return { ...message, content: parts }
243
+ }
244
+
245
+ /**
246
+ * Delete transcripts that have not been modified for `olderThanMs`.
247
+ *
248
+ * The transcript duplicates what OpenCode already stores in the session
249
+ * record, so expiring it costs only the searchable index — not the
250
+ * conversation itself.
251
+ */
252
+ sweep(olderThanMs: number, now = Date.now()): number {
253
+ return TranscriptStore.sweepDir(this.dirFor(""), olderThanMs, now)
254
+ }
255
+
256
+ /** Sweep one directory of transcript files. */
257
+ static sweepDir(dir: string, olderThanMs: number, now = Date.now()): number {
258
+ let removed = 0
259
+ let files: string[] = []
260
+ try {
261
+ files = readdirSync(dir)
262
+ } catch {
263
+ return 0
264
+ }
265
+ for (const file of files) {
266
+ if (!file.endsWith(".jsonl")) continue
267
+ const path = join(dir, file)
268
+ try {
269
+ const stats = statSync(path)
270
+ if (now - stats.mtimeMs < olderThanMs) continue
271
+ rmSync(path, { force: true })
272
+ removed++
273
+ } catch {
274
+ // A file we cannot stat or remove is left alone.
275
+ }
276
+ }
277
+ return removed
278
+ }
279
+ }
@@ -0,0 +1,51 @@
1
+ # `src/vcc` — vendored port of pi-vcc
2
+
3
+ This directory is a port of **[pi-vcc](https://github.com/sting8k/pi-vcc)**
4
+ (MIT — Copyright (c) 2026 sting8k). The full licence text is in
5
+ [`LICENSE.vendor`](../../LICENSE.vendor) in the package root; it ships with the
6
+ published package, as the MIT licence requires.
7
+
8
+ pi-vcc is Pi-agnostic pure TypeScript: it turns a conversation into a
9
+ structured summary (goal, files, commits, preferences, outstanding context) and
10
+ a ranked brief transcript, and it provides the recall/search core. It is the
11
+ engine this plugin runs.
12
+
13
+ ## Why it lives here
14
+
15
+ The port is kept as a **separate, close-to-upstream module** rather than
16
+ rewritten. pi-vcc is actively developed, and upstream fixes should be merged,
17
+ not re-implemented — which only works if the vendored code stays diffable
18
+ against its source.
19
+
20
+ **Rule for changes here:** keep the divergence from upstream as small as
21
+ possible, and never reformat, re-order or "tidy" a file you did not have to
22
+ touch. A large diff here costs a future merge.
23
+
24
+ ## What actually changed
25
+
26
+ Of the 30 files, **21 are byte-identical to upstream** and 2 are new; 7 carry a
27
+ small edit, each one removing a Pi coupling:
28
+
29
+ | File | Change |
30
+ | --- | --- |
31
+ | `types.ts`, `core/content.ts`, `core/render-entries.ts`, `core/search-entries.ts`, `core/summarize.ts` | `import type { Message } from "@earendil-works/pi-ai"` → `from "./pi-types"` / `"../pi-types"` (a local copy of Pi's message shape) |
32
+ | `core/normalize.ts` | the same import change, plus `msg.toolName ?? "tool"` for the optional field |
33
+ | `core/drill-down.ts` | `loadAllMessages(sessionFile)` → `loadMessages(messages)`: Pi reads the session from a JSONL file, OpenCode owns its history and passes the messages in |
34
+
35
+ New files, written for this port:
36
+
37
+ | File | Purpose |
38
+ | --- | --- |
39
+ | `pi-types.ts` | the message shape pi-vcc expects, copied from Pi's types so the vendored files need no import of Pi's packages |
40
+ | `load-messages.ts` | renders a materialised message list instead of reading a session file |
41
+
42
+ ## Re-syncing with upstream
43
+
44
+ ```sh
45
+ git clone https://github.com/sting8k/pi-vcc vendor/pi-vcc # ignored by git
46
+ diff -r vendor/pi-vcc/src src/vcc
47
+ ```
48
+
49
+ Port any upstream change by hand, keeping the seven edits above intact. After
50
+ re-syncing, `bun run check` must stay green — the vendored code has no
51
+ dependency on Pi's packages, which is the whole point of the port.