@aiwayds/dsh-topics-memory 0.6.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/README.md +112 -0
- package/README.zh.md +112 -0
- package/cordis.patch.yml +8 -0
- package/lib/commands.d.ts +21 -0
- package/lib/commands.js +368 -0
- package/lib/config.d.ts +122 -0
- package/lib/config.js +123 -0
- package/lib/delegation.d.ts +23 -0
- package/lib/delegation.js +21 -0
- package/lib/digest.d.ts +42 -0
- package/lib/digest.js +101 -0
- package/lib/distill.d.ts +186 -0
- package/lib/distill.js +759 -0
- package/lib/git.d.ts +66 -0
- package/lib/git.js +169 -0
- package/lib/ilog.d.ts +53 -0
- package/lib/ilog.js +70 -0
- package/lib/index.d.ts +73 -0
- package/lib/index.js +504 -0
- package/lib/observer.d.ts +41 -0
- package/lib/observer.js +128 -0
- package/lib/okf.d.ts +112 -0
- package/lib/okf.js +279 -0
- package/lib/onboard.d.ts +205 -0
- package/lib/onboard.js +664 -0
- package/lib/paths.d.ts +48 -0
- package/lib/paths.js +84 -0
- package/lib/retrieval.d.ts +61 -0
- package/lib/retrieval.js +178 -0
- package/lib/service.d.ts +96 -0
- package/lib/service.js +375 -0
- package/lib/store.d.ts +153 -0
- package/lib/store.js +489 -0
- package/lib/sync.d.ts +52 -0
- package/lib/sync.js +154 -0
- package/lib/tools.d.ts +8 -0
- package/lib/tools.js +208 -0
- package/lib/viz.d.ts +33 -0
- package/lib/viz.js +310 -0
- package/lib/yaml.d.ts +18 -0
- package/lib/yaml.js +334 -0
- package/package.json +97 -0
- package/src/commands.ts +397 -0
- package/src/config.ts +149 -0
- package/src/delegation.ts +27 -0
- package/src/digest.ts +125 -0
- package/src/distill.ts +885 -0
- package/src/git.ts +209 -0
- package/src/ilog.ts +99 -0
- package/src/index.ts +574 -0
- package/src/observer.ts +150 -0
- package/src/okf.ts +317 -0
- package/src/onboard.ts +762 -0
- package/src/paths.ts +104 -0
- package/src/retrieval.ts +219 -0
- package/src/service.ts +400 -0
- package/src/store.ts +567 -0
- package/src/sync.ts +170 -0
- package/src/tools.ts +225 -0
- package/src/viz.ts +333 -0
- package/src/yaml.ts +322 -0
package/src/store.ts
ADDED
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundle store — reads and writes the local OKF bundle (the Cache).
|
|
3
|
+
*
|
|
4
|
+
* All writes go through a serialized queue: one git commit per topic save
|
|
5
|
+
* (write-through, ADR 0003) plus a regenerated `index.md`. Observations and
|
|
6
|
+
* the injection log are JSONL sidecars under `meta/`; they are committed on
|
|
7
|
+
* the flush cadence (sync layer), not per append.
|
|
8
|
+
*
|
|
9
|
+
* @module store
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
readdir,
|
|
14
|
+
readFile,
|
|
15
|
+
writeFile,
|
|
16
|
+
rename,
|
|
17
|
+
mkdir,
|
|
18
|
+
appendFile,
|
|
19
|
+
access,
|
|
20
|
+
} from 'node:fs/promises'
|
|
21
|
+
import { readFileSync } from 'node:fs'
|
|
22
|
+
import { constants as FsConstants } from 'node:fs'
|
|
23
|
+
import { dirname, join } from 'node:path'
|
|
24
|
+
import { randomUUID } from 'node:crypto'
|
|
25
|
+
import * as okf from './okf.ts'
|
|
26
|
+
import * as gitmod from './git.ts'
|
|
27
|
+
import { actorFor } from './paths.ts'
|
|
28
|
+
import type { InjectionRecord } from './ilog.ts'
|
|
29
|
+
|
|
30
|
+
export interface TopicMeta {
|
|
31
|
+
slug: string
|
|
32
|
+
title: string
|
|
33
|
+
description?: string
|
|
34
|
+
status: okf.TopicStatus
|
|
35
|
+
tags: string[]
|
|
36
|
+
depends: string[]
|
|
37
|
+
generatedAt: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type ObservationKind = 'decision' | 'finding' | 'constraint' | 'question' | 'turn'
|
|
41
|
+
|
|
42
|
+
export interface Observation {
|
|
43
|
+
id: string
|
|
44
|
+
at: string
|
|
45
|
+
kind: ObservationKind
|
|
46
|
+
source: 'model' | 'auto'
|
|
47
|
+
text: string
|
|
48
|
+
sessionId?: string
|
|
49
|
+
distilled: boolean
|
|
50
|
+
distilledInto?: string[]
|
|
51
|
+
/** Failed distill attempts: fed to the model but consumed by no op (GC counter). */
|
|
52
|
+
attempts?: number
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface SaveResult {
|
|
56
|
+
slug: string
|
|
57
|
+
path: string
|
|
58
|
+
committed: boolean
|
|
59
|
+
created: boolean
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class StoreError extends Error {
|
|
63
|
+
override name = 'StoreError'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function existsSync(path: string): Promise<boolean> {
|
|
67
|
+
return access(path, FsConstants.F_OK).then(
|
|
68
|
+
() => true,
|
|
69
|
+
() => false,
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function atomicWrite(file: string, content: string): Promise<void> {
|
|
74
|
+
await mkdir(dirname(file), { recursive: true })
|
|
75
|
+
const tmp = `${file}.tmp-${randomUUID()}`
|
|
76
|
+
await writeFile(tmp, content, 'utf8')
|
|
77
|
+
await rename(tmp, file)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface BundleStoreOptions {
|
|
81
|
+
/** Override the stamped actor (tests). Defaults to `agent:dsh-topics-memory@<host>`. */
|
|
82
|
+
actor?: string
|
|
83
|
+
/** Disable git operations entirely (pure-filesystem tests). */
|
|
84
|
+
gitDisabled?: boolean
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export class BundleStore {
|
|
88
|
+
readonly root: string
|
|
89
|
+
private readonly actor: string
|
|
90
|
+
private readonly gitDisabled: boolean
|
|
91
|
+
/** Serializes all write paths; reads are lock-free. */
|
|
92
|
+
private queue: Promise<unknown> = Promise.resolve()
|
|
93
|
+
|
|
94
|
+
constructor(root: string, opts: BundleStoreOptions = {}) {
|
|
95
|
+
this.root = root
|
|
96
|
+
this.actor = opts.actor ?? actorFor()
|
|
97
|
+
this.gitDisabled = opts.gitDisabled === true
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Serialize an async write operation behind the store-wide queue. */
|
|
101
|
+
private enqueue<T>(op: () => Promise<T>): Promise<T> {
|
|
102
|
+
const run = this.queue.then(op, op)
|
|
103
|
+
this.queue = run.catch(() => undefined)
|
|
104
|
+
return run
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
topicsDir(): string {
|
|
108
|
+
return join(this.root, 'topics')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
metaDir(): string {
|
|
112
|
+
return join(this.root, 'meta')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
topicPath(slug: string): string {
|
|
116
|
+
return join(this.root, 'topics', `${slug}.md`)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private observationsPath(): string {
|
|
120
|
+
return join(this.metaDir(), 'observations.jsonl')
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private injectionsPath(): string {
|
|
124
|
+
return join(this.metaDir(), 'injections.jsonl')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private conflictsPath(): string {
|
|
128
|
+
return join(this.metaDir(), 'conflicts.json')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private distillStatePath(): string {
|
|
132
|
+
return join(this.metaDir(), 'distill-state.json')
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Last distill-lane run outcome (diagnostics for /topics status and e2e). */
|
|
136
|
+
async writeDistillState(state: unknown): Promise<void> {
|
|
137
|
+
await mkdir(this.metaDir(), { recursive: true })
|
|
138
|
+
await atomicWrite(this.distillStatePath(), `${JSON.stringify(state, null, 2)}\n`)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async readDistillState(): Promise<Record<string, unknown> | undefined> {
|
|
142
|
+
try {
|
|
143
|
+
return JSON.parse(await readFile(this.distillStatePath(), 'utf8')) as Record<string, unknown>
|
|
144
|
+
} catch {
|
|
145
|
+
return undefined
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Create the directory skeleton, git repo, and initial index (idempotent). */
|
|
150
|
+
async ensure(): Promise<void> {
|
|
151
|
+
await mkdir(this.topicsDir(), { recursive: true })
|
|
152
|
+
await mkdir(this.metaDir(), { recursive: true })
|
|
153
|
+
if (!this.gitDisabled && !(await gitmod.isRepo(this.root))) {
|
|
154
|
+
await gitmod.initRepo(this.root)
|
|
155
|
+
}
|
|
156
|
+
if (!(await existsSync(join(this.root, 'index.md')))) {
|
|
157
|
+
await this.enqueue(() => this.regenerateIndex())
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Repo initialized and not gitDisabled. */
|
|
162
|
+
async hasGit(): Promise<boolean> {
|
|
163
|
+
return !this.gitDisabled && gitmod.isRepo(this.root)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private async commit(paths: readonly string[], message: string): Promise<boolean> {
|
|
167
|
+
if (this.gitDisabled) return false
|
|
168
|
+
if (!(await gitmod.isRepo(this.root))) return false
|
|
169
|
+
return gitmod.addAndCommit(this.root, paths, message)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async listTopics(): Promise<TopicMeta[]> {
|
|
173
|
+
let files: string[]
|
|
174
|
+
try {
|
|
175
|
+
files = await readdir(this.topicsDir())
|
|
176
|
+
} catch {
|
|
177
|
+
return []
|
|
178
|
+
}
|
|
179
|
+
const metas: TopicMeta[] = []
|
|
180
|
+
for (const f of files) {
|
|
181
|
+
if (!f.endsWith('.md') || f === 'index.md') continue
|
|
182
|
+
try {
|
|
183
|
+
const raw = await readFile(join(this.topicsDir(), f), 'utf8')
|
|
184
|
+
const doc = okf.parseTopicDoc(raw)
|
|
185
|
+
metas.push({
|
|
186
|
+
slug: f.slice(0, -3),
|
|
187
|
+
title: doc.fm.title,
|
|
188
|
+
description: doc.fm.description,
|
|
189
|
+
status: doc.fm.status,
|
|
190
|
+
tags: doc.fm.tags,
|
|
191
|
+
depends: doc.fm.depends,
|
|
192
|
+
generatedAt: doc.fm.generated.at,
|
|
193
|
+
})
|
|
194
|
+
} catch {
|
|
195
|
+
// Broken topic files are skipped from rosters; status() surfaces them.
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return metas
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Topics broken beyond parsing — surfaced by /topics status, never silently dropped. */
|
|
202
|
+
async brokenTopics(): Promise<string[]> {
|
|
203
|
+
let files: string[]
|
|
204
|
+
try {
|
|
205
|
+
files = await readdir(this.topicsDir())
|
|
206
|
+
} catch {
|
|
207
|
+
return []
|
|
208
|
+
}
|
|
209
|
+
const broken: string[] = []
|
|
210
|
+
for (const f of files) {
|
|
211
|
+
if (!f.endsWith('.md') || f === 'index.md') continue
|
|
212
|
+
try {
|
|
213
|
+
okf.parseTopicDoc(await readFile(join(this.topicsDir(), f), 'utf8'))
|
|
214
|
+
} catch {
|
|
215
|
+
broken.push(f)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return broken
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async readTopic(slug: string): Promise<okf.TopicDoc | undefined> {
|
|
222
|
+
if (!okf.RESERVED_FILES.has(`${slug}.md`)) {
|
|
223
|
+
try {
|
|
224
|
+
return okf.parseTopicDoc(await readFile(this.topicPath(slug), 'utf8'))
|
|
225
|
+
} catch (e) {
|
|
226
|
+
if (e instanceof Error && e.name === 'OkfError') throw e
|
|
227
|
+
return undefined
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return undefined
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async exists(slug: string): Promise<boolean> {
|
|
234
|
+
return existsSync(this.topicPath(slug))
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** First free slug: `foo`, then `foo-2`, `foo-3`, … */
|
|
238
|
+
async uniqueSlug(base: string): Promise<string> {
|
|
239
|
+
const clean = okf.slugify(base)
|
|
240
|
+
if (!(await this.exists(clean))) return clean
|
|
241
|
+
for (let i = 2; ; i += 1) {
|
|
242
|
+
const candidate = `${clean}-${i}`
|
|
243
|
+
if (!(await this.exists(candidate))) return candidate
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Write a topic (atomic), regenerate the index, and commit both — one
|
|
249
|
+
* commit per conclusion change (ADR 0003). `slug` in the doc's depends is
|
|
250
|
+
* NOT normalized here; callers pass bundle-relative paths.
|
|
251
|
+
*/
|
|
252
|
+
async saveTopic(
|
|
253
|
+
input: { slug: string; doc: okf.TopicDoc },
|
|
254
|
+
opts: { message: string; actor?: string; created?: boolean },
|
|
255
|
+
): Promise<SaveResult> {
|
|
256
|
+
return this.enqueue(async () => {
|
|
257
|
+
const slug = okf.slugify(input.slug)
|
|
258
|
+
const file = this.topicPath(slug)
|
|
259
|
+
const created = opts.created ?? !(await existsSync(file))
|
|
260
|
+
const now = new Date().toISOString()
|
|
261
|
+
const doc: okf.TopicDoc = {
|
|
262
|
+
fm: { ...input.doc.fm, generated: { by: opts.actor ?? this.actor, at: now } },
|
|
263
|
+
body: input.doc.body,
|
|
264
|
+
}
|
|
265
|
+
const raw = okf.serializeTopicDoc(doc)
|
|
266
|
+
await atomicWrite(file, raw)
|
|
267
|
+
await this.regenerateIndex()
|
|
268
|
+
const committed = await this.commit([`topics/${slug}.md`, 'index.md'], opts.message)
|
|
269
|
+
return { slug, path: `topics/${slug}.md`, committed, created }
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private async regenerateIndex(): Promise<void> {
|
|
274
|
+
const metas = await this.listTopics()
|
|
275
|
+
const entries: okf.IndexEntry[] = metas.map((m) => ({
|
|
276
|
+
slug: m.slug,
|
|
277
|
+
title: m.title,
|
|
278
|
+
description: m.description,
|
|
279
|
+
status: m.status,
|
|
280
|
+
tags: m.tags,
|
|
281
|
+
}))
|
|
282
|
+
await atomicWrite(join(this.root, 'index.md'), okf.renderIndex(entries))
|
|
283
|
+
await atomicWrite(join(this.metaDir(), 'backlinks.json'), `${JSON.stringify(await this.computeBacklinks(), null, 2)}\n`)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Reverse-reference index: slug → who references it and how (`depends`
|
|
288
|
+
* edges + body links). Regenerated on every topic write (ADR 0003
|
|
289
|
+
* write-through), so it never drifts from the files.
|
|
290
|
+
*/
|
|
291
|
+
private async computeBacklinks(): Promise<Record<string, { slug: string; via: 'depends' | 'link' }[]>> {
|
|
292
|
+
const backlinks: Record<string, { slug: string; via: 'depends' | 'link' }[]> = {}
|
|
293
|
+
const push = (target: string, from: string, via: 'depends' | 'link'): void => {
|
|
294
|
+
if (target === from) return
|
|
295
|
+
const list = backlinks[target] ?? []
|
|
296
|
+
if (!list.some((e) => e.slug === from && e.via === via)) list.push({ slug: from, via })
|
|
297
|
+
backlinks[target] = list
|
|
298
|
+
}
|
|
299
|
+
let files: string[]
|
|
300
|
+
try {
|
|
301
|
+
files = await readdir(this.topicsDir())
|
|
302
|
+
} catch {
|
|
303
|
+
return {}
|
|
304
|
+
}
|
|
305
|
+
for (const f of files) {
|
|
306
|
+
if (!f.endsWith('.md') || f === 'index.md') continue
|
|
307
|
+
const slug = f.slice(0, -3)
|
|
308
|
+
try {
|
|
309
|
+
const doc = okf.parseTopicDoc(await readFile(join(this.topicsDir(), f), 'utf8'))
|
|
310
|
+
for (const dep of okf.dependsSlugs(doc.fm)) push(dep, slug, 'depends')
|
|
311
|
+
for (const link of okf.bodyLinkSlugs(doc.body)) push(link, slug, 'link')
|
|
312
|
+
} catch {
|
|
313
|
+
// broken files contribute no edges
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return backlinks
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Read the generated backlinks index (empty map when absent). */
|
|
320
|
+
async readBacklinks(): Promise<Record<string, { slug: string; via: 'depends' | 'link' }[]>> {
|
|
321
|
+
try {
|
|
322
|
+
const raw = await readFile(join(this.metaDir(), 'backlinks.json'), 'utf8')
|
|
323
|
+
const parsed: unknown = JSON.parse(raw)
|
|
324
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
325
|
+
return parsed as Record<string, { slug: string; via: 'depends' | 'link' }[]>
|
|
326
|
+
}
|
|
327
|
+
} catch {
|
|
328
|
+
// absent = no backlinks computed yet
|
|
329
|
+
}
|
|
330
|
+
return {}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ------------------------------------------------------------------
|
|
334
|
+
// Observations (M2 raw material; tool-written from M1)
|
|
335
|
+
// ------------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
async appendObservation(input: {
|
|
338
|
+
kind: ObservationKind
|
|
339
|
+
source: 'model' | 'auto'
|
|
340
|
+
text: string
|
|
341
|
+
sessionId?: string
|
|
342
|
+
}): Promise<Observation> {
|
|
343
|
+
const obs: Observation = {
|
|
344
|
+
id: `obs-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`,
|
|
345
|
+
at: new Date().toISOString(),
|
|
346
|
+
kind: input.kind,
|
|
347
|
+
source: input.source,
|
|
348
|
+
text: input.text,
|
|
349
|
+
distilled: false,
|
|
350
|
+
}
|
|
351
|
+
if (input.sessionId !== undefined) obs.sessionId = input.sessionId
|
|
352
|
+
// Behind the store-wide queue: markDistilled / recordUnconsumed rewrite
|
|
353
|
+
// the whole JSONL file — an unsynchronized append here could interleave
|
|
354
|
+
// with a rewrite and lose either the new observation or the rewrite.
|
|
355
|
+
return this.enqueue(async () => {
|
|
356
|
+
await mkdir(this.metaDir(), { recursive: true })
|
|
357
|
+
await appendFile(this.observationsPath(), `${JSON.stringify(obs)}\n`, 'utf8')
|
|
358
|
+
return obs
|
|
359
|
+
})
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async allObservations(limit = 500): Promise<Observation[]> {
|
|
363
|
+
let raw: string
|
|
364
|
+
try {
|
|
365
|
+
raw = await readFile(this.observationsPath(), 'utf8')
|
|
366
|
+
} catch {
|
|
367
|
+
return []
|
|
368
|
+
}
|
|
369
|
+
const out: Observation[] = []
|
|
370
|
+
for (const line of raw.split('\n')) {
|
|
371
|
+
if (line.trim() === '') continue
|
|
372
|
+
try {
|
|
373
|
+
out.push(JSON.parse(line) as Observation)
|
|
374
|
+
} catch {
|
|
375
|
+
// Tolerate a torn last line (crash mid-append).
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return out.slice(-limit)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async undistilledObservations(limit = 40): Promise<Observation[]> {
|
|
382
|
+
return (await this.allObservations(2000)).filter((o) => !o.distilled).slice(-limit)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async markDistilled(ids: readonly string[], intoSlugs: readonly string[]): Promise<number> {
|
|
386
|
+
return this.enqueue(async () => {
|
|
387
|
+
const all = await this.allObservations(2000)
|
|
388
|
+
const idSet = new Set(ids)
|
|
389
|
+
let changed = 0
|
|
390
|
+
for (const o of all) {
|
|
391
|
+
if (idSet.has(o.id) && !o.distilled) {
|
|
392
|
+
o.distilled = true
|
|
393
|
+
o.distilledInto = [...intoSlugs]
|
|
394
|
+
changed += 1
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (changed > 0) {
|
|
398
|
+
await atomicWrite(this.observationsPath(), all.map((o) => `${JSON.stringify(o)}\n`).join(''))
|
|
399
|
+
await this.commit(['meta/observations.jsonl'], `topics(meta): distill ${ids.length} observation(s) into ${intoSlugs.join(', ')}`)
|
|
400
|
+
}
|
|
401
|
+
return changed
|
|
402
|
+
})
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* GC policy for the distill lane: an observation fed to the model but left
|
|
407
|
+
* unconsumed by any op has failed one attempt; after OBSERVATION_MAX_ATTEMPTS
|
|
408
|
+
* failed attempts it is physically deleted. The user explicitly authorized
|
|
409
|
+
* deleting raw observations the lane demonstrably cannot process — the
|
|
410
|
+
* alternative is the same head haunting the backlog forever. Consumed
|
|
411
|
+
* observations leave the pool via markDistilled and never accrue attempts.
|
|
412
|
+
*/
|
|
413
|
+
static readonly OBSERVATION_MAX_ATTEMPTS = 3
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Post-run GC bookkeeping: increment `attempts` for every observation in
|
|
417
|
+
* `fedIds` that no op in `consumedIds` accounts for, then delete those that
|
|
418
|
+
* reached OBSERVATION_MAX_ATTEMPTS. Returns the deletion count (the caller
|
|
419
|
+
* records it in the distill-state detail). Increments persist with the
|
|
420
|
+
* usual flush cadence; a deletion commits immediately — destroying data
|
|
421
|
+
* must be traceable in git.
|
|
422
|
+
*/
|
|
423
|
+
async recordUnconsumed(fedIds: readonly string[], consumedIds: readonly string[]): Promise<{ dropped: number }> {
|
|
424
|
+
return this.enqueue(async () => {
|
|
425
|
+
const all = await this.allObservations(2000)
|
|
426
|
+
const fed = new Set(fedIds)
|
|
427
|
+
const consumed = new Set(consumedIds)
|
|
428
|
+
let changed = 0
|
|
429
|
+
let dropped = 0
|
|
430
|
+
const kept: Observation[] = []
|
|
431
|
+
for (const o of all) {
|
|
432
|
+
if (fed.has(o.id) && !consumed.has(o.id) && !o.distilled) {
|
|
433
|
+
o.attempts = (o.attempts ?? 0) + 1
|
|
434
|
+
changed += 1
|
|
435
|
+
}
|
|
436
|
+
if (!o.distilled && (o.attempts ?? 0) >= BundleStore.OBSERVATION_MAX_ATTEMPTS) {
|
|
437
|
+
dropped += 1
|
|
438
|
+
continue
|
|
439
|
+
}
|
|
440
|
+
kept.push(o)
|
|
441
|
+
}
|
|
442
|
+
if (changed > 0 || dropped > 0) {
|
|
443
|
+
await atomicWrite(this.observationsPath(), kept.map((o) => `${JSON.stringify(o)}\n`).join(''))
|
|
444
|
+
}
|
|
445
|
+
if (dropped > 0) {
|
|
446
|
+
await this.commit(['meta/observations.jsonl'], `topics(meta): gc ${dropped} unprocessable observation(s)`)
|
|
447
|
+
}
|
|
448
|
+
return { dropped }
|
|
449
|
+
})
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ------------------------------------------------------------------
|
|
453
|
+
// Injection log (ADR 0007) — appended per round, committed on flush
|
|
454
|
+
// ------------------------------------------------------------------
|
|
455
|
+
|
|
456
|
+
async appendInjectionRecord(record: unknown): Promise<void> {
|
|
457
|
+
await mkdir(this.metaDir(), { recursive: true })
|
|
458
|
+
await appendFile(this.injectionsPath(), `${JSON.stringify(record)}\n`, 'utf8')
|
|
459
|
+
await this.compactInjectionsIfNeeded()
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
private async compactInjectionsIfNeeded(): Promise<void> {
|
|
463
|
+
const file = this.injectionsPath()
|
|
464
|
+
let size = 0
|
|
465
|
+
try {
|
|
466
|
+
size = (await readFile(file, 'utf8')).length
|
|
467
|
+
} catch {
|
|
468
|
+
return
|
|
469
|
+
}
|
|
470
|
+
// ~512KB cap; keep the most recent quarter when exceeded (headroom for
|
|
471
|
+
// the next debounce window before the next compaction).
|
|
472
|
+
if (size <= 512 * 1024) return
|
|
473
|
+
const lines = (await readFile(file, 'utf8')).split('\n').filter((l) => l.trim() !== '')
|
|
474
|
+
await atomicWrite(file, lines.slice(-Math.max(1, Math.floor(lines.length / 4))).map((l) => `${l}\n`).join(''))
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async readInjectionRecords(limit = 2000): Promise<InjectionRecord[]> {
|
|
478
|
+
let raw: string
|
|
479
|
+
try {
|
|
480
|
+
raw = await readFile(this.injectionsPath(), 'utf8')
|
|
481
|
+
} catch {
|
|
482
|
+
return []
|
|
483
|
+
}
|
|
484
|
+
const out: InjectionRecord[] = []
|
|
485
|
+
for (const line of raw.split('\n')) {
|
|
486
|
+
if (line.trim() === '') continue
|
|
487
|
+
try {
|
|
488
|
+
out.push(JSON.parse(line) as InjectionRecord)
|
|
489
|
+
} catch {
|
|
490
|
+
// Torn tail line tolerated.
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return out.slice(-limit)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ------------------------------------------------------------------
|
|
497
|
+
// Conflicted topics (ADR 0003) — retrieval demotes these
|
|
498
|
+
// ------------------------------------------------------------------
|
|
499
|
+
|
|
500
|
+
async getConflicts(): Promise<Set<string>> {
|
|
501
|
+
try {
|
|
502
|
+
const raw = await readFile(this.conflictsPath(), 'utf8')
|
|
503
|
+
const parsed: unknown = JSON.parse(raw)
|
|
504
|
+
if (Array.isArray(parsed)) return new Set(parsed.filter((x): x is string => typeof x === 'string').map((p) => okf.pathToSlug(p)))
|
|
505
|
+
} catch {
|
|
506
|
+
// absent = no conflicts
|
|
507
|
+
}
|
|
508
|
+
return new Set()
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** Sync variant for the injection hot path. */
|
|
512
|
+
getConflictsSync(): Set<string> {
|
|
513
|
+
try {
|
|
514
|
+
const raw = readFileSync(this.conflictsPath(), 'utf8')
|
|
515
|
+
const parsed: unknown = JSON.parse(raw)
|
|
516
|
+
if (Array.isArray(parsed)) return new Set(parsed.filter((x): x is string => typeof x === 'string').map((p) => okf.pathToSlug(p)))
|
|
517
|
+
} catch {
|
|
518
|
+
// absent = no conflicts
|
|
519
|
+
}
|
|
520
|
+
return new Set()
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async setConflicts(paths: readonly string[]): Promise<void> {
|
|
524
|
+
await this.enqueue(async () => {
|
|
525
|
+
if (paths.length === 0) {
|
|
526
|
+
await atomicWrite(this.conflictsPath(), '[]\n')
|
|
527
|
+
} else {
|
|
528
|
+
await atomicWrite(this.conflictsPath(), `${JSON.stringify([...paths], null, 2)}\n`)
|
|
529
|
+
}
|
|
530
|
+
await this.commit(['meta/conflicts.json'], `topics(meta): mark ${paths.length} conflicted topic(s)`)
|
|
531
|
+
})
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// ------------------------------------------------------------------
|
|
535
|
+
// Roster / status
|
|
536
|
+
// ------------------------------------------------------------------
|
|
537
|
+
|
|
538
|
+
async status(): Promise<{
|
|
539
|
+
root: string
|
|
540
|
+
topicCount: number
|
|
541
|
+
byStatus: Record<okf.TopicStatus, number>
|
|
542
|
+
observationsPending: number
|
|
543
|
+
observationsTotal: number
|
|
544
|
+
conflicts: string[]
|
|
545
|
+
broken: string[]
|
|
546
|
+
git: boolean
|
|
547
|
+
head?: string
|
|
548
|
+
}> {
|
|
549
|
+
const metas = await this.listTopics()
|
|
550
|
+
const byStatus: Record<okf.TopicStatus, number> = { draft: 0, stable: 0, deprecated: 0 }
|
|
551
|
+
for (const m of metas) byStatus[m.status] += 1
|
|
552
|
+
const obs = await this.allObservations(2000)
|
|
553
|
+
const conflicts = [...(await this.getConflicts())]
|
|
554
|
+
const head = this.gitDisabled ? undefined : await gitmod.headRev(this.root).catch(() => undefined)
|
|
555
|
+
return {
|
|
556
|
+
root: this.root,
|
|
557
|
+
topicCount: metas.length,
|
|
558
|
+
byStatus,
|
|
559
|
+
observationsPending: obs.filter((o) => !o.distilled).length,
|
|
560
|
+
observationsTotal: obs.length,
|
|
561
|
+
conflicts,
|
|
562
|
+
broken: await this.brokenTopics(),
|
|
563
|
+
git: !this.gitDisabled && (await gitmod.isRepo(this.root)),
|
|
564
|
+
head,
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|