@aiwayds/dsh-topics-memory 0.24.1 → 0.25.1

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.
@@ -33,6 +33,7 @@ const KNOWN_KINDS = new Set([
33
33
  'link-ref-rejected',
34
34
  'seam-observations-fed',
35
35
  'seam-lesson-status',
36
+ 'lesson-conflict-flagged',
36
37
  ]);
37
38
  export function pipelineEventsFile(bundleDir) {
38
39
  return join(metaDir(bundleDir), PIPELINE_EVENTS_FILE);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwayds/dsh-topics-memory",
3
- "version": "0.24.1",
3
+ "version": "0.25.1",
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 test/lesson-seams.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/distill-lesson.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": {},
@@ -84,22 +84,22 @@
84
84
  },
85
85
  "devDependencies": {
86
86
  "@deepseek-ai/cordis": "4.0.4",
87
- "@deepseek-ai/dsh-agent": "0.2.0-rc.2",
88
- "@deepseek-ai/dsh-attachment": "0.2.0-rc.2",
89
- "@deepseek-ai/dsh-brand": "0.2.0-rc.2",
90
- "@deepseek-ai/dsh-commands": "0.2.0-rc.2",
87
+ "@deepseek-ai/dsh-agent": "0.2.1-rc.2",
88
+ "@deepseek-ai/dsh-attachment": "0.2.1-rc.2",
89
+ "@deepseek-ai/dsh-brand": "0.2.1-rc.2",
90
+ "@deepseek-ai/dsh-commands": "0.2.1-rc.2",
91
91
  "@deepseek-ai/dsh-invariants": "0.2.0-rc.2",
92
- "@deepseek-ai/dsh-llm": "0.2.0-rc.2",
93
- "@deepseek-ai/dsh-scope": "0.2.0-rc.2",
94
- "@deepseek-ai/dsh-sandbox": "0.2.0-rc.2",
95
- "@deepseek-ai/dsh-session": "0.2.0-rc.2",
96
- "@deepseek-ai/dsh-settings": "0.2.0-rc.2",
97
- "@deepseek-ai/dsh-skill": "0.2.0-rc.2",
98
- "@deepseek-ai/dsh-system-prompt": "0.2.0-rc.2",
99
- "@deepseek-ai/dsh-timeout": "0.2.0-rc.2",
100
- "@deepseek-ai/dsh-tools": "0.2.0-rc.2",
101
- "@deepseek-ai/dsh-user-approval": "0.2.0-rc.2",
102
- "@deepseek-ai/dsh-util-values": "0.2.0-rc.2",
92
+ "@deepseek-ai/dsh-llm": "0.2.1-rc.2",
93
+ "@deepseek-ai/dsh-scope": "0.2.1-rc.2",
94
+ "@deepseek-ai/dsh-sandbox": "0.2.1-rc.2",
95
+ "@deepseek-ai/dsh-session": "0.2.1-rc.2",
96
+ "@deepseek-ai/dsh-settings": "0.2.1-rc.2",
97
+ "@deepseek-ai/dsh-skill": "0.2.1-rc.2",
98
+ "@deepseek-ai/dsh-system-prompt": "0.2.1-rc.2",
99
+ "@deepseek-ai/dsh-timeout": "0.2.1-rc.2",
100
+ "@deepseek-ai/dsh-tools": "0.2.1-rc.2",
101
+ "@deepseek-ai/dsh-user-approval": "0.2.1-rc.2",
102
+ "@deepseek-ai/dsh-util-values": "0.2.1-rc.2",
103
103
  "@deepseek-ai/schemastery": "3.18.4",
104
104
  "@types/node": "^22.19.0",
105
105
  "typescript": "^5.9.2"
package/src/distill.ts CHANGED
@@ -54,6 +54,16 @@ export interface DistillOp {
54
54
  conclusion?: string
55
55
  recommendations?: string
56
56
  status?: string
57
+ /** Lesson distill branch (B2): lesson-kind create op — a behavior lesson. */
58
+ kind?: string
59
+ /** Lesson scope (B2 §6.2, lesson 必带; the write defaults absent → workspace). */
60
+ scope?: string
61
+ /**
62
+ * Conflict self-check markers (B2 决议 5, baseline): established lesson
63
+ * slugs from the prompt's baseline list that this draft contradicts. The
64
+ * draft still lands — the flag is ledgered for the B4 approval gate.
65
+ */
66
+ conflictsWith?: string[]
57
67
  observed_ids?: string[]
58
68
  }
59
69
 
@@ -86,6 +96,47 @@ export const SYSTEM_PROMPT = [
86
96
  '- 中文主题用中文写;tags 全小写。',
87
97
  ].join('\n')
88
98
 
99
+ /**
100
+ * The lesson distill branch's system prompt (B2 决议 3①, spec 卷二 §3.1):
101
+ * fed structured signals (`kind:'signal'` + `source:'fed'`, feature lines
102
+ * only — 对话原文结构上进不来) distill into behavior LESSONS, not factual
103
+ * topics. The 判定问句 is the gate: a signal batch that cannot yield an
104
+ * executable behavioral rule must come back EMPTY (宁缺勿滥 — a fabricated
105
+ * lesson is noise the approval gate then has to burn down). The lane stamps
106
+ * confidence='hint' + source='distilled' itself; promotion to established is
107
+ * the B4 approval gate's exclusive write path (setLessonStatus).
108
+ */
109
+ export const LESSON_PROMPT = [
110
+ '你是 lesson 记忆库的蒸馏引擎。输入是若干条来自改进回路的结构化信号观察(JSON,只含特征与计数,无对话原文)和现有 established lesson 清单。',
111
+ '任务:把信号提炼成行为教训(lesson)操作。只输出一个 JSON 对象,不要任何其他文字、Markdown 代码块或解释。',
112
+ '判定问句:「违反它会犯错误吗?」——只有当信号指向一条可执行的行为规则(遵守或违反它会直接改变做事的对错)才立 lesson;只是事实、环境描述或给不出行动指令的信号批没有教训可立:明确输出 {"ops":[]}(宁缺勿滥,禁止为凑数编造教训)。',
113
+ '输出格式:{"ops": [...]},每个元素只允许 create:',
114
+ ' {"op":"create","kind":"lesson","title":"条件式短句(什么情境下)","triggers":["condition 触发词,恰好 3-8 个具体短语"],',
115
+ ' "conclusion":"action:怎么做,祈使句,自含可执行","scope":"workspace 或 global","observed_ids":["obs-..."]}',
116
+ '规则:',
117
+ '- 硬性要求:每个 op 必须带 observed_ids 字段,值只能从本次输入「信号观察」里列出的 id 中逐字复制(形如 "obs-...");含列表之外 id 的条目会被过滤;observed_ids 缺失、为空或过滤后不剩任何有效 id 的 op 会被整体丢弃。',
118
+ '- 硬性要求:每个 create 必须带 triggers(恰好 3-8 个「什么情境出现时应该想起这条教训」的 condition 触发短语)与 conclusion(祈使句行动指令);缺失或数量不符的 create 会被补问一次,补全后仍不合规的 op 会被丢弃并落账。',
119
+ '- title 写情境侧(条件式短句),conclusion 写行动侧(怎么做,祈使句);confidence 一律 hint、来源一律 distilled,由系统落库时固定,不要输出这两个字段。',
120
+ '- scope:只在本工作区适用的教训用 "workspace"(默认),跨项目普适的才用 "global"。',
121
+ '- 矛盾自查:输出前对照输入里的「现有 established lesson 清单」——若候选教训与某条已有教训在同一情境下给出相反做法,照常输出草稿并加 "conflictsWith":["该教训的slug"] 标记(只能引用清单里的 slug;是否新覆旧由审批门裁决,不要因此放弃输出)。',
122
+ '- 同一情境只允许一个 create;中文写。',
123
+ ].join('\n')
124
+
125
+ /** Which prompt branch a batch rides: the standing topic lane or the B-line
126
+ * lesson lane (fed+signal observations only). */
127
+ type DistillBranch = 'topic' | 'lesson'
128
+
129
+ /**
130
+ * The B-line routing predicate (B2 决议 3①): the write seam stamps fed
131
+ * structured signals `kind:'signal'` + `source:'fed'` (feedObservations);
132
+ * exactly that combination rides the lesson prompt branch. Every other
133
+ * observation keeps the topic prompt — the topic lane's behavior is
134
+ * byte-for-byte unchanged.
135
+ */
136
+ function isFedSignal(o: ObservationLike): boolean {
137
+ return o.source === 'fed' && o.kind === 'signal'
138
+ }
139
+
89
140
  /** Adaptive-batch floor — below this, shrinking cannot rescue an output limit. */
90
141
  const MIN_BATCH_SIZE = 5
91
142
  /** Fallbacks when the config fields are absent (bare test harnesses). */
@@ -121,6 +172,8 @@ interface BatchOutcome {
121
172
  invalidOps?: number
122
173
  /** links stripped by the whitelist (A2) this batch. */
123
174
  rejectedLinks?: number
175
+ /** lesson-conflict-flagged events ledgered this batch (lesson branch only). */
176
+ conflictsFlagged?: number
124
177
  detail?: string
125
178
  /**
126
179
  * Specific zero-consumption explanation for the stalled stop (undefined =
@@ -187,6 +240,29 @@ function checkCreateSchema(op: DistillOp): { valid: boolean; missing: string } {
187
240
  return { valid: problems.length === 0, missing: problems.join('、') }
188
241
  }
189
242
 
243
+ /**
244
+ * The lesson-branch schema check (B2): a lesson create must carry the
245
+ * condition (triggers 3-8 — the same strong-field contract as topics) and the
246
+ * action (conclusion, the 祈使句 imperative). `description` is NOT required
247
+ * on lessons — the §6.2 frontmatter contract is scope/confidence/source and
248
+ * the title carries the condition side. An ABSENT scope is not a defect (the
249
+ * write defaults it to workspace — lesson 必带 scope is a storage invariant
250
+ * enforced at saveTopic, not a model-output demand); a present-but-illegal
251
+ * scope is.
252
+ */
253
+ function checkLessonSchema(op: DistillOp): { valid: boolean; missing: string } {
254
+ const problems: string[] = []
255
+ if (typeof op.conclusion !== 'string' || op.conclusion.trim() === '') problems.push('conclusion(action)')
256
+ const triggers = Array.isArray(op.triggers) ? op.triggers.filter((t): t is string => typeof t === 'string' && t.trim() !== '') : []
257
+ if (triggers.length < TRIGGERS_MIN || triggers.length > TRIGGERS_MAX) {
258
+ problems.push(`triggers(实际 ${triggers.length},需 ${TRIGGERS_MIN}-${TRIGGERS_MAX})`)
259
+ }
260
+ if (op.scope !== undefined && op.scope !== 'workspace' && op.scope !== 'global') {
261
+ problems.push('scope(需 workspace|global)')
262
+ }
263
+ return { valid: problems.length === 0, missing: problems.join('、') }
264
+ }
265
+
190
266
  export class Distiller {
191
267
  private inFlight = new Map<string, Promise<DistillResult>>()
192
268
  private readonly service: TopicsService
@@ -327,6 +403,7 @@ export class Distiller {
327
403
  let filteredTotal = 0
328
404
  let invalidTotal = 0
329
405
  let rejectedTotal = 0
406
+ let conflictTotal = 0
330
407
  let stallDetail: string | undefined
331
408
  // GC bookkeeping (store.recordUnconsumed): every id the model EVALUATED
332
409
  // this run (parseable answer, however useless) minus the ids an op
@@ -351,14 +428,25 @@ export class Distiller {
351
428
  stopped = 'budget'
352
429
  break
353
430
  }
431
+ // B-line routing (lesson distill branch): fed+signal observations carry
432
+ // the self-improve structured signals and distill through the lesson
433
+ // prompt branch into lesson ops; every other observation keeps the topic
434
+ // prompt byte-for-byte. A mixed window runs ONE sub-pool per iteration —
435
+ // the topic sub-pool first (the topic lane keeps its claim on the batch
436
+ // head and its existing stop semantics); the other sub-pool stays
437
+ // pending in the pool and is fetched by the next iteration.
438
+ const topicObs = observations.filter((o) => !isFedSignal(o))
439
+ const branch: DistillBranch = topicObs.length > 0 ? 'topic' : 'lesson'
440
+ const pool = topicObs.length > 0 ? topicObs : observations
354
441
  // The batch spends 1 call, or 2 when the corrective observed_ids retry
355
442
  // fires — the budget check above already guaranteed room for both.
356
- const outcome = await this.runBatch(sessionId, observations, maxCalls - calls)
443
+ const outcome = await this.runBatch(sessionId, pool, maxCalls - calls, branch)
357
444
  calls += Math.max(1, outcome.callsUsed ?? 1)
358
445
  for (const id of outcome.consumedIds ?? []) consumedRunIds.add(id)
359
446
  filteredTotal += outcome.filteredIds ?? 0
360
447
  invalidTotal += outcome.invalidOps ?? 0
361
448
  rejectedTotal += outcome.rejectedLinks ?? 0
449
+ conflictTotal += outcome.conflictsFlagged ?? 0
362
450
  created.push(...outcome.created)
363
451
  updated.push(...outcome.updated)
364
452
  marked += outcome.marked
@@ -379,7 +467,7 @@ export class Distiller {
379
467
  // Floor reached: repeated output-limit finishes ARE an evaluation
380
468
  // verdict on this content (the model saw it and could not fit an
381
469
  // answer) — the batch counts toward the GC.
382
- for (const o of observations) fedIds.add(o.id)
470
+ for (const o of pool) fedIds.add(o.id)
383
471
  stopped = 'max-tokens'
384
472
  failureDetail = `批次无法再缩小(当前 ${this.batchSize} 条)仍触发输出上限:${outcome.detail ?? 'model finish: max-tokens'}`
385
473
  break
@@ -395,7 +483,7 @@ export class Distiller {
395
483
  }
396
484
  // Parseable answer (ops, empty or not): the batch was genuinely
397
485
  // evaluated — its unconsumed ids gain one failed attempt.
398
- for (const o of observations) fedIds.add(o.id)
486
+ for (const o of pool) fedIds.add(o.id)
399
487
  if (outcome.noOps) {
400
488
  stopped = 'no-ops'
401
489
  break
@@ -439,7 +527,8 @@ export class Distiller {
439
527
  const filteredNote = filteredTotal > 0 ? `;filtered ${filteredTotal} invalid observed_ids` : ''
440
528
  const schemaNote = invalidTotal > 0 ? `;schema-dropped ${invalidTotal} create op(s)` : ''
441
529
  const linkNote = rejectedTotal > 0 ? `;rejected ${rejectedTotal} invalid link ref(s)` : ''
442
- const notes = `${filteredNote}${schemaNote}${linkNote}`
530
+ const conflictNote = conflictTotal > 0 ? `;conflict-flagged ${conflictTotal} lesson draft(s)` : ''
531
+ const notes = `${filteredNote}${schemaNote}${linkNote}${conflictNote}`
443
532
  const base = head === undefined ? undefined : `partial: 已蒸馏标记 ${marked} 条观察(${calls} 次模型调用);${head}`
444
533
  result = {
445
534
  ok: true,
@@ -478,39 +567,68 @@ export class Distiller {
478
567
 
479
568
  /**
480
569
  * One model call (plus at most one corrective retry) over one batch:
481
- * payload build → ops → topic writes → marks. `budgetLeft` is the calls
482
- * still available to the run including this batch's first call (≥ 1); the
483
- * corrective retry only fires when it can stay inside that budget.
570
+ * payload build → ops → topic/lesson writes → marks. `budgetLeft` is the
571
+ * calls still available to the run including this batch's first call (≥ 1);
572
+ * the corrective retry only fires when it can stay inside that budget.
573
+ * `branch` picks the prompt and the payload: the topic branch carries the
574
+ * topic index (ops may update slugs), the lesson branch carries the
575
+ * established-lesson baseline for the conflict self-check instead — lesson
576
+ * ops never reference the topic roster. Both branches share the
577
+ * observed_ids sanitation, the schema gate, the corrective retry, the GC
578
+ * and the marks; only the payload/system/op-shape differ.
484
579
  */
485
580
  private async runBatch(
486
581
  sessionId: string | undefined,
487
582
  observations: readonly ObservationLike[],
488
583
  budgetLeft: number,
584
+ branch: DistillBranch,
489
585
  ): Promise<BatchOutcome> {
586
+ const lesson = branch === 'lesson'
587
+ const system = lesson ? LESSON_PROMPT : SYSTEM_PROMPT
490
588
  const metas = await this.service.store.listTopics()
491
- // Real open questions need the docs; fetch for index (bounded).
492
- const indexDetailed = []
493
- for (const m of metas.slice(0, 100)) {
494
- const doc = await this.service.store.readTopic(m.slug).catch(() => undefined)
495
- indexDetailed.push({
496
- slug: m.slug,
497
- title: m.title,
498
- status: m.status,
499
- tags: m.tags,
500
- open_questions: doc?.fm.open_questions ?? [],
501
- conclusion: (okf.firstParagraph(okf.sectionOf(doc?.body ?? '', okf.CONCLUSION_HEADING) ?? '')).slice(0, 200),
502
- })
589
+ let establishedSlugs: ReadonlySet<string> = new Set<string>()
590
+ let user: string
591
+ if (lesson) {
592
+ // Lesson branch payload: the conflict self-check baseline (B2 决议 5)
593
+ // rides IN the prompt — a same-prompt self-check, not a separate slow
594
+ // call; the B4 gate owns any 新覆旧 disposition afterwards.
595
+ const established = await this.establishedLessonBriefs(metas)
596
+ establishedSlugs = new Set(established.map((l) => l.slug))
597
+ const observationsPayload = observations.map((o) => ({ id: o.id, kind: o.kind, source: o.source, text: o.text }))
598
+ user = [
599
+ `现有 established lesson 清单(${established.length} 条):`,
600
+ JSON.stringify(established),
601
+ '',
602
+ `信号观察(${observationsPayload.length} 条):`,
603
+ JSON.stringify(observationsPayload),
604
+ '',
605
+ '请输出蒸馏结果(严格 JSON,{"ops":[...]}):',
606
+ ].join('\n')
607
+ } else {
608
+ // Real open questions need the docs; fetch for index (bounded).
609
+ const indexDetailed = []
610
+ for (const m of metas.slice(0, 100)) {
611
+ const doc = await this.service.store.readTopic(m.slug).catch(() => undefined)
612
+ indexDetailed.push({
613
+ slug: m.slug,
614
+ title: m.title,
615
+ status: m.status,
616
+ tags: m.tags,
617
+ open_questions: doc?.fm.open_questions ?? [],
618
+ conclusion: (okf.firstParagraph(okf.sectionOf(doc?.body ?? '', okf.CONCLUSION_HEADING) ?? '')).slice(0, 200),
619
+ })
620
+ }
621
+ const observationsPayload = observations.map((o) => ({ id: o.id, kind: o.kind, source: o.source, text: o.text }))
622
+ user = [
623
+ `现有 topic 索引(${indexDetailed.length} 个):`,
624
+ JSON.stringify(indexDetailed),
625
+ '',
626
+ `未蒸馏观察(${observationsPayload.length} 条):`,
627
+ JSON.stringify(observationsPayload),
628
+ '',
629
+ '请输出蒸馏结果(严格 JSON,{"ops":[...]}):',
630
+ ].join('\n')
503
631
  }
504
- const observationsPayload = observations.map((o) => ({ id: o.id, kind: o.kind, source: o.source, text: o.text }))
505
- const user = [
506
- `现有 topic 索引(${indexDetailed.length} 个):`,
507
- JSON.stringify(indexDetailed),
508
- '',
509
- `未蒸馏观察(${observationsPayload.length} 条):`,
510
- JSON.stringify(observationsPayload),
511
- '',
512
- '请输出蒸馏结果(严格 JSON,{"ops":[...]}):',
513
- ].join('\n')
514
632
  let raw: string
515
633
  const caller = this.caller
516
634
  if (caller === undefined) {
@@ -519,7 +637,7 @@ export class Distiller {
519
637
  return { created: [], updated: [], marked: 0, fatalReason: 'model-error', detail: 'distill caller unavailable' }
520
638
  }
521
639
  try {
522
- raw = await caller({ system: SYSTEM_PROMPT, user, purpose: 'topics-distill', sessionId, maxTokens: 4000 })
640
+ raw = await caller({ system, user, purpose: 'topics-distill', sessionId, maxTokens: 4000 })
523
641
  } catch (e) {
524
642
  const detail = String(e instanceof Error ? e.message : e).slice(0, 200)
525
643
  return isMaxTokens(e)
@@ -538,6 +656,15 @@ export class Distiller {
538
656
  detail: String(e instanceof Error ? e.message : e).slice(0, 200),
539
657
  }
540
658
  }
659
+ if (lesson) {
660
+ // The lesson branch drafts NEW lessons only — a lesson "update" would
661
+ // rewrite another lane's lifecycle state (confidence/status transitions
662
+ // are the B4 approval gate's exclusive write path), so non-create shapes
663
+ // are dropped before the gate. Their observations stay unconsumed and
664
+ // walk the existing GC path; an all-update batch resolves to the honest
665
+ // no-ops stop.
666
+ ops = ops.filter((o) => o.op === 'create')
667
+ }
541
668
  if (ops.length === 0) {
542
669
  return { created: [], updated: [], marked: 0, noOps: true, callsUsed: 1 }
543
670
  }
@@ -545,7 +672,7 @@ export class Distiller {
545
672
  // 补问 retry (budget-guarded), still-deficient ops are dropped and
546
673
  // ledgered — never a silent drop. Their observations simply stay
547
674
  // unconsumed and walk the existing GC/corrective path (three strikes).
548
- const schema = await this.enforceSchema(sessionId, user, ops, budgetLeft)
675
+ const schema = await this.enforceSchema(sessionId, system, user, ops, budgetLeft, branch)
549
676
  let callsUsed = 1 + schema.retrySpent
550
677
  for (const drop of schema.drops) {
551
678
  await recordPipelineEvent(this.service.store.root, {
@@ -561,9 +688,15 @@ export class Distiller {
561
688
  marked: 0,
562
689
  callsUsed,
563
690
  invalidOps: schema.drops.length,
564
- stallDetail: `全部 create op 未通过 schema 校验(description 必填 + triggers 3-8 条),已丢弃并落账(${schema.drops.length} 个)`,
691
+ stallDetail: lesson
692
+ ? `全部 lesson op 未通过 schema 校验(triggers 3-8 条 + conclusion(action) 必填 + scope 枚举),已丢弃并落账(${schema.drops.length} 个)`
693
+ : `全部 create op 未通过 schema 校验(description 必填 + triggers 3-8 条),已丢弃并落账(${schema.drops.length} 个)`,
565
694
  }
566
695
  }
696
+ // B2 conflict self-check ledgering: the draft still lands as a hint —
697
+ // the flag is for the approval surface, never a lifecycle write.
698
+ let conflictsFlagged = 0
699
+ if (lesson) conflictsFlagged = await this.ledgerLessonConflicts(schema.finalOps, establishedSlugs)
567
700
  // observed_ids are the ONLY bridge between ops and marks — without a valid
568
701
  // id the store cannot mark anything and the batch head can never advance
569
702
  // (the real-machine zero-consumption livelock: topics landed, backlog
@@ -597,7 +730,7 @@ export class Distiller {
597
730
  }
598
731
  }
599
732
  const pass1 = applied
600
- const { applied: pass2, why } = await this.correctiveRetry(sessionId, user, idList, validIds, knownSlugs)
733
+ const { applied: pass2, why } = await this.correctiveRetry(sessionId, system, user, idList, validIds, knownSlugs)
601
734
  callsUsed += 1
602
735
  if (pass2 === undefined || pass2.consumedIds.length === 0) {
603
736
  const tail =
@@ -638,10 +771,65 @@ export class Distiller {
638
771
  filteredIds: applied.filteredIds,
639
772
  invalidOps: schema.drops.length,
640
773
  rejectedLinks: applied.rejectedLinks,
774
+ conflictsFlagged,
641
775
  consumedIds: applied.consumedIds,
642
776
  }
643
777
  }
644
778
 
779
+ /**
780
+ * The conflict self-check baseline (B2 决议 5): established lessons ONLY —
781
+ * hint drafts are not yet norms, so there is nothing for a candidate to
782
+ * contradict. Each brief carries the lesson's condition surface (title +
783
+ * triggers) and its slug (the only legal conflictsWith reference). Bounded
784
+ * reads like the topic index (first 100 roster entries); established
785
+ * lessons are post-gate artifacts and stay a handful in practice.
786
+ */
787
+ private async establishedLessonBriefs(
788
+ metas: readonly { slug: string; title: string }[],
789
+ ): Promise<{ slug: string; title: string; triggers: string[] }[]> {
790
+ const out: { slug: string; title: string; triggers: string[] }[] = []
791
+ for (const m of metas.slice(0, 100)) {
792
+ const doc = await this.service.store.readTopic(m.slug).catch(() => undefined)
793
+ if (doc === undefined) continue
794
+ if (okf.topicKindOf(doc.fm.type) !== 'lesson') continue
795
+ if ((doc.fm.confidence ?? 'hint') !== 'established') continue
796
+ out.push({ slug: m.slug, title: m.title, triggers: doc.fm.triggers ?? [] })
797
+ }
798
+ return out
799
+ }
800
+
801
+ /**
802
+ * B2 conflict self-check, LEDGER-ONLY disposition: a draft that declares
803
+ * `conflictsWith` still lands as a hint draft (新覆旧 is the B4 approval
804
+ * gate's call, never the distill lane's). Each declared slug is
805
+ * whitelist-checked against the established baseline the prompt carried;
806
+ * roster-unknown slugs are model noise and are dropped without an event (a
807
+ * hallucinated slug must not fabricate a conflict). The event is a flag for
808
+ * the approval surface — no lifecycle state changes here.
809
+ */
810
+ private async ledgerLessonConflicts(
811
+ ops: readonly DistillOp[],
812
+ establishedSlugs: ReadonlySet<string>,
813
+ ): Promise<number> {
814
+ let flagged = 0
815
+ for (const op of ops) {
816
+ if (!Array.isArray(op.conflictsWith) || op.conflictsWith.length === 0) continue
817
+ const valid = [...new Set(op.conflictsWith)].filter(
818
+ (s): s is string => typeof s === 'string' && establishedSlugs.has(s),
819
+ )
820
+ if (valid.length === 0) continue
821
+ await recordPipelineEvent(this.service.store.root, {
822
+ at: new Date().toISOString(),
823
+ kind: 'lesson-conflict-flagged',
824
+ // detail values are scalar-only (pipeline-events whitelist): the slug
825
+ // list rides as a comma join, the raw declaration count rides as a number.
826
+ detail: { op: opKey(op), conflictsWith: valid.join(','), declared: op.conflictsWith.length },
827
+ })
828
+ flagged += valid.length
829
+ }
830
+ return flagged
831
+ }
832
+
645
833
  /**
646
834
  * The ONE corrective retry for a zero-valid-observed_ids batch: re-ask with
647
835
  * the batch's legal id list spelled out verbatim. It spends a second call
@@ -652,6 +840,7 @@ export class Distiller {
652
840
  */
653
841
  private async correctiveRetry(
654
842
  sessionId: string | undefined,
843
+ system: string,
655
844
  originalUser: string,
656
845
  idList: readonly string[],
657
846
  validIds: ReadonlySet<string>,
@@ -670,7 +859,7 @@ export class Distiller {
670
859
  ].join('\n')
671
860
  let raw: string
672
861
  try {
673
- raw = await caller({ system: SYSTEM_PROMPT, user, purpose: 'topics-distill', sessionId, maxTokens: 4000 })
862
+ raw = await caller({ system, user, purpose: 'topics-distill', sessionId, maxTokens: 4000 })
674
863
  } catch (e) {
675
864
  return { why: `模型调用失败:${String(e instanceof Error ? e.message : e).slice(0, 160)}` }
676
865
  }
@@ -699,10 +888,14 @@ export class Distiller {
699
888
  */
700
889
  private async enforceSchema(
701
890
  sessionId: string | undefined,
891
+ system: string,
702
892
  originalUser: string,
703
893
  ops: readonly DistillOp[],
704
894
  budgetLeft: number,
895
+ branch: DistillBranch,
705
896
  ): Promise<{ finalOps: DistillOp[]; drops: SchemaDrop[]; retrySpent: number }> {
897
+ const lesson = branch === 'lesson'
898
+ const check = lesson ? checkLessonSchema : checkCreateSchema
706
899
  const valid: DistillOp[] = []
707
900
  const invalid: { op: DistillOp; key: string; missing: string }[] = []
708
901
  for (const op of ops) {
@@ -710,9 +903,9 @@ export class Distiller {
710
903
  valid.push(op)
711
904
  continue
712
905
  }
713
- const check = checkCreateSchema(op)
714
- if (check.valid) valid.push(op)
715
- else invalid.push({ op, key: opKey(op), missing: check.missing })
906
+ const verdict = check(op)
907
+ if (verdict.valid) valid.push(op)
908
+ else invalid.push({ op, key: opKey(op), missing: verdict.missing })
716
909
  }
717
910
  if (invalid.length === 0) return { finalOps: valid, drops: [], retrySpent: 0 }
718
911
  if (budgetLeft < 2) {
@@ -726,7 +919,7 @@ export class Distiller {
726
919
  retrySpent: 0,
727
920
  }
728
921
  }
729
- const rescued = await this.schemaRetryCall(sessionId, originalUser, invalid)
922
+ const rescued = await this.schemaRetryCall(sessionId, system, originalUser, invalid, branch)
730
923
  if (rescued === undefined) {
731
924
  return {
732
925
  finalOps: valid,
@@ -741,7 +934,7 @@ export class Distiller {
741
934
  const drops: SchemaDrop[] = []
742
935
  for (const c of invalid) {
743
936
  const replacement = rescued.get(c.key)
744
- if (replacement !== undefined && checkCreateSchema(replacement).valid) {
937
+ if (replacement !== undefined && check(replacement).valid) {
745
938
  valid.push(replacement)
746
939
  } else {
747
940
  drops.push({ key: c.key, kind: 'distill-op-invalid', missing: c.missing })
@@ -758,23 +951,31 @@ export class Distiller {
758
951
  */
759
952
  private async schemaRetryCall(
760
953
  sessionId: string | undefined,
954
+ system: string,
761
955
  originalUser: string,
762
956
  invalid: readonly { op: DistillOp; key: string; missing: string }[],
957
+ branch: DistillBranch,
763
958
  ): Promise<Map<string, DistillOp> | undefined> {
764
959
  const caller = this.caller
765
960
  if (caller === undefined) return undefined
766
961
  const list = invalid.map((c) => `- create「${typeof c.op.title === 'string' ? c.op.title : ''}」(缺:${c.missing})`).join('\n')
962
+ // The completion demand names the branch's own required fields; the topic
963
+ // line is byte-identical to its pre-lesson-branch form.
964
+ const instruction =
965
+ branch === 'lesson'
966
+ ? '请只重新输出这些 op 的补全版本(严格 JSON,{"ops":[...]}):每个 create 必须带 triggers(恰好 3-8 个 condition 触发短语)与 conclusion(祈使句行动指令),scope 只能是 workspace 或 global,并保留原有的 title/observed_ids 等字段。无法补全的 op 不要输出。'
967
+ : '请只重新输出这些 op 的补全版本(严格 JSON,{"ops":[...]}):每个 create 必须带 description(一句话概括)与 triggers(恰好 3-8 条具体触发短语,禁止 dsh、配置、错误 之类的泛词),并保留原有的 title/conclusion/observed_ids 等字段。无法补全的 op 不要输出。'
767
968
  const user = [
768
969
  originalUser,
769
970
  '',
770
971
  '---',
771
972
  'schema 补问:你上一次返回的以下 create op 缺少必填字段:',
772
973
  list,
773
- '请只重新输出这些 op 的补全版本(严格 JSON,{"ops":[...]}):每个 create 必须带 description(一句话概括)与 triggers(恰好 3-8 条具体触发短语,禁止 dsh、配置、错误 之类的泛词),并保留原有的 title/conclusion/observed_ids 等字段。无法补全的 op 不要输出。',
974
+ instruction,
774
975
  ].join('\n')
775
976
  let raw: string
776
977
  try {
777
- raw = await caller({ system: SYSTEM_PROMPT, user, purpose: 'topics-distill', sessionId, maxTokens: 4000 })
978
+ raw = await caller({ system, user, purpose: 'topics-distill', sessionId, maxTokens: 4000 })
778
979
  } catch {
779
980
  return undefined
780
981
  }
@@ -810,6 +1011,11 @@ export class Distiller {
810
1011
  * the store transaction (先落 topic 再建边 — one write, no separate edge
811
1012
  * pass). The set is locally seeded with this call's predicted op slugs and
812
1013
  * grows as creates land, so same-batch references resolve in op order.
1014
+ *
1015
+ * Lesson creates (B2) ride the same sanitation and consume their
1016
+ * observations through the same observed_ids bridge; the write goes through
1017
+ * saveTopic's lesson branch (kind:'lesson', confidence hint, distilled
1018
+ * provenance) — the observation→mark closure is identical to topics.
813
1019
  */
814
1020
  private async applyOps(
815
1021
  ops: readonly DistillOp[],
@@ -851,20 +1057,40 @@ export class Distiller {
851
1057
  if (op.op === 'create' && typeof op.title === 'string' && typeof op.conclusion === 'string') {
852
1058
  const links = await this.acceptLinks(op, slugs)
853
1059
  applied.rejectedLinks += links.rejected
854
- const res = await this.service.saveTopic({
855
- title: op.title,
856
- conclusion: op.conclusion,
857
- description: op.description,
858
- tags: op.tags,
859
- triggers: Array.isArray(op.triggers) ? op.triggers.filter((t): t is string => typeof t === 'string') : undefined,
860
- depends: op.depends,
861
- openQuestions: op.open_questions,
862
- impact: op.impact,
863
- recommendations: op.recommendations,
864
- status: op.status === 'stable' ? 'stable' : 'draft',
865
- source: 'distill',
866
- links: links.targets,
867
- })
1060
+ const res =
1061
+ op.kind === 'lesson'
1062
+ ? // Lesson data plane (B2 §6.2): the draft lands as a hint with
1063
+ // distilled provenance — status derives from confidence, and
1064
+ // promotion to established is the B4 approval gate's exclusive
1065
+ // write path (setLessonStatus), so `status` is NOT taken from
1066
+ // the op. Scope: the op's explicit choice, default workspace
1067
+ // (lesson 必带 scope, global 需显式).
1068
+ await this.service.saveTopic({
1069
+ title: op.title,
1070
+ kind: 'lesson',
1071
+ conclusion: op.conclusion,
1072
+ description: op.description,
1073
+ triggers: Array.isArray(op.triggers) ? op.triggers.filter((t): t is string => typeof t === 'string') : undefined,
1074
+ scope: op.scope === 'global' ? 'global' : 'workspace',
1075
+ confidence: 'hint',
1076
+ lessonSource: 'distilled',
1077
+ source: 'distill',
1078
+ links: links.targets,
1079
+ })
1080
+ : await this.service.saveTopic({
1081
+ title: op.title,
1082
+ conclusion: op.conclusion,
1083
+ description: op.description,
1084
+ tags: op.tags,
1085
+ triggers: Array.isArray(op.triggers) ? op.triggers.filter((t): t is string => typeof t === 'string') : undefined,
1086
+ depends: op.depends,
1087
+ openQuestions: op.open_questions,
1088
+ impact: op.impact,
1089
+ recommendations: op.recommendations,
1090
+ status: op.status === 'stable' ? 'stable' : 'draft',
1091
+ source: 'distill',
1092
+ links: links.targets,
1093
+ })
868
1094
  applied.created.push(res.slug)
869
1095
  applied.consumedSlugs.push(res.slug)
870
1096
  slugs.add(res.slug)
package/src/index.ts CHANGED
@@ -891,10 +891,11 @@ export async function apply(ctx: Context, config: TopicsRuntimeConfig = {}): Pro
891
891
  // `agent/created` listener and a throw or rejection ROLLS THE ENTIRE AGENT
892
892
  // CREATION BACK. This listener is therefore total and synchronous — it
893
893
  // never throws, never returns a promise — and the chain is deferred off
894
- // the creation transaction entirely (setImmediate, unref'd): the pull /
894
+ // the creation transaction entirely (1.5s unref'd timer): the pull /
895
895
  // replay-distill / consolidation slow work starts only after the serial
896
- // dispatch has moved on. Plugin initialization must never block, delay,
897
- // or abort session startup; a chain failure at most warns.
896
+ // dispatch has moved on and the first frame is up. Plugin initialization
897
+ // must never block, delay, or abort session startup; a chain failure at
898
+ // most warns.
898
899
  const runBootChain = (sessionId: string) => {
899
900
  // Contained at the top too: a synchronous prologue throw (config read,
900
901
  // sync bookkeeping) must degrade to a warn, not leak toward the host.
@@ -987,11 +988,14 @@ export async function apply(ctx: Context, config: TopicsRuntimeConfig = {}): Pro
987
988
  try {
988
989
  const sessionId = String(payload?.agent?.id ?? '')
989
990
  if (sessionId === '') return
990
- // Off the creation transaction: the unref'd immediate fires after the
991
- // current serial dispatch slice, so the chain's synchronous prologue
992
- // (config read, sync state) never delays the remaining listeners nor
993
- // the loop start, and the timer cannot hold a dying process open.
994
- const deferred = setImmediate(() => runBootChain(sessionId))
991
+ // Off the creation transaction AND off the boot window: the unref'd
992
+ // 1.5s timer fires after the serial dispatch has fully moved on and
993
+ // after the host's first frame, so the pull's git/DNS child processes
994
+ // never compete with startup I/O on a cold (possibly network-starved)
995
+ // machine — the 2026-10-19 boot-stall report showed the boot window
996
+ // and the fetch share the same constrained threadpool on a flaky VPN.
997
+ // The timer cannot hold a dying process open.
998
+ const deferred = setTimeout(() => runBootChain(sessionId), 1500)
995
999
  deferred.unref?.()
996
1000
  } catch (error) {
997
1001
  warn(`dsh-topics-memory boot 链触发失败(不影响会话启动):${error instanceof Error ? error.message : String(error)}`)