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,254 @@
1
+ /**
2
+ * The memory ledger.
3
+ *
4
+ * One JSONL file per session holds everything blackhole remembers:
5
+ * observations (facts extracted from the conversation), reflections (durable
6
+ * insights distilled from observations) and the state cursor that says how far
7
+ * the observer has read.
8
+ *
9
+ * Append-only while a session is live — dropping an observation writes a
10
+ * `dropped` marker rather than rewriting history, so `/blackhole-memory full`
11
+ * can still show it. The file is compacted when it grows past a size limit:
12
+ * the live observations, the reflections and the cursor are rewritten, and the
13
+ * dropped markers are folded into a counter.
14
+ */
15
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
16
+ import { join } from "node:path"
17
+
18
+ export interface Observation {
19
+ id: string
20
+ ts: string
21
+ text: string
22
+ /** Rough token cost, used for the pool budget. */
23
+ tokens: number
24
+ }
25
+
26
+ export interface Reflection {
27
+ id: string
28
+ ts: string
29
+ text: string
30
+ /** Observation ids that support this reflection. */
31
+ evidence: string[]
32
+ }
33
+
34
+ export type LedgerEntry =
35
+ | ({ kind: "observation" } & Observation)
36
+ | ({ kind: "reflection" } & Reflection)
37
+ | { kind: "dropped"; id: string; ts: string }
38
+ | { kind: "cursor"; observedThrough: number; ts: string; dropped?: number }
39
+ /** Observations the reflector has already reviewed. */
40
+ | { kind: "reflected"; ids: string[]; ts: string }
41
+
42
+ export interface LedgerState {
43
+ observations: Observation[]
44
+ reflections: Reflection[]
45
+ dropped: Observation[]
46
+ /** How many observations have been dropped over the session's life. */
47
+ droppedCount: number
48
+ /** The transcript index the observer has read up to. */
49
+ observedThrough: number
50
+ /** The observations the reflector has already crystallized. */
51
+ reflectedIds: Set<string>
52
+ }
53
+
54
+ export interface MemoryLedgerOptions {
55
+ /** How many parsed ledgers stay in memory at once. */
56
+ maxCachedSessions?: number
57
+ /** Compact a ledger file when it grows past this size. */
58
+ maxFileBytes?: number
59
+ }
60
+
61
+ /**
62
+ * Where a session's ledger 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 LedgerDirResolver = string | ((sessionID: string) => string)
68
+
69
+ const emptyState = (): LedgerState => ({
70
+ observations: [],
71
+ reflections: [],
72
+ dropped: [],
73
+ droppedCount: 0,
74
+ observedThrough: 0,
75
+ reflectedIds: new Set(),
76
+ })
77
+
78
+ export class MemoryLedger {
79
+ private readonly dirFor: (sessionID: string) => string
80
+ private readonly maxCachedSessions: number
81
+ private readonly maxFileBytes: number
82
+ /** Insertion order is the LRU order: the first key is the oldest. */
83
+ private readonly cache = new Map<string, LedgerState>()
84
+
85
+ constructor(dir: LedgerDirResolver, options: MemoryLedgerOptions = {}) {
86
+ this.dirFor = typeof dir === "function" ? dir : () => dir
87
+ this.maxCachedSessions = Math.max(1, options.maxCachedSessions ?? 8)
88
+ this.maxFileBytes = Math.max(4_096, options.maxFileBytes ?? 65_536)
89
+ }
90
+
91
+ private path(sessionID: string) {
92
+ return join(this.dirFor(sessionID), `${sessionID.replace(/[^a-zA-Z0-9_-]/g, "_")}.jsonl`)
93
+ }
94
+
95
+ private touch(sessionID: string, state: LedgerState) {
96
+ this.cache.delete(sessionID)
97
+ this.cache.set(sessionID, state)
98
+ while (this.cache.size > this.maxCachedSessions) {
99
+ const oldest = this.cache.keys().next().value
100
+ if (oldest === undefined) break
101
+ this.cache.delete(oldest)
102
+ }
103
+ }
104
+
105
+ read(sessionID: string): LedgerState {
106
+ const cached = this.cache.get(sessionID)
107
+ if (cached) {
108
+ this.touch(sessionID, cached)
109
+ return cached
110
+ }
111
+
112
+ const state = emptyState()
113
+ const path = this.path(sessionID)
114
+ try {
115
+ if (existsSync(path)) {
116
+ for (const line of readFileSync(path, "utf8").split("\n")) {
117
+ if (!line.trim()) continue
118
+ let entry: LedgerEntry
119
+ try {
120
+ entry = JSON.parse(line)
121
+ } catch {
122
+ continue
123
+ }
124
+ this.apply(state, entry)
125
+ }
126
+ }
127
+ } catch {
128
+ // An unreadable ledger is an empty ledger.
129
+ }
130
+
131
+ this.touch(sessionID, state)
132
+ return state
133
+ }
134
+
135
+ /** Apply one entry to a state, in order. */
136
+ private apply(state: LedgerState, entry: LedgerEntry) {
137
+ if (entry.kind === "observation") state.observations.push(entry)
138
+ else if (entry.kind === "reflection") state.reflections.push(entry)
139
+ else if (entry.kind === "dropped") {
140
+ const index = state.observations.findIndex((o) => o.id === entry.id)
141
+ if (index >= 0) state.dropped.push(state.observations.splice(index, 1)[0])
142
+ state.droppedCount++
143
+ } else if (entry.kind === "cursor") {
144
+ state.observedThrough = entry.observedThrough
145
+ if (typeof entry.dropped === "number") state.droppedCount = Math.max(state.droppedCount, entry.dropped)
146
+ } else if (entry.kind === "reflected") {
147
+ for (const id of entry.ids) state.reflectedIds.add(id)
148
+ }
149
+ }
150
+
151
+ append(sessionID: string, entries: LedgerEntry[]) {
152
+ if (entries.length === 0) return
153
+ // Read before writing: `read` populates the cache, so the state below is
154
+ // the pre-append state and the entries are applied exactly once.
155
+ const state = this.read(sessionID)
156
+ mkdirSync(this.dirFor(sessionID), { recursive: true })
157
+ appendFileSync(this.path(sessionID), entries.map((entry) => JSON.stringify(entry)).join("\n") + "\n")
158
+ for (const entry of entries) {
159
+ this.apply(state, entry)
160
+ }
161
+ this.maybeCompact(sessionID)
162
+ }
163
+
164
+ /**
165
+ * Rewrite a ledger file with only what is still live.
166
+ *
167
+ * The dropped markers are the only thing that accumulates without bound, so
168
+ * they are folded into a counter on the cursor entry.
169
+ */
170
+ compact(sessionID: string): boolean {
171
+ const state = this.read(sessionID)
172
+ const path = this.path(sessionID)
173
+ if (!existsSync(path)) return false
174
+
175
+ const lines: string[] = []
176
+ for (const observation of state.observations) {
177
+ lines.push(JSON.stringify({ kind: "observation", ...observation } as LedgerEntry))
178
+ }
179
+ for (const reflection of state.reflections) {
180
+ lines.push(JSON.stringify({ kind: "reflection", ...reflection } as LedgerEntry))
181
+ }
182
+ lines.push(
183
+ JSON.stringify({
184
+ kind: "cursor",
185
+ observedThrough: state.observedThrough,
186
+ ts: new Date().toISOString(),
187
+ dropped: state.droppedCount,
188
+ } as LedgerEntry),
189
+ )
190
+ // The reflector's cursor has to survive the rewrite, or the reflector
191
+ // would review every observation again after a compaction.
192
+ if (state.reflectedIds.size > 0) {
193
+ lines.push(
194
+ JSON.stringify({
195
+ kind: "reflected",
196
+ ids: [...state.reflectedIds],
197
+ ts: new Date().toISOString(),
198
+ } as LedgerEntry),
199
+ )
200
+ }
201
+
202
+ try {
203
+ writeFileSync(path, lines.join("\n") + "\n")
204
+ } catch {
205
+ return false
206
+ }
207
+ return true
208
+ }
209
+
210
+ /** Compact the ledger when its file has grown past the size limit. */
211
+ private maybeCompact(sessionID: string) {
212
+ try {
213
+ const stats = statSync(this.path(sessionID))
214
+ if (stats.size <= this.maxFileBytes) return
215
+ } catch {
216
+ return
217
+ }
218
+ this.compact(sessionID)
219
+ }
220
+
221
+ /**
222
+ * Delete ledgers that have not been modified for `olderThanMs`.
223
+ *
224
+ * Ledgers are small (the live memory of a session), so the window is
225
+ * generous — they are the thing worth keeping.
226
+ */
227
+ sweep(olderThanMs: number, now = Date.now()): number {
228
+ return MemoryLedger.sweepDir(this.dirFor(""), olderThanMs, now)
229
+ }
230
+
231
+ /** Sweep one directory of ledger files. */
232
+ static sweepDir(dir: string, olderThanMs: number, now = Date.now()): number {
233
+ let removed = 0
234
+ let files: string[] = []
235
+ try {
236
+ files = readdirSync(dir)
237
+ } catch {
238
+ return 0
239
+ }
240
+ for (const file of files) {
241
+ if (!file.endsWith(".jsonl")) continue
242
+ const path = join(dir, file)
243
+ try {
244
+ const stats = statSync(path)
245
+ if (now - stats.mtimeMs < olderThanMs) continue
246
+ rmSync(path, { force: true })
247
+ removed++
248
+ } catch {
249
+ // A file we cannot stat or remove is left alone.
250
+ }
251
+ }
252
+ return removed
253
+ }
254
+ }