@aiwayds/dsh-topics-memory 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/store.js CHANGED
@@ -42,6 +42,14 @@ function dedupePaths(paths) {
42
42
  function sameStrings(a, b) {
43
43
  return a.length === b.length && a.every((v, i) => v === b[i]);
44
44
  }
45
+ /**
46
+ * Body heading for caller-supplied semantic links (A2). Links are rendered as
47
+ * `[[wikilinks]]` under this section so the EXISTING link-edge machinery
48
+ * (okf.bodyLinkSlugs → computeBacklinks → retrieval graph walk) indexes them
49
+ * as `via: 'link'` edges — the edge representation gains no new type; the
50
+ * body landing spot is what makes them first-class links for free.
51
+ */
52
+ const LINKS_HEADING = 'Links';
45
53
  export class BundleStore {
46
54
  root;
47
55
  actor;
@@ -285,6 +293,14 @@ export class BundleStore {
285
293
  * Write a topic (atomic), regenerate the index, and commit both — one
286
294
  * commit per conclusion change (ADR 0003). `slug` in the doc's depends is
287
295
  * NOT normalized here; callers pass bundle-relative paths.
296
+ *
297
+ * `links` (A2, optional): whitelist-surviving semantic targets from the
298
+ * distill lane. Applied INSIDE this one write transaction — the topic and
299
+ * its edges land together (先落 topic 再建边); the Links section lands as
300
+ * body wikilinks and the index/backlinks regeneration below (same
301
+ * transaction) recomputes the edge index over them. Normalized and
302
+ * self/dedup-guarded here too, so any caller benefits from the same guard
303
+ * the distill lane already ran.
288
304
  */
289
305
  async saveTopic(input, opts) {
290
306
  return this.enqueue(async () => {
@@ -292,9 +308,24 @@ export class BundleStore {
292
308
  const file = this.topicPath(slug);
293
309
  const created = opts.created ?? !(await existsSync(file));
294
310
  const now = new Date().toISOString();
311
+ let body = input.doc.body;
312
+ if (input.links !== undefined && input.links.length > 0) {
313
+ const targets = [];
314
+ for (const raw of input.links) {
315
+ if (typeof raw !== 'string')
316
+ continue;
317
+ const target = okf.normalizeLinkTarget(raw);
318
+ if (target === undefined || target === slug || targets.includes(target))
319
+ continue;
320
+ targets.push(target);
321
+ }
322
+ if (targets.length > 0) {
323
+ body = okf.setSection(body, LINKS_HEADING, targets.map((t) => `- [[${t}]]`).join('\n'));
324
+ }
325
+ }
295
326
  const doc = {
296
327
  fm: { ...input.doc.fm, generated: { by: opts.actor ?? this.actor, at: now } },
297
- body: input.doc.body,
328
+ body,
298
329
  };
299
330
  const raw = okf.serializeTopicDoc(doc);
300
331
  await atomicWrite(file, raw);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwayds/dsh-topics-memory",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "dsh-plugin: quality-first topic memory for dsh — a topic keeps the starting question, conclusion, impact and dependencies, not the process; local-first git-traced OKF bundle, budgeted LLM-free injection",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -42,7 +42,7 @@
42
42
  "build": "tsc -p tsconfig.json",
43
43
  "check": "tsc --noEmit -p tsconfig.json",
44
44
  "pretest": "tsc -p tsconfig.json",
45
- "test": "node --test test/yaml.test.mjs test/okf.test.mjs test/retrieval.test.mjs test/digest.test.mjs test/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",
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",
46
46
  "smoke": "node scripts/smoke-boot.mjs"
47
47
  },
48
48
  "dependencies": {},
package/src/config.ts CHANGED
@@ -105,6 +105,21 @@ export const TopicsConfig = z.object({
105
105
  /** laya-serve systemone endpoint for the pace-maker (default: the local
106
106
  * laya-serve bind from the laya repo's serve mode). */
107
107
  jevLayaUrl: z.string().default('http://127.0.0.1:8000/v1/systemone').volatile(),
108
+ /** Local embedding provider (A4 semantic reranker, spec §3.1): ''
109
+ * sentinel = the whole semantic layer is OFF (pure lexical); 'ollama' =
110
+ * the local ollama embed API (localhost is the ADR 0017 local exception).
111
+ * v1 ships no remote provider — a remote embedding may only ever enter a
112
+ * slow-lane/offline path, never the fast lane (red line ①). */
113
+ embeddingProvider: z.string().default('').volatile(),
114
+ /** Embedding model; '' = provider default (ollama → nomic-embed-text),
115
+ * same sentinel pattern as distillModel. */
116
+ embeddingModel: z.string().default('').volatile(),
117
+ /** Injection-package master gate (A-line, spec §2/§3): off | shadow | on.
118
+ * Carries the WHOLE new-behavior surface — query sanitization, the
119
+ * three-layer package, nearFloor semantic rerank, degradation. off = zero
120
+ * behavior change anywhere (byte-for-byte legacy); shadow = compute +
121
+ * bypass-ledger only, nothing lands in context; on = engage. */
122
+ packageMode: z.string().default('off').volatile(),
108
123
  })
109
124
 
110
125
  export type TopicsConfigValue = {
@@ -149,6 +164,13 @@ export type TopicsConfigValue = {
149
164
  jevSecretFile?: string
150
165
  jevLayaFallback?: boolean
151
166
  jevLayaUrl?: string
167
+ /** Injection-quality line keys (spec §7). Optional in this TS view only
168
+ * (same sentinel pattern as the jev keys): index.ts's bare-harness
169
+ * DEFAULTS literal predates them — consumers must treat undefined as the
170
+ * documented default ('' / '' / 'off' = every new behavior zeroed). */
171
+ embeddingProvider?: string
172
+ embeddingModel?: string
173
+ packageMode?: 'off' | 'shadow' | 'on'
152
174
  }
153
175
 
154
176
  export const CONFIG_KEYS = [
@@ -186,6 +208,9 @@ export const CONFIG_KEYS = [
186
208
  'jevSecretFile',
187
209
  'jevLayaFallback',
188
210
  'jevLayaUrl',
211
+ 'embeddingProvider',
212
+ 'embeddingModel',
213
+ 'packageMode',
189
214
  ] as const
190
215
 
191
216
  export type ConfigKey = (typeof CONFIG_KEYS)[number]
@@ -257,10 +282,19 @@ export function parseConfigValue(key: ConfigKey, raw: string): boolean | number
257
282
  }
258
283
  case 'distillProvider':
259
284
  case 'distillModel':
285
+ case 'embeddingModel':
260
286
  case 'jevModel':
261
287
  case 'jevSecretFile':
262
288
  case 'jevLayaUrl':
263
289
  return raw
290
+ case 'embeddingProvider': {
291
+ if (raw === '' || raw === 'ollama') return raw
292
+ return { error: 'embedding-provider 仅支持 ollama(留空=语义层关;远程 provider 不进快道)' }
293
+ }
294
+ case 'packageMode': {
295
+ if (raw === 'off' || raw === 'shadow' || raw === 'on') return raw
296
+ return { error: 'package-mode 取值 off|shadow|on' }
297
+ }
264
298
  case 'jevLayaFallback': {
265
299
  if (raw === 'on' || raw === 'true') return true
266
300
  if (raw === 'off' || raw === 'false') return false
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 {
@@ -230,3 +231,418 @@ export function assembleInjection(
230
231
  ): AssembleResult {
231
232
  return packInjection(entries, cfg, topicDigest, slow)
232
233
  }
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // A3 相关性三层信息包 (spec 2026-10-self-improve-topics §2) — the gated new
237
+ // shape. Coexists with assemblePointer: the service-layer mode gate decides
238
+ // which renderer runs; both stay pure here. 分层预算取代单条 80(§2.6):
239
+ // 直接相关 ~120×≤2、邻域 ~40×≤3、弱信号合并一行;总预算钉 600 不浮动
240
+ // (A3 决议 3)。降档=琐碎 query 且与全部候选零词面实体 → null(整包跳过)。
241
+ // ---------------------------------------------------------------------------
242
+
243
+ /** One retrieval-side candidate, roster metadata pre-extracted by the caller. */
244
+ export interface PackageCandidate {
245
+ slug: string
246
+ title: string
247
+ status: string
248
+ description?: string
249
+ /** Conclusion section first line (fallback for a missing description). */
250
+ conclusion?: string
251
+ score: number
252
+ /** Retrieval reason tags (`triggers:0.83` / `title:0.50` / `tags:…` / …). */
253
+ reasons: string[]
254
+ /** Edge/lane provenance: `depends:dN` | `slow-pick` | `semantic…` | raw. */
255
+ via?: string
256
+ }
257
+
258
+ export interface PackageInput {
259
+ query: string
260
+ /** Query lexemes the caller retrieved with (tokenize + §3.3 cleaning). */
261
+ queryTokens: string[]
262
+ /** Lexical strong hits (gate-passed); lands in the direct layer. */
263
+ direct: PackageCandidate[]
264
+ /** Graph expansion + slow-lane picks; lands in the neighborhood layer. */
265
+ neighborhood: PackageCandidate[]
266
+ /** nearFloor band (0.20–0.30) head; lands in the merged weak-signal line. */
267
+ weak: PackageCandidate[]
268
+ /** ADR 0015 usage signal — within-layer ordering tiebreak only (v1). */
269
+ usageBySlug?: Map<string, number>
270
+ }
271
+
272
+ export interface PackageOpts {
273
+ /**
274
+ * `'on'` renders the live package; `'shadow'` means the caller double-runs
275
+ * (old assemblePointer + this) for the bypass ledger (spec §8.3). The pure
276
+ * output is identical in both modes — the mode is call-site bookkeeping.
277
+ */
278
+ mode?: 'on' | 'shadow'
279
+ }
280
+
281
+ export interface PackageResult {
282
+ text: string
283
+ usedTokens: number
284
+ included: string[]
285
+ dropped: { slug: string; reason: string }[]
286
+ /** Final per-layer rosters (what the 包头 counts report). */
287
+ layers: { direct: string[]; neighborhood: string[]; weak: string[] }
288
+ }
289
+
290
+ /** Total package cap — pinned, does not float with hit strength (A3 决议 3). */
291
+ export const PACKAGE_TOTAL = 600
292
+ export const PACKAGE_DIRECT_MAX = 2
293
+ export const PACKAGE_DIRECT_BUDGET = 120
294
+ export const PACKAGE_NEIGHBOR_MAX = 3
295
+ export const PACKAGE_NEIGHBOR_BUDGET = 40
296
+ export const PACKAGE_WEAK_MAX = 2
297
+ /**
298
+ * Padded-trivial arm of the downgrade guard (spec §2.3 名词实体口径, an
299
+ * implementation-time item per §11): after stripping whitespace, a query this
300
+ * short counts as 琐碎 even when the tokenizer expands CJK bigrams past 3
301
+ * tokens. Calibrated on the two named scenarios: 场景① ack query strips to
302
+ * 15 chars (downgrade), a real review query strips to 18 (kept).
303
+ */
304
+ export const PACKAGE_TRIVIAL_QUERY_CHARS = 16
305
+
306
+ const PACKAGE_WRAPPER_OPEN = '<topic-memory>'
307
+ const PACKAGE_WRAPPER_CLOSE = '</topic-memory>'
308
+
309
+ /** Edge/lane note for the why line / neighborhood annotation (A3 决议 2). */
310
+ function viaNote(via: string | undefined): string | null {
311
+ if (via === undefined || via === '') return null
312
+ if (via.startsWith('depends')) return '经 depends 关联'
313
+ if (via.startsWith('slow')) return '慢道 pick'
314
+ if (via.startsWith('semantic')) return '语义近邻'
315
+ return via
316
+ }
317
+
318
+ /** Trigger reason → the trigger word when the caller carries it, `''` channel-only. */
319
+ function triggerWordOfReason(reason: string): string | undefined {
320
+ const m = reason.match(/^triggers?[::](.*)$/)
321
+ if (m === null) return undefined
322
+ const v = m[1].trim()
323
+ return /^[\d.+-]+$/.test(v) ? '' : v
324
+ }
325
+
326
+ /**
327
+ * slug/title 词面,含分隔符子词(`cs-geely-voice-agent` → `voice`/`agent`):
328
+ * query 里的连字符词(voice-agent)与 slug 复合词永远不相等,实体判定必须
329
+ * 下探到子词才有交集;CJK 已由 tokenize 的二元切分覆盖。
330
+ */
331
+ function subtokenSurface(text: string): Set<string> {
332
+ const out = new Set<string>()
333
+ for (const t of tokenize(text)) {
334
+ out.add(t)
335
+ if (/[+#.-]/.test(t)) {
336
+ for (const sub of t.split(/[+#.-]+/)) if (sub.length >= 2) out.add(sub)
337
+ }
338
+ }
339
+ return out
340
+ }
341
+
342
+ function tokenMatchesSurface(token: string, surface: ReadonlySet<string>): boolean {
343
+ const t = token.toLowerCase()
344
+ if (surface.has(t)) return true
345
+ if (/[+#.-]/.test(t)) {
346
+ for (const sub of t.split(/[+#.-]+/)) if (sub.length >= 2 && surface.has(sub)) return true
347
+ }
348
+ return false
349
+ }
350
+
351
+ /**
352
+ * Strict display matcher for why-line attribution: a compound query token
353
+ * counts as a title/weak word only when EVERY separator part ties to the
354
+ * surface — `voice-agent` matches `cs-geely-voice-agent`, but
355
+ * `cs-geely-alert-manager-api` must not match it via the shared `cs`.
356
+ */
357
+ function tokenMatchesSurfaceStrict(token: string, surface: ReadonlySet<string>): boolean {
358
+ const t = token.toLowerCase()
359
+ if (surface.has(t)) return true
360
+ if (!/[+#.-]/.test(t)) return false
361
+ const subs = t.split(/[+#.-]+/).filter((s) => s.length >= 2)
362
+ return subs.length > 0 && subs.every((s) => surface.has(s))
363
+ }
364
+
365
+ /** Query tokens (original spelling, query order) with a词面 tie to the surface. */
366
+ function matchedQueryTokens(queryTokens: readonly string[], surfaceText: string): string[] {
367
+ const surface = subtokenSurface(surfaceText)
368
+ const out: string[] = []
369
+ for (const tok of queryTokens) {
370
+ if (!out.includes(tok) && tokenMatchesSurfaceStrict(tok, surface)) out.push(tok)
371
+ }
372
+ return out
373
+ }
374
+
375
+ /** Entity-anchored candidate: its slug/title surface shares a query token
376
+ * (验证波次修正——A③ 场景实证:词法第 3 名恰是 query 点名实体时,≤2 席
377
+ * 会把任务对象整体剪掉;实体锚定者不受席帽约束,仍受总预算约束)。 */
378
+ function isEntityAnchored(queryTokens: readonly string[], c: PackageCandidate): boolean {
379
+ if (queryTokens.length === 0) return false
380
+ const surface = subtokenSurface(`${c.slug} ${c.title}`)
381
+ for (const tok of queryTokens) {
382
+ if (tokenMatchesSurface(tok, surface)) return true
383
+ }
384
+ return false
385
+ }
386
+
387
+ function hasEntityOverlap(queryTokens: readonly string[], candidates: readonly PackageCandidate[]): boolean {
388
+ if (queryTokens.length === 0) return false
389
+ for (const c of candidates) {
390
+ const surface = subtokenSurface(`${c.slug} ${c.title}`)
391
+ for (const tok of queryTokens) {
392
+ if (tokenMatchesSurface(tok, surface)) return true
393
+ }
394
+ }
395
+ return false
396
+ }
397
+
398
+ function isTrivialQuery(query: string, queryTokens: readonly string[]): boolean {
399
+ if (queryTokens.length <= 3) return true
400
+ const stripped = query.replace(/\s+/g, '')
401
+ return stripped.length > 0 && stripped.length <= PACKAGE_TRIVIAL_QUERY_CHARS
402
+ }
403
+
404
+ /** Stable within-layer order: score desc, then usage, then input order. */
405
+ function rankStable(list: readonly PackageCandidate[], usage: Map<string, number> | undefined): PackageCandidate[] {
406
+ return list
407
+ .map((c, i) => ({ c, i, u: usage?.get(c.slug) ?? 0 }))
408
+ .sort((a, b) => b.c.score - a.c.score || b.u - a.u || a.i - b.i)
409
+ .map((x) => x.c)
410
+ }
411
+
412
+ /** Machine-assembled why line (A3 决议 2): hit channel + edge source + score. */
413
+ function packageWhyLine(c: PackageCandidate, queryTokens: readonly string[]): string {
414
+ const parts: string[] = []
415
+ let triggerWord: string | null = null
416
+ let triggerChannel = false
417
+ let titleHit = false
418
+ const fields: string[] = []
419
+ for (const r of c.reasons) {
420
+ const tw = triggerWordOfReason(r)
421
+ if (tw !== undefined) {
422
+ if (tw === '') triggerChannel = true
423
+ else if (triggerWord === null) triggerWord = tw
424
+ continue
425
+ }
426
+ if (r.startsWith('title:')) titleHit = true
427
+ else if (r.startsWith('tags:')) fields.push('标签')
428
+ else if (r.startsWith('description:')) fields.push('描述')
429
+ else if (r.startsWith('conclusion:')) fields.push('正文')
430
+ else if (r.startsWith('slug:')) fields.push('slug')
431
+ }
432
+ if (triggerWord !== null) parts.push(`trigger「${triggerWord}」`)
433
+ else if (triggerChannel) parts.push('trigger 命中')
434
+ if (titleHit) {
435
+ const words = matchedQueryTokens(queryTokens, `${c.slug} ${c.title}`)
436
+ parts.push(words.length > 0 ? `标题词「${words.slice(0, 2).join('」「')}」` : '标题词命中')
437
+ }
438
+ if (fields.length > 0) parts.push(`${[...new Set(fields)].join('/')}词面命中`)
439
+ const via = viaNote(c.via)
440
+ if (via !== null) parts.push(via)
441
+ if (parts.length === 0) parts.push('词面命中')
442
+ return `命中: ${truncateToTokens(parts.join('、'), 32).text}(score:${c.score.toFixed(2)})`
443
+ }
444
+
445
+ /**
446
+ * One direct-layer full entry (~120 tok): 标题+[status] + 一行实质 +
447
+ * 机器 why 行 + 指令式打开引导(预告源 description 优先,缺失回退结论行时
448
+ * 不再重复预告)。实质行是弹性段,超预算先截它(spec §2.4)。
449
+ */
450
+ function directEntryText(
451
+ c: PackageCandidate,
452
+ queryTokens: readonly string[],
453
+ budget: number,
454
+ ): { text: string; usedTokens: number } {
455
+ const head = `### ${c.title} [${c.status}]`
456
+ const why = packageWhyLine(c, queryTokens)
457
+ const warn = c.reasons.includes('conflicted-demoted') ? '⚠ 此条存在未合并冲突,结论可能过期。' : null
458
+ const openBase = `细节: topic_open "${c.slug}"`
459
+ const conclusionLine = (c.conclusion ?? '').trim()
460
+ const substanceRaw = (conclusionLine !== '' ? conclusionLine : (c.description ?? '')).trim()
461
+ const previewRaw = (c.description ?? '').replace(/\s+/g, ' ').trim()
462
+
463
+ const lines: string[] = [head, why]
464
+ let substanceText = ''
465
+ if (substanceRaw !== '') {
466
+ const others = estimateTokens([head, why, openBase].join('\n')) + (warn !== null ? estimateTokens(warn) + 1 : 0)
467
+ const room = budget - others - 1
468
+ if (room >= 8) substanceText = truncateToTokens(substanceRaw, room).text
469
+ }
470
+ if (substanceText !== '') lines.push(substanceText)
471
+ if (warn !== null) lines.push(warn)
472
+ let open = openBase
473
+ if (previewRaw !== '' && previewRaw !== substanceText) {
474
+ const room = budget - estimateTokens([...lines, open].join('\n')) - 3
475
+ if (room >= 6) open = `${openBase},${truncateToTokens(previewRaw, room).text}`
476
+ }
477
+ lines.push(open)
478
+ // Last resort for pathological heads (giant titles): shed elastic lines.
479
+ while (lines.length > 2 && estimateTokens(lines.join('\n')) > budget) {
480
+ const dropWarn = lines.indexOf(warn ?? '')
481
+ if (warn !== null && dropWarn !== -1) lines.splice(dropWarn, 1)
482
+ else if (substanceText !== '' && lines.includes(substanceText)) lines.splice(lines.indexOf(substanceText), 1)
483
+ else break
484
+ }
485
+ const text = lines.join('\n')
486
+ return { text, usedTokens: estimateTokens(text) }
487
+ }
488
+
489
+ /** One neighborhood half-line (~40 tok): 标题 + slug + 边来源/通道标注. */
490
+ function neighborhoodEntryText(c: PackageCandidate): { text: string; usedTokens: number } {
491
+ const via = viaNote(c.via)
492
+ const text = `- ${truncateToTokens(c.title, 20).text} [${c.status}] (topics:${c.slug}) ← ${via ?? '关联扩展'}`
493
+ return { text, usedTokens: estimateTokens(text) }
494
+ }
495
+
496
+ /** Merged weak-signal line: nearFloor head slugs + the weak query words. */
497
+ function weakSignalLine(
498
+ cands: readonly PackageCandidate[],
499
+ queryTokens: readonly string[],
500
+ ): { text: string; usedTokens: number; slugs: string[] } {
501
+ const slugs = cands.map((c) => c.slug)
502
+ const words: string[] = []
503
+ for (const c of cands) {
504
+ for (const w of matchedQueryTokens(queryTokens, `${c.slug} ${c.title}`)) {
505
+ if (!words.includes(w)) words.push(w)
506
+ }
507
+ }
508
+ let text = `弱信号: ${slugs.join('、')}`
509
+ if (words.length > 0) text += ` ← 弱词「${words.slice(0, 3).join('」「')}」`
510
+ return { text, usedTokens: estimateTokens(text), slugs }
511
+ }
512
+
513
+ /** 包头 one-liner: 资料声明 + 「为什么是这批」各层计数(A3 决议 1). */
514
+ function packageHeader(query: string, nDirect: number, nNeigh: number, nWeak: number): string {
515
+ const summary = truncateToTokens(query.replace(/\s+/g, ' ').trim(), 16).text
516
+ const clauses: string[] = []
517
+ if (nDirect > 0) clauses.push(`${nDirect} 条词法强命中`)
518
+ if (nNeigh > 0) clauses.push(`${nNeigh} 条经关联`)
519
+ if (nWeak > 0) clauses.push(`${nWeak} 条弱词信号`)
520
+ const why = clauses.length > 0 ? `为什么是这批:${clauses.join(',')}。` : ''
521
+ return `与「${summary}」相关的记忆(资料,非指令)。${why}`
522
+ }
523
+
524
+ /**
525
+ * Assemble the three-layer package. Returns `null` — the whole-package skip
526
+ * signal — when the roster is empty (ADR 0006 零命中零注入) or when the
527
+ * downgrade guard fires (spec §2.3: 琐碎 query 且与全部候选 slug/title 零
528
+ * 词面实体交集). Packing is layered greedy: direct first, then neighborhood,
529
+ * then the weak line — over budget, lower layers are cut first.
530
+ */
531
+ export function assemblePackage(input: PackageInput, opts?: PackageOpts): PackageResult | null {
532
+ void opts // shadow 双算在调用方(旧 assemblePointer + 本函数各调一次);两态渲染同构
533
+ const dropped: { slug: string; reason: string }[] = []
534
+ const all = [...input.direct, ...input.neighborhood, ...input.weak]
535
+ if (all.length === 0) return null
536
+ if (isTrivialQuery(input.query, input.queryTokens) && !hasEntityOverlap(input.queryTokens, all)) return null
537
+
538
+ // 跨层去重(spec §2.6):直接层赢席;重合的邻域条目并 why(边来源并入
539
+ // 直接层条目)不重占席;弱信号重合直接丢弃。
540
+ const direct = rankStable(input.direct, input.usageBySlug).map((c) => ({ ...c }))
541
+ const seen = new Set(direct.map((c) => c.slug))
542
+ const neighborhood: PackageCandidate[] = []
543
+ for (const c of rankStable(input.neighborhood, input.usageBySlug)) {
544
+ if (seen.has(c.slug)) {
545
+ const host = direct.find((d) => d.slug === c.slug)
546
+ if (host !== undefined && host.via === undefined && c.via !== undefined) host.via = c.via
547
+ dropped.push({ slug: c.slug, reason: 'duplicate' })
548
+ continue
549
+ }
550
+ seen.add(c.slug)
551
+ neighborhood.push(c)
552
+ }
553
+ const weak: PackageCandidate[] = []
554
+ for (const c of rankStable(input.weak, input.usageBySlug)) {
555
+ if (seen.has(c.slug)) {
556
+ dropped.push({ slug: c.slug, reason: 'duplicate' })
557
+ continue
558
+ }
559
+ seen.add(c.slug)
560
+ weak.push(c)
561
+ }
562
+
563
+ // Reserve with an upper-bound header (pre-cap counts); the final header is
564
+ // never longer, so the 600 cap holds after the swap.
565
+ let used =
566
+ estimateTokens(`${PACKAGE_WRAPPER_OPEN}\n${packageHeader(input.query, direct.length, neighborhood.length, weak.length)}\n\n${PACKAGE_WRAPPER_CLOSE}`)
567
+
568
+ const layers: PackageResult['layers'] = { direct: [], neighborhood: [], weak: [] }
569
+ const directBlocks: string[] = []
570
+ for (const c of direct) {
571
+ if (directBlocks.length >= PACKAGE_DIRECT_MAX && !isEntityAnchored(input.queryTokens, c)) {
572
+ dropped.push({ slug: c.slug, reason: 'direct-cap' })
573
+ continue
574
+ }
575
+ const budget = Math.min(PACKAGE_DIRECT_BUDGET, PACKAGE_TOTAL - used)
576
+ if (budget < 24) {
577
+ dropped.push({ slug: c.slug, reason: 'total-budget' })
578
+ continue
579
+ }
580
+ const block = directEntryText(c, input.queryTokens, budget)
581
+ if (used + block.usedTokens > PACKAGE_TOTAL) {
582
+ dropped.push({ slug: c.slug, reason: 'total-budget' })
583
+ continue
584
+ }
585
+ directBlocks.push(block.text)
586
+ layers.direct.push(c.slug)
587
+ used += block.usedTokens
588
+ }
589
+
590
+ const neighBlocks: string[] = []
591
+ for (const c of neighborhood) {
592
+ if (neighBlocks.length >= PACKAGE_NEIGHBOR_MAX) {
593
+ dropped.push({ slug: c.slug, reason: 'neighborhood-cap' })
594
+ continue
595
+ }
596
+ const budget = Math.min(PACKAGE_NEIGHBOR_BUDGET, PACKAGE_TOTAL - used)
597
+ if (budget < 16) {
598
+ dropped.push({ slug: c.slug, reason: 'total-budget' })
599
+ continue
600
+ }
601
+ const block = neighborhoodEntryText(c)
602
+ if (used + block.usedTokens > PACKAGE_TOTAL) {
603
+ dropped.push({ slug: c.slug, reason: 'total-budget' })
604
+ continue
605
+ }
606
+ neighBlocks.push(block.text)
607
+ layers.neighborhood.push(c.slug)
608
+ used += block.usedTokens
609
+ }
610
+
611
+ const weakTake = weak.slice(0, PACKAGE_WEAK_MAX)
612
+ for (const c of weak.slice(PACKAGE_WEAK_MAX)) dropped.push({ slug: c.slug, reason: 'weak-cap' })
613
+ let weakText: string | null = null
614
+ if (weakTake.length > 0) {
615
+ const full = weakSignalLine(weakTake, input.queryTokens)
616
+ if (used + full.usedTokens <= PACKAGE_TOTAL) {
617
+ weakText = full.text
618
+ layers.weak = full.slugs
619
+ used += full.usedTokens
620
+ } else if (weakTake.length > 1) {
621
+ const single = weakSignalLine(weakTake.slice(0, 1), input.queryTokens)
622
+ if (used + single.usedTokens <= PACKAGE_TOTAL) {
623
+ weakText = single.text
624
+ layers.weak = single.slugs
625
+ used += single.usedTokens
626
+ for (const slug of weakTake.slice(1).map((c) => c.slug)) dropped.push({ slug, reason: 'total-budget' })
627
+ } else {
628
+ for (const slug of full.slugs) dropped.push({ slug, reason: 'total-budget' })
629
+ }
630
+ } else {
631
+ for (const slug of full.slugs) dropped.push({ slug, reason: 'total-budget' })
632
+ }
633
+ }
634
+
635
+ const parts: string[] = [packageHeader(input.query, layers.direct.length, layers.neighborhood.length, layers.weak.length)]
636
+ if (directBlocks.length > 0) parts.push(['### 直接相关', ...directBlocks].join('\n\n'))
637
+ if (neighBlocks.length > 0) parts.push(['### 邻域', ...neighBlocks].join('\n'))
638
+ if (weakText !== null) parts.push(weakText)
639
+ if (parts.length === 1) return null // everything budget-cut → 零命中零注入
640
+ const text = `${PACKAGE_WRAPPER_OPEN}\n${parts.join('\n\n')}\n${PACKAGE_WRAPPER_CLOSE}`
641
+ return {
642
+ text,
643
+ usedTokens: estimateTokens(text),
644
+ included: [...layers.direct, ...layers.neighborhood, ...layers.weak],
645
+ dropped,
646
+ layers,
647
+ }
648
+ }