@aiwayds/dsh-topics-memory 0.23.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/lib/store.js CHANGED
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * @module store
10
10
  */
11
- import { readdir, readFile, writeFile, rename, mkdir, appendFile, access, rm, } from 'node:fs/promises';
11
+ import { readdir, readFile, writeFile, rename, mkdir, appendFile, access, rm, copyFile, } from 'node:fs/promises';
12
12
  import { readFileSync } from 'node:fs';
13
13
  import { constants as FsConstants } from 'node:fs';
14
14
  import { dirname, join } from 'node:path';
@@ -16,6 +16,7 @@ import { randomUUID } from 'node:crypto';
16
16
  import * as okf from "./okf.js";
17
17
  import * as gitmod from "./git.js";
18
18
  import { actorFor } from "./paths.js";
19
+ export const topicKindOf = okf.topicKindOf;
19
20
  export class StoreError extends Error {
20
21
  name = 'StoreError';
21
22
  }
@@ -352,6 +353,30 @@ export class BundleStore {
352
353
  return true;
353
354
  });
354
355
  }
356
+ /**
357
+ * Lesson removal seam (B2/卷二 §6.2 MJ-B, spec 红线④): archive BEFORE the
358
+ * roster removal — the file is copied byte-exact to `archive/lessons/`
359
+ * inside the bundle and only then deleted from `topics/`, both landing in
360
+ * ONE git commit so `git revert` restores the lesson exactly (自动写资产
361
+ * 必须可回滚). Returns undefined when the slug doesn't exist (idempotent).
362
+ */
363
+ async archiveLesson(slug, opts) {
364
+ return this.enqueue(async () => {
365
+ const clean = okf.slugify(slug);
366
+ const file = this.topicPath(clean);
367
+ if (!(await existsSync(file)))
368
+ return undefined;
369
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
370
+ const archivedTo = `archive/lessons/${clean}-${stamp}.md`;
371
+ const absTo = join(this.root, archivedTo);
372
+ await mkdir(dirname(absTo), { recursive: true });
373
+ await copyFile(file, absTo);
374
+ await rm(file);
375
+ await this.regenerateIndex();
376
+ const committed = await this.commit([archivedTo, `topics/${clean}.md`, 'index.md'], opts.message);
377
+ return { archivedTo, committed };
378
+ });
379
+ }
355
380
  async regenerateIndex() {
356
381
  const metas = await this.listTopics();
357
382
  const entries = metas.map((m) => ({
@@ -431,6 +456,8 @@ export class BundleStore {
431
456
  };
432
457
  if (input.sessionId !== undefined)
433
458
  obs.sessionId = input.sessionId;
459
+ if (input.by !== undefined)
460
+ obs.by = input.by;
434
461
  // Behind the store-wide queue: markDistilled / recordUnconsumed rewrite
435
462
  // the whole JSONL file — an unsynchronized append here could interleave
436
463
  // with a rewrite and lose either the new observation or the rewrite.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwayds/dsh-topics-memory",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "dsh-plugin: quality-first topic memory for dsh — a topic keeps the starting question, conclusion, impact and dependencies, not the process; local-first git-traced OKF bundle, budgeted LLM-free injection",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -42,7 +42,7 @@
42
42
  "build": "tsc -p tsconfig.json",
43
43
  "check": "tsc --noEmit -p tsconfig.json",
44
44
  "pretest": "tsc -p tsconfig.json",
45
- "test": "node --test test/yaml.test.mjs test/okf.test.mjs test/retrieval.test.mjs test/digest.test.mjs test/digest-package.test.mjs test/ilog.test.mjs test/store.test.mjs test/paths.test.mjs test/git.test.mjs test/sync.test.mjs test/tools.test.mjs test/dedup.test.mjs test/commands.test.mjs test/onboard.test.mjs test/delegation.test.mjs test/observer.test.mjs test/distill.test.mjs test/consolidate.test.mjs test/viz.test.mjs test/corpus.test.mjs test/llm-scope.test.mjs test/quality.test.mjs test/replay-gate.test.mjs test/depends-repair.test.mjs test/skill.test.mjs test/fast-lane.test.mjs test/legacy-import.test.mjs test/jev-config.test.mjs test/jev-gate.test.mjs test/jev-client.test.mjs test/jev-log.test.mjs test/jev-dual.test.mjs test/jev-fastgate.test.mjs test/zero-open-decay.test.mjs test/embedding.test.mjs test/embedding-index.test.mjs test/query-sanitize.test.mjs test/semantic-rerank.test.mjs test/inject-config.test.mjs test/distill-links.test.mjs test/store-links.test.mjs",
45
+ "test": "node --test test/yaml.test.mjs test/okf.test.mjs test/retrieval.test.mjs test/digest.test.mjs test/digest-package.test.mjs test/ilog.test.mjs test/store.test.mjs test/paths.test.mjs test/git.test.mjs test/sync.test.mjs test/tools.test.mjs test/dedup.test.mjs test/commands.test.mjs test/onboard.test.mjs test/delegation.test.mjs test/observer.test.mjs test/distill.test.mjs test/consolidate.test.mjs test/viz.test.mjs test/corpus.test.mjs test/llm-scope.test.mjs test/quality.test.mjs test/replay-gate.test.mjs test/depends-repair.test.mjs test/skill.test.mjs test/fast-lane.test.mjs test/legacy-import.test.mjs test/jev-config.test.mjs test/jev-gate.test.mjs test/jev-client.test.mjs test/jev-log.test.mjs test/jev-dual.test.mjs test/jev-fastgate.test.mjs test/zero-open-decay.test.mjs test/embedding.test.mjs test/embedding-index.test.mjs test/query-sanitize.test.mjs test/semantic-rerank.test.mjs test/inject-config.test.mjs test/distill-links.test.mjs test/store-links.test.mjs test/lesson-seams.test.mjs",
46
46
  "smoke": "node scripts/smoke-boot.mjs"
47
47
  },
48
48
  "dependencies": {},
package/src/config.ts CHANGED
@@ -120,6 +120,13 @@ export const TopicsConfig = z.object({
120
120
  * behavior change anywhere (byte-for-byte legacy); shadow = compute +
121
121
  * bypass-ledger only, nothing lands in context; on = engage. */
122
122
  packageMode: z.string().default('off').volatile(),
123
+ /** Lesson injection gate (B2, spec §7 C1 MJ-4): off | shadow | on, default
124
+ * off. off = established lessons neither inject nor shadow (zero injection-
125
+ * side behavior); shadow = the lesson priority seat double-computes into
126
+ * the bypass ledger (meta/lesson-shadow.jsonl), never lands in context;
127
+ * on = established lessons inject. Only the injection side lives here —
128
+ * the lesson loop (distill/approval) is the new repo's improveLoop gate. */
129
+ lessonInject: z.string().default('off').volatile(),
123
130
  })
124
131
 
125
132
  export type TopicsConfigValue = {
@@ -171,6 +178,9 @@ export type TopicsConfigValue = {
171
178
  embeddingProvider?: string
172
179
  embeddingModel?: string
173
180
  packageMode?: 'off' | 'shadow' | 'on'
181
+ /** Lesson injection gate (B2). Optional in this TS view only (sentinel
182
+ * pattern as above) — consumers treat undefined as 'off'. */
183
+ lessonInject?: 'off' | 'shadow' | 'on'
174
184
  }
175
185
 
176
186
  export const CONFIG_KEYS = [
@@ -211,6 +221,7 @@ export const CONFIG_KEYS = [
211
221
  'embeddingProvider',
212
222
  'embeddingModel',
213
223
  'packageMode',
224
+ 'lessonInject',
214
225
  ] as const
215
226
 
216
227
  export type ConfigKey = (typeof CONFIG_KEYS)[number]
@@ -295,6 +306,10 @@ export function parseConfigValue(key: ConfigKey, raw: string): boolean | number
295
306
  if (raw === 'off' || raw === 'shadow' || raw === 'on') return raw
296
307
  return { error: 'package-mode 取值 off|shadow|on' }
297
308
  }
309
+ case 'lessonInject': {
310
+ if (raw === 'off' || raw === 'shadow' || raw === 'on') return raw
311
+ return { error: 'lesson-inject 取值 off|shadow|on' }
312
+ }
298
313
  case 'jevLayaFallback': {
299
314
  if (raw === 'on' || raw === 'true') return true
300
315
  if (raw === 'off' || raw === 'false') return false
package/src/digest.ts CHANGED
@@ -110,6 +110,8 @@ export interface AssembleResult {
110
110
  dropped: { slug: string; reason: string }[]
111
111
  /** Slow-lane pointers that entered the context (v4 §4.2). */
112
112
  slowIncluded?: string[]
113
+ /** Established lessons that entered the context (B2, spec 卷一 §6.3). */
114
+ lessonIncluded?: string[]
113
115
  }
114
116
 
115
117
  export interface AssembleConfig {
@@ -126,6 +128,36 @@ export interface SlowPointerInput {
126
128
  why: string
127
129
  }
128
130
 
131
+ /**
132
+ * One established-lesson pointer (B2, spec 卷一 §6.3): flat-mode normal-entry
133
+ * rendering with the machine-recognizable source marker (MJ-C combination
134
+ * matrix — lessonInject=on without the A3 package injects lessons as plain
135
+ * entries, tagged `lesson source:<kind>`).
136
+ */
137
+ export interface LessonPointerInput {
138
+ slug: string
139
+ title: string
140
+ status: string
141
+ description?: string
142
+ score: number
143
+ source: string
144
+ }
145
+
146
+ /** The `(lesson source:x)` marker on lesson entries — flat AND package form. */
147
+ export function lessonSourceTag(source: string): string {
148
+ return `lesson source:${source}`
149
+ }
150
+
151
+ /** One flat lesson entry: same two-line pointer shape + the source marker. */
152
+ export function lessonEntryText(input: LessonPointerInput, budgetTokens: number): { text: string; usedTokens: number } {
153
+ const lines: string[] = []
154
+ lines.push(`### ${input.title} [${input.status}] (topics:${input.slug} score:${input.score.toFixed(2)}) (${lessonSourceTag(input.source)})`)
155
+ const one = (input.description ?? '').replace(/\s+/g, ' ').trim()
156
+ if (one !== '') lines.push(truncateToTokens(one, Math.max(16, budgetTokens - 32)).text)
157
+ const text = lines.join('\n')
158
+ return { text, usedTokens: estimateTokens(text) }
159
+ }
160
+
129
161
  /**
130
162
  * One slow-lane pointer: same shape as a fast pointer plus the mandatory why
131
163
  * line (v4 §4.2 — 每条带 why 行). No score: the gate here was the LLM rerank,
@@ -153,8 +185,9 @@ function wrapBlocks(blocks: readonly string[]): string {
153
185
 
154
186
  /**
155
187
  * Shared greedy packing: fast entries first (score-ordered), then optional
156
- * slow-lane pointers, all under one total budget (v4 B2: shared 600 — the
157
- * slow lane spends whatever the fast lane leaves; 分账终值 is a P3 question).
188
+ * slow-lane pointers, then optional lesson entries (B2: flat-mode normal
189
+ * entry slots), all under one total budget (v4 B2: shared 600 — the slow lane
190
+ * spends whatever the fast lane leaves; 分账终值 is a P3 question).
158
191
  * Zero blocks → empty text (ADR 0006: 零命中零注入).
159
192
  */
160
193
  function packInjection(
@@ -162,12 +195,14 @@ function packInjection(
162
195
  cfg: AssembleConfig,
163
196
  render: (entry: DigestInput, budget: number) => { text: string; usedTokens: number },
164
197
  slow?: readonly SlowPointerInput[],
198
+ lessons?: readonly LessonPointerInput[],
165
199
  ): AssembleResult {
166
- if (entries.length === 0 && (slow === undefined || slow.length === 0)) {
200
+ if (entries.length === 0 && (slow === undefined || slow.length === 0) && (lessons === undefined || lessons.length === 0)) {
167
201
  return { text: '', usedTokens: 0, included: [], dropped: [] }
168
202
  }
169
203
  const included: string[] = []
170
204
  const slowIncluded: string[] = []
205
+ const lessonIncluded: string[] = []
171
206
  const dropped: { slug: string; reason: string }[] = []
172
207
  const blocks: string[] = []
173
208
  let used = estimateTokens(`${WRAPPER_OPEN}\n${WRAPPER_INTRO}\n\n${WRAPPER_CLOSE}`)
@@ -201,35 +236,57 @@ function packInjection(
201
236
  slowIncluded.push(item.slug)
202
237
  used += block.usedTokens
203
238
  }
239
+ for (const item of lessons ?? []) {
240
+ const budget = Math.min(cfg.perTopicBudget, cfg.totalBudget - used)
241
+ if (budget < 24) {
242
+ dropped.push({ slug: item.slug, reason: 'lesson-budget' })
243
+ continue
244
+ }
245
+ const block = lessonEntryText(item, budget)
246
+ if (used + block.usedTokens > cfg.totalBudget) {
247
+ dropped.push({ slug: item.slug, reason: 'lesson-budget' })
248
+ continue
249
+ }
250
+ blocks.push(block.text)
251
+ lessonIncluded.push(item.slug)
252
+ used += block.usedTokens
253
+ }
204
254
  if (blocks.length === 0) return { text: '', usedTokens: 0, included: [], dropped }
205
255
  const text = wrapBlocks(blocks)
206
256
  const result: AssembleResult = { text, usedTokens: estimateTokens(text), included, dropped }
207
257
  if (slowIncluded.length > 0) result.slowIncluded = slowIncluded
258
+ if (lessonIncluded.length > 0) {
259
+ result.lessonIncluded = lessonIncluded
260
+ result.included = [...included, ...lessonIncluded]
261
+ }
208
262
  return result
209
263
  }
210
264
 
211
265
  /**
212
266
  * Greedily pack pointer entries (hits already score-ordered) under the total
213
- * budget. Zero hits → empty text (ADR 0006: 零命中零注入).
267
+ * budget. Zero hits → empty text (ADR 0006: 零命中零注入). `lessons` (B2)
268
+ * packs after the slow pointers as flat entries with the source marker.
214
269
  */
215
270
  export function assemblePointer(
216
271
  entries: readonly DigestInput[],
217
272
  cfg: AssembleConfig,
218
273
  slow?: readonly SlowPointerInput[],
274
+ lessons?: readonly LessonPointerInput[],
219
275
  ): AssembleResult {
220
- return packInjection(entries, cfg, pointerEntry, slow)
276
+ return packInjection(entries, cfg, pointerEntry, slow, lessons)
221
277
  }
222
278
 
223
279
  /**
224
- * Greedily pack per-topic digests (hits already score-ordered) under the
225
- * total budget. Zero hits → empty text (ADR 0006: 零命中零注入).
280
+ * Greedily pack per-topic digests (hits already score-ordered) under the total
281
+ * budget. Zero hits → empty text (ADR 0006: 零命中零注入). `lessons` (B2).
226
282
  */
227
283
  export function assembleInjection(
228
284
  entries: readonly DigestInput[],
229
285
  cfg: AssembleConfig,
230
286
  slow?: readonly SlowPointerInput[],
287
+ lessons?: readonly LessonPointerInput[],
231
288
  ): AssembleResult {
232
- return packInjection(entries, cfg, topicDigest, slow)
289
+ return packInjection(entries, cfg, topicDigest, slow, lessons)
233
290
  }
234
291
 
235
292
  // ---------------------------------------------------------------------------
@@ -253,6 +310,15 @@ export interface PackageCandidate {
253
310
  reasons: string[]
254
311
  /** Edge/lane provenance: `depends:dN` | `slow-pick` | `semantic…` | raw. */
255
312
  via?: string
313
+ /**
314
+ * Lesson priority seat (B2, spec 卷一 §6.3): sorts FIRST in the direct
315
+ * layer and is exempt from the ≤2 direct-seat cap (still budget-bound) —
316
+ * 「主层 ≤2 席中命中 lesson 至少 1 席」. Absent on every topic candidate.
317
+ */
318
+ prioritySeat?: boolean
319
+ /** Rendered as a `(${sourceTag})` suffix on the entry head — lesson source
320
+ * marker (`lesson source:correction`), MJ-C. */
321
+ sourceTag?: string
256
322
  }
257
323
 
258
324
  export interface PackageInput {
@@ -401,11 +467,16 @@ function isTrivialQuery(query: string, queryTokens: readonly string[]): boolean
401
467
  return stripped.length > 0 && stripped.length <= PACKAGE_TRIVIAL_QUERY_CHARS
402
468
  }
403
469
 
404
- /** Stable within-layer order: score desc, then usage, then input order. */
470
+ /** Stable within-layer order: prioritySeat first (lesson 优先席), then score
471
+ * desc, then usage, then input order. */
405
472
  function rankStable(list: readonly PackageCandidate[], usage: Map<string, number> | undefined): PackageCandidate[] {
406
473
  return list
407
474
  .map((c, i) => ({ c, i, u: usage?.get(c.slug) ?? 0 }))
408
- .sort((a, b) => b.c.score - a.c.score || b.u - a.u || a.i - b.i)
475
+ .sort((a, b) =>
476
+ (b.c.prioritySeat === true ? 1 : 0) - (a.c.prioritySeat === true ? 1 : 0) ||
477
+ b.c.score - a.c.score ||
478
+ b.u - a.u ||
479
+ a.i - b.i)
409
480
  .map((x) => x.c)
410
481
  }
411
482
 
@@ -452,7 +523,7 @@ function directEntryText(
452
523
  queryTokens: readonly string[],
453
524
  budget: number,
454
525
  ): { text: string; usedTokens: number } {
455
- const head = `### ${c.title} [${c.status}]`
526
+ const head = `### ${c.title} [${c.status}]${c.sourceTag !== undefined && c.sourceTag !== '' ? ` (${c.sourceTag})` : ''}`
456
527
  const why = packageWhyLine(c, queryTokens)
457
528
  const warn = c.reasons.includes('conflicted-demoted') ? '⚠ 此条存在未合并冲突,结论可能过期。' : null
458
529
  const openBase = `细节: topic_open "${c.slug}"`
@@ -568,7 +639,9 @@ export function assemblePackage(input: PackageInput, opts?: PackageOpts): Packag
568
639
  const layers: PackageResult['layers'] = { direct: [], neighborhood: [], weak: [] }
569
640
  const directBlocks: string[] = []
570
641
  for (const c of direct) {
571
- if (directBlocks.length >= PACKAGE_DIRECT_MAX && !isEntityAnchored(input.queryTokens, c)) {
642
+ // prioritySeat (lesson 优先席) is exempt from the direct-seat cap — the
643
+ // budget is the only thing that can still cut it.
644
+ if (directBlocks.length >= PACKAGE_DIRECT_MAX && !isEntityAnchored(input.queryTokens, c) && c.prioritySeat !== true) {
572
645
  dropped.push({ slug: c.slug, reason: 'direct-cap' })
573
646
  continue
574
647
  }
package/src/ilog.ts CHANGED
@@ -77,6 +77,15 @@ export interface InjectionRecord {
77
77
  * Written once with the record, never backfilled (ilog append-only).
78
78
  */
79
79
  slowOutcome?: SlowOutcome
80
+ /**
81
+ * Lesson gate fields (B2, spec 卷一 §6.3/§7) — present only on rounds where
82
+ * lessonInject is shadow|on AND at least one established lesson matched the
83
+ * query. `lessons` is the trigger-channel match list with whether the entry
84
+ * entered the context; shadow rounds always carry included:false there (the
85
+ * double-compute lands in meta/lesson-shadow.jsonl instead).
86
+ */
87
+ lessonInject?: 'shadow' | 'on'
88
+ lessons?: { slug: string; score: number; source: string; included: boolean }[]
80
89
  }
81
90
 
82
91
  /**
package/src/index.ts CHANGED
@@ -259,6 +259,14 @@ interface VolatileRef<T> {
259
259
  * on bare test harnesses — {@link readConfigValue} accepts both. */
260
260
  export type TopicsRuntimeConfig = Partial<{ [K in keyof TopicsConfigValue]: VolatileRef<TopicsConfigValue[K]> | TopicsConfigValue[K] }>
261
261
 
262
+ /**
263
+ * B-line dual-write seam contract (卷二 §6.2) — shared types are exported by
264
+ * THIS repo (C1 决议 3: no shared lib until a third consumer appears). The
265
+ * new repo calls TopicsService.feedObservations / setLessonStatus through the
266
+ * host process; direct bundle writes are forbidden on its side.
267
+ */
268
+ export type { StructuredObservation, FeedResult, StoreResult } from './service.ts'
269
+
262
270
  /** Snapshot one config slot: live ref → current value, plain value → itself. */
263
271
  function readConfigValue(slot: unknown): unknown {
264
272
  const ref = slot as { get?: unknown } | undefined
@@ -1164,6 +1172,7 @@ const DEFAULTS: TopicsConfigValue = {
1164
1172
  embeddingProvider: '',
1165
1173
  embeddingModel: '',
1166
1174
  packageMode: 'off',
1175
+ lessonInject: 'off',
1167
1176
  usageBoost: 0.15,
1168
1177
  pushDebounceSeconds: 45,
1169
1178
  }
package/src/okf.ts CHANGED
@@ -17,6 +17,43 @@ import { parseYaml, stringifyYaml, YamlError } from './yaml.ts'
17
17
 
18
18
  export type TopicStatus = 'draft' | 'stable' | 'deprecated'
19
19
 
20
+ /**
21
+ * Bundle entry kinds (B2, spec 2026-10-self-improve-topics §6.1): `topic` =
22
+ * factual knowledge (「是什么」), `lesson` = behavioral lesson (「怎么做事」).
23
+ * The kind rides the OKF `type` frontmatter field — the canonical serialized
24
+ * value for a lesson is `Lesson` (matching the `Topic` capitalization
25
+ * convention); recognition is case-insensitive so bundles written by the
26
+ * new repo's loop parse regardless of casing drift.
27
+ */
28
+ export type TopicKind = 'topic' | 'lesson'
29
+
30
+ /** lesson scope (B2 MJ-2, spec §6.2): workspace lessons inject only in the
31
+ * workspace that produced them (cwd-hash match); global need no filter. */
32
+ export type LessonScope = 'workspace' | 'global'
33
+ /** lesson confidence (B2, spec §6.2): the FACT source — `status` derives from
34
+ * it (hint↔draft, established↔stable); hint never injects (MJ-3). */
35
+ export type LessonConfidence = 'hint' | 'established'
36
+ /** lesson provenance (B2 BL-2, spec §6.2): machine-recognizable source tag;
37
+ * uninstall reclamation keys on source≠manual (卷二 §9). */
38
+ export type LessonSource = 'distilled' | 'correction' | 'manual'
39
+
40
+ /** kind from the raw `type` frontmatter value (case-insensitive `lesson`). */
41
+ export function topicKindOf(type: string): TopicKind {
42
+ return typeof type === 'string' && type.trim().toLowerCase() === 'lesson' ? 'lesson' : 'topic'
43
+ }
44
+
45
+ /** The canonical serialized `type` value per kind. */
46
+ export function kindTypeValue(kind: TopicKind): string {
47
+ return kind === 'lesson' ? 'Lesson' : 'Topic'
48
+ }
49
+
50
+ /** confidence 为事实源(B2 Minor):hint↔draft、established↔stable.
51
+ * `deprecated` is a lifecycle endpoint OUTSIDE the confidence axis — callers
52
+ * set it directly (setLessonStatus / consolidation / TTL sweep). */
53
+ export function deriveLessonStatus(confidence: LessonConfidence): TopicStatus {
54
+ return confidence === 'established' ? 'stable' : 'draft'
55
+ }
56
+
20
57
  export interface SourceEntry {
21
58
  id?: string
22
59
  resource: string
@@ -52,6 +89,15 @@ export interface TopicFrontmatter {
52
89
  verified?: TrustStamp
53
90
  stale_after?: string
54
91
  sources?: SourceEntry[]
92
+ // ---- lesson-kind fields (B2, spec §6.2) — present on kind=lesson docs ----
93
+ /** lesson 必带 scope(默认 workspace;global 需显式)。 */
94
+ scope?: LessonScope
95
+ /** confidence 事实源;status 派生(deriveLessonStatus)。默认 hint。 */
96
+ confidence?: LessonConfidence
97
+ /** provenance 标记(卸载回收按 source≠manual)。默认 manual。 */
98
+ source?: LessonSource
99
+ /** 写入时的 workspace 标识(cwd hash,MJ-2);scope=global 时可空。 */
100
+ workspaceId?: string
55
101
  /** Producer-defined extras — preserved on round-trip per OKF §4.1. */
56
102
  [extra: string]: unknown
57
103
  }
@@ -148,8 +194,25 @@ export function parseTopicDoc(raw: string): TopicDoc {
148
194
  return s as SourceEntry
149
195
  })
150
196
  }
197
+ // ---- lesson-kind fields (B2, spec §6.2) — parsed on lesson docs only ----
198
+ // Invalid enum values DEGRADE TO THE DEFAULT (triggers precedent: a producer
199
+ // slip must never invalidate an otherwise sound doc); the seam writers own
200
+ // the closed vocabulary. On lesson docs these four keys join the known set
201
+ // so the extras loop cannot double-store a raw unvalidated value.
202
+ if (topicKindOf(fmRaw.type) === 'lesson') {
203
+ fm.scope = fmRaw.scope === 'global' ? 'global' : 'workspace'
204
+ fm.confidence = fmRaw.confidence === 'established' ? 'established' : 'hint'
205
+ fm.source = fmRaw.source === 'distilled' || fmRaw.source === 'correction' ? fmRaw.source : 'manual'
206
+ if (typeof fmRaw.workspaceId === 'string' && fmRaw.workspaceId !== '') fm.workspaceId = fmRaw.workspaceId
207
+ }
151
208
  // Preserve unknown producer-defined keys verbatim (OKF §4.1 extensions).
152
209
  const known = new Set(['type', 'title', 'description', 'tags', 'triggers', 'depends', 'open_questions', 'impact', 'status', 'generated', 'verified', 'stale_after', 'sources'])
210
+ if (fm.scope !== undefined || fm.confidence !== undefined || fm.source !== undefined) {
211
+ known.add('scope')
212
+ known.add('confidence')
213
+ known.add('source')
214
+ known.add('workspaceId')
215
+ }
153
216
  for (const [k, v] of Object.entries(fmRaw)) {
154
217
  if (!known.has(k)) fm[k] = v
155
218
  }
@@ -174,6 +237,11 @@ export function serializeTopicDoc(doc: TopicDoc): string {
174
237
  if (fm.verified !== undefined) out.verified = { by: fm.verified.by, at: fm.verified.at }
175
238
  if (fm.stale_after !== undefined) out.stale_after = fm.stale_after
176
239
  if (fm.sources !== undefined && fm.sources.length > 0) out.sources = fm.sources
240
+ // lesson fields serialize in a fixed slot (deterministic order, spec §6.2).
241
+ if (fm.scope !== undefined) out.scope = fm.scope
242
+ if (fm.confidence !== undefined) out.confidence = fm.confidence
243
+ if (fm.source !== undefined) out.source = fm.source
244
+ if (fm.workspaceId !== undefined) out.workspaceId = fm.workspaceId
177
245
  for (const [k, v] of Object.entries(fm)) {
178
246
  if (!(k in out)) out[k] = v
179
247
  }
package/src/paths.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import { existsSync, renameSync } from 'node:fs'
16
+ import { createHash } from 'node:crypto'
16
17
  import { homedir } from 'node:os'
17
18
  import { hostname as osHostname } from 'node:os'
18
19
  import { join } from 'node:path'
@@ -102,3 +103,17 @@ export function hostId(): string {
102
103
  export function actorFor(): string {
103
104
  return `agent:dsh-topics-memory@${hostId()}`
104
105
  }
106
+
107
+ /**
108
+ * Current workspace identifier for lesson scope filtering (B2 MJ-2, spec
109
+ * 卷一 §6.2): the sha256 prefix of the process cwd, overridable via
110
+ * $DSH_TOPICS_WORKSPACE_ID (tests / multi-root hosts; the override also lets
111
+ * a host pin one identity across chdirs). 拍板(spec 未钉死来源): cwd hash at
112
+ * read time is the minimal viable source — the same derivation the lesson
113
+ * write path stamps into frontmatter.workspaceId, so no config plumbing.
114
+ */
115
+ export function currentWorkspaceId(): string {
116
+ const env = process.env.DSH_TOPICS_WORKSPACE_ID?.trim()
117
+ if (env !== undefined && env !== '') return env
118
+ return createHash('sha256').update(process.cwd()).digest('hex').slice(0, 16)
119
+ }
@@ -21,8 +21,10 @@ import { metaDir } from './paths.ts'
21
21
 
22
22
  /** Closed vocabulary, spec §8.1 + W-C wave start set. `slow-lane-outcome` is
23
23
  * the slow lane's per-run disposition row (quality.ts); the distill/link
24
- * kinds are the A2/A6 producers' contract — they import this module. */
25
- export type PipelineEventKind = 'slow-lane-outcome' | 'distill-op-invalid' | 'distill-op-retry-exhausted' | 'link-ref-rejected'
24
+ * kinds are the A2/A6 producers' contract — they import this module. The
25
+ * two `seam-*` kinds are the B-line dual-write audit trail (卷二 §6.2:
26
+ * caller-identity audit — opts.actor — rides every seam call). */
27
+ export type PipelineEventKind = 'slow-lane-outcome' | 'distill-op-invalid' | 'distill-op-retry-exhausted' | 'link-ref-rejected' | 'seam-observations-fed' | 'seam-lesson-status'
26
28
 
27
29
  /** File NAME inside the bundle's meta dir (path via pipelineEventsFile). */
28
30
  export const PIPELINE_EVENTS_FILE = 'pipeline-events.jsonl'
@@ -47,6 +49,8 @@ const KNOWN_KINDS: ReadonlySet<string> = new Set<string>([
47
49
  'distill-op-invalid',
48
50
  'distill-op-retry-exhausted',
49
51
  'link-ref-rejected',
52
+ 'seam-observations-fed',
53
+ 'seam-lesson-status',
50
54
  ])
51
55
 
52
56
  export function pipelineEventsFile(bundleDir: string): string {