@aiwayds/dsh-topics-memory 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/digest.js CHANGED
@@ -9,6 +9,7 @@
9
9
  * @module digest
10
10
  */
11
11
  import { sectionOf, firstParagraph, CONCLUSION_HEADING, RECOMMENDATIONS_HEADING } from "./okf.js";
12
+ import { tokenize } from "./retrieval.js";
12
13
  /** Rough CJK-aware token estimate: ~0.75 token per CJK char, ~4 chars per latin token. */
13
14
  export function estimateTokens(text) {
14
15
  let cjk = 0;
@@ -92,6 +93,20 @@ export function pointerEntry(input, budgetTokens) {
92
93
  const text = lines.join('\n');
93
94
  return { text, usedTokens: estimateTokens(text) };
94
95
  }
96
+ /** The `(lesson source:x)` marker on lesson entries — flat AND package form. */
97
+ export function lessonSourceTag(source) {
98
+ return `lesson source:${source}`;
99
+ }
100
+ /** One flat lesson entry: same two-line pointer shape + the source marker. */
101
+ export function lessonEntryText(input, budgetTokens) {
102
+ const lines = [];
103
+ lines.push(`### ${input.title} [${input.status}] (topics:${input.slug} score:${input.score.toFixed(2)}) (${lessonSourceTag(input.source)})`);
104
+ const one = (input.description ?? '').replace(/\s+/g, ' ').trim();
105
+ if (one !== '')
106
+ lines.push(truncateToTokens(one, Math.max(16, budgetTokens - 32)).text);
107
+ const text = lines.join('\n');
108
+ return { text, usedTokens: estimateTokens(text) };
109
+ }
95
110
  /**
96
111
  * One slow-lane pointer: same shape as a fast pointer plus the mandatory why
97
112
  * line (v4 §4.2 — 每条带 why 行). No score: the gate here was the LLM rerank,
@@ -115,16 +130,18 @@ function wrapBlocks(blocks) {
115
130
  }
116
131
  /**
117
132
  * Shared greedy packing: fast entries first (score-ordered), then optional
118
- * slow-lane pointers, all under one total budget (v4 B2: shared 600 — the
119
- * slow lane spends whatever the fast lane leaves; 分账终值 is a P3 question).
133
+ * slow-lane pointers, then optional lesson entries (B2: flat-mode normal
134
+ * entry slots), all under one total budget (v4 B2: shared 600 — the slow lane
135
+ * spends whatever the fast lane leaves; 分账终值 is a P3 question).
120
136
  * Zero blocks → empty text (ADR 0006: 零命中零注入).
121
137
  */
122
- function packInjection(entries, cfg, render, slow) {
123
- if (entries.length === 0 && (slow === undefined || slow.length === 0)) {
138
+ function packInjection(entries, cfg, render, slow, lessons) {
139
+ if (entries.length === 0 && (slow === undefined || slow.length === 0) && (lessons === undefined || lessons.length === 0)) {
124
140
  return { text: '', usedTokens: 0, included: [], dropped: [] };
125
141
  }
126
142
  const included = [];
127
143
  const slowIncluded = [];
144
+ const lessonIncluded = [];
128
145
  const dropped = [];
129
146
  const blocks = [];
130
147
  let used = estimateTokens(`${WRAPPER_OPEN}\n${WRAPPER_INTRO}\n\n${WRAPPER_CLOSE}`);
@@ -158,25 +175,434 @@ function packInjection(entries, cfg, render, slow) {
158
175
  slowIncluded.push(item.slug);
159
176
  used += block.usedTokens;
160
177
  }
178
+ for (const item of lessons ?? []) {
179
+ const budget = Math.min(cfg.perTopicBudget, cfg.totalBudget - used);
180
+ if (budget < 24) {
181
+ dropped.push({ slug: item.slug, reason: 'lesson-budget' });
182
+ continue;
183
+ }
184
+ const block = lessonEntryText(item, budget);
185
+ if (used + block.usedTokens > cfg.totalBudget) {
186
+ dropped.push({ slug: item.slug, reason: 'lesson-budget' });
187
+ continue;
188
+ }
189
+ blocks.push(block.text);
190
+ lessonIncluded.push(item.slug);
191
+ used += block.usedTokens;
192
+ }
161
193
  if (blocks.length === 0)
162
194
  return { text: '', usedTokens: 0, included: [], dropped };
163
195
  const text = wrapBlocks(blocks);
164
196
  const result = { text, usedTokens: estimateTokens(text), included, dropped };
165
197
  if (slowIncluded.length > 0)
166
198
  result.slowIncluded = slowIncluded;
199
+ if (lessonIncluded.length > 0) {
200
+ result.lessonIncluded = lessonIncluded;
201
+ result.included = [...included, ...lessonIncluded];
202
+ }
167
203
  return result;
168
204
  }
169
205
  /**
170
206
  * Greedily pack pointer entries (hits already score-ordered) under the total
171
- * budget. Zero hits → empty text (ADR 0006: 零命中零注入).
207
+ * budget. Zero hits → empty text (ADR 0006: 零命中零注入). `lessons` (B2)
208
+ * packs after the slow pointers as flat entries with the source marker.
209
+ */
210
+ export function assemblePointer(entries, cfg, slow, lessons) {
211
+ return packInjection(entries, cfg, pointerEntry, slow, lessons);
212
+ }
213
+ /**
214
+ * Greedily pack per-topic digests (hits already score-ordered) under the total
215
+ * budget. Zero hits → empty text (ADR 0006: 零命中零注入). `lessons` (B2).
216
+ */
217
+ export function assembleInjection(entries, cfg, slow, lessons) {
218
+ return packInjection(entries, cfg, topicDigest, slow, lessons);
219
+ }
220
+ /** Total package cap — pinned, does not float with hit strength (A3 决议 3). */
221
+ export const PACKAGE_TOTAL = 600;
222
+ export const PACKAGE_DIRECT_MAX = 2;
223
+ export const PACKAGE_DIRECT_BUDGET = 120;
224
+ export const PACKAGE_NEIGHBOR_MAX = 3;
225
+ export const PACKAGE_NEIGHBOR_BUDGET = 40;
226
+ export const PACKAGE_WEAK_MAX = 2;
227
+ /**
228
+ * Padded-trivial arm of the downgrade guard (spec §2.3 名词实体口径, an
229
+ * implementation-time item per §11): after stripping whitespace, a query this
230
+ * short counts as 琐碎 even when the tokenizer expands CJK bigrams past 3
231
+ * tokens. Calibrated on the two named scenarios: 场景① ack query strips to
232
+ * 15 chars (downgrade), a real review query strips to 18 (kept).
172
233
  */
173
- export function assemblePointer(entries, cfg, slow) {
174
- return packInjection(entries, cfg, pointerEntry, slow);
234
+ export const PACKAGE_TRIVIAL_QUERY_CHARS = 16;
235
+ const PACKAGE_WRAPPER_OPEN = '<topic-memory>';
236
+ const PACKAGE_WRAPPER_CLOSE = '</topic-memory>';
237
+ /** Edge/lane note for the why line / neighborhood annotation (A3 决议 2). */
238
+ function viaNote(via) {
239
+ if (via === undefined || via === '')
240
+ return null;
241
+ if (via.startsWith('depends'))
242
+ return '经 depends 关联';
243
+ if (via.startsWith('slow'))
244
+ return '慢道 pick';
245
+ if (via.startsWith('semantic'))
246
+ return '语义近邻';
247
+ return via;
248
+ }
249
+ /** Trigger reason → the trigger word when the caller carries it, `''` channel-only. */
250
+ function triggerWordOfReason(reason) {
251
+ const m = reason.match(/^triggers?[::](.*)$/);
252
+ if (m === null)
253
+ return undefined;
254
+ const v = m[1].trim();
255
+ return /^[\d.+-]+$/.test(v) ? '' : v;
256
+ }
257
+ /**
258
+ * slug/title 词面,含分隔符子词(`cs-geely-voice-agent` → `voice`/`agent`):
259
+ * query 里的连字符词(voice-agent)与 slug 复合词永远不相等,实体判定必须
260
+ * 下探到子词才有交集;CJK 已由 tokenize 的二元切分覆盖。
261
+ */
262
+ function subtokenSurface(text) {
263
+ const out = new Set();
264
+ for (const t of tokenize(text)) {
265
+ out.add(t);
266
+ if (/[+#.-]/.test(t)) {
267
+ for (const sub of t.split(/[+#.-]+/))
268
+ if (sub.length >= 2)
269
+ out.add(sub);
270
+ }
271
+ }
272
+ return out;
273
+ }
274
+ function tokenMatchesSurface(token, surface) {
275
+ const t = token.toLowerCase();
276
+ if (surface.has(t))
277
+ return true;
278
+ if (/[+#.-]/.test(t)) {
279
+ for (const sub of t.split(/[+#.-]+/))
280
+ if (sub.length >= 2 && surface.has(sub))
281
+ return true;
282
+ }
283
+ return false;
175
284
  }
176
285
  /**
177
- * Greedily pack per-topic digests (hits already score-ordered) under the
178
- * total budget. Zero hits → empty text (ADR 0006: 零命中零注入).
286
+ * Strict display matcher for why-line attribution: a compound query token
287
+ * counts as a title/weak word only when EVERY separator part ties to the
288
+ * surface — `voice-agent` matches `cs-geely-voice-agent`, but
289
+ * `cs-geely-alert-manager-api` must not match it via the shared `cs`.
179
290
  */
180
- export function assembleInjection(entries, cfg, slow) {
181
- return packInjection(entries, cfg, topicDigest, slow);
291
+ function tokenMatchesSurfaceStrict(token, surface) {
292
+ const t = token.toLowerCase();
293
+ if (surface.has(t))
294
+ return true;
295
+ if (!/[+#.-]/.test(t))
296
+ return false;
297
+ const subs = t.split(/[+#.-]+/).filter((s) => s.length >= 2);
298
+ return subs.length > 0 && subs.every((s) => surface.has(s));
299
+ }
300
+ /** Query tokens (original spelling, query order) with a词面 tie to the surface. */
301
+ function matchedQueryTokens(queryTokens, surfaceText) {
302
+ const surface = subtokenSurface(surfaceText);
303
+ const out = [];
304
+ for (const tok of queryTokens) {
305
+ if (!out.includes(tok) && tokenMatchesSurfaceStrict(tok, surface))
306
+ out.push(tok);
307
+ }
308
+ return out;
309
+ }
310
+ /** Entity-anchored candidate: its slug/title surface shares a query token
311
+ * (验证波次修正——A③ 场景实证:词法第 3 名恰是 query 点名实体时,≤2 席
312
+ * 会把任务对象整体剪掉;实体锚定者不受席帽约束,仍受总预算约束)。 */
313
+ function isEntityAnchored(queryTokens, c) {
314
+ if (queryTokens.length === 0)
315
+ return false;
316
+ const surface = subtokenSurface(`${c.slug} ${c.title}`);
317
+ for (const tok of queryTokens) {
318
+ if (tokenMatchesSurface(tok, surface))
319
+ return true;
320
+ }
321
+ return false;
322
+ }
323
+ function hasEntityOverlap(queryTokens, candidates) {
324
+ if (queryTokens.length === 0)
325
+ return false;
326
+ for (const c of candidates) {
327
+ const surface = subtokenSurface(`${c.slug} ${c.title}`);
328
+ for (const tok of queryTokens) {
329
+ if (tokenMatchesSurface(tok, surface))
330
+ return true;
331
+ }
332
+ }
333
+ return false;
334
+ }
335
+ function isTrivialQuery(query, queryTokens) {
336
+ if (queryTokens.length <= 3)
337
+ return true;
338
+ const stripped = query.replace(/\s+/g, '');
339
+ return stripped.length > 0 && stripped.length <= PACKAGE_TRIVIAL_QUERY_CHARS;
340
+ }
341
+ /** Stable within-layer order: prioritySeat first (lesson 优先席), then score
342
+ * desc, then usage, then input order. */
343
+ function rankStable(list, usage) {
344
+ return list
345
+ .map((c, i) => ({ c, i, u: usage?.get(c.slug) ?? 0 }))
346
+ .sort((a, b) => (b.c.prioritySeat === true ? 1 : 0) - (a.c.prioritySeat === true ? 1 : 0) ||
347
+ b.c.score - a.c.score ||
348
+ b.u - a.u ||
349
+ a.i - b.i)
350
+ .map((x) => x.c);
351
+ }
352
+ /** Machine-assembled why line (A3 决议 2): hit channel + edge source + score. */
353
+ function packageWhyLine(c, queryTokens) {
354
+ const parts = [];
355
+ let triggerWord = null;
356
+ let triggerChannel = false;
357
+ let titleHit = false;
358
+ const fields = [];
359
+ for (const r of c.reasons) {
360
+ const tw = triggerWordOfReason(r);
361
+ if (tw !== undefined) {
362
+ if (tw === '')
363
+ triggerChannel = true;
364
+ else if (triggerWord === null)
365
+ triggerWord = tw;
366
+ continue;
367
+ }
368
+ if (r.startsWith('title:'))
369
+ titleHit = true;
370
+ else if (r.startsWith('tags:'))
371
+ fields.push('标签');
372
+ else if (r.startsWith('description:'))
373
+ fields.push('描述');
374
+ else if (r.startsWith('conclusion:'))
375
+ fields.push('正文');
376
+ else if (r.startsWith('slug:'))
377
+ fields.push('slug');
378
+ }
379
+ if (triggerWord !== null)
380
+ parts.push(`trigger「${triggerWord}」`);
381
+ else if (triggerChannel)
382
+ parts.push('trigger 命中');
383
+ if (titleHit) {
384
+ const words = matchedQueryTokens(queryTokens, `${c.slug} ${c.title}`);
385
+ parts.push(words.length > 0 ? `标题词「${words.slice(0, 2).join('」「')}」` : '标题词命中');
386
+ }
387
+ if (fields.length > 0)
388
+ parts.push(`${[...new Set(fields)].join('/')}词面命中`);
389
+ const via = viaNote(c.via);
390
+ if (via !== null)
391
+ parts.push(via);
392
+ if (parts.length === 0)
393
+ parts.push('词面命中');
394
+ return `命中: ${truncateToTokens(parts.join('、'), 32).text}(score:${c.score.toFixed(2)})`;
395
+ }
396
+ /**
397
+ * One direct-layer full entry (~120 tok): 标题+[status] + 一行实质 +
398
+ * 机器 why 行 + 指令式打开引导(预告源 description 优先,缺失回退结论行时
399
+ * 不再重复预告)。实质行是弹性段,超预算先截它(spec §2.4)。
400
+ */
401
+ function directEntryText(c, queryTokens, budget) {
402
+ const head = `### ${c.title} [${c.status}]${c.sourceTag !== undefined && c.sourceTag !== '' ? ` (${c.sourceTag})` : ''}`;
403
+ const why = packageWhyLine(c, queryTokens);
404
+ const warn = c.reasons.includes('conflicted-demoted') ? '⚠ 此条存在未合并冲突,结论可能过期。' : null;
405
+ const openBase = `细节: topic_open "${c.slug}"`;
406
+ const conclusionLine = (c.conclusion ?? '').trim();
407
+ const substanceRaw = (conclusionLine !== '' ? conclusionLine : (c.description ?? '')).trim();
408
+ const previewRaw = (c.description ?? '').replace(/\s+/g, ' ').trim();
409
+ const lines = [head, why];
410
+ let substanceText = '';
411
+ if (substanceRaw !== '') {
412
+ const others = estimateTokens([head, why, openBase].join('\n')) + (warn !== null ? estimateTokens(warn) + 1 : 0);
413
+ const room = budget - others - 1;
414
+ if (room >= 8)
415
+ substanceText = truncateToTokens(substanceRaw, room).text;
416
+ }
417
+ if (substanceText !== '')
418
+ lines.push(substanceText);
419
+ if (warn !== null)
420
+ lines.push(warn);
421
+ let open = openBase;
422
+ if (previewRaw !== '' && previewRaw !== substanceText) {
423
+ const room = budget - estimateTokens([...lines, open].join('\n')) - 3;
424
+ if (room >= 6)
425
+ open = `${openBase},${truncateToTokens(previewRaw, room).text}`;
426
+ }
427
+ lines.push(open);
428
+ // Last resort for pathological heads (giant titles): shed elastic lines.
429
+ while (lines.length > 2 && estimateTokens(lines.join('\n')) > budget) {
430
+ const dropWarn = lines.indexOf(warn ?? '');
431
+ if (warn !== null && dropWarn !== -1)
432
+ lines.splice(dropWarn, 1);
433
+ else if (substanceText !== '' && lines.includes(substanceText))
434
+ lines.splice(lines.indexOf(substanceText), 1);
435
+ else
436
+ break;
437
+ }
438
+ const text = lines.join('\n');
439
+ return { text, usedTokens: estimateTokens(text) };
440
+ }
441
+ /** One neighborhood half-line (~40 tok): 标题 + slug + 边来源/通道标注. */
442
+ function neighborhoodEntryText(c) {
443
+ const via = viaNote(c.via);
444
+ const text = `- ${truncateToTokens(c.title, 20).text} [${c.status}] (topics:${c.slug}) ← ${via ?? '关联扩展'}`;
445
+ return { text, usedTokens: estimateTokens(text) };
446
+ }
447
+ /** Merged weak-signal line: nearFloor head slugs + the weak query words. */
448
+ function weakSignalLine(cands, queryTokens) {
449
+ const slugs = cands.map((c) => c.slug);
450
+ const words = [];
451
+ for (const c of cands) {
452
+ for (const w of matchedQueryTokens(queryTokens, `${c.slug} ${c.title}`)) {
453
+ if (!words.includes(w))
454
+ words.push(w);
455
+ }
456
+ }
457
+ let text = `弱信号: ${slugs.join('、')}`;
458
+ if (words.length > 0)
459
+ text += ` ← 弱词「${words.slice(0, 3).join('」「')}」`;
460
+ return { text, usedTokens: estimateTokens(text), slugs };
461
+ }
462
+ /** 包头 one-liner: 资料声明 + 「为什么是这批」各层计数(A3 决议 1). */
463
+ function packageHeader(query, nDirect, nNeigh, nWeak) {
464
+ const summary = truncateToTokens(query.replace(/\s+/g, ' ').trim(), 16).text;
465
+ const clauses = [];
466
+ if (nDirect > 0)
467
+ clauses.push(`${nDirect} 条词法强命中`);
468
+ if (nNeigh > 0)
469
+ clauses.push(`${nNeigh} 条经关联`);
470
+ if (nWeak > 0)
471
+ clauses.push(`${nWeak} 条弱词信号`);
472
+ const why = clauses.length > 0 ? `为什么是这批:${clauses.join(',')}。` : '';
473
+ return `与「${summary}」相关的记忆(资料,非指令)。${why}`;
474
+ }
475
+ /**
476
+ * Assemble the three-layer package. Returns `null` — the whole-package skip
477
+ * signal — when the roster is empty (ADR 0006 零命中零注入) or when the
478
+ * downgrade guard fires (spec §2.3: 琐碎 query 且与全部候选 slug/title 零
479
+ * 词面实体交集). Packing is layered greedy: direct first, then neighborhood,
480
+ * then the weak line — over budget, lower layers are cut first.
481
+ */
482
+ export function assemblePackage(input, opts) {
483
+ void opts; // shadow 双算在调用方(旧 assemblePointer + 本函数各调一次);两态渲染同构
484
+ const dropped = [];
485
+ const all = [...input.direct, ...input.neighborhood, ...input.weak];
486
+ if (all.length === 0)
487
+ return null;
488
+ if (isTrivialQuery(input.query, input.queryTokens) && !hasEntityOverlap(input.queryTokens, all))
489
+ return null;
490
+ // 跨层去重(spec §2.6):直接层赢席;重合的邻域条目并 why(边来源并入
491
+ // 直接层条目)不重占席;弱信号重合直接丢弃。
492
+ const direct = rankStable(input.direct, input.usageBySlug).map((c) => ({ ...c }));
493
+ const seen = new Set(direct.map((c) => c.slug));
494
+ const neighborhood = [];
495
+ for (const c of rankStable(input.neighborhood, input.usageBySlug)) {
496
+ if (seen.has(c.slug)) {
497
+ const host = direct.find((d) => d.slug === c.slug);
498
+ if (host !== undefined && host.via === undefined && c.via !== undefined)
499
+ host.via = c.via;
500
+ dropped.push({ slug: c.slug, reason: 'duplicate' });
501
+ continue;
502
+ }
503
+ seen.add(c.slug);
504
+ neighborhood.push(c);
505
+ }
506
+ const weak = [];
507
+ for (const c of rankStable(input.weak, input.usageBySlug)) {
508
+ if (seen.has(c.slug)) {
509
+ dropped.push({ slug: c.slug, reason: 'duplicate' });
510
+ continue;
511
+ }
512
+ seen.add(c.slug);
513
+ weak.push(c);
514
+ }
515
+ // Reserve with an upper-bound header (pre-cap counts); the final header is
516
+ // never longer, so the 600 cap holds after the swap.
517
+ let used = estimateTokens(`${PACKAGE_WRAPPER_OPEN}\n${packageHeader(input.query, direct.length, neighborhood.length, weak.length)}\n\n${PACKAGE_WRAPPER_CLOSE}`);
518
+ const layers = { direct: [], neighborhood: [], weak: [] };
519
+ const directBlocks = [];
520
+ for (const c of direct) {
521
+ // prioritySeat (lesson 优先席) is exempt from the direct-seat cap — the
522
+ // budget is the only thing that can still cut it.
523
+ if (directBlocks.length >= PACKAGE_DIRECT_MAX && !isEntityAnchored(input.queryTokens, c) && c.prioritySeat !== true) {
524
+ dropped.push({ slug: c.slug, reason: 'direct-cap' });
525
+ continue;
526
+ }
527
+ const budget = Math.min(PACKAGE_DIRECT_BUDGET, PACKAGE_TOTAL - used);
528
+ if (budget < 24) {
529
+ dropped.push({ slug: c.slug, reason: 'total-budget' });
530
+ continue;
531
+ }
532
+ const block = directEntryText(c, input.queryTokens, budget);
533
+ if (used + block.usedTokens > PACKAGE_TOTAL) {
534
+ dropped.push({ slug: c.slug, reason: 'total-budget' });
535
+ continue;
536
+ }
537
+ directBlocks.push(block.text);
538
+ layers.direct.push(c.slug);
539
+ used += block.usedTokens;
540
+ }
541
+ const neighBlocks = [];
542
+ for (const c of neighborhood) {
543
+ if (neighBlocks.length >= PACKAGE_NEIGHBOR_MAX) {
544
+ dropped.push({ slug: c.slug, reason: 'neighborhood-cap' });
545
+ continue;
546
+ }
547
+ const budget = Math.min(PACKAGE_NEIGHBOR_BUDGET, PACKAGE_TOTAL - used);
548
+ if (budget < 16) {
549
+ dropped.push({ slug: c.slug, reason: 'total-budget' });
550
+ continue;
551
+ }
552
+ const block = neighborhoodEntryText(c);
553
+ if (used + block.usedTokens > PACKAGE_TOTAL) {
554
+ dropped.push({ slug: c.slug, reason: 'total-budget' });
555
+ continue;
556
+ }
557
+ neighBlocks.push(block.text);
558
+ layers.neighborhood.push(c.slug);
559
+ used += block.usedTokens;
560
+ }
561
+ const weakTake = weak.slice(0, PACKAGE_WEAK_MAX);
562
+ for (const c of weak.slice(PACKAGE_WEAK_MAX))
563
+ dropped.push({ slug: c.slug, reason: 'weak-cap' });
564
+ let weakText = null;
565
+ if (weakTake.length > 0) {
566
+ const full = weakSignalLine(weakTake, input.queryTokens);
567
+ if (used + full.usedTokens <= PACKAGE_TOTAL) {
568
+ weakText = full.text;
569
+ layers.weak = full.slugs;
570
+ used += full.usedTokens;
571
+ }
572
+ else if (weakTake.length > 1) {
573
+ const single = weakSignalLine(weakTake.slice(0, 1), input.queryTokens);
574
+ if (used + single.usedTokens <= PACKAGE_TOTAL) {
575
+ weakText = single.text;
576
+ layers.weak = single.slugs;
577
+ used += single.usedTokens;
578
+ for (const slug of weakTake.slice(1).map((c) => c.slug))
579
+ dropped.push({ slug, reason: 'total-budget' });
580
+ }
581
+ else {
582
+ for (const slug of full.slugs)
583
+ dropped.push({ slug, reason: 'total-budget' });
584
+ }
585
+ }
586
+ else {
587
+ for (const slug of full.slugs)
588
+ dropped.push({ slug, reason: 'total-budget' });
589
+ }
590
+ }
591
+ const parts = [packageHeader(input.query, layers.direct.length, layers.neighborhood.length, layers.weak.length)];
592
+ if (directBlocks.length > 0)
593
+ parts.push(['### 直接相关', ...directBlocks].join('\n\n'));
594
+ if (neighBlocks.length > 0)
595
+ parts.push(['### 邻域', ...neighBlocks].join('\n'));
596
+ if (weakText !== null)
597
+ parts.push(weakText);
598
+ if (parts.length === 1)
599
+ return null; // everything budget-cut → 零命中零注入
600
+ const text = `${PACKAGE_WRAPPER_OPEN}\n${parts.join('\n\n')}\n${PACKAGE_WRAPPER_CLOSE}`;
601
+ return {
602
+ text,
603
+ usedTokens: estimateTokens(text),
604
+ included: [...layers.direct, ...layers.neighborhood, ...layers.weak],
605
+ dropped,
606
+ layers,
607
+ };
182
608
  }
package/lib/distill.d.ts CHANGED
@@ -37,6 +37,8 @@ export interface DistillOp {
37
37
  depends?: string[];
38
38
  open_questions?: string[];
39
39
  impact?: string[];
40
+ /** Semantic-association targets (A2): existing slugs or same-batch op slugs. */
41
+ links?: string[];
40
42
  conclusion?: string;
41
43
  recommendations?: string;
42
44
  status?: string;
@@ -103,6 +105,27 @@ export declare class Distiller {
103
105
  * escape hatch belongs to the main pass).
104
106
  */
105
107
  private correctiveRetry;
108
+ /**
109
+ * The A6 §5.1 schema gate, applied to the parsed batch BEFORE any write:
110
+ * create ops must carry description + triggers 3-8 条 (the prompt's standing
111
+ * requirement, now enforced). Deficient creates get exactly ONE 补问 retry
112
+ * that re-asks for just those ops completed — budget-guarded exactly like
113
+ * the corrective ids retry (`retrySpent` reports the extra call). Every
114
+ * still-deficient op is dropped and comes back as a SchemaDrop carrying the
115
+ * ledger kind: `distill-op-invalid` after a used-but-unsuccessful retry,
116
+ * `distill-op-retry-exhausted` when no retry could run or the retry call
117
+ * itself failed. Dropped ops' observations stay unconsumed — the existing
118
+ * GC/corrective path accounts for them, never a silent loss. Update ops
119
+ * keep their narrower contract (slug only).
120
+ */
121
+ private enforceSchema;
122
+ /**
123
+ * The one schema 补问: re-ask for exactly the deficient creates, completed.
124
+ * Returns key→op for what the model re-emitted (matched by slugified title),
125
+ * or undefined when the call itself failed / output was unparseable — the
126
+ * caller then ledgeres `distill-op-retry-exhausted` for every deficient op.
127
+ */
128
+ private schemaRetryCall;
106
129
  /**
107
130
  * Execute a batch's ops with observed_ids sanitized upfront. A scalar
108
131
  * observed_ids string (the commonest model shape drift) is normalized to a
@@ -114,8 +137,24 @@ export declare class Distiller {
114
137
  * failures (missing fields, unknown update slug) keep the per-op try/catch
115
138
  * isolation — one bad op never sinks the batch, and a stall they cause is
116
139
  * NOT attributed to observed_ids (no corrective retry for those).
140
+ *
141
+ * A2 links: every link target is whitelist-checked against `knownSlugs`
142
+ * (existing slugs ∪ same-batch op slugs); rejected refs are stripped and
143
+ * ledgered (`link-ref-rejected`), survivors ride the op's topic write into
144
+ * the store transaction (先落 topic 再建边 — one write, no separate edge
145
+ * pass). The set is locally seeded with this call's predicted op slugs and
146
+ * grows as creates land, so same-batch references resolve in op order.
117
147
  */
118
148
  private applyOps;
149
+ /**
150
+ * A2 links whitelist: a link survives only when its normalized target is a
151
+ * slug that already exists or belongs to this batch (`knownSlugs`), and not
152
+ * the op's own topic. Everything else is stripped and ledgered
153
+ * (`link-ref-rejected` — a dead edge must at least leave a trace). Returns
154
+ * the surviving targets (undefined when none — no Links section is written)
155
+ * plus the rejection count for the run detail.
156
+ */
157
+ private acceptLinks;
119
158
  }
120
159
  /** Extract the first balanced JSON object from model output (tolerates fences). */
121
160
  export declare function parseOps(raw: string): DistillOp[];