@aiwayds/dsh-topics-memory 0.22.0 → 0.24.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/src/store.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  appendFile,
19
19
  access,
20
20
  rm,
21
+ copyFile,
21
22
  } from 'node:fs/promises'
22
23
  import { readFileSync } from 'node:fs'
23
24
  import { constants as FsConstants } from 'node:fs'
@@ -38,19 +39,34 @@ export interface TopicMeta {
38
39
  generatedAt: string
39
40
  }
40
41
 
41
- export type ObservationKind = 'decision' | 'finding' | 'constraint' | 'question' | 'turn'
42
+ /** Bundle entry kinds (B2, spec §6.5 落点表: store kind 枚举). Canonical
43
+ * model lives in okf.ts next to TopicStatus; re-exported here as the store
44
+ * surface the spec anchors on. */
45
+ export type TopicKind = okf.TopicKind
46
+ export const topicKindOf = okf.topicKindOf
47
+
48
+ export type ObservationKind = 'decision' | 'finding' | 'constraint' | 'question' | 'turn' | 'signal'
49
+
50
+ /**
51
+ * Where an observation came from. `fed` marks entries that arrived through
52
+ * the feedObservations write seam (B-line dual-write, spec 卷二 §6.2) — the
53
+ * provenance marker the distill lane and audits key on.
54
+ */
55
+ export type ObservationSource = 'model' | 'auto' | 'fed'
42
56
 
43
57
  export interface Observation {
44
58
  id: string
45
59
  at: string
46
60
  kind: ObservationKind
47
- source: 'model' | 'auto'
61
+ source: ObservationSource
48
62
  text: string
49
63
  sessionId?: string
50
64
  distilled: boolean
51
65
  distilledInto?: string[]
52
66
  /** Failed distill attempts: fed to the model but consumed by no op (GC counter). */
53
67
  attempts?: number
68
+ /** Write-seam audit: the feedObservations caller identity (opts.actor). */
69
+ by?: string
54
70
  }
55
71
 
56
72
  export interface SaveResult {
@@ -93,6 +109,15 @@ function sameStrings(a: readonly string[], b: readonly string[]): boolean {
93
109
  return a.length === b.length && a.every((v, i) => v === b[i])
94
110
  }
95
111
 
112
+ /**
113
+ * Body heading for caller-supplied semantic links (A2). Links are rendered as
114
+ * `[[wikilinks]]` under this section so the EXISTING link-edge machinery
115
+ * (okf.bodyLinkSlugs → computeBacklinks → retrieval graph walk) indexes them
116
+ * as `via: 'link'` edges — the edge representation gains no new type; the
117
+ * body landing spot is what makes them first-class links for free.
118
+ */
119
+ const LINKS_HEADING = 'Links'
120
+
96
121
  export interface BundleStoreOptions {
97
122
  /** Override the stamped actor (tests). Defaults to `agent:dsh-topics-memory@<host>`. */
98
123
  actor?: string
@@ -348,9 +373,17 @@ export class BundleStore {
348
373
  * Write a topic (atomic), regenerate the index, and commit both — one
349
374
  * commit per conclusion change (ADR 0003). `slug` in the doc's depends is
350
375
  * NOT normalized here; callers pass bundle-relative paths.
376
+ *
377
+ * `links` (A2, optional): whitelist-surviving semantic targets from the
378
+ * distill lane. Applied INSIDE this one write transaction — the topic and
379
+ * its edges land together (先落 topic 再建边); the Links section lands as
380
+ * body wikilinks and the index/backlinks regeneration below (same
381
+ * transaction) recomputes the edge index over them. Normalized and
382
+ * self/dedup-guarded here too, so any caller benefits from the same guard
383
+ * the distill lane already ran.
351
384
  */
352
385
  async saveTopic(
353
- input: { slug: string; doc: okf.TopicDoc },
386
+ input: { slug: string; doc: okf.TopicDoc; links?: readonly string[] },
354
387
  opts: { message: string; actor?: string; created?: boolean },
355
388
  ): Promise<SaveResult> {
356
389
  return this.enqueue(async () => {
@@ -358,9 +391,22 @@ export class BundleStore {
358
391
  const file = this.topicPath(slug)
359
392
  const created = opts.created ?? !(await existsSync(file))
360
393
  const now = new Date().toISOString()
394
+ let body = input.doc.body
395
+ if (input.links !== undefined && input.links.length > 0) {
396
+ const targets: string[] = []
397
+ for (const raw of input.links) {
398
+ if (typeof raw !== 'string') continue
399
+ const target = okf.normalizeLinkTarget(raw)
400
+ if (target === undefined || target === slug || targets.includes(target)) continue
401
+ targets.push(target)
402
+ }
403
+ if (targets.length > 0) {
404
+ body = okf.setSection(body, LINKS_HEADING, targets.map((t) => `- [[${t}]]`).join('\n'))
405
+ }
406
+ }
361
407
  const doc: okf.TopicDoc = {
362
408
  fm: { ...input.doc.fm, generated: { by: opts.actor ?? this.actor, at: now } },
363
- body: input.doc.body,
409
+ body,
364
410
  }
365
411
  const raw = okf.serializeTopicDoc(doc)
366
412
  await atomicWrite(file, raw)
@@ -388,6 +434,30 @@ export class BundleStore {
388
434
  })
389
435
  }
390
436
 
437
+ /**
438
+ * Lesson removal seam (B2/卷二 §6.2 MJ-B, spec 红线④): archive BEFORE the
439
+ * roster removal — the file is copied byte-exact to `archive/lessons/`
440
+ * inside the bundle and only then deleted from `topics/`, both landing in
441
+ * ONE git commit so `git revert` restores the lesson exactly (自动写资产
442
+ * 必须可回滚). Returns undefined when the slug doesn't exist (idempotent).
443
+ */
444
+ async archiveLesson(slug: string, opts: { message: string }): Promise<{ archivedTo: string; committed: boolean } | undefined> {
445
+ return this.enqueue(async () => {
446
+ const clean = okf.slugify(slug)
447
+ const file = this.topicPath(clean)
448
+ if (!(await existsSync(file))) return undefined
449
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-')
450
+ const archivedTo = `archive/lessons/${clean}-${stamp}.md`
451
+ const absTo = join(this.root, archivedTo)
452
+ await mkdir(dirname(absTo), { recursive: true })
453
+ await copyFile(file, absTo)
454
+ await rm(file)
455
+ await this.regenerateIndex()
456
+ const committed = await this.commit([archivedTo, `topics/${clean}.md`, 'index.md'], opts.message)
457
+ return { archivedTo, committed }
458
+ })
459
+ }
460
+
391
461
  private async regenerateIndex(): Promise<void> { const metas = await this.listTopics()
392
462
  const entries: okf.IndexEntry[] = metas.map((m) => ({
393
463
  slug: m.slug,
@@ -453,9 +523,10 @@ export class BundleStore {
453
523
 
454
524
  async appendObservation(input: {
455
525
  kind: ObservationKind
456
- source: 'model' | 'auto'
526
+ source: ObservationSource
457
527
  text: string
458
528
  sessionId?: string
529
+ by?: string
459
530
  }): Promise<Observation> {
460
531
  const obs: Observation = {
461
532
  id: `obs-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`,
@@ -466,6 +537,7 @@ export class BundleStore {
466
537
  distilled: false,
467
538
  }
468
539
  if (input.sessionId !== undefined) obs.sessionId = input.sessionId
540
+ if (input.by !== undefined) obs.by = input.by
469
541
  // Behind the store-wide queue: markDistilled / recordUnconsumed rewrite
470
542
  // the whole JSONL file — an unsynchronized append here could interleave
471
543
  // with a rewrite and lose either the new observation or the rewrite.