@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.
Files changed (61) hide show
  1. package/README.md +112 -0
  2. package/README.zh.md +112 -0
  3. package/cordis.patch.yml +8 -0
  4. package/lib/commands.d.ts +21 -0
  5. package/lib/commands.js +368 -0
  6. package/lib/config.d.ts +122 -0
  7. package/lib/config.js +123 -0
  8. package/lib/delegation.d.ts +23 -0
  9. package/lib/delegation.js +21 -0
  10. package/lib/digest.d.ts +42 -0
  11. package/lib/digest.js +101 -0
  12. package/lib/distill.d.ts +186 -0
  13. package/lib/distill.js +759 -0
  14. package/lib/git.d.ts +66 -0
  15. package/lib/git.js +169 -0
  16. package/lib/ilog.d.ts +53 -0
  17. package/lib/ilog.js +70 -0
  18. package/lib/index.d.ts +73 -0
  19. package/lib/index.js +504 -0
  20. package/lib/observer.d.ts +41 -0
  21. package/lib/observer.js +128 -0
  22. package/lib/okf.d.ts +112 -0
  23. package/lib/okf.js +279 -0
  24. package/lib/onboard.d.ts +205 -0
  25. package/lib/onboard.js +664 -0
  26. package/lib/paths.d.ts +48 -0
  27. package/lib/paths.js +84 -0
  28. package/lib/retrieval.d.ts +61 -0
  29. package/lib/retrieval.js +178 -0
  30. package/lib/service.d.ts +96 -0
  31. package/lib/service.js +375 -0
  32. package/lib/store.d.ts +153 -0
  33. package/lib/store.js +489 -0
  34. package/lib/sync.d.ts +52 -0
  35. package/lib/sync.js +154 -0
  36. package/lib/tools.d.ts +8 -0
  37. package/lib/tools.js +208 -0
  38. package/lib/viz.d.ts +33 -0
  39. package/lib/viz.js +310 -0
  40. package/lib/yaml.d.ts +18 -0
  41. package/lib/yaml.js +334 -0
  42. package/package.json +97 -0
  43. package/src/commands.ts +397 -0
  44. package/src/config.ts +149 -0
  45. package/src/delegation.ts +27 -0
  46. package/src/digest.ts +125 -0
  47. package/src/distill.ts +885 -0
  48. package/src/git.ts +209 -0
  49. package/src/ilog.ts +99 -0
  50. package/src/index.ts +574 -0
  51. package/src/observer.ts +150 -0
  52. package/src/okf.ts +317 -0
  53. package/src/onboard.ts +762 -0
  54. package/src/paths.ts +104 -0
  55. package/src/retrieval.ts +219 -0
  56. package/src/service.ts +400 -0
  57. package/src/store.ts +567 -0
  58. package/src/sync.ts +170 -0
  59. package/src/tools.ts +225 -0
  60. package/src/viz.ts +333 -0
  61. package/src/yaml.ts +322 -0
package/lib/service.js ADDED
@@ -0,0 +1,375 @@
1
+ /**
2
+ * TopicsService — the facade shared by tools, commands, the injection seam,
3
+ * and the distill lane. Owns the roster cache (mtime-keyed, pi-topic-memory
4
+ * lesson #3: never re-parse unchanged files on the hot path).
5
+ *
6
+ * @module service
7
+ */
8
+ import { readFile, stat } from 'node:fs/promises';
9
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import * as okf from "./okf.js";
12
+ import { searchTopics, tokenize } from "./retrieval.js";
13
+ import { assembleInjection } from "./digest.js";
14
+ import { aggregateStats, querySample } from "./ilog.js";
15
+ import { fileHistory, fileAtRev } from "./git.js";
16
+ export class TopicsService {
17
+ cache = new Map();
18
+ store;
19
+ getConfig;
20
+ sync;
21
+ constructor(store, getConfig, sync) {
22
+ this.store = store;
23
+ this.getConfig = getConfig;
24
+ this.sync = sync;
25
+ }
26
+ get cfg() {
27
+ return this.getConfig();
28
+ }
29
+ get githubMode() {
30
+ return this.cfg.repo !== '';
31
+ }
32
+ /** All retrievable topics with conclusion text, mtime-cached. */
33
+ async roster() {
34
+ let files;
35
+ try {
36
+ files = (await this.store.listTopics()).map((m) => `${m.slug}.md`);
37
+ }
38
+ catch {
39
+ return [];
40
+ }
41
+ const out = [];
42
+ for (const file of files) {
43
+ const path = join(this.store.topicsDir(), file);
44
+ let st;
45
+ try {
46
+ st = await stat(path);
47
+ }
48
+ catch {
49
+ continue;
50
+ }
51
+ const cached = this.cache.get(file);
52
+ let entry = cached;
53
+ if (cached === undefined || cached.mtimeMs !== st.mtimeMs || cached.size !== st.size) {
54
+ try {
55
+ const doc = okf.parseTopicDoc(await readFile(path, 'utf8'));
56
+ entry = { mtimeMs: st.mtimeMs, size: st.size, doc, slug: file.slice(0, -3) };
57
+ this.cache.set(file, entry);
58
+ }
59
+ catch {
60
+ this.cache.delete(file);
61
+ continue;
62
+ }
63
+ }
64
+ if (entry === undefined)
65
+ continue;
66
+ const doc = entry.doc;
67
+ out.push({
68
+ slug: entry.slug,
69
+ title: doc.fm.title,
70
+ description: doc.fm.description,
71
+ status: doc.fm.status,
72
+ tags: doc.fm.tags,
73
+ depends: doc.fm.depends,
74
+ links: okf.bodyLinkSlugs(doc.body),
75
+ generatedAt: doc.fm.generated.at,
76
+ conclusion: okf.sectionOf(doc.body, okf.CONCLUSION_HEADING) ?? '',
77
+ });
78
+ }
79
+ return out;
80
+ }
81
+ /** Drop the roster cache (tests, bulk external edits). */
82
+ invalidate() {
83
+ this.cache.clear();
84
+ }
85
+ /**
86
+ * Full retrieval round: search → assemble → log (ADR 0006/0007). The
87
+ * injection record is written even for zero-hit rounds — the hit-rate
88
+ * denominator must count every round (near-miss evidence).
89
+ */
90
+ async retrieve(query, sessionId) {
91
+ const cfg = this.cfg;
92
+ const roster = await this.roster();
93
+ if (!cfg.autoInject || query.trim() === '' || roster.length === 0) {
94
+ return {
95
+ outcome: { hits: [], nearMisses: [], rosterSize: roster.length },
96
+ injection: { text: '', usedTokens: 0, included: [], dropped: [] },
97
+ recorded: false,
98
+ };
99
+ }
100
+ const conflicts = await this.store.getConflicts();
101
+ const outcome = searchTopics(query, roster, {
102
+ threshold: cfg.matchThreshold,
103
+ topK: cfg.topK,
104
+ tagBoost: cfg.tagBoost,
105
+ graphDepth: cfg.graphDepth,
106
+ recencyWindowDays: cfg.recencyWindowDays,
107
+ conflicts,
108
+ });
109
+ const bySlug = new Map(roster.map((r) => [r.slug, r]));
110
+ const entries = [];
111
+ for (const hit of outcome.hits) {
112
+ const topic = bySlug.get(hit.slug);
113
+ if (topic === undefined)
114
+ continue;
115
+ const doc = await this.readDoc(hit.slug);
116
+ if (doc !== undefined)
117
+ entries.push({ slug: hit.slug, doc, hit });
118
+ }
119
+ const injection = assembleInjection(entries, {
120
+ perTopicBudget: cfg.perTopicBudget,
121
+ totalBudget: cfg.totalBudget,
122
+ });
123
+ const record = {
124
+ at: new Date().toISOString(),
125
+ queryTokenCount: tokenize(query).length,
126
+ querySample: querySample(query),
127
+ rosterSize: outcome.rosterSize,
128
+ hits: outcome.hits.map((h) => ({ slug: h.slug, score: h.score, reasons: h.reasons, viaGraph: h.viaGraph })),
129
+ nearMisses: outcome.nearMisses.map((h) => ({ slug: h.slug, score: h.score })),
130
+ injected: injection.text !== '',
131
+ usedTokens: injection.usedTokens,
132
+ };
133
+ if (sessionId !== undefined)
134
+ record.sessionId = sessionId;
135
+ if (injection.text === '' && outcome.hits.length > 0)
136
+ record.why = 'below-budget-or-dropped';
137
+ if (inclusionDropped(injection))
138
+ record.dropped = injection.dropped;
139
+ await this.store.appendInjectionRecord(record);
140
+ return { outcome, injection, recorded: true };
141
+ }
142
+ /**
143
+ * SYNCHRONOUS hot path for same-turn injection (the chancelu lesson):
144
+ * `agent/inbox/spliced` dispatches before prompt assembly and the context
145
+ * provider reads the assembled text synchronously — an async retrieval
146
+ * would always lose the race and inject nothing. Reads files with sync fs
147
+ * behind the mtime cache; the log record is written fire-and-forget.
148
+ *
149
+ * `dedup.exclude` (session-level injection dedup) filters hits AFTER
150
+ * retrieval and BEFORE assembly: excluded slugs are reported back as
151
+ * `deduped` (and logged as `record.deduped`), never packed. `included`
152
+ * mirrors what actually entered the context — only these may be marked
153
+ * as injected by the caller; budget-dropped slugs stay injectable later.
154
+ */
155
+ retrieveSync(query, sessionId, dedup) {
156
+ const cfg = this.cfg;
157
+ const roster = this.rosterSync();
158
+ const empty = { hits: [], nearMisses: [], rosterSize: roster.length };
159
+ if (!cfg.autoInject || query.trim() === '' || roster.length === 0) {
160
+ return { text: '', outcome: empty, deduped: [], included: [] };
161
+ }
162
+ const conflicts = this.store.getConflictsSync();
163
+ const outcome = searchTopics(query, roster, {
164
+ threshold: cfg.matchThreshold,
165
+ topK: cfg.topK,
166
+ tagBoost: cfg.tagBoost,
167
+ graphDepth: cfg.graphDepth,
168
+ recencyWindowDays: cfg.recencyWindowDays,
169
+ conflicts,
170
+ });
171
+ const exclude = dedup?.exclude;
172
+ const deduped = exclude === undefined ? [] : outcome.hits.filter((h) => exclude.has(h.slug)).map((h) => h.slug);
173
+ const entries = [];
174
+ for (const hit of outcome.hits) {
175
+ if (exclude?.has(hit.slug))
176
+ continue;
177
+ const doc = this.readDocSync(hit.slug);
178
+ if (doc !== undefined)
179
+ entries.push({ slug: hit.slug, doc, hit });
180
+ }
181
+ const injection = assembleInjection(entries, {
182
+ perTopicBudget: cfg.perTopicBudget,
183
+ totalBudget: cfg.totalBudget,
184
+ });
185
+ const record = {
186
+ at: new Date().toISOString(),
187
+ queryTokenCount: tokenize(query).length,
188
+ querySample: querySample(query),
189
+ rosterSize: outcome.rosterSize,
190
+ hits: outcome.hits.map((h) => ({ slug: h.slug, score: h.score, reasons: h.reasons, viaGraph: h.viaGraph })),
191
+ nearMisses: outcome.nearMisses.map((h) => ({ slug: h.slug, score: h.score })),
192
+ injected: injection.text !== '',
193
+ usedTokens: injection.usedTokens,
194
+ };
195
+ if (sessionId !== undefined)
196
+ record.sessionId = sessionId;
197
+ if (deduped.length > 0)
198
+ record.deduped = deduped;
199
+ if (injection.text === '' && outcome.hits.length > 0) {
200
+ // All hits deduped is a different story than a budget squeeze — say so.
201
+ record.why = deduped.length === outcome.hits.length ? 'dedup' : 'below-budget-or-dropped';
202
+ }
203
+ if (injection.dropped.length > 0)
204
+ record.dropped = injection.dropped;
205
+ void this.store.appendInjectionRecord(record).catch(() => undefined);
206
+ return { text: injection.text, outcome, deduped, included: injection.included };
207
+ }
208
+ /** Sync roster read (same mtime cache as roster()). */
209
+ rosterSync() {
210
+ let files;
211
+ try {
212
+ files = readdirSync(this.store.topicsDir());
213
+ }
214
+ catch {
215
+ return [];
216
+ }
217
+ const out = [];
218
+ for (const file of files) {
219
+ if (!file.endsWith('.md') || file === 'index.md')
220
+ continue;
221
+ const path = join(this.store.topicsDir(), file);
222
+ let st;
223
+ try {
224
+ st = statSync(path);
225
+ }
226
+ catch {
227
+ continue;
228
+ }
229
+ const cached = this.cache.get(file);
230
+ let entry = cached;
231
+ if (cached === undefined || cached.mtimeMs !== st.mtimeMs || cached.size !== st.size) {
232
+ try {
233
+ const doc = okf.parseTopicDoc(readFileSync(path, 'utf8'));
234
+ entry = { mtimeMs: st.mtimeMs, size: st.size, doc, slug: file.slice(0, -3) };
235
+ this.cache.set(file, entry);
236
+ }
237
+ catch {
238
+ this.cache.delete(file);
239
+ continue;
240
+ }
241
+ }
242
+ if (entry === undefined)
243
+ continue;
244
+ const doc = entry.doc;
245
+ out.push({
246
+ slug: entry.slug,
247
+ title: doc.fm.title,
248
+ description: doc.fm.description,
249
+ status: doc.fm.status,
250
+ tags: doc.fm.tags,
251
+ depends: doc.fm.depends,
252
+ links: okf.bodyLinkSlugs(doc.body),
253
+ generatedAt: doc.fm.generated.at,
254
+ conclusion: okf.sectionOf(doc.body, okf.CONCLUSION_HEADING) ?? '',
255
+ });
256
+ }
257
+ return out;
258
+ }
259
+ readDocSync(slug) {
260
+ try {
261
+ return okf.parseTopicDoc(readFileSync(join(this.store.topicsDir(), `${slug}.md`), 'utf8'));
262
+ }
263
+ catch {
264
+ return undefined;
265
+ }
266
+ }
267
+ async readDoc(slug) {
268
+ try {
269
+ return await this.store.readTopic(slug);
270
+ }
271
+ catch {
272
+ return undefined;
273
+ }
274
+ }
275
+ /** topic_save tool path: create or update with actor stamping. */
276
+ async saveTopic(input) {
277
+ const now = new Date().toISOString();
278
+ const existing = input.slug !== undefined ? await this.store.readTopic(input.slug) : undefined;
279
+ let body = existing?.body ?? '';
280
+ if (input.conclusion !== undefined && input.conclusion !== '') {
281
+ body = okf.setSection(body, okf.CONCLUSION_HEADING, input.conclusion);
282
+ }
283
+ else if (existing === undefined) {
284
+ body = okf.setSection(body, okf.CONCLUSION_HEADING, input.description ?? input.title);
285
+ }
286
+ if (input.recommendations !== undefined && input.recommendations !== '') {
287
+ body = okf.setSection(body, okf.RECOMMENDATIONS_HEADING, input.recommendations);
288
+ }
289
+ const baseSlug = input.slug ?? okf.slugify(input.title);
290
+ const slug = existing === undefined ? await this.store.uniqueSlug(baseSlug) : okf.slugify(input.slug ?? baseSlug);
291
+ // store.saveTopic stamps generated {by, at} itself (write-through actor).
292
+ const fm = {
293
+ type: 'Topic',
294
+ title: input.title,
295
+ tags: dedupeLower(input.tags ?? existing?.fm.tags ?? []),
296
+ depends: (input.depends ?? existing?.fm.depends ?? []).map(okf.slugToPath),
297
+ open_questions: input.openQuestions ?? existing?.fm.open_questions ?? [],
298
+ impact: input.impact ?? existing?.fm.impact ?? [],
299
+ status: input.status ?? existing?.fm.status ?? 'draft',
300
+ generated: existing?.fm.generated ?? { by: 'pending', at: now },
301
+ };
302
+ if (input.description !== undefined && input.description !== '')
303
+ fm.description = input.description;
304
+ if (existing !== undefined && existing.fm.verified !== undefined)
305
+ fm.verified = existing.fm.verified;
306
+ const result = await this.store.saveTopic({ slug, doc: { fm, body } }, { message: existing === undefined ? `topics(topic): create ${slug}` : `topics(topic): update ${slug}`, created: existing === undefined });
307
+ this.invalidate();
308
+ void this.sync?.schedulePush();
309
+ return { ...result, slug: result.slug };
310
+ }
311
+ async observe(input) {
312
+ return this.store.appendObservation({ kind: input.kind, source: input.source ?? 'model', text: input.text, sessionId: input.sessionId });
313
+ }
314
+ async search(query, topK) {
315
+ const cfg = this.cfg;
316
+ return searchTopics(query, await this.roster(), {
317
+ threshold: cfg.matchThreshold,
318
+ topK: topK ?? Math.max(cfg.topK, 8),
319
+ tagBoost: cfg.tagBoost,
320
+ graphDepth: cfg.graphDepth,
321
+ recencyWindowDays: cfg.recencyWindowDays,
322
+ conflicts: await this.store.getConflicts(),
323
+ });
324
+ }
325
+ async history(slug, limit = 20) {
326
+ if (!(await this.store.hasGit()))
327
+ return { entries: [] };
328
+ const path = `topics/${okf.slugify(slug)}.md`;
329
+ const log = await fileHistory(this.store.root, path, limit);
330
+ const entries = [];
331
+ for (const e of log) {
332
+ const raw = await fileAtRev(this.store.root, path, e.hash);
333
+ let conclusion;
334
+ if (raw !== undefined) {
335
+ try {
336
+ conclusion = firstLine(okf.sectionOf(okf.parseTopicDoc(raw).body, okf.CONCLUSION_HEADING) ?? '');
337
+ }
338
+ catch {
339
+ conclusion = undefined;
340
+ }
341
+ }
342
+ entries.push({ hash: e.hash.slice(0, 10), date: e.date, message: e.message, conclusion });
343
+ }
344
+ return { entries };
345
+ }
346
+ async stats() {
347
+ return aggregateStats(await this.store.readInjectionRecords());
348
+ }
349
+ }
350
+ function inclusionDropped(injection) {
351
+ return injection.dropped.length > 0;
352
+ }
353
+ function firstLine(text) {
354
+ return firstParagraphOf(text);
355
+ }
356
+ function firstParagraphOf(text) {
357
+ for (const line of text.split('\n')) {
358
+ const t = line.trim();
359
+ if (t !== '')
360
+ return t;
361
+ }
362
+ return '';
363
+ }
364
+ function dedupeLower(tags) {
365
+ const seen = new Set();
366
+ const out = [];
367
+ for (const t of tags) {
368
+ const key = t.trim().toLowerCase();
369
+ if (key === '' || seen.has(key))
370
+ continue;
371
+ seen.add(key);
372
+ out.push(key);
373
+ }
374
+ return out;
375
+ }
package/lib/store.d.ts ADDED
@@ -0,0 +1,153 @@
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
+ import * as okf from './okf.ts';
12
+ import type { InjectionRecord } from './ilog.ts';
13
+ export interface TopicMeta {
14
+ slug: string;
15
+ title: string;
16
+ description?: string;
17
+ status: okf.TopicStatus;
18
+ tags: string[];
19
+ depends: string[];
20
+ generatedAt: string;
21
+ }
22
+ export type ObservationKind = 'decision' | 'finding' | 'constraint' | 'question' | 'turn';
23
+ export interface Observation {
24
+ id: string;
25
+ at: string;
26
+ kind: ObservationKind;
27
+ source: 'model' | 'auto';
28
+ text: string;
29
+ sessionId?: string;
30
+ distilled: boolean;
31
+ distilledInto?: string[];
32
+ /** Failed distill attempts: fed to the model but consumed by no op (GC counter). */
33
+ attempts?: number;
34
+ }
35
+ export interface SaveResult {
36
+ slug: string;
37
+ path: string;
38
+ committed: boolean;
39
+ created: boolean;
40
+ }
41
+ export declare class StoreError extends Error {
42
+ name: string;
43
+ }
44
+ export interface BundleStoreOptions {
45
+ /** Override the stamped actor (tests). Defaults to `agent:dsh-topics-memory@<host>`. */
46
+ actor?: string;
47
+ /** Disable git operations entirely (pure-filesystem tests). */
48
+ gitDisabled?: boolean;
49
+ }
50
+ export declare class BundleStore {
51
+ readonly root: string;
52
+ private readonly actor;
53
+ private readonly gitDisabled;
54
+ /** Serializes all write paths; reads are lock-free. */
55
+ private queue;
56
+ constructor(root: string, opts?: BundleStoreOptions);
57
+ /** Serialize an async write operation behind the store-wide queue. */
58
+ private enqueue;
59
+ topicsDir(): string;
60
+ metaDir(): string;
61
+ topicPath(slug: string): string;
62
+ private observationsPath;
63
+ private injectionsPath;
64
+ private conflictsPath;
65
+ private distillStatePath;
66
+ /** Last distill-lane run outcome (diagnostics for /topics status and e2e). */
67
+ writeDistillState(state: unknown): Promise<void>;
68
+ readDistillState(): Promise<Record<string, unknown> | undefined>;
69
+ /** Create the directory skeleton, git repo, and initial index (idempotent). */
70
+ ensure(): Promise<void>;
71
+ /** Repo initialized and not gitDisabled. */
72
+ hasGit(): Promise<boolean>;
73
+ private commit;
74
+ listTopics(): Promise<TopicMeta[]>;
75
+ /** Topics broken beyond parsing — surfaced by /topics status, never silently dropped. */
76
+ brokenTopics(): Promise<string[]>;
77
+ readTopic(slug: string): Promise<okf.TopicDoc | undefined>;
78
+ exists(slug: string): Promise<boolean>;
79
+ /** First free slug: `foo`, then `foo-2`, `foo-3`, … */
80
+ uniqueSlug(base: string): Promise<string>;
81
+ /**
82
+ * Write a topic (atomic), regenerate the index, and commit both — one
83
+ * commit per conclusion change (ADR 0003). `slug` in the doc's depends is
84
+ * NOT normalized here; callers pass bundle-relative paths.
85
+ */
86
+ saveTopic(input: {
87
+ slug: string;
88
+ doc: okf.TopicDoc;
89
+ }, opts: {
90
+ message: string;
91
+ actor?: string;
92
+ created?: boolean;
93
+ }): Promise<SaveResult>;
94
+ private regenerateIndex;
95
+ /**
96
+ * Reverse-reference index: slug → who references it and how (`depends`
97
+ * edges + body links). Regenerated on every topic write (ADR 0003
98
+ * write-through), so it never drifts from the files.
99
+ */
100
+ private computeBacklinks;
101
+ /** Read the generated backlinks index (empty map when absent). */
102
+ readBacklinks(): Promise<Record<string, {
103
+ slug: string;
104
+ via: 'depends' | 'link';
105
+ }[]>>;
106
+ appendObservation(input: {
107
+ kind: ObservationKind;
108
+ source: 'model' | 'auto';
109
+ text: string;
110
+ sessionId?: string;
111
+ }): Promise<Observation>;
112
+ allObservations(limit?: number): Promise<Observation[]>;
113
+ undistilledObservations(limit?: number): Promise<Observation[]>;
114
+ markDistilled(ids: readonly string[], intoSlugs: readonly string[]): Promise<number>;
115
+ /**
116
+ * GC policy for the distill lane: an observation fed to the model but left
117
+ * unconsumed by any op has failed one attempt; after OBSERVATION_MAX_ATTEMPTS
118
+ * failed attempts it is physically deleted. The user explicitly authorized
119
+ * deleting raw observations the lane demonstrably cannot process — the
120
+ * alternative is the same head haunting the backlog forever. Consumed
121
+ * observations leave the pool via markDistilled and never accrue attempts.
122
+ */
123
+ static readonly OBSERVATION_MAX_ATTEMPTS = 3;
124
+ /**
125
+ * Post-run GC bookkeeping: increment `attempts` for every observation in
126
+ * `fedIds` that no op in `consumedIds` accounts for, then delete those that
127
+ * reached OBSERVATION_MAX_ATTEMPTS. Returns the deletion count (the caller
128
+ * records it in the distill-state detail). Increments persist with the
129
+ * usual flush cadence; a deletion commits immediately — destroying data
130
+ * must be traceable in git.
131
+ */
132
+ recordUnconsumed(fedIds: readonly string[], consumedIds: readonly string[]): Promise<{
133
+ dropped: number;
134
+ }>;
135
+ appendInjectionRecord(record: unknown): Promise<void>;
136
+ private compactInjectionsIfNeeded;
137
+ readInjectionRecords(limit?: number): Promise<InjectionRecord[]>;
138
+ getConflicts(): Promise<Set<string>>;
139
+ /** Sync variant for the injection hot path. */
140
+ getConflictsSync(): Set<string>;
141
+ setConflicts(paths: readonly string[]): Promise<void>;
142
+ status(): Promise<{
143
+ root: string;
144
+ topicCount: number;
145
+ byStatus: Record<okf.TopicStatus, number>;
146
+ observationsPending: number;
147
+ observationsTotal: number;
148
+ conflicts: string[];
149
+ broken: string[];
150
+ git: boolean;
151
+ head?: string;
152
+ }>;
153
+ }