@aiwayds/dsh-topics-memory 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/digest.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  import type { TopicDoc } from './okf.ts'
13
13
  import { sectionOf, firstParagraph, CONCLUSION_HEADING, RECOMMENDATIONS_HEADING } from './okf.ts'
14
14
  import type { SearchHit } from './retrieval.ts'
15
+ import { tokenize } from './retrieval.ts'
15
16
 
16
17
  /** Rough CJK-aware token estimate: ~0.75 token per CJK char, ~4 chars per latin token. */
17
18
  export function estimateTokens(text: string): number {
@@ -109,6 +110,8 @@ export interface AssembleResult {
109
110
  dropped: { slug: string; reason: string }[]
110
111
  /** Slow-lane pointers that entered the context (v4 §4.2). */
111
112
  slowIncluded?: string[]
113
+ /** Established lessons that entered the context (B2, spec 卷一 §6.3). */
114
+ lessonIncluded?: string[]
112
115
  }
113
116
 
114
117
  export interface AssembleConfig {
@@ -125,6 +128,36 @@ export interface SlowPointerInput {
125
128
  why: string
126
129
  }
127
130
 
131
+ /**
132
+ * One established-lesson pointer (B2, spec 卷一 §6.3): flat-mode normal-entry
133
+ * rendering with the machine-recognizable source marker (MJ-C combination
134
+ * matrix — lessonInject=on without the A3 package injects lessons as plain
135
+ * entries, tagged `lesson source:<kind>`).
136
+ */
137
+ export interface LessonPointerInput {
138
+ slug: string
139
+ title: string
140
+ status: string
141
+ description?: string
142
+ score: number
143
+ source: string
144
+ }
145
+
146
+ /** The `(lesson source:x)` marker on lesson entries — flat AND package form. */
147
+ export function lessonSourceTag(source: string): string {
148
+ return `lesson source:${source}`
149
+ }
150
+
151
+ /** One flat lesson entry: same two-line pointer shape + the source marker. */
152
+ export function lessonEntryText(input: LessonPointerInput, budgetTokens: number): { text: string; usedTokens: number } {
153
+ const lines: string[] = []
154
+ lines.push(`### ${input.title} [${input.status}] (topics:${input.slug} score:${input.score.toFixed(2)}) (${lessonSourceTag(input.source)})`)
155
+ const one = (input.description ?? '').replace(/\s+/g, ' ').trim()
156
+ if (one !== '') lines.push(truncateToTokens(one, Math.max(16, budgetTokens - 32)).text)
157
+ const text = lines.join('\n')
158
+ return { text, usedTokens: estimateTokens(text) }
159
+ }
160
+
128
161
  /**
129
162
  * One slow-lane pointer: same shape as a fast pointer plus the mandatory why
130
163
  * line (v4 §4.2 — 每条带 why 行). No score: the gate here was the LLM rerank,
@@ -152,8 +185,9 @@ function wrapBlocks(blocks: readonly string[]): string {
152
185
 
153
186
  /**
154
187
  * Shared greedy packing: fast entries first (score-ordered), then optional
155
- * slow-lane pointers, all under one total budget (v4 B2: shared 600 — the
156
- * slow lane spends whatever the fast lane leaves; 分账终值 is a P3 question).
188
+ * slow-lane pointers, then optional lesson entries (B2: flat-mode normal
189
+ * entry slots), all under one total budget (v4 B2: shared 600 — the slow lane
190
+ * spends whatever the fast lane leaves; 分账终值 is a P3 question).
157
191
  * Zero blocks → empty text (ADR 0006: 零命中零注入).
158
192
  */
159
193
  function packInjection(
@@ -161,12 +195,14 @@ function packInjection(
161
195
  cfg: AssembleConfig,
162
196
  render: (entry: DigestInput, budget: number) => { text: string; usedTokens: number },
163
197
  slow?: readonly SlowPointerInput[],
198
+ lessons?: readonly LessonPointerInput[],
164
199
  ): AssembleResult {
165
- if (entries.length === 0 && (slow === undefined || slow.length === 0)) {
200
+ if (entries.length === 0 && (slow === undefined || slow.length === 0) && (lessons === undefined || lessons.length === 0)) {
166
201
  return { text: '', usedTokens: 0, included: [], dropped: [] }
167
202
  }
168
203
  const included: string[] = []
169
204
  const slowIncluded: string[] = []
205
+ const lessonIncluded: string[] = []
170
206
  const dropped: { slug: string; reason: string }[] = []
171
207
  const blocks: string[] = []
172
208
  let used = estimateTokens(`${WRAPPER_OPEN}\n${WRAPPER_INTRO}\n\n${WRAPPER_CLOSE}`)
@@ -200,33 +236,486 @@ function packInjection(
200
236
  slowIncluded.push(item.slug)
201
237
  used += block.usedTokens
202
238
  }
239
+ for (const item of lessons ?? []) {
240
+ const budget = Math.min(cfg.perTopicBudget, cfg.totalBudget - used)
241
+ if (budget < 24) {
242
+ dropped.push({ slug: item.slug, reason: 'lesson-budget' })
243
+ continue
244
+ }
245
+ const block = lessonEntryText(item, budget)
246
+ if (used + block.usedTokens > cfg.totalBudget) {
247
+ dropped.push({ slug: item.slug, reason: 'lesson-budget' })
248
+ continue
249
+ }
250
+ blocks.push(block.text)
251
+ lessonIncluded.push(item.slug)
252
+ used += block.usedTokens
253
+ }
203
254
  if (blocks.length === 0) return { text: '', usedTokens: 0, included: [], dropped }
204
255
  const text = wrapBlocks(blocks)
205
256
  const result: AssembleResult = { text, usedTokens: estimateTokens(text), included, dropped }
206
257
  if (slowIncluded.length > 0) result.slowIncluded = slowIncluded
258
+ if (lessonIncluded.length > 0) {
259
+ result.lessonIncluded = lessonIncluded
260
+ result.included = [...included, ...lessonIncluded]
261
+ }
207
262
  return result
208
263
  }
209
264
 
210
265
  /**
211
266
  * Greedily pack pointer entries (hits already score-ordered) under the total
212
- * budget. Zero hits → empty text (ADR 0006: 零命中零注入).
267
+ * budget. Zero hits → empty text (ADR 0006: 零命中零注入). `lessons` (B2)
268
+ * packs after the slow pointers as flat entries with the source marker.
213
269
  */
214
270
  export function assemblePointer(
215
271
  entries: readonly DigestInput[],
216
272
  cfg: AssembleConfig,
217
273
  slow?: readonly SlowPointerInput[],
274
+ lessons?: readonly LessonPointerInput[],
218
275
  ): AssembleResult {
219
- return packInjection(entries, cfg, pointerEntry, slow)
276
+ return packInjection(entries, cfg, pointerEntry, slow, lessons)
220
277
  }
221
278
 
222
279
  /**
223
- * Greedily pack per-topic digests (hits already score-ordered) under the
224
- * total budget. Zero hits → empty text (ADR 0006: 零命中零注入).
280
+ * Greedily pack per-topic digests (hits already score-ordered) under the total
281
+ * budget. Zero hits → empty text (ADR 0006: 零命中零注入). `lessons` (B2).
225
282
  */
226
283
  export function assembleInjection(
227
284
  entries: readonly DigestInput[],
228
285
  cfg: AssembleConfig,
229
286
  slow?: readonly SlowPointerInput[],
287
+ lessons?: readonly LessonPointerInput[],
230
288
  ): AssembleResult {
231
- return packInjection(entries, cfg, topicDigest, slow)
289
+ return packInjection(entries, cfg, topicDigest, slow, lessons)
290
+ }
291
+
292
+ // ---------------------------------------------------------------------------
293
+ // A3 相关性三层信息包 (spec 2026-10-self-improve-topics §2) — the gated new
294
+ // shape. Coexists with assemblePointer: the service-layer mode gate decides
295
+ // which renderer runs; both stay pure here. 分层预算取代单条 80(§2.6):
296
+ // 直接相关 ~120×≤2、邻域 ~40×≤3、弱信号合并一行;总预算钉 600 不浮动
297
+ // (A3 决议 3)。降档=琐碎 query 且与全部候选零词面实体 → null(整包跳过)。
298
+ // ---------------------------------------------------------------------------
299
+
300
+ /** One retrieval-side candidate, roster metadata pre-extracted by the caller. */
301
+ export interface PackageCandidate {
302
+ slug: string
303
+ title: string
304
+ status: string
305
+ description?: string
306
+ /** Conclusion section first line (fallback for a missing description). */
307
+ conclusion?: string
308
+ score: number
309
+ /** Retrieval reason tags (`triggers:0.83` / `title:0.50` / `tags:…` / …). */
310
+ reasons: string[]
311
+ /** Edge/lane provenance: `depends:dN` | `slow-pick` | `semantic…` | raw. */
312
+ via?: string
313
+ /**
314
+ * Lesson priority seat (B2, spec 卷一 §6.3): sorts FIRST in the direct
315
+ * layer and is exempt from the ≤2 direct-seat cap (still budget-bound) —
316
+ * 「主层 ≤2 席中命中 lesson 至少 1 席」. Absent on every topic candidate.
317
+ */
318
+ prioritySeat?: boolean
319
+ /** Rendered as a `(${sourceTag})` suffix on the entry head — lesson source
320
+ * marker (`lesson source:correction`), MJ-C. */
321
+ sourceTag?: string
322
+ }
323
+
324
+ export interface PackageInput {
325
+ query: string
326
+ /** Query lexemes the caller retrieved with (tokenize + §3.3 cleaning). */
327
+ queryTokens: string[]
328
+ /** Lexical strong hits (gate-passed); lands in the direct layer. */
329
+ direct: PackageCandidate[]
330
+ /** Graph expansion + slow-lane picks; lands in the neighborhood layer. */
331
+ neighborhood: PackageCandidate[]
332
+ /** nearFloor band (0.20–0.30) head; lands in the merged weak-signal line. */
333
+ weak: PackageCandidate[]
334
+ /** ADR 0015 usage signal — within-layer ordering tiebreak only (v1). */
335
+ usageBySlug?: Map<string, number>
336
+ }
337
+
338
+ export interface PackageOpts {
339
+ /**
340
+ * `'on'` renders the live package; `'shadow'` means the caller double-runs
341
+ * (old assemblePointer + this) for the bypass ledger (spec §8.3). The pure
342
+ * output is identical in both modes — the mode is call-site bookkeeping.
343
+ */
344
+ mode?: 'on' | 'shadow'
345
+ }
346
+
347
+ export interface PackageResult {
348
+ text: string
349
+ usedTokens: number
350
+ included: string[]
351
+ dropped: { slug: string; reason: string }[]
352
+ /** Final per-layer rosters (what the 包头 counts report). */
353
+ layers: { direct: string[]; neighborhood: string[]; weak: string[] }
354
+ }
355
+
356
+ /** Total package cap — pinned, does not float with hit strength (A3 决议 3). */
357
+ export const PACKAGE_TOTAL = 600
358
+ export const PACKAGE_DIRECT_MAX = 2
359
+ export const PACKAGE_DIRECT_BUDGET = 120
360
+ export const PACKAGE_NEIGHBOR_MAX = 3
361
+ export const PACKAGE_NEIGHBOR_BUDGET = 40
362
+ export const PACKAGE_WEAK_MAX = 2
363
+ /**
364
+ * Padded-trivial arm of the downgrade guard (spec §2.3 名词实体口径, an
365
+ * implementation-time item per §11): after stripping whitespace, a query this
366
+ * short counts as 琐碎 even when the tokenizer expands CJK bigrams past 3
367
+ * tokens. Calibrated on the two named scenarios: 场景① ack query strips to
368
+ * 15 chars (downgrade), a real review query strips to 18 (kept).
369
+ */
370
+ export const PACKAGE_TRIVIAL_QUERY_CHARS = 16
371
+
372
+ const PACKAGE_WRAPPER_OPEN = '<topic-memory>'
373
+ const PACKAGE_WRAPPER_CLOSE = '</topic-memory>'
374
+
375
+ /** Edge/lane note for the why line / neighborhood annotation (A3 决议 2). */
376
+ function viaNote(via: string | undefined): string | null {
377
+ if (via === undefined || via === '') return null
378
+ if (via.startsWith('depends')) return '经 depends 关联'
379
+ if (via.startsWith('slow')) return '慢道 pick'
380
+ if (via.startsWith('semantic')) return '语义近邻'
381
+ return via
382
+ }
383
+
384
+ /** Trigger reason → the trigger word when the caller carries it, `''` channel-only. */
385
+ function triggerWordOfReason(reason: string): string | undefined {
386
+ const m = reason.match(/^triggers?[::](.*)$/)
387
+ if (m === null) return undefined
388
+ const v = m[1].trim()
389
+ return /^[\d.+-]+$/.test(v) ? '' : v
390
+ }
391
+
392
+ /**
393
+ * slug/title 词面,含分隔符子词(`cs-geely-voice-agent` → `voice`/`agent`):
394
+ * query 里的连字符词(voice-agent)与 slug 复合词永远不相等,实体判定必须
395
+ * 下探到子词才有交集;CJK 已由 tokenize 的二元切分覆盖。
396
+ */
397
+ function subtokenSurface(text: string): Set<string> {
398
+ const out = new Set<string>()
399
+ for (const t of tokenize(text)) {
400
+ out.add(t)
401
+ if (/[+#.-]/.test(t)) {
402
+ for (const sub of t.split(/[+#.-]+/)) if (sub.length >= 2) out.add(sub)
403
+ }
404
+ }
405
+ return out
406
+ }
407
+
408
+ function tokenMatchesSurface(token: string, surface: ReadonlySet<string>): boolean {
409
+ const t = token.toLowerCase()
410
+ if (surface.has(t)) return true
411
+ if (/[+#.-]/.test(t)) {
412
+ for (const sub of t.split(/[+#.-]+/)) if (sub.length >= 2 && surface.has(sub)) return true
413
+ }
414
+ return false
415
+ }
416
+
417
+ /**
418
+ * Strict display matcher for why-line attribution: a compound query token
419
+ * counts as a title/weak word only when EVERY separator part ties to the
420
+ * surface — `voice-agent` matches `cs-geely-voice-agent`, but
421
+ * `cs-geely-alert-manager-api` must not match it via the shared `cs`.
422
+ */
423
+ function tokenMatchesSurfaceStrict(token: string, surface: ReadonlySet<string>): boolean {
424
+ const t = token.toLowerCase()
425
+ if (surface.has(t)) return true
426
+ if (!/[+#.-]/.test(t)) return false
427
+ const subs = t.split(/[+#.-]+/).filter((s) => s.length >= 2)
428
+ return subs.length > 0 && subs.every((s) => surface.has(s))
429
+ }
430
+
431
+ /** Query tokens (original spelling, query order) with a词面 tie to the surface. */
432
+ function matchedQueryTokens(queryTokens: readonly string[], surfaceText: string): string[] {
433
+ const surface = subtokenSurface(surfaceText)
434
+ const out: string[] = []
435
+ for (const tok of queryTokens) {
436
+ if (!out.includes(tok) && tokenMatchesSurfaceStrict(tok, surface)) out.push(tok)
437
+ }
438
+ return out
439
+ }
440
+
441
+ /** Entity-anchored candidate: its slug/title surface shares a query token
442
+ * (验证波次修正——A③ 场景实证:词法第 3 名恰是 query 点名实体时,≤2 席
443
+ * 会把任务对象整体剪掉;实体锚定者不受席帽约束,仍受总预算约束)。 */
444
+ function isEntityAnchored(queryTokens: readonly string[], c: PackageCandidate): boolean {
445
+ if (queryTokens.length === 0) return false
446
+ const surface = subtokenSurface(`${c.slug} ${c.title}`)
447
+ for (const tok of queryTokens) {
448
+ if (tokenMatchesSurface(tok, surface)) return true
449
+ }
450
+ return false
451
+ }
452
+
453
+ function hasEntityOverlap(queryTokens: readonly string[], candidates: readonly PackageCandidate[]): boolean {
454
+ if (queryTokens.length === 0) return false
455
+ for (const c of candidates) {
456
+ const surface = subtokenSurface(`${c.slug} ${c.title}`)
457
+ for (const tok of queryTokens) {
458
+ if (tokenMatchesSurface(tok, surface)) return true
459
+ }
460
+ }
461
+ return false
462
+ }
463
+
464
+ function isTrivialQuery(query: string, queryTokens: readonly string[]): boolean {
465
+ if (queryTokens.length <= 3) return true
466
+ const stripped = query.replace(/\s+/g, '')
467
+ return stripped.length > 0 && stripped.length <= PACKAGE_TRIVIAL_QUERY_CHARS
468
+ }
469
+
470
+ /** Stable within-layer order: prioritySeat first (lesson 优先席), then score
471
+ * desc, then usage, then input order. */
472
+ function rankStable(list: readonly PackageCandidate[], usage: Map<string, number> | undefined): PackageCandidate[] {
473
+ return list
474
+ .map((c, i) => ({ c, i, u: usage?.get(c.slug) ?? 0 }))
475
+ .sort((a, b) =>
476
+ (b.c.prioritySeat === true ? 1 : 0) - (a.c.prioritySeat === true ? 1 : 0) ||
477
+ b.c.score - a.c.score ||
478
+ b.u - a.u ||
479
+ a.i - b.i)
480
+ .map((x) => x.c)
481
+ }
482
+
483
+ /** Machine-assembled why line (A3 决议 2): hit channel + edge source + score. */
484
+ function packageWhyLine(c: PackageCandidate, queryTokens: readonly string[]): string {
485
+ const parts: string[] = []
486
+ let triggerWord: string | null = null
487
+ let triggerChannel = false
488
+ let titleHit = false
489
+ const fields: string[] = []
490
+ for (const r of c.reasons) {
491
+ const tw = triggerWordOfReason(r)
492
+ if (tw !== undefined) {
493
+ if (tw === '') triggerChannel = true
494
+ else if (triggerWord === null) triggerWord = tw
495
+ continue
496
+ }
497
+ if (r.startsWith('title:')) titleHit = true
498
+ else if (r.startsWith('tags:')) fields.push('标签')
499
+ else if (r.startsWith('description:')) fields.push('描述')
500
+ else if (r.startsWith('conclusion:')) fields.push('正文')
501
+ else if (r.startsWith('slug:')) fields.push('slug')
502
+ }
503
+ if (triggerWord !== null) parts.push(`trigger「${triggerWord}」`)
504
+ else if (triggerChannel) parts.push('trigger 命中')
505
+ if (titleHit) {
506
+ const words = matchedQueryTokens(queryTokens, `${c.slug} ${c.title}`)
507
+ parts.push(words.length > 0 ? `标题词「${words.slice(0, 2).join('」「')}」` : '标题词命中')
508
+ }
509
+ if (fields.length > 0) parts.push(`${[...new Set(fields)].join('/')}词面命中`)
510
+ const via = viaNote(c.via)
511
+ if (via !== null) parts.push(via)
512
+ if (parts.length === 0) parts.push('词面命中')
513
+ return `命中: ${truncateToTokens(parts.join('、'), 32).text}(score:${c.score.toFixed(2)})`
514
+ }
515
+
516
+ /**
517
+ * One direct-layer full entry (~120 tok): 标题+[status] + 一行实质 +
518
+ * 机器 why 行 + 指令式打开引导(预告源 description 优先,缺失回退结论行时
519
+ * 不再重复预告)。实质行是弹性段,超预算先截它(spec §2.4)。
520
+ */
521
+ function directEntryText(
522
+ c: PackageCandidate,
523
+ queryTokens: readonly string[],
524
+ budget: number,
525
+ ): { text: string; usedTokens: number } {
526
+ const head = `### ${c.title} [${c.status}]${c.sourceTag !== undefined && c.sourceTag !== '' ? ` (${c.sourceTag})` : ''}`
527
+ const why = packageWhyLine(c, queryTokens)
528
+ const warn = c.reasons.includes('conflicted-demoted') ? '⚠ 此条存在未合并冲突,结论可能过期。' : null
529
+ const openBase = `细节: topic_open "${c.slug}"`
530
+ const conclusionLine = (c.conclusion ?? '').trim()
531
+ const substanceRaw = (conclusionLine !== '' ? conclusionLine : (c.description ?? '')).trim()
532
+ const previewRaw = (c.description ?? '').replace(/\s+/g, ' ').trim()
533
+
534
+ const lines: string[] = [head, why]
535
+ let substanceText = ''
536
+ if (substanceRaw !== '') {
537
+ const others = estimateTokens([head, why, openBase].join('\n')) + (warn !== null ? estimateTokens(warn) + 1 : 0)
538
+ const room = budget - others - 1
539
+ if (room >= 8) substanceText = truncateToTokens(substanceRaw, room).text
540
+ }
541
+ if (substanceText !== '') lines.push(substanceText)
542
+ if (warn !== null) lines.push(warn)
543
+ let open = openBase
544
+ if (previewRaw !== '' && previewRaw !== substanceText) {
545
+ const room = budget - estimateTokens([...lines, open].join('\n')) - 3
546
+ if (room >= 6) open = `${openBase},${truncateToTokens(previewRaw, room).text}`
547
+ }
548
+ lines.push(open)
549
+ // Last resort for pathological heads (giant titles): shed elastic lines.
550
+ while (lines.length > 2 && estimateTokens(lines.join('\n')) > budget) {
551
+ const dropWarn = lines.indexOf(warn ?? '')
552
+ if (warn !== null && dropWarn !== -1) lines.splice(dropWarn, 1)
553
+ else if (substanceText !== '' && lines.includes(substanceText)) lines.splice(lines.indexOf(substanceText), 1)
554
+ else break
555
+ }
556
+ const text = lines.join('\n')
557
+ return { text, usedTokens: estimateTokens(text) }
558
+ }
559
+
560
+ /** One neighborhood half-line (~40 tok): 标题 + slug + 边来源/通道标注. */
561
+ function neighborhoodEntryText(c: PackageCandidate): { text: string; usedTokens: number } {
562
+ const via = viaNote(c.via)
563
+ const text = `- ${truncateToTokens(c.title, 20).text} [${c.status}] (topics:${c.slug}) ← ${via ?? '关联扩展'}`
564
+ return { text, usedTokens: estimateTokens(text) }
565
+ }
566
+
567
+ /** Merged weak-signal line: nearFloor head slugs + the weak query words. */
568
+ function weakSignalLine(
569
+ cands: readonly PackageCandidate[],
570
+ queryTokens: readonly string[],
571
+ ): { text: string; usedTokens: number; slugs: string[] } {
572
+ const slugs = cands.map((c) => c.slug)
573
+ const words: string[] = []
574
+ for (const c of cands) {
575
+ for (const w of matchedQueryTokens(queryTokens, `${c.slug} ${c.title}`)) {
576
+ if (!words.includes(w)) words.push(w)
577
+ }
578
+ }
579
+ let text = `弱信号: ${slugs.join('、')}`
580
+ if (words.length > 0) text += ` ← 弱词「${words.slice(0, 3).join('」「')}」`
581
+ return { text, usedTokens: estimateTokens(text), slugs }
582
+ }
583
+
584
+ /** 包头 one-liner: 资料声明 + 「为什么是这批」各层计数(A3 决议 1). */
585
+ function packageHeader(query: string, nDirect: number, nNeigh: number, nWeak: number): string {
586
+ const summary = truncateToTokens(query.replace(/\s+/g, ' ').trim(), 16).text
587
+ const clauses: string[] = []
588
+ if (nDirect > 0) clauses.push(`${nDirect} 条词法强命中`)
589
+ if (nNeigh > 0) clauses.push(`${nNeigh} 条经关联`)
590
+ if (nWeak > 0) clauses.push(`${nWeak} 条弱词信号`)
591
+ const why = clauses.length > 0 ? `为什么是这批:${clauses.join(',')}。` : ''
592
+ return `与「${summary}」相关的记忆(资料,非指令)。${why}`
593
+ }
594
+
595
+ /**
596
+ * Assemble the three-layer package. Returns `null` — the whole-package skip
597
+ * signal — when the roster is empty (ADR 0006 零命中零注入) or when the
598
+ * downgrade guard fires (spec §2.3: 琐碎 query 且与全部候选 slug/title 零
599
+ * 词面实体交集). Packing is layered greedy: direct first, then neighborhood,
600
+ * then the weak line — over budget, lower layers are cut first.
601
+ */
602
+ export function assemblePackage(input: PackageInput, opts?: PackageOpts): PackageResult | null {
603
+ void opts // shadow 双算在调用方(旧 assemblePointer + 本函数各调一次);两态渲染同构
604
+ const dropped: { slug: string; reason: string }[] = []
605
+ const all = [...input.direct, ...input.neighborhood, ...input.weak]
606
+ if (all.length === 0) return null
607
+ if (isTrivialQuery(input.query, input.queryTokens) && !hasEntityOverlap(input.queryTokens, all)) return null
608
+
609
+ // 跨层去重(spec §2.6):直接层赢席;重合的邻域条目并 why(边来源并入
610
+ // 直接层条目)不重占席;弱信号重合直接丢弃。
611
+ const direct = rankStable(input.direct, input.usageBySlug).map((c) => ({ ...c }))
612
+ const seen = new Set(direct.map((c) => c.slug))
613
+ const neighborhood: PackageCandidate[] = []
614
+ for (const c of rankStable(input.neighborhood, input.usageBySlug)) {
615
+ if (seen.has(c.slug)) {
616
+ const host = direct.find((d) => d.slug === c.slug)
617
+ if (host !== undefined && host.via === undefined && c.via !== undefined) host.via = c.via
618
+ dropped.push({ slug: c.slug, reason: 'duplicate' })
619
+ continue
620
+ }
621
+ seen.add(c.slug)
622
+ neighborhood.push(c)
623
+ }
624
+ const weak: PackageCandidate[] = []
625
+ for (const c of rankStable(input.weak, input.usageBySlug)) {
626
+ if (seen.has(c.slug)) {
627
+ dropped.push({ slug: c.slug, reason: 'duplicate' })
628
+ continue
629
+ }
630
+ seen.add(c.slug)
631
+ weak.push(c)
632
+ }
633
+
634
+ // Reserve with an upper-bound header (pre-cap counts); the final header is
635
+ // never longer, so the 600 cap holds after the swap.
636
+ let used =
637
+ estimateTokens(`${PACKAGE_WRAPPER_OPEN}\n${packageHeader(input.query, direct.length, neighborhood.length, weak.length)}\n\n${PACKAGE_WRAPPER_CLOSE}`)
638
+
639
+ const layers: PackageResult['layers'] = { direct: [], neighborhood: [], weak: [] }
640
+ const directBlocks: string[] = []
641
+ for (const c of direct) {
642
+ // prioritySeat (lesson 优先席) is exempt from the direct-seat cap — the
643
+ // budget is the only thing that can still cut it.
644
+ if (directBlocks.length >= PACKAGE_DIRECT_MAX && !isEntityAnchored(input.queryTokens, c) && c.prioritySeat !== true) {
645
+ dropped.push({ slug: c.slug, reason: 'direct-cap' })
646
+ continue
647
+ }
648
+ const budget = Math.min(PACKAGE_DIRECT_BUDGET, PACKAGE_TOTAL - used)
649
+ if (budget < 24) {
650
+ dropped.push({ slug: c.slug, reason: 'total-budget' })
651
+ continue
652
+ }
653
+ const block = directEntryText(c, input.queryTokens, budget)
654
+ if (used + block.usedTokens > PACKAGE_TOTAL) {
655
+ dropped.push({ slug: c.slug, reason: 'total-budget' })
656
+ continue
657
+ }
658
+ directBlocks.push(block.text)
659
+ layers.direct.push(c.slug)
660
+ used += block.usedTokens
661
+ }
662
+
663
+ const neighBlocks: string[] = []
664
+ for (const c of neighborhood) {
665
+ if (neighBlocks.length >= PACKAGE_NEIGHBOR_MAX) {
666
+ dropped.push({ slug: c.slug, reason: 'neighborhood-cap' })
667
+ continue
668
+ }
669
+ const budget = Math.min(PACKAGE_NEIGHBOR_BUDGET, PACKAGE_TOTAL - used)
670
+ if (budget < 16) {
671
+ dropped.push({ slug: c.slug, reason: 'total-budget' })
672
+ continue
673
+ }
674
+ const block = neighborhoodEntryText(c)
675
+ if (used + block.usedTokens > PACKAGE_TOTAL) {
676
+ dropped.push({ slug: c.slug, reason: 'total-budget' })
677
+ continue
678
+ }
679
+ neighBlocks.push(block.text)
680
+ layers.neighborhood.push(c.slug)
681
+ used += block.usedTokens
682
+ }
683
+
684
+ const weakTake = weak.slice(0, PACKAGE_WEAK_MAX)
685
+ for (const c of weak.slice(PACKAGE_WEAK_MAX)) dropped.push({ slug: c.slug, reason: 'weak-cap' })
686
+ let weakText: string | null = null
687
+ if (weakTake.length > 0) {
688
+ const full = weakSignalLine(weakTake, input.queryTokens)
689
+ if (used + full.usedTokens <= PACKAGE_TOTAL) {
690
+ weakText = full.text
691
+ layers.weak = full.slugs
692
+ used += full.usedTokens
693
+ } else if (weakTake.length > 1) {
694
+ const single = weakSignalLine(weakTake.slice(0, 1), input.queryTokens)
695
+ if (used + single.usedTokens <= PACKAGE_TOTAL) {
696
+ weakText = single.text
697
+ layers.weak = single.slugs
698
+ used += single.usedTokens
699
+ for (const slug of weakTake.slice(1).map((c) => c.slug)) dropped.push({ slug, reason: 'total-budget' })
700
+ } else {
701
+ for (const slug of full.slugs) dropped.push({ slug, reason: 'total-budget' })
702
+ }
703
+ } else {
704
+ for (const slug of full.slugs) dropped.push({ slug, reason: 'total-budget' })
705
+ }
706
+ }
707
+
708
+ const parts: string[] = [packageHeader(input.query, layers.direct.length, layers.neighborhood.length, layers.weak.length)]
709
+ if (directBlocks.length > 0) parts.push(['### 直接相关', ...directBlocks].join('\n\n'))
710
+ if (neighBlocks.length > 0) parts.push(['### 邻域', ...neighBlocks].join('\n'))
711
+ if (weakText !== null) parts.push(weakText)
712
+ if (parts.length === 1) return null // everything budget-cut → 零命中零注入
713
+ const text = `${PACKAGE_WRAPPER_OPEN}\n${parts.join('\n\n')}\n${PACKAGE_WRAPPER_CLOSE}`
714
+ return {
715
+ text,
716
+ usedTokens: estimateTokens(text),
717
+ included: [...layers.direct, ...layers.neighborhood, ...layers.weak],
718
+ dropped,
719
+ layers,
720
+ }
232
721
  }