@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.
package/lib/distill.d.ts CHANGED
@@ -42,6 +42,16 @@ export interface DistillOp {
42
42
  conclusion?: string;
43
43
  recommendations?: string;
44
44
  status?: string;
45
+ /** Lesson distill branch (B2): lesson-kind create op — a behavior lesson. */
46
+ kind?: string;
47
+ /** Lesson scope (B2 §6.2, lesson 必带; the write defaults absent → workspace). */
48
+ scope?: string;
49
+ /**
50
+ * Conflict self-check markers (B2 决议 5, baseline): established lesson
51
+ * slugs from the prompt's baseline list that this draft contradicts. The
52
+ * draft still lands — the flag is ledgered for the B4 approval gate.
53
+ */
54
+ conflictsWith?: string[];
45
55
  observed_ids?: string[];
46
56
  }
47
57
  export interface DistillResult {
@@ -55,6 +65,17 @@ export interface DistillResult {
55
65
  detail?: string;
56
66
  }
57
67
  export declare const SYSTEM_PROMPT: string;
68
+ /**
69
+ * The lesson distill branch's system prompt (B2 决议 3①, spec 卷二 §3.1):
70
+ * fed structured signals (`kind:'signal'` + `source:'fed'`, feature lines
71
+ * only — 对话原文结构上进不来) distill into behavior LESSONS, not factual
72
+ * topics. The 判定问句 is the gate: a signal batch that cannot yield an
73
+ * executable behavioral rule must come back EMPTY (宁缺勿滥 — a fabricated
74
+ * lesson is noise the approval gate then has to burn down). The lane stamps
75
+ * confidence='hint' + source='distilled' itself; promotion to established is
76
+ * the B4 approval gate's exclusive write path (setLessonStatus).
77
+ */
78
+ export declare const LESSON_PROMPT: string;
58
79
  export declare class Distiller {
59
80
  private inFlight;
60
81
  private readonly service;
@@ -91,11 +112,36 @@ export declare class Distiller {
91
112
  private runInner;
92
113
  /**
93
114
  * One model call (plus at most one corrective retry) over one batch:
94
- * payload build → ops → topic writes → marks. `budgetLeft` is the calls
95
- * still available to the run including this batch's first call (≥ 1); the
96
- * corrective retry only fires when it can stay inside that budget.
115
+ * payload build → ops → topic/lesson writes → marks. `budgetLeft` is the
116
+ * calls still available to the run including this batch's first call (≥ 1);
117
+ * the corrective retry only fires when it can stay inside that budget.
118
+ * `branch` picks the prompt and the payload: the topic branch carries the
119
+ * topic index (ops may update slugs), the lesson branch carries the
120
+ * established-lesson baseline for the conflict self-check instead — lesson
121
+ * ops never reference the topic roster. Both branches share the
122
+ * observed_ids sanitation, the schema gate, the corrective retry, the GC
123
+ * and the marks; only the payload/system/op-shape differ.
97
124
  */
98
125
  private runBatch;
126
+ /**
127
+ * The conflict self-check baseline (B2 决议 5): established lessons ONLY —
128
+ * hint drafts are not yet norms, so there is nothing for a candidate to
129
+ * contradict. Each brief carries the lesson's condition surface (title +
130
+ * triggers) and its slug (the only legal conflictsWith reference). Bounded
131
+ * reads like the topic index (first 100 roster entries); established
132
+ * lessons are post-gate artifacts and stay a handful in practice.
133
+ */
134
+ private establishedLessonBriefs;
135
+ /**
136
+ * B2 conflict self-check, LEDGER-ONLY disposition: a draft that declares
137
+ * `conflictsWith` still lands as a hint draft (新覆旧 is the B4 approval
138
+ * gate's call, never the distill lane's). Each declared slug is
139
+ * whitelist-checked against the established baseline the prompt carried;
140
+ * roster-unknown slugs are model noise and are dropped without an event (a
141
+ * hallucinated slug must not fabricate a conflict). The event is a flag for
142
+ * the approval surface — no lifecycle state changes here.
143
+ */
144
+ private ledgerLessonConflicts;
99
145
  /**
100
146
  * The ONE corrective retry for a zero-valid-observed_ids batch: re-ask with
101
147
  * the batch's legal id list spelled out verbatim. It spends a second call
@@ -144,6 +190,11 @@ export declare class Distiller {
144
190
  * the store transaction (先落 topic 再建边 — one write, no separate edge
145
191
  * pass). The set is locally seeded with this call's predicted op slugs and
146
192
  * grows as creates land, so same-batch references resolve in op order.
193
+ *
194
+ * Lesson creates (B2) ride the same sanitation and consume their
195
+ * observations through the same observed_ids bridge; the write goes through
196
+ * saveTopic's lesson branch (kind:'lesson', confidence hint, distilled
197
+ * provenance) — the observation→mark closure is identical to topics.
147
198
  */
148
199
  private applyOps;
149
200
  /**
package/lib/distill.js CHANGED
@@ -28,6 +28,41 @@ export const SYSTEM_PROMPT = [
28
28
  '- 观察里有价值就沉淀,没价值就跳过该观察(被跳过观察的 id 不要出现在任何 op 里);没有可沉淀内容时输出 {"ops":[]}。',
29
29
  '- 中文主题用中文写;tags 全小写。',
30
30
  ].join('\n');
31
+ /**
32
+ * The lesson distill branch's system prompt (B2 决议 3①, spec 卷二 §3.1):
33
+ * fed structured signals (`kind:'signal'` + `source:'fed'`, feature lines
34
+ * only — 对话原文结构上进不来) distill into behavior LESSONS, not factual
35
+ * topics. The 判定问句 is the gate: a signal batch that cannot yield an
36
+ * executable behavioral rule must come back EMPTY (宁缺勿滥 — a fabricated
37
+ * lesson is noise the approval gate then has to burn down). The lane stamps
38
+ * confidence='hint' + source='distilled' itself; promotion to established is
39
+ * the B4 approval gate's exclusive write path (setLessonStatus).
40
+ */
41
+ export const LESSON_PROMPT = [
42
+ '你是 lesson 记忆库的蒸馏引擎。输入是若干条来自改进回路的结构化信号观察(JSON,只含特征与计数,无对话原文)和现有 established lesson 清单。',
43
+ '任务:把信号提炼成行为教训(lesson)操作。只输出一个 JSON 对象,不要任何其他文字、Markdown 代码块或解释。',
44
+ '判定问句:「违反它会犯错误吗?」——只有当信号指向一条可执行的行为规则(遵守或违反它会直接改变做事的对错)才立 lesson;只是事实、环境描述或给不出行动指令的信号批没有教训可立:明确输出 {"ops":[]}(宁缺勿滥,禁止为凑数编造教训)。',
45
+ '输出格式:{"ops": [...]},每个元素只允许 create:',
46
+ ' {"op":"create","kind":"lesson","title":"条件式短句(什么情境下)","triggers":["condition 触发词,恰好 3-8 个具体短语"],',
47
+ ' "conclusion":"action:怎么做,祈使句,自含可执行","scope":"workspace 或 global","observed_ids":["obs-..."]}',
48
+ '规则:',
49
+ '- 硬性要求:每个 op 必须带 observed_ids 字段,值只能从本次输入「信号观察」里列出的 id 中逐字复制(形如 "obs-...");含列表之外 id 的条目会被过滤;observed_ids 缺失、为空或过滤后不剩任何有效 id 的 op 会被整体丢弃。',
50
+ '- 硬性要求:每个 create 必须带 triggers(恰好 3-8 个「什么情境出现时应该想起这条教训」的 condition 触发短语)与 conclusion(祈使句行动指令);缺失或数量不符的 create 会被补问一次,补全后仍不合规的 op 会被丢弃并落账。',
51
+ '- title 写情境侧(条件式短句),conclusion 写行动侧(怎么做,祈使句);confidence 一律 hint、来源一律 distilled,由系统落库时固定,不要输出这两个字段。',
52
+ '- scope:只在本工作区适用的教训用 "workspace"(默认),跨项目普适的才用 "global"。',
53
+ '- 矛盾自查:输出前对照输入里的「现有 established lesson 清单」——若候选教训与某条已有教训在同一情境下给出相反做法,照常输出草稿并加 "conflictsWith":["该教训的slug"] 标记(只能引用清单里的 slug;是否新覆旧由审批门裁决,不要因此放弃输出)。',
54
+ '- 同一情境只允许一个 create;中文写。',
55
+ ].join('\n');
56
+ /**
57
+ * The B-line routing predicate (B2 决议 3①): the write seam stamps fed
58
+ * structured signals `kind:'signal'` + `source:'fed'` (feedObservations);
59
+ * exactly that combination rides the lesson prompt branch. Every other
60
+ * observation keeps the topic prompt — the topic lane's behavior is
61
+ * byte-for-byte unchanged.
62
+ */
63
+ function isFedSignal(o) {
64
+ return o.source === 'fed' && o.kind === 'signal';
65
+ }
31
66
  /** Adaptive-batch floor — below this, shrinking cannot rescue an output limit. */
32
67
  const MIN_BATCH_SIZE = 5;
33
68
  /** Fallbacks when the config fields are absent (bare test harnesses). */
@@ -63,6 +98,29 @@ function checkCreateSchema(op) {
63
98
  }
64
99
  return { valid: problems.length === 0, missing: problems.join('、') };
65
100
  }
101
+ /**
102
+ * The lesson-branch schema check (B2): a lesson create must carry the
103
+ * condition (triggers 3-8 — the same strong-field contract as topics) and the
104
+ * action (conclusion, the 祈使句 imperative). `description` is NOT required
105
+ * on lessons — the §6.2 frontmatter contract is scope/confidence/source and
106
+ * the title carries the condition side. An ABSENT scope is not a defect (the
107
+ * write defaults it to workspace — lesson 必带 scope is a storage invariant
108
+ * enforced at saveTopic, not a model-output demand); a present-but-illegal
109
+ * scope is.
110
+ */
111
+ function checkLessonSchema(op) {
112
+ const problems = [];
113
+ if (typeof op.conclusion !== 'string' || op.conclusion.trim() === '')
114
+ problems.push('conclusion(action)');
115
+ const triggers = Array.isArray(op.triggers) ? op.triggers.filter((t) => typeof t === 'string' && t.trim() !== '') : [];
116
+ if (triggers.length < TRIGGERS_MIN || triggers.length > TRIGGERS_MAX) {
117
+ problems.push(`triggers(实际 ${triggers.length},需 ${TRIGGERS_MIN}-${TRIGGERS_MAX})`);
118
+ }
119
+ if (op.scope !== undefined && op.scope !== 'workspace' && op.scope !== 'global') {
120
+ problems.push('scope(需 workspace|global)');
121
+ }
122
+ return { valid: problems.length === 0, missing: problems.join('、') };
123
+ }
66
124
  export class Distiller {
67
125
  inFlight = new Map();
68
126
  service;
@@ -194,6 +252,7 @@ export class Distiller {
194
252
  let filteredTotal = 0;
195
253
  let invalidTotal = 0;
196
254
  let rejectedTotal = 0;
255
+ let conflictTotal = 0;
197
256
  let stallDetail;
198
257
  // GC bookkeeping (store.recordUnconsumed): every id the model EVALUATED
199
258
  // this run (parseable answer, however useless) minus the ids an op
@@ -218,15 +277,26 @@ export class Distiller {
218
277
  stopped = 'budget';
219
278
  break;
220
279
  }
280
+ // B-line routing (lesson distill branch): fed+signal observations carry
281
+ // the self-improve structured signals and distill through the lesson
282
+ // prompt branch into lesson ops; every other observation keeps the topic
283
+ // prompt byte-for-byte. A mixed window runs ONE sub-pool per iteration —
284
+ // the topic sub-pool first (the topic lane keeps its claim on the batch
285
+ // head and its existing stop semantics); the other sub-pool stays
286
+ // pending in the pool and is fetched by the next iteration.
287
+ const topicObs = observations.filter((o) => !isFedSignal(o));
288
+ const branch = topicObs.length > 0 ? 'topic' : 'lesson';
289
+ const pool = topicObs.length > 0 ? topicObs : observations;
221
290
  // The batch spends 1 call, or 2 when the corrective observed_ids retry
222
291
  // fires — the budget check above already guaranteed room for both.
223
- const outcome = await this.runBatch(sessionId, observations, maxCalls - calls);
292
+ const outcome = await this.runBatch(sessionId, pool, maxCalls - calls, branch);
224
293
  calls += Math.max(1, outcome.callsUsed ?? 1);
225
294
  for (const id of outcome.consumedIds ?? [])
226
295
  consumedRunIds.add(id);
227
296
  filteredTotal += outcome.filteredIds ?? 0;
228
297
  invalidTotal += outcome.invalidOps ?? 0;
229
298
  rejectedTotal += outcome.rejectedLinks ?? 0;
299
+ conflictTotal += outcome.conflictsFlagged ?? 0;
230
300
  created.push(...outcome.created);
231
301
  updated.push(...outcome.updated);
232
302
  marked += outcome.marked;
@@ -247,7 +317,7 @@ export class Distiller {
247
317
  // Floor reached: repeated output-limit finishes ARE an evaluation
248
318
  // verdict on this content (the model saw it and could not fit an
249
319
  // answer) — the batch counts toward the GC.
250
- for (const o of observations)
320
+ for (const o of pool)
251
321
  fedIds.add(o.id);
252
322
  stopped = 'max-tokens';
253
323
  failureDetail = `批次无法再缩小(当前 ${this.batchSize} 条)仍触发输出上限:${outcome.detail ?? 'model finish: max-tokens'}`;
@@ -264,7 +334,7 @@ export class Distiller {
264
334
  }
265
335
  // Parseable answer (ops, empty or not): the batch was genuinely
266
336
  // evaluated — its unconsumed ids gain one failed attempt.
267
- for (const o of observations)
337
+ for (const o of pool)
268
338
  fedIds.add(o.id);
269
339
  if (outcome.noOps) {
270
340
  stopped = 'no-ops';
@@ -310,7 +380,8 @@ export class Distiller {
310
380
  const filteredNote = filteredTotal > 0 ? `;filtered ${filteredTotal} invalid observed_ids` : '';
311
381
  const schemaNote = invalidTotal > 0 ? `;schema-dropped ${invalidTotal} create op(s)` : '';
312
382
  const linkNote = rejectedTotal > 0 ? `;rejected ${rejectedTotal} invalid link ref(s)` : '';
313
- const notes = `${filteredNote}${schemaNote}${linkNote}`;
383
+ const conflictNote = conflictTotal > 0 ? `;conflict-flagged ${conflictTotal} lesson draft(s)` : '';
384
+ const notes = `${filteredNote}${schemaNote}${linkNote}${conflictNote}`;
314
385
  const base = head === undefined ? undefined : `partial: 已蒸馏标记 ${marked} 条观察(${calls} 次模型调用);${head}`;
315
386
  result = {
316
387
  ok: true,
@@ -347,35 +418,64 @@ export class Distiller {
347
418
  }
348
419
  /**
349
420
  * One model call (plus at most one corrective retry) over one batch:
350
- * payload build → ops → topic writes → marks. `budgetLeft` is the calls
351
- * still available to the run including this batch's first call (≥ 1); the
352
- * corrective retry only fires when it can stay inside that budget.
421
+ * payload build → ops → topic/lesson writes → marks. `budgetLeft` is the
422
+ * calls still available to the run including this batch's first call (≥ 1);
423
+ * the corrective retry only fires when it can stay inside that budget.
424
+ * `branch` picks the prompt and the payload: the topic branch carries the
425
+ * topic index (ops may update slugs), the lesson branch carries the
426
+ * established-lesson baseline for the conflict self-check instead — lesson
427
+ * ops never reference the topic roster. Both branches share the
428
+ * observed_ids sanitation, the schema gate, the corrective retry, the GC
429
+ * and the marks; only the payload/system/op-shape differ.
353
430
  */
354
- async runBatch(sessionId, observations, budgetLeft) {
431
+ async runBatch(sessionId, observations, budgetLeft, branch) {
432
+ const lesson = branch === 'lesson';
433
+ const system = lesson ? LESSON_PROMPT : SYSTEM_PROMPT;
355
434
  const metas = await this.service.store.listTopics();
356
- // Real open questions need the docs; fetch for index (bounded).
357
- const indexDetailed = [];
358
- for (const m of metas.slice(0, 100)) {
359
- const doc = await this.service.store.readTopic(m.slug).catch(() => undefined);
360
- indexDetailed.push({
361
- slug: m.slug,
362
- title: m.title,
363
- status: m.status,
364
- tags: m.tags,
365
- open_questions: doc?.fm.open_questions ?? [],
366
- conclusion: (okf.firstParagraph(okf.sectionOf(doc?.body ?? '', okf.CONCLUSION_HEADING) ?? '')).slice(0, 200),
367
- });
435
+ let establishedSlugs = new Set();
436
+ let user;
437
+ if (lesson) {
438
+ // Lesson branch payload: the conflict self-check baseline (B2 决议 5)
439
+ // rides IN the prompt — a same-prompt self-check, not a separate slow
440
+ // call; the B4 gate owns any 新覆旧 disposition afterwards.
441
+ const established = await this.establishedLessonBriefs(metas);
442
+ establishedSlugs = new Set(established.map((l) => l.slug));
443
+ const observationsPayload = observations.map((o) => ({ id: o.id, kind: o.kind, source: o.source, text: o.text }));
444
+ user = [
445
+ `现有 established lesson 清单(${established.length} 条):`,
446
+ JSON.stringify(established),
447
+ '',
448
+ `信号观察(${observationsPayload.length} 条):`,
449
+ JSON.stringify(observationsPayload),
450
+ '',
451
+ '请输出蒸馏结果(严格 JSON,{"ops":[...]}):',
452
+ ].join('\n');
453
+ }
454
+ else {
455
+ // Real open questions need the docs; fetch for index (bounded).
456
+ const indexDetailed = [];
457
+ for (const m of metas.slice(0, 100)) {
458
+ const doc = await this.service.store.readTopic(m.slug).catch(() => undefined);
459
+ indexDetailed.push({
460
+ slug: m.slug,
461
+ title: m.title,
462
+ status: m.status,
463
+ tags: m.tags,
464
+ open_questions: doc?.fm.open_questions ?? [],
465
+ conclusion: (okf.firstParagraph(okf.sectionOf(doc?.body ?? '', okf.CONCLUSION_HEADING) ?? '')).slice(0, 200),
466
+ });
467
+ }
468
+ const observationsPayload = observations.map((o) => ({ id: o.id, kind: o.kind, source: o.source, text: o.text }));
469
+ user = [
470
+ `现有 topic 索引(${indexDetailed.length} 个):`,
471
+ JSON.stringify(indexDetailed),
472
+ '',
473
+ `未蒸馏观察(${observationsPayload.length} 条):`,
474
+ JSON.stringify(observationsPayload),
475
+ '',
476
+ '请输出蒸馏结果(严格 JSON,{"ops":[...]}):',
477
+ ].join('\n');
368
478
  }
369
- const observationsPayload = observations.map((o) => ({ id: o.id, kind: o.kind, source: o.source, text: o.text }));
370
- const user = [
371
- `现有 topic 索引(${indexDetailed.length} 个):`,
372
- JSON.stringify(indexDetailed),
373
- '',
374
- `未蒸馏观察(${observationsPayload.length} 条):`,
375
- JSON.stringify(observationsPayload),
376
- '',
377
- '请输出蒸馏结果(严格 JSON,{"ops":[...]}):',
378
- ].join('\n');
379
479
  let raw;
380
480
  const caller = this.caller;
381
481
  if (caller === undefined) {
@@ -384,7 +484,7 @@ export class Distiller {
384
484
  return { created: [], updated: [], marked: 0, fatalReason: 'model-error', detail: 'distill caller unavailable' };
385
485
  }
386
486
  try {
387
- raw = await caller({ system: SYSTEM_PROMPT, user, purpose: 'topics-distill', sessionId, maxTokens: 4000 });
487
+ raw = await caller({ system, user, purpose: 'topics-distill', sessionId, maxTokens: 4000 });
388
488
  }
389
489
  catch (e) {
390
490
  const detail = String(e instanceof Error ? e.message : e).slice(0, 200);
@@ -405,6 +505,15 @@ export class Distiller {
405
505
  detail: String(e instanceof Error ? e.message : e).slice(0, 200),
406
506
  };
407
507
  }
508
+ if (lesson) {
509
+ // The lesson branch drafts NEW lessons only — a lesson "update" would
510
+ // rewrite another lane's lifecycle state (confidence/status transitions
511
+ // are the B4 approval gate's exclusive write path), so non-create shapes
512
+ // are dropped before the gate. Their observations stay unconsumed and
513
+ // walk the existing GC path; an all-update batch resolves to the honest
514
+ // no-ops stop.
515
+ ops = ops.filter((o) => o.op === 'create');
516
+ }
408
517
  if (ops.length === 0) {
409
518
  return { created: [], updated: [], marked: 0, noOps: true, callsUsed: 1 };
410
519
  }
@@ -412,7 +521,7 @@ export class Distiller {
412
521
  // 补问 retry (budget-guarded), still-deficient ops are dropped and
413
522
  // ledgered — never a silent drop. Their observations simply stay
414
523
  // unconsumed and walk the existing GC/corrective path (three strikes).
415
- const schema = await this.enforceSchema(sessionId, user, ops, budgetLeft);
524
+ const schema = await this.enforceSchema(sessionId, system, user, ops, budgetLeft, branch);
416
525
  let callsUsed = 1 + schema.retrySpent;
417
526
  for (const drop of schema.drops) {
418
527
  await recordPipelineEvent(this.service.store.root, {
@@ -428,9 +537,16 @@ export class Distiller {
428
537
  marked: 0,
429
538
  callsUsed,
430
539
  invalidOps: schema.drops.length,
431
- stallDetail: `全部 create op 未通过 schema 校验(description 必填 + triggers 3-8 条),已丢弃并落账(${schema.drops.length} 个)`,
540
+ stallDetail: lesson
541
+ ? `全部 lesson op 未通过 schema 校验(triggers 3-8 条 + conclusion(action) 必填 + scope 枚举),已丢弃并落账(${schema.drops.length} 个)`
542
+ : `全部 create op 未通过 schema 校验(description 必填 + triggers 3-8 条),已丢弃并落账(${schema.drops.length} 个)`,
432
543
  };
433
544
  }
545
+ // B2 conflict self-check ledgering: the draft still lands as a hint —
546
+ // the flag is for the approval surface, never a lifecycle write.
547
+ let conflictsFlagged = 0;
548
+ if (lesson)
549
+ conflictsFlagged = await this.ledgerLessonConflicts(schema.finalOps, establishedSlugs);
434
550
  // observed_ids are the ONLY bridge between ops and marks — without a valid
435
551
  // id the store cannot mark anything and the batch head can never advance
436
552
  // (the real-machine zero-consumption livelock: topics landed, backlog
@@ -465,7 +581,7 @@ export class Distiller {
465
581
  };
466
582
  }
467
583
  const pass1 = applied;
468
- const { applied: pass2, why } = await this.correctiveRetry(sessionId, user, idList, validIds, knownSlugs);
584
+ const { applied: pass2, why } = await this.correctiveRetry(sessionId, system, user, idList, validIds, knownSlugs);
469
585
  callsUsed += 1;
470
586
  if (pass2 === undefined || pass2.consumedIds.length === 0) {
471
587
  const tail = pass2 === undefined
@@ -505,9 +621,60 @@ export class Distiller {
505
621
  filteredIds: applied.filteredIds,
506
622
  invalidOps: schema.drops.length,
507
623
  rejectedLinks: applied.rejectedLinks,
624
+ conflictsFlagged,
508
625
  consumedIds: applied.consumedIds,
509
626
  };
510
627
  }
628
+ /**
629
+ * The conflict self-check baseline (B2 决议 5): established lessons ONLY —
630
+ * hint drafts are not yet norms, so there is nothing for a candidate to
631
+ * contradict. Each brief carries the lesson's condition surface (title +
632
+ * triggers) and its slug (the only legal conflictsWith reference). Bounded
633
+ * reads like the topic index (first 100 roster entries); established
634
+ * lessons are post-gate artifacts and stay a handful in practice.
635
+ */
636
+ async establishedLessonBriefs(metas) {
637
+ const out = [];
638
+ for (const m of metas.slice(0, 100)) {
639
+ const doc = await this.service.store.readTopic(m.slug).catch(() => undefined);
640
+ if (doc === undefined)
641
+ continue;
642
+ if (okf.topicKindOf(doc.fm.type) !== 'lesson')
643
+ continue;
644
+ if ((doc.fm.confidence ?? 'hint') !== 'established')
645
+ continue;
646
+ out.push({ slug: m.slug, title: m.title, triggers: doc.fm.triggers ?? [] });
647
+ }
648
+ return out;
649
+ }
650
+ /**
651
+ * B2 conflict self-check, LEDGER-ONLY disposition: a draft that declares
652
+ * `conflictsWith` still lands as a hint draft (新覆旧 is the B4 approval
653
+ * gate's call, never the distill lane's). Each declared slug is
654
+ * whitelist-checked against the established baseline the prompt carried;
655
+ * roster-unknown slugs are model noise and are dropped without an event (a
656
+ * hallucinated slug must not fabricate a conflict). The event is a flag for
657
+ * the approval surface — no lifecycle state changes here.
658
+ */
659
+ async ledgerLessonConflicts(ops, establishedSlugs) {
660
+ let flagged = 0;
661
+ for (const op of ops) {
662
+ if (!Array.isArray(op.conflictsWith) || op.conflictsWith.length === 0)
663
+ continue;
664
+ const valid = [...new Set(op.conflictsWith)].filter((s) => typeof s === 'string' && establishedSlugs.has(s));
665
+ if (valid.length === 0)
666
+ continue;
667
+ await recordPipelineEvent(this.service.store.root, {
668
+ at: new Date().toISOString(),
669
+ kind: 'lesson-conflict-flagged',
670
+ // detail values are scalar-only (pipeline-events whitelist): the slug
671
+ // list rides as a comma join, the raw declaration count rides as a number.
672
+ detail: { op: opKey(op), conflictsWith: valid.join(','), declared: op.conflictsWith.length },
673
+ });
674
+ flagged += valid.length;
675
+ }
676
+ return flagged;
677
+ }
511
678
  /**
512
679
  * The ONE corrective retry for a zero-valid-observed_ids batch: re-ask with
513
680
  * the batch's legal id list spelled out verbatim. It spends a second call
@@ -516,7 +683,7 @@ export class Distiller {
516
683
  * stop — a max-tokens correction does NOT trigger the batch halving (that
517
684
  * escape hatch belongs to the main pass).
518
685
  */
519
- async correctiveRetry(sessionId, originalUser, idList, validIds, knownSlugs) {
686
+ async correctiveRetry(sessionId, system, originalUser, idList, validIds, knownSlugs) {
520
687
  const caller = this.caller;
521
688
  if (caller === undefined)
522
689
  return { why: 'distill caller unavailable' };
@@ -531,7 +698,7 @@ export class Distiller {
531
698
  ].join('\n');
532
699
  let raw;
533
700
  try {
534
- raw = await caller({ system: SYSTEM_PROMPT, user, purpose: 'topics-distill', sessionId, maxTokens: 4000 });
701
+ raw = await caller({ system, user, purpose: 'topics-distill', sessionId, maxTokens: 4000 });
535
702
  }
536
703
  catch (e) {
537
704
  return { why: `模型调用失败:${String(e instanceof Error ? e.message : e).slice(0, 160)}` };
@@ -560,7 +727,9 @@ export class Distiller {
560
727
  * GC/corrective path accounts for them, never a silent loss. Update ops
561
728
  * keep their narrower contract (slug only).
562
729
  */
563
- async enforceSchema(sessionId, originalUser, ops, budgetLeft) {
730
+ async enforceSchema(sessionId, system, originalUser, ops, budgetLeft, branch) {
731
+ const lesson = branch === 'lesson';
732
+ const check = lesson ? checkLessonSchema : checkCreateSchema;
564
733
  const valid = [];
565
734
  const invalid = [];
566
735
  for (const op of ops) {
@@ -568,11 +737,11 @@ export class Distiller {
568
737
  valid.push(op);
569
738
  continue;
570
739
  }
571
- const check = checkCreateSchema(op);
572
- if (check.valid)
740
+ const verdict = check(op);
741
+ if (verdict.valid)
573
742
  valid.push(op);
574
743
  else
575
- invalid.push({ op, key: opKey(op), missing: check.missing });
744
+ invalid.push({ op, key: opKey(op), missing: verdict.missing });
576
745
  }
577
746
  if (invalid.length === 0)
578
747
  return { finalOps: valid, drops: [], retrySpent: 0 };
@@ -587,7 +756,7 @@ export class Distiller {
587
756
  retrySpent: 0,
588
757
  };
589
758
  }
590
- const rescued = await this.schemaRetryCall(sessionId, originalUser, invalid);
759
+ const rescued = await this.schemaRetryCall(sessionId, system, originalUser, invalid, branch);
591
760
  if (rescued === undefined) {
592
761
  return {
593
762
  finalOps: valid,
@@ -602,7 +771,7 @@ export class Distiller {
602
771
  const drops = [];
603
772
  for (const c of invalid) {
604
773
  const replacement = rescued.get(c.key);
605
- if (replacement !== undefined && checkCreateSchema(replacement).valid) {
774
+ if (replacement !== undefined && check(replacement).valid) {
606
775
  valid.push(replacement);
607
776
  }
608
777
  else {
@@ -617,22 +786,27 @@ export class Distiller {
617
786
  * or undefined when the call itself failed / output was unparseable — the
618
787
  * caller then ledgeres `distill-op-retry-exhausted` for every deficient op.
619
788
  */
620
- async schemaRetryCall(sessionId, originalUser, invalid) {
789
+ async schemaRetryCall(sessionId, system, originalUser, invalid, branch) {
621
790
  const caller = this.caller;
622
791
  if (caller === undefined)
623
792
  return undefined;
624
793
  const list = invalid.map((c) => `- create「${typeof c.op.title === 'string' ? c.op.title : ''}」(缺:${c.missing})`).join('\n');
794
+ // The completion demand names the branch's own required fields; the topic
795
+ // line is byte-identical to its pre-lesson-branch form.
796
+ const instruction = branch === 'lesson'
797
+ ? '请只重新输出这些 op 的补全版本(严格 JSON,{"ops":[...]}):每个 create 必须带 triggers(恰好 3-8 个 condition 触发短语)与 conclusion(祈使句行动指令),scope 只能是 workspace 或 global,并保留原有的 title/observed_ids 等字段。无法补全的 op 不要输出。'
798
+ : '请只重新输出这些 op 的补全版本(严格 JSON,{"ops":[...]}):每个 create 必须带 description(一句话概括)与 triggers(恰好 3-8 条具体触发短语,禁止 dsh、配置、错误 之类的泛词),并保留原有的 title/conclusion/observed_ids 等字段。无法补全的 op 不要输出。';
625
799
  const user = [
626
800
  originalUser,
627
801
  '',
628
802
  '---',
629
803
  'schema 补问:你上一次返回的以下 create op 缺少必填字段:',
630
804
  list,
631
- '请只重新输出这些 op 的补全版本(严格 JSON,{"ops":[...]}):每个 create 必须带 description(一句话概括)与 triggers(恰好 3-8 条具体触发短语,禁止 dsh、配置、错误 之类的泛词),并保留原有的 title/conclusion/observed_ids 等字段。无法补全的 op 不要输出。',
805
+ instruction,
632
806
  ].join('\n');
633
807
  let raw;
634
808
  try {
635
- raw = await caller({ system: SYSTEM_PROMPT, user, purpose: 'topics-distill', sessionId, maxTokens: 4000 });
809
+ raw = await caller({ system, user, purpose: 'topics-distill', sessionId, maxTokens: 4000 });
636
810
  }
637
811
  catch {
638
812
  return undefined;
@@ -670,6 +844,11 @@ export class Distiller {
670
844
  * the store transaction (先落 topic 再建边 — one write, no separate edge
671
845
  * pass). The set is locally seeded with this call's predicted op slugs and
672
846
  * grows as creates land, so same-batch references resolve in op order.
847
+ *
848
+ * Lesson creates (B2) ride the same sanitation and consume their
849
+ * observations through the same observed_ids bridge; the write goes through
850
+ * saveTopic's lesson branch (kind:'lesson', confidence hint, distilled
851
+ * provenance) — the observation→mark closure is identical to topics.
673
852
  */
674
853
  async applyOps(ops, validIds, knownSlugs) {
675
854
  const slugs = new Set(knownSlugs);
@@ -708,20 +887,39 @@ export class Distiller {
708
887
  if (op.op === 'create' && typeof op.title === 'string' && typeof op.conclusion === 'string') {
709
888
  const links = await this.acceptLinks(op, slugs);
710
889
  applied.rejectedLinks += links.rejected;
711
- const res = await this.service.saveTopic({
712
- title: op.title,
713
- conclusion: op.conclusion,
714
- description: op.description,
715
- tags: op.tags,
716
- triggers: Array.isArray(op.triggers) ? op.triggers.filter((t) => typeof t === 'string') : undefined,
717
- depends: op.depends,
718
- openQuestions: op.open_questions,
719
- impact: op.impact,
720
- recommendations: op.recommendations,
721
- status: op.status === 'stable' ? 'stable' : 'draft',
722
- source: 'distill',
723
- links: links.targets,
724
- });
890
+ const res = op.kind === 'lesson'
891
+ ? // Lesson data plane (B2 §6.2): the draft lands as a hint with
892
+ // distilled provenance — status derives from confidence, and
893
+ // promotion to established is the B4 approval gate's exclusive
894
+ // write path (setLessonStatus), so `status` is NOT taken from
895
+ // the op. Scope: the op's explicit choice, default workspace
896
+ // (lesson 必带 scope, global 需显式).
897
+ await this.service.saveTopic({
898
+ title: op.title,
899
+ kind: 'lesson',
900
+ conclusion: op.conclusion,
901
+ description: op.description,
902
+ triggers: Array.isArray(op.triggers) ? op.triggers.filter((t) => typeof t === 'string') : undefined,
903
+ scope: op.scope === 'global' ? 'global' : 'workspace',
904
+ confidence: 'hint',
905
+ lessonSource: 'distilled',
906
+ source: 'distill',
907
+ links: links.targets,
908
+ })
909
+ : await this.service.saveTopic({
910
+ title: op.title,
911
+ conclusion: op.conclusion,
912
+ description: op.description,
913
+ tags: op.tags,
914
+ triggers: Array.isArray(op.triggers) ? op.triggers.filter((t) => typeof t === 'string') : undefined,
915
+ depends: op.depends,
916
+ openQuestions: op.open_questions,
917
+ impact: op.impact,
918
+ recommendations: op.recommendations,
919
+ status: op.status === 'stable' ? 'stable' : 'draft',
920
+ source: 'distill',
921
+ links: links.targets,
922
+ });
725
923
  applied.created.push(res.slug);
726
924
  applied.consumedSlugs.push(res.slug);
727
925
  slugs.add(res.slug);
package/lib/index.js CHANGED
@@ -751,10 +751,11 @@ export async function apply(ctx, config = {}) {
751
751
  // `agent/created` listener and a throw or rejection ROLLS THE ENTIRE AGENT
752
752
  // CREATION BACK. This listener is therefore total and synchronous — it
753
753
  // never throws, never returns a promise — and the chain is deferred off
754
- // the creation transaction entirely (setImmediate, unref'd): the pull /
754
+ // the creation transaction entirely (1.5s unref'd timer): the pull /
755
755
  // replay-distill / consolidation slow work starts only after the serial
756
- // dispatch has moved on. Plugin initialization must never block, delay,
757
- // or abort session startup; a chain failure at most warns.
756
+ // dispatch has moved on and the first frame is up. Plugin initialization
757
+ // must never block, delay, or abort session startup; a chain failure at
758
+ // most warns.
758
759
  const runBootChain = (sessionId) => {
759
760
  // Contained at the top too: a synchronous prologue throw (config read,
760
761
  // sync bookkeeping) must degrade to a warn, not leak toward the host.
@@ -852,11 +853,14 @@ export async function apply(ctx, config = {}) {
852
853
  const sessionId = String(payload?.agent?.id ?? '');
853
854
  if (sessionId === '')
854
855
  return;
855
- // Off the creation transaction: the unref'd immediate fires after the
856
- // current serial dispatch slice, so the chain's synchronous prologue
857
- // (config read, sync state) never delays the remaining listeners nor
858
- // the loop start, and the timer cannot hold a dying process open.
859
- const deferred = setImmediate(() => runBootChain(sessionId));
856
+ // Off the creation transaction AND off the boot window: the unref'd
857
+ // 1.5s timer fires after the serial dispatch has fully moved on and
858
+ // after the host's first frame, so the pull's git/DNS child processes
859
+ // never compete with startup I/O on a cold (possibly network-starved)
860
+ // machine — the 2026-10-19 boot-stall report showed the boot window
861
+ // and the fetch share the same constrained threadpool on a flaky VPN.
862
+ // The timer cannot hold a dying process open.
863
+ const deferred = setTimeout(() => runBootChain(sessionId), 1500);
860
864
  deferred.unref?.();
861
865
  }
862
866
  catch (error) {
@@ -17,8 +17,11 @@
17
17
  * the slow lane's per-run disposition row (quality.ts); the distill/link
18
18
  * kinds are the A2/A6 producers' contract — they import this module. The
19
19
  * two `seam-*` kinds are the B-line dual-write audit trail (卷二 §6.2:
20
- * caller-identity audit — opts.actor — rides every seam call). */
21
- export type PipelineEventKind = 'slow-lane-outcome' | 'distill-op-invalid' | 'distill-op-retry-exhausted' | 'link-ref-rejected' | 'seam-observations-fed' | 'seam-lesson-status';
20
+ * caller-identity audit — opts.actor — rides every seam call).
21
+ * `lesson-conflict-flagged` is the lesson distill branch's conflict
22
+ * self-check flag (B2 决议 5): informational for the B4 approval surface —
23
+ * the draft still lands as hint, no lifecycle state changes on this event. */
24
+ export type PipelineEventKind = 'slow-lane-outcome' | 'distill-op-invalid' | 'distill-op-retry-exhausted' | 'link-ref-rejected' | 'seam-observations-fed' | 'seam-lesson-status' | 'lesson-conflict-flagged';
22
25
  /** File NAME inside the bundle's meta dir (path via pipelineEventsFile). */
23
26
  export declare const PIPELINE_EVENTS_FILE = "pipeline-events.jsonl";
24
27
  export interface PipelineEvent {