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