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
|
@@ -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
|
+
}
|