@aiwayds/dsh-topics-memory 0.21.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/README.md CHANGED
@@ -201,6 +201,7 @@ First-time setup belongs to `/topics onboard`; day-to-day tuning is `/topics set
201
201
  | `consolidateCadence` | `daily` | Consolidation-lane cadence: `daily`/`3d`/`7d`/`off`. At session start the plugin checks the last consolidation time (`meta/consolidate-state.json`) and, when due, runs the LLM gardener in the background (reusing the distill model route): local lexical clustering only feeds near-look-alike candidate clusters; four op kinds merge/promote/deprecate/refresh, out-of-scope ops are dropped; a failed call never advances the stamp, so the next start retries |
202
202
  | `deprecatedTtlDays` | `15` | Deprecated topics older than N days are dropped at session start (local rule, no model; each drop is its own git commit — history stays recoverable); `0` disables the sweep |
203
203
  | `usageBoost` | `0.15` | Usage boost (ADR 0015): topics injected/opened in the last 30 days score higher at retrieval (gate-scoped, capped at 0.2, never granted to zero-lexical candidates, structural gate not waived); `0` disables |
204
+ | `zeroOpenDecay` | `false` | Zero-open decay: halve the retrieval score of zero-open high-frequency slugs before the threshold cut (see the section below) |
204
205
  | `pushDebounceSeconds` | `45` | GitHub-mode debounced push interval |
205
206
  | `jevEnabled` | `false` | System One decision layer master switch (experimental): `false` = zero behavior change — see the Jev decision layer section above |
206
207
  | `jevBackend` | `zen` | Decision endpoint: `zen` (free) / `native` / `openrouter` |
@@ -210,6 +211,10 @@ First-time setup belongs to `/topics onboard`; day-to-day tuning is `/topics set
210
211
  | `jevLayaFallback` | `false` | Local laya pace-maker: fire a parallel laya call on every jev request; its answer takes over (degraded, relative-ranking only) when the primary fails |
211
212
  | `jevLayaUrl` | `http://127.0.0.1:8000/v1/systemone` | laya-serve endpoint for the pace-maker |
212
213
 
214
+ ### Zero-open decay (default off)
215
+
216
+ `zeroOpenDecay: true` halves the hit score of slugs the stats panel lists as 零打开高频 — truly rendered ≥5 injections in the rolling 30 days with zero `topic_open` events ever. The decay applies at score finalization, before the match threshold, so a decayed candidate that drops below it lands in nearMisses carrying the `zero-open-decay` reason — the injection log shows why it stopped ranking. Motivation: 24 high-frequency topics held the injection leaderboard with zero opens while score and opens proved only weakly correlated (the 0.3 threshold stays untouched). Default `false` means zero behavior change; unreadable injection/open logs skip the decay (fail-open). Toggle live with `/topics set zero-open-decay on|off`.
217
+
213
218
  ## Acknowledgements
214
219
 
215
220
  This project's shape is directly inspired and supported by:
package/README.zh.md CHANGED
@@ -187,6 +187,7 @@ dsh plugin --profile <name> remove @aiwayds/dsh-topics-memory
187
187
  | `consolidateCadence` | `daily` | 整理 lane 节拍:`daily`/`3d`/`7d`/`off`。会话启动时检查上次整理时间(`meta/consolidate-state.json`),到期即在后台自动跑 LLM 园丁(复用蒸馏模型路由):本地词面聚类只送「长得像」的候选簇,四类动作 merge/promote/deprecate/refresh,越权 op 一律丢弃;模型调用失败不推进节拍,下次启动重试 |
188
188
  | `deprecatedTtlDays` | `15` | deprecated 条目超过 N 天在会话启动时自动删除(本地规则不依赖模型,逐条 git commit 可回溯找回);`0` 关闭清扫 |
189
189
  | `usageBoost` | `0.15` | 使用加成(ADR 0015):近 30 天被注入命中/点开过的 Topic 检索加分(计入门槛分、帽 0.2、零词面相关不加、结构门不豁免);`0` 关闭 |
190
+ | `zeroOpenDecay` | `false` | 零打开衰减:零打开高频 slug 的命中分在阈值裁剪前减半(见下方小节) |
190
191
  | `pushDebounceSeconds` | `45` | GitHub 模式去抖推送间隔 |
191
192
  | `jevEnabled` | `false` | System One 决策层总开关(实验):`false` = 零行为变化——见上方「Jev 决策层」一节 |
192
193
  | `jevBackend` | `zen` | 决策端点:`zen`(免费)/ `native` / `openrouter` |
@@ -197,6 +198,10 @@ dsh plugin --profile <name> remove @aiwayds/dsh-topics-memory
197
198
  | `jevLayaUrl` | `http://127.0.0.1:8000/v1/systemone` | 陪跑用的 laya-serve 端点 |
198
199
  | `jevDebug` | 关 | 诊断日志开关(走宿主 logger,默认静默) |
199
200
 
201
+ ### 零打开衰减(默认关)
202
+
203
+ `zeroOpenDecay: true` 时,命中分在 matchThreshold 裁剪前乘 0.5:滚动 30 天内被真正注入 ≥5 次、且 opens.jsonl 全期 0 次打开的 slug(即 `/topics stats` 的「零打开高频」清单——stats 面板与打分衰减共用同一条共享规则)不再霸榜;跌破阈值的候选自然落入 nearMisses 并带 `zero-open-decay` 标签,注入日志可见原因。动机:24 个「注入 ≥5 次、0 打开」的高频话题长期霸榜,而 score 与打开仅弱相关(阈值 0.3 不动)。默认 `false` 零行为变化;injections/opens 读取失败跳过衰减(fail-open);`/topics set zero-open-decay on|off` 运行时切换。
204
+
200
205
  ### laya 本地陪跑(实验,默认关)
201
206
 
202
207
  `jevLayaFallback: true` 且本地跑着 [laya](https://github.com/NandhaKishorM/laya) serve 时,每次 jev 调用并发一路本地 laya 请求:主路答案驱动决策,laya 答案并排落账——一组永久运行的 laya vs jev 同题对照。laya 是**纯遥测**(真实负载实测批内排序与主路一致率 0/14、绝对分不可用),主路失败照旧回退旧路径;laya 没起 = 毫秒级连接拒绝。
package/lib/commands.js CHANGED
@@ -12,7 +12,7 @@ import { serializeTopicDoc, slugify, firstParagraph } from "./okf.js";
12
12
  import { buildGraph, renderGraphHtml } from "./viz.js";
13
13
  import { CONFIG_KEYS, displayKey, parseConfigValue } from "./config.js";
14
14
  import { reloadSecrets } from "./jev/gate.js";
15
- import { aggregateStats } from "./ilog.js";
15
+ import { aggregateStats, aggregateOpenPanel, ZERO_OPEN_MIN_INJECTIONS } from "./ilog.js";
16
16
  import { askDistillSingle, createOnboardHandler, llmDirectoryNotice, makePanel, parseDistillInput, pickLlmDirectory, } from "./onboard.js";
17
17
  export const HELP = [
18
18
  'dsh-topics-memory — OKF topic 记忆(本地 bundle,git 可追溯,可选 GitHub 同步)',
@@ -20,7 +20,7 @@ export const HELP = [
20
20
  ' /topics status bundle 健康:topic 数、观察积压、冲突、同步状态',
21
21
  ' /topics distill 手动触发一次蒸馏(观察池 → Topic,输出 marked/created/updated/gc 摘要)',
22
22
  ' /topics consolidate 手动触发一次整理(合并重复/晋升 stable/废弃过时/刷新元数据,无视 cadence 立即跑)',
23
- ' /topics stats 注入统计:hit rate、top-N、near-miss 分布与调参建议',
23
+ ' /topics stats 注入统计:hit rate、open rate、注入 vs 打开、near-miss 分布与调参建议',
24
24
  ' /topics list 列出全部 Topic',
25
25
  ' /topics show <slug> 查看一个 Topic 全文(含反向引用)',
26
26
  ' /topics history <slug> 一个 Topic 的结论变更史(git log)',
@@ -277,16 +277,14 @@ async function renderStats(service) {
277
277
  }
278
278
  lines.push(`| 慢道参与轮 | ${slowRounds}(快 ${records.length - slowRounds} / 慢或混合 ${slowRounds}${medianLag}) |`);
279
279
  }
280
- // Pointer open rate (v4 §4.3): topic_open calls vs pointer entries injected.
281
- // Echoed hits ride the retrieval but never became pointers — excluded here.
280
+ // Injection-vs-open metrics (v5): the denominator counts only pointers
281
+ // that truly rendered (deduped/echoed/dropped all excluded, slow pointers
282
+ // included) and the numerator only counts DISTINCT slugs from that
283
+ // rendered set opened inside the injection window — search opens and
284
+ // never-rendered slugs cannot inflate the rate.
282
285
  const opens = await service.store.readOpenRecords();
283
- if (opens.length > 0 || records.some((r) => r.lane !== undefined)) {
284
- const entries = records.reduce((acc, r) => acc +
285
- r.hits.filter((h) => !(r.deduped ?? []).includes(h.slug) && !(r.echoed ?? []).includes(h.slug)).length +
286
- (r.slow?.length ?? 0), 0);
287
- const rate = entries === 0 ? 0 : Math.min(1, opens.length / entries);
288
- lines.push(`| 指针打开率 | ${(rate * 100).toFixed(1)}%(${opens.length} 次 topic_open / ${entries} 条注入指针) |`);
289
- }
286
+ const panel = aggregateOpenPanel(records, opens);
287
+ lines.push(`| open rate | ${(panel.openRate * 100).toFixed(1)}%(${panel.opensInWindow} 个被打开指针 / ${panel.pointerEntries} 条注入指针) |`);
290
288
  const echoedCount = records.reduce((acc, r) => acc + (r.echoed?.length ?? 0), 0);
291
289
  if (echoedCount > 0) {
292
290
  lines.push(`| 回声抑制 | ${echoedCount} 次(本会话蒸馏出的 topic 不回注) |`);
@@ -296,6 +294,24 @@ async function renderStats(service) {
296
294
  for (const t of stats.topTopics.slice(0, 5))
297
295
  lines.push(`| \`${cell(t.slug)}\` | ${t.count} |`);
298
296
  }
297
+ // ---- 注入 vs 打开 panel ----
298
+ // Per-topic cut: what the header's windowed rate cannot show — which
299
+ // topics the model actually bothers to open (wide-scope slug attribution).
300
+ if (panel.topics.length > 0) {
301
+ lines.push('', '注入 vs 打开(Top-10,注入=真正渲染的指针;打开按 slug 归因,全窗口):', '', '| Topic | 注入 | 打开 | 打开率 |', '| --- | --- | --- | --- |', ...panel.topics.map((t) => `| \`${cell(t.slug)}\` | ${t.injections} | ${t.opens} | ${(t.openRate * 100).toFixed(1)}% |`));
302
+ }
303
+ if (panel.zeroOpenTotal > 0) {
304
+ const shown = panel.zeroOpen.map((z) => `\`${cell(z.slug)}\``).join('、');
305
+ const more = panel.zeroOpenTotal > panel.zeroOpen.length ? `(共 ${panel.zeroOpenTotal} 个)` : '';
306
+ lines.push('', `零打开高频(注入 ≥${ZERO_OPEN_MIN_INJECTIONS}、打开 0):${shown}${more}`);
307
+ }
308
+ lines.push('', panel.hasSourceData ? `打开来源:pointer ${panel.pointerOpens} / search ${panel.searchOpens}` : '打开来源:暂无来源数据(需新版本 open 记录)');
309
+ if (panel.pointerEntries > 0) {
310
+ lines.push('', '| 分桶 | 指针 | 打开率 |', '| --- | --- | --- |');
311
+ for (const b of panel.bands) {
312
+ lines.push(`| ${b.label} | ${b.pointers} | ${b.pointers === 0 ? '—' : `${(b.openRate * 100).toFixed(1)}%`} |`);
313
+ }
314
+ }
299
315
  if (stats.nearMissHistogram.length > 0) {
300
316
  lines.push('', `Near-miss 分布(低于阈值 ${service.cfg.matchThreshold.toFixed(2)} 或被结构门挡下,对数刻度):`, '', '```', ...renderNearMissSparkline(stats.nearMissHistogram), '```');
301
317
  const hint = tuningHint(stats, service.cfg.matchThreshold);
package/lib/config.d.ts CHANGED
@@ -70,6 +70,12 @@ export declare const TopicsConfig: z<Schemastery.ObjectS<NoInfer<{
70
70
  * granted to zero-lexical candidates; the structural gate still applies.
71
71
  * 0 = off. */
72
72
  usageBoost: z<number, number, "volatile-defined">;
73
+ /** Zero-open decay (v5, default off): halve the retrieval score of slugs
74
+ * injected ≥5 times in the rolling 30 days with zero opens ever — the
75
+ * 零打开高频 leaderboard stops out-ranking topics the model actually
76
+ * reads. false = zero behavior change; the reason tag `zero-open-decay`
77
+ * rides the hit/near-miss reasons when on. */
78
+ zeroOpenDecay: z<boolean, boolean, "volatile-defined">;
73
79
  /** Debounced push delay in GitHub mode. */
74
80
  pushDebounceSeconds: z<number, number, "volatile-defined">;
75
81
  /** System One decision-model integration (design 2026-09-25), default-off
@@ -97,6 +103,21 @@ export declare const TopicsConfig: z<Schemastery.ObjectS<NoInfer<{
97
103
  /** laya-serve systemone endpoint for the pace-maker (default: the local
98
104
  * laya-serve bind from the laya repo's serve mode). */
99
105
  jevLayaUrl: z<string, string, "volatile-defined">;
106
+ /** Local embedding provider (A4 semantic reranker, spec §3.1): ''
107
+ * sentinel = the whole semantic layer is OFF (pure lexical); 'ollama' =
108
+ * the local ollama embed API (localhost is the ADR 0017 local exception).
109
+ * v1 ships no remote provider — a remote embedding may only ever enter a
110
+ * slow-lane/offline path, never the fast lane (red line ①). */
111
+ embeddingProvider: z<string, string, "volatile-defined">;
112
+ /** Embedding model; '' = provider default (ollama → nomic-embed-text),
113
+ * same sentinel pattern as distillModel. */
114
+ embeddingModel: z<string, string, "volatile-defined">;
115
+ /** Injection-package master gate (A-line, spec §2/§3): off | shadow | on.
116
+ * Carries the WHOLE new-behavior surface — query sanitization, the
117
+ * three-layer package, nearFloor semantic rerank, degradation. off = zero
118
+ * behavior change anywhere (byte-for-byte legacy); shadow = compute +
119
+ * bypass-ledger only, nothing lands in context; on = engage. */
120
+ packageMode: z<string, string, "volatile-defined">;
100
121
  }>>, Schemastery.ObjectT<NoInfer<{
101
122
  /** GitHub repo `owner/name`; empty = local-only mode (ADR 0008). */
102
123
  repo: z<string, string, "volatile-defined">;
@@ -162,6 +183,12 @@ export declare const TopicsConfig: z<Schemastery.ObjectS<NoInfer<{
162
183
  * granted to zero-lexical candidates; the structural gate still applies.
163
184
  * 0 = off. */
164
185
  usageBoost: z<number, number, "volatile-defined">;
186
+ /** Zero-open decay (v5, default off): halve the retrieval score of slugs
187
+ * injected ≥5 times in the rolling 30 days with zero opens ever — the
188
+ * 零打开高频 leaderboard stops out-ranking topics the model actually
189
+ * reads. false = zero behavior change; the reason tag `zero-open-decay`
190
+ * rides the hit/near-miss reasons when on. */
191
+ zeroOpenDecay: z<boolean, boolean, "volatile-defined">;
165
192
  /** Debounced push delay in GitHub mode. */
166
193
  pushDebounceSeconds: z<number, number, "volatile-defined">;
167
194
  /** System One decision-model integration (design 2026-09-25), default-off
@@ -189,6 +216,21 @@ export declare const TopicsConfig: z<Schemastery.ObjectS<NoInfer<{
189
216
  /** laya-serve systemone endpoint for the pace-maker (default: the local
190
217
  * laya-serve bind from the laya repo's serve mode). */
191
218
  jevLayaUrl: z<string, string, "volatile-defined">;
219
+ /** Local embedding provider (A4 semantic reranker, spec §3.1): ''
220
+ * sentinel = the whole semantic layer is OFF (pure lexical); 'ollama' =
221
+ * the local ollama embed API (localhost is the ADR 0017 local exception).
222
+ * v1 ships no remote provider — a remote embedding may only ever enter a
223
+ * slow-lane/offline path, never the fast lane (red line ①). */
224
+ embeddingProvider: z<string, string, "volatile-defined">;
225
+ /** Embedding model; '' = provider default (ollama → nomic-embed-text),
226
+ * same sentinel pattern as distillModel. */
227
+ embeddingModel: z<string, string, "volatile-defined">;
228
+ /** Injection-package master gate (A-line, spec §2/§3): off | shadow | on.
229
+ * Carries the WHOLE new-behavior surface — query sanitization, the
230
+ * three-layer package, nearFloor semantic rerank, degradation. off = zero
231
+ * behavior change anywhere (byte-for-byte legacy); shadow = compute +
232
+ * bypass-ledger only, nothing lands in context; on = engage. */
233
+ packageMode: z<string, string, "volatile-defined">;
192
234
  }>>, "plain">;
193
235
  export type TopicsConfigValue = {
194
236
  repo: string;
@@ -216,6 +258,10 @@ export type TopicsConfigValue = {
216
258
  consolidateCadence: string;
217
259
  deprecatedTtlDays: number;
218
260
  usageBoost: number;
261
+ /** Zero-open decay (v5). Optional in this TS view only (same sentinel
262
+ * pattern as the jev keys): bare-harness DEFAULTS predates it —
263
+ * consumers must treat undefined as the documented default (false). */
264
+ zeroOpenDecay?: boolean;
219
265
  pushDebounceSeconds: number;
220
266
  /** System One integration keys (design 2026-09-25 §4). Optional in this TS
221
267
  * view only: index.ts's bare-harness DEFAULTS literal predates them and
@@ -228,8 +274,15 @@ export type TopicsConfigValue = {
228
274
  jevSecretFile?: string;
229
275
  jevLayaFallback?: boolean;
230
276
  jevLayaUrl?: string;
277
+ /** Injection-quality line keys (spec §7). Optional in this TS view only
278
+ * (same sentinel pattern as the jev keys): index.ts's bare-harness
279
+ * DEFAULTS literal predates them — consumers must treat undefined as the
280
+ * documented default ('' / '' / 'off' = every new behavior zeroed). */
281
+ embeddingProvider?: string;
282
+ embeddingModel?: string;
283
+ packageMode?: 'off' | 'shadow' | 'on';
231
284
  };
232
- export declare const CONFIG_KEYS: readonly ["repo", "autoInject", "injectDedup", "suppressEcho", "topK", "perTopicBudget", "totalBudget", "matchThreshold", "tagBoost", "injectMode", "qualityLane", "graphDepth", "recencyWindowDays", "autoObserve", "includeSubagents", "observationMaxChars", "distillEveryTurns", "distillOnSessionEnd", "distillProvider", "distillModel", "distillBatchSize", "distillMaxModelCalls", "consolidateCadence", "deprecatedTtlDays", "usageBoost", "pushDebounceSeconds", "jevEnabled", "jevBackend", "jevModel", "jevTimeoutMs", "jevSecretFile", "jevLayaFallback", "jevLayaUrl"];
285
+ export declare const CONFIG_KEYS: readonly ["repo", "autoInject", "injectDedup", "suppressEcho", "topK", "perTopicBudget", "totalBudget", "matchThreshold", "tagBoost", "injectMode", "qualityLane", "graphDepth", "recencyWindowDays", "autoObserve", "includeSubagents", "observationMaxChars", "distillEveryTurns", "distillOnSessionEnd", "distillProvider", "distillModel", "distillBatchSize", "distillMaxModelCalls", "consolidateCadence", "deprecatedTtlDays", "usageBoost", "zeroOpenDecay", "pushDebounceSeconds", "jevEnabled", "jevBackend", "jevModel", "jevTimeoutMs", "jevSecretFile", "jevLayaFallback", "jevLayaUrl", "embeddingProvider", "embeddingModel", "packageMode"];
233
286
  export type ConfigKey = (typeof CONFIG_KEYS)[number];
234
287
  /** CamelCase → dash-display (topK → top-k) for command output. */
235
288
  export declare function displayKey(key: string): string;
package/lib/config.js CHANGED
@@ -70,6 +70,12 @@ export const TopicsConfig = z.object({
70
70
  * granted to zero-lexical candidates; the structural gate still applies.
71
71
  * 0 = off. */
72
72
  usageBoost: z.number().default(0.15).volatile(),
73
+ /** Zero-open decay (v5, default off): halve the retrieval score of slugs
74
+ * injected ≥5 times in the rolling 30 days with zero opens ever — the
75
+ * 零打开高频 leaderboard stops out-ranking topics the model actually
76
+ * reads. false = zero behavior change; the reason tag `zero-open-decay`
77
+ * rides the hit/near-miss reasons when on. */
78
+ zeroOpenDecay: z.boolean().default(false).volatile(),
73
79
  /** Debounced push delay in GitHub mode. */
74
80
  pushDebounceSeconds: z.number().default(45).volatile(),
75
81
  /** System One decision-model integration (design 2026-09-25), default-off
@@ -97,6 +103,21 @@ export const TopicsConfig = z.object({
97
103
  /** laya-serve systemone endpoint for the pace-maker (default: the local
98
104
  * laya-serve bind from the laya repo's serve mode). */
99
105
  jevLayaUrl: z.string().default('http://127.0.0.1:8000/v1/systemone').volatile(),
106
+ /** Local embedding provider (A4 semantic reranker, spec §3.1): ''
107
+ * sentinel = the whole semantic layer is OFF (pure lexical); 'ollama' =
108
+ * the local ollama embed API (localhost is the ADR 0017 local exception).
109
+ * v1 ships no remote provider — a remote embedding may only ever enter a
110
+ * slow-lane/offline path, never the fast lane (red line ①). */
111
+ embeddingProvider: z.string().default('').volatile(),
112
+ /** Embedding model; '' = provider default (ollama → nomic-embed-text),
113
+ * same sentinel pattern as distillModel. */
114
+ embeddingModel: z.string().default('').volatile(),
115
+ /** Injection-package master gate (A-line, spec §2/§3): off | shadow | on.
116
+ * Carries the WHOLE new-behavior surface — query sanitization, the
117
+ * three-layer package, nearFloor semantic rerank, degradation. off = zero
118
+ * behavior change anywhere (byte-for-byte legacy); shadow = compute +
119
+ * bypass-ledger only, nothing lands in context; on = engage. */
120
+ packageMode: z.string().default('off').volatile(),
100
121
  });
101
122
  export const CONFIG_KEYS = [
102
123
  'repo',
@@ -124,6 +145,7 @@ export const CONFIG_KEYS = [
124
145
  'consolidateCadence',
125
146
  'deprecatedTtlDays',
126
147
  'usageBoost',
148
+ 'zeroOpenDecay',
127
149
  'pushDebounceSeconds',
128
150
  'jevEnabled',
129
151
  'jevBackend',
@@ -132,6 +154,9 @@ export const CONFIG_KEYS = [
132
154
  'jevSecretFile',
133
155
  'jevLayaFallback',
134
156
  'jevLayaUrl',
157
+ 'embeddingProvider',
158
+ 'embeddingModel',
159
+ 'packageMode',
135
160
  ];
136
161
  /** CamelCase → dash-display (topK → top-k) for command output. */
137
162
  export function displayKey(key) {
@@ -152,7 +177,8 @@ export function parseConfigValue(key, raw) {
152
177
  case 'autoObserve':
153
178
  case 'distillOnSessionEnd':
154
179
  case 'includeSubagents':
155
- case 'jevEnabled': {
180
+ case 'jevEnabled':
181
+ case 'zeroOpenDecay': {
156
182
  if (raw === 'on' || raw === 'true')
157
183
  return true;
158
184
  if (raw === 'off' || raw === 'false')
@@ -206,10 +232,21 @@ export function parseConfigValue(key, raw) {
206
232
  }
207
233
  case 'distillProvider':
208
234
  case 'distillModel':
235
+ case 'embeddingModel':
209
236
  case 'jevModel':
210
237
  case 'jevSecretFile':
211
238
  case 'jevLayaUrl':
212
239
  return raw;
240
+ case 'embeddingProvider': {
241
+ if (raw === '' || raw === 'ollama')
242
+ return raw;
243
+ return { error: 'embedding-provider 仅支持 ollama(留空=语义层关;远程 provider 不进快道)' };
244
+ }
245
+ case 'packageMode': {
246
+ if (raw === 'off' || raw === 'shadow' || raw === 'on')
247
+ return raw;
248
+ return { error: 'package-mode 取值 off|shadow|on' };
249
+ }
213
250
  case 'jevLayaFallback': {
214
251
  if (raw === 'on' || raw === 'true')
215
252
  return true;
package/lib/digest.d.ts CHANGED
@@ -77,3 +77,76 @@ export declare function assemblePointer(entries: readonly DigestInput[], cfg: As
77
77
  * total budget. Zero hits → empty text (ADR 0006: 零命中零注入).
78
78
  */
79
79
  export declare function assembleInjection(entries: readonly DigestInput[], cfg: AssembleConfig, slow?: readonly SlowPointerInput[]): AssembleResult;
80
+ /** One retrieval-side candidate, roster metadata pre-extracted by the caller. */
81
+ export interface PackageCandidate {
82
+ slug: string;
83
+ title: string;
84
+ status: string;
85
+ description?: string;
86
+ /** Conclusion section first line (fallback for a missing description). */
87
+ conclusion?: string;
88
+ score: number;
89
+ /** Retrieval reason tags (`triggers:0.83` / `title:0.50` / `tags:…` / …). */
90
+ reasons: string[];
91
+ /** Edge/lane provenance: `depends:dN` | `slow-pick` | `semantic…` | raw. */
92
+ via?: string;
93
+ }
94
+ export interface PackageInput {
95
+ query: string;
96
+ /** Query lexemes the caller retrieved with (tokenize + §3.3 cleaning). */
97
+ queryTokens: string[];
98
+ /** Lexical strong hits (gate-passed); lands in the direct layer. */
99
+ direct: PackageCandidate[];
100
+ /** Graph expansion + slow-lane picks; lands in the neighborhood layer. */
101
+ neighborhood: PackageCandidate[];
102
+ /** nearFloor band (0.20–0.30) head; lands in the merged weak-signal line. */
103
+ weak: PackageCandidate[];
104
+ /** ADR 0015 usage signal — within-layer ordering tiebreak only (v1). */
105
+ usageBySlug?: Map<string, number>;
106
+ }
107
+ export interface PackageOpts {
108
+ /**
109
+ * `'on'` renders the live package; `'shadow'` means the caller double-runs
110
+ * (old assemblePointer + this) for the bypass ledger (spec §8.3). The pure
111
+ * output is identical in both modes — the mode is call-site bookkeeping.
112
+ */
113
+ mode?: 'on' | 'shadow';
114
+ }
115
+ export interface PackageResult {
116
+ text: string;
117
+ usedTokens: number;
118
+ included: string[];
119
+ dropped: {
120
+ slug: string;
121
+ reason: string;
122
+ }[];
123
+ /** Final per-layer rosters (what the 包头 counts report). */
124
+ layers: {
125
+ direct: string[];
126
+ neighborhood: string[];
127
+ weak: string[];
128
+ };
129
+ }
130
+ /** Total package cap — pinned, does not float with hit strength (A3 决议 3). */
131
+ export declare const PACKAGE_TOTAL = 600;
132
+ export declare const PACKAGE_DIRECT_MAX = 2;
133
+ export declare const PACKAGE_DIRECT_BUDGET = 120;
134
+ export declare const PACKAGE_NEIGHBOR_MAX = 3;
135
+ export declare const PACKAGE_NEIGHBOR_BUDGET = 40;
136
+ export declare const PACKAGE_WEAK_MAX = 2;
137
+ /**
138
+ * Padded-trivial arm of the downgrade guard (spec §2.3 名词实体口径, an
139
+ * implementation-time item per §11): after stripping whitespace, a query this
140
+ * short counts as 琐碎 even when the tokenizer expands CJK bigrams past 3
141
+ * tokens. Calibrated on the two named scenarios: 场景① ack query strips to
142
+ * 15 chars (downgrade), a real review query strips to 18 (kept).
143
+ */
144
+ export declare const PACKAGE_TRIVIAL_QUERY_CHARS = 16;
145
+ /**
146
+ * Assemble the three-layer package. Returns `null` — the whole-package skip
147
+ * signal — when the roster is empty (ADR 0006 零命中零注入) or when the
148
+ * downgrade guard fires (spec §2.3: 琐碎 query 且与全部候选 slug/title 零
149
+ * 词面实体交集). Packing is layered greedy: direct first, then neighborhood,
150
+ * then the weak line — over budget, lower layers are cut first.
151
+ */
152
+ export declare function assemblePackage(input: PackageInput, opts?: PackageOpts): PackageResult | null;