@aiwayds/dsh-topics-memory 0.20.0 → 0.22.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
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
4
4
 
5
5
  A dsh plugin: maintains "working topic memory" as an [OKF (Open Knowledge Format v0.2)](https://github.com/GoogleCloudPlatform/open-knowledge-format) knowledge bundle, persisted in a local git repository (optionally synced to a private GitHub repo), with conclusions traceable through git history, sessions automatically observed and distilled into knowledge, and relevant topics injected to the model before every turn.
6
6
 
7
- > **Requires dsh >= 0.1.7-rc.1** — this plugin targets the dsh RC/stable line only (CI and releases resolve the newest of the `latest`/`next` dist-tags at runtime). **The alpha line is no longer supported.**
7
+ > **Requires dsh >= 0.2.0-rc.2** — this plugin targets the dsh RC/stable line only (CI and releases resolve the newest of the `latest`/`next` dist-tags at runtime). **The alpha line is no longer supported.**
8
8
 
9
9
  ## Demo
10
10
 
@@ -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
@@ -4,7 +4,7 @@
4
4
 
5
5
  一个 dsh 插件:把「工作 topic 记忆」维护成 [OKF 标准(Open Knowledge Format v0.2)](https://github.com/GoogleCloudPlatform/open-knowledge-format)的知识 bundle,持久化在本地 git 仓库(可选同步到 GitHub 私有仓库),利用 git 历史提供结论可追溯性,自动观察会话沉淀知识,并在每轮对话前向模型注入相关 Topic。
6
6
 
7
- > **要求 dsh >= 0.1.7-rc.1** — 本插件只跟随 dsh RC/stable 线(CI 与发版在运行时解析 latest/next 中更新的 dist-tag)。**不再支持 alpha 线。**
7
+ > **要求 dsh >= 0.2.0-rc.2** — 本插件只跟随 dsh RC/stable 线(CI 与发版在运行时解析 latest/next 中更新的 dist-tag)。**不再支持 alpha 线。**
8
8
 
9
9
  ## 演示
10
10
 
@@ -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
@@ -162,6 +168,12 @@ export declare const TopicsConfig: z<Schemastery.ObjectS<NoInfer<{
162
168
  * granted to zero-lexical candidates; the structural gate still applies.
163
169
  * 0 = off. */
164
170
  usageBoost: z<number, number, "volatile-defined">;
171
+ /** Zero-open decay (v5, default off): halve the retrieval score of slugs
172
+ * injected ≥5 times in the rolling 30 days with zero opens ever — the
173
+ * 零打开高频 leaderboard stops out-ranking topics the model actually
174
+ * reads. false = zero behavior change; the reason tag `zero-open-decay`
175
+ * rides the hit/near-miss reasons when on. */
176
+ zeroOpenDecay: z<boolean, boolean, "volatile-defined">;
165
177
  /** Debounced push delay in GitHub mode. */
166
178
  pushDebounceSeconds: z<number, number, "volatile-defined">;
167
179
  /** System One decision-model integration (design 2026-09-25), default-off
@@ -216,6 +228,10 @@ export type TopicsConfigValue = {
216
228
  consolidateCadence: string;
217
229
  deprecatedTtlDays: number;
218
230
  usageBoost: number;
231
+ /** Zero-open decay (v5). Optional in this TS view only (same sentinel
232
+ * pattern as the jev keys): bare-harness DEFAULTS predates it —
233
+ * consumers must treat undefined as the documented default (false). */
234
+ zeroOpenDecay?: boolean;
219
235
  pushDebounceSeconds: number;
220
236
  /** System One integration keys (design 2026-09-25 §4). Optional in this TS
221
237
  * view only: index.ts's bare-harness DEFAULTS literal predates them and
@@ -229,7 +245,7 @@ export type TopicsConfigValue = {
229
245
  jevLayaFallback?: boolean;
230
246
  jevLayaUrl?: string;
231
247
  };
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"];
248
+ 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"];
233
249
  export type ConfigKey = (typeof CONFIG_KEYS)[number];
234
250
  /** CamelCase → dash-display (topK → top-k) for command output. */
235
251
  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
@@ -124,6 +130,7 @@ export const CONFIG_KEYS = [
124
130
  'consolidateCadence',
125
131
  'deprecatedTtlDays',
126
132
  'usageBoost',
133
+ 'zeroOpenDecay',
127
134
  'pushDebounceSeconds',
128
135
  'jevEnabled',
129
136
  'jevBackend',
@@ -152,7 +159,8 @@ export function parseConfigValue(key, raw) {
152
159
  case 'autoObserve':
153
160
  case 'distillOnSessionEnd':
154
161
  case 'includeSubagents':
155
- case 'jevEnabled': {
162
+ case 'jevEnabled':
163
+ case 'zeroOpenDecay': {
156
164
  if (raw === 'on' || raw === 'true')
157
165
  return true;
158
166
  if (raw === 'off' || raw === 'false')
package/lib/ilog.d.ts CHANGED
@@ -70,6 +70,23 @@ export interface InjectionRecord {
70
70
  /** Slow-lane pointers this round, in delivery order. */
71
71
  slow?: SlowItem[];
72
72
  }
73
+ /**
74
+ * One topic_open event (pointer-open log, v4 §4.3). Everything beyond
75
+ * `slug`/`at` is optional and late-added: older lines carry only the
76
+ * original pair, and every reader must tolerate the rest being absent.
77
+ */
78
+ export interface OpenRecord {
79
+ slug: string;
80
+ at: string;
81
+ /** Session that issued the topic_open, when the host surface exposed one. */
82
+ sessionId?: string;
83
+ /** Attribution: opened off an injected pointer, or off a topic_search hit. */
84
+ source?: 'pointer' | 'search';
85
+ /** Retrieval score of the matched injection hit (source='pointer' only). */
86
+ score?: number;
87
+ /** open time − the matched injection record's at, in ms (source='pointer' only). */
88
+ sinceInjectionMs?: number;
89
+ }
73
90
  export interface AggregateStats {
74
91
  rounds: number;
75
92
  injectedRounds: number;
@@ -107,3 +124,115 @@ export declare function aggregateUsage(injections: readonly InjectionRecord[], o
107
124
  slug: string;
108
125
  at: string;
109
126
  }[], windowDays: number, now: number): Map<string, UsageSignal>;
127
+ /** How many tail injection records the cheap open attribution scans. */
128
+ export declare const OPEN_ATTRIBUTION_TAIL = 50;
129
+ /** An injected pointer stays attributable for 48h (a work session's span). */
130
+ export declare const OPEN_ATTRIBUTION_WINDOW_MS: number;
131
+ /**
132
+ * Cheap source attribution for one open event: scan the injection tail
133
+ * (newest first) for a same-session record within the attribution window
134
+ * where the slug was truly delivered to the model's eyes. That is hits
135
+ * minus echoed/dropped (echo suppression and budget drops never rendered),
136
+ * PLUS slow-lane deliveries, PLUS deduped hits — dedup only blocks
137
+ * re-packing THIS round; the pointer rendered earlier in the same session
138
+ * and the model saw it (deliberately wider than renderedPointers: the panel
139
+ * denominator asks "did it render this round", attribution asks "did the
140
+ * model ever see it"). A fast-hit match carries that hit's score; a
141
+ * slow-only match carries none (slow pointers have no lexical score).
142
+ * Anything else (no session id, other session, expired, never rendered)
143
+ * is `search`.
144
+ */
145
+ export declare function attributeOpen(injections: readonly InjectionRecord[], slug: string, sessionId: string | undefined, nowMs: number): {
146
+ source: 'pointer' | 'search';
147
+ score?: number;
148
+ sinceInjectionMs?: number;
149
+ };
150
+ /** Score bands for the open-rate-by-score cut of the stats panel. */
151
+ export declare const SCORE_BANDS: readonly {
152
+ label: string;
153
+ lo: number;
154
+ hi: number;
155
+ }[];
156
+ /** Zero-open listing threshold: injections at or above this with zero opens. */
157
+ export declare const ZERO_OPEN_MIN_INJECTIONS = 5;
158
+ /** Rolling window of the zero-open rule: only rendered injections inside it count. */
159
+ export declare const ZERO_OPEN_WINDOW_DAYS = 30;
160
+ /** Injection-tail rows the decay read scans — a bound comfortably covering
161
+ * the 30-day window at observed round volumes. */
162
+ export declare const ZERO_OPEN_TAIL = 500;
163
+ /**
164
+ * Slugs that truly rendered in one round, in delivery order: raw hits minus
165
+ * the round's exclusions (deduped/echoed/dropped were never assembled), plus
166
+ * delivered slow pointers; a slug the fast lane already delivered counts
167
+ * once even when the slow lane also reports it delivered ("not packed
168
+ * twice"). Scores ride along from the fast hit only — slow pointers carry
169
+ * no lexical score. Shared by the stats panel and the zero-open rule.
170
+ */
171
+ export declare function renderedPointers(record: InjectionRecord): {
172
+ slug: string;
173
+ score?: number;
174
+ }[];
175
+ /**
176
+ * Zero-open slug set — the single shared 零打开高频 rule (v5): slugs whose
177
+ * truly-rendered pointer count within the rolling ZERO_OPEN_WINDOW_DAYS
178
+ * reaches ZERO_OPEN_MIN_INJECTIONS while the WHOLE open log has never
179
+ * recorded an open (wide scope — an open long after the last injection
180
+ * still disqualifies). Returns slug → windowed rendered-pointer count for
181
+ * the qualifying slugs; `/topics stats` (the panel's zero-open list) and
182
+ * the retrieval decay (`zeroOpenDecay`) both consume this one
183
+ * implementation. Records with unparsable `at` cannot prove window
184
+ * membership and are skipped (same tolerance as aggregateUsage).
185
+ */
186
+ export declare function aggregateZeroOpen(injections: readonly InjectionRecord[], opens: readonly {
187
+ slug: string;
188
+ }[], nowMs: number): Map<string, number>;
189
+ export interface OpenTopicRow {
190
+ slug: string;
191
+ /** Truly rendered pointers for this slug (deduped/echoed/dropped excluded). */
192
+ injections: number;
193
+ /** Opens attributed by slug over the whole open log (wide scope, by design). */
194
+ opens: number;
195
+ openRate: number;
196
+ }
197
+ export interface OpenBandRow {
198
+ label: string;
199
+ /** Rendered fast pointers whose hit score falls in this band. */
200
+ pointers: number;
201
+ /** Pointer-sourced opens whose attributed score falls in this band. */
202
+ opens: number;
203
+ openRate: number;
204
+ }
205
+ export interface OpenPanelStats {
206
+ /** Rendered pointer entries across the window: hits − deduped − echoed − dropped, plus slow pointers (same-slug fast+slow pairs counted once). */
207
+ pointerEntries: number;
208
+ /** DISTINCT slugs from the window's rendered set with an in-window open
209
+ * (search-sourced and never-rendered slugs excluded; one per slug). */
210
+ opensInWindow: number;
211
+ openRate: number;
212
+ topics: OpenTopicRow[];
213
+ zeroOpen: {
214
+ slug: string;
215
+ injections: number;
216
+ }[];
217
+ zeroOpenTotal: number;
218
+ pointerOpens: number;
219
+ searchOpens: number;
220
+ /** False when no open record carries a source field (pre-enrichment data). */
221
+ hasSourceData: boolean;
222
+ bands: OpenBandRow[];
223
+ }
224
+ /**
225
+ * Aggregate the injection-vs-open panel from the two JSONL windows. The
226
+ * per-topic/band denominators count only pointers that truly rendered
227
+ * (deduped/echoed/dropped excluded — same exclusion family as
228
+ * aggregateUsage); the header numerator counts only DISTINCT slugs from
229
+ * that rendered set with an open inside the injection window — a search
230
+ * open or an open of a never-rendered slug cannot enter, and repeat opens
231
+ * of the same slug count once, so the rate can no longer be pinned at
232
+ * 100% by repeated or off-scope opens. Opens per slug stay wide-scope
233
+ * (the model may open a topic long after its last injection). The
234
+ * zero-open list shares the decay's rule: rendered injections counted
235
+ * within the rolling 30-day window (anchored at the newest round), opens
236
+ * wide-scope.
237
+ */
238
+ export declare function aggregateOpenPanel(injections: readonly InjectionRecord[], opens: readonly OpenRecord[]): OpenPanelStats;
package/lib/ilog.js CHANGED
@@ -117,3 +117,239 @@ export function aggregateUsage(injections, opens, windowDays, now) {
117
117
  }
118
118
  return out;
119
119
  }
120
+ // ---------------------------------------------------------------------
121
+ // Injection-vs-open metrics (v5) — open enrichment attribution + the
122
+ // /topics stats panel aggregate. All pure: file I/O stays in store/service.
123
+ // ---------------------------------------------------------------------
124
+ /** How many tail injection records the cheap open attribution scans. */
125
+ export const OPEN_ATTRIBUTION_TAIL = 50;
126
+ /** An injected pointer stays attributable for 48h (a work session's span). */
127
+ export const OPEN_ATTRIBUTION_WINDOW_MS = 48 * 3_600_000;
128
+ /**
129
+ * Cheap source attribution for one open event: scan the injection tail
130
+ * (newest first) for a same-session record within the attribution window
131
+ * where the slug was truly delivered to the model's eyes. That is hits
132
+ * minus echoed/dropped (echo suppression and budget drops never rendered),
133
+ * PLUS slow-lane deliveries, PLUS deduped hits — dedup only blocks
134
+ * re-packing THIS round; the pointer rendered earlier in the same session
135
+ * and the model saw it (deliberately wider than renderedPointers: the panel
136
+ * denominator asks "did it render this round", attribution asks "did the
137
+ * model ever see it"). A fast-hit match carries that hit's score; a
138
+ * slow-only match carries none (slow pointers have no lexical score).
139
+ * Anything else (no session id, other session, expired, never rendered)
140
+ * is `search`.
141
+ */
142
+ export function attributeOpen(injections, slug, sessionId, nowMs) {
143
+ if (sessionId === undefined)
144
+ return { source: 'search' };
145
+ for (let i = injections.length - 1; i >= 0; i -= 1) {
146
+ const record = injections[i];
147
+ if (record.sessionId !== sessionId)
148
+ continue;
149
+ const atMs = Date.parse(record.at);
150
+ if (!Number.isFinite(atMs) || nowMs - atMs > OPEN_ATTRIBUTION_WINDOW_MS)
151
+ continue;
152
+ const sinceInjectionMs = Math.max(0, nowMs - atMs);
153
+ const suppressed = new Set([...(record.echoed ?? []), ...(record.dropped ?? []).map((d) => d.slug)]);
154
+ const hit = record.hits.find((h) => h.slug === slug && !suppressed.has(h.slug));
155
+ if (hit !== undefined)
156
+ return { source: 'pointer', score: hit.score, sinceInjectionMs };
157
+ if ((record.slow ?? []).some((s) => s.slug === slug))
158
+ return { source: 'pointer', sinceInjectionMs };
159
+ }
160
+ return { source: 'search' };
161
+ }
162
+ /** Score bands for the open-rate-by-score cut of the stats panel. */
163
+ export const SCORE_BANDS = [
164
+ { label: '[0.3,0.5)', lo: 0.3, hi: 0.5 },
165
+ { label: '[0.5,1)', lo: 0.5, hi: 1 },
166
+ { label: '[1,2)', lo: 1, hi: 2 },
167
+ { label: '[2,5)', lo: 2, hi: 5 },
168
+ { label: '[5,∞)', lo: 5, hi: Number.POSITIVE_INFINITY },
169
+ ];
170
+ /** Zero-open listing threshold: injections at or above this with zero opens. */
171
+ export const ZERO_OPEN_MIN_INJECTIONS = 5;
172
+ /** Rolling window of the zero-open rule: only rendered injections inside it count. */
173
+ export const ZERO_OPEN_WINDOW_DAYS = 30;
174
+ /** Injection-tail rows the decay read scans — a bound comfortably covering
175
+ * the 30-day window at observed round volumes. */
176
+ export const ZERO_OPEN_TAIL = 500;
177
+ /**
178
+ * Slugs that truly rendered in one round, in delivery order: raw hits minus
179
+ * the round's exclusions (deduped/echoed/dropped were never assembled), plus
180
+ * delivered slow pointers; a slug the fast lane already delivered counts
181
+ * once even when the slow lane also reports it delivered ("not packed
182
+ * twice"). Scores ride along from the fast hit only — slow pointers carry
183
+ * no lexical score. Shared by the stats panel and the zero-open rule.
184
+ */
185
+ export function renderedPointers(record) {
186
+ const excluded = new Set([
187
+ ...(record.deduped ?? []),
188
+ ...(record.echoed ?? []),
189
+ ...(record.dropped ?? []).map((d) => d.slug),
190
+ ]);
191
+ const seen = new Set();
192
+ const out = [];
193
+ for (const h of record.hits) {
194
+ if (excluded.has(h.slug) || seen.has(h.slug))
195
+ continue;
196
+ seen.add(h.slug);
197
+ out.push({ slug: h.slug, score: h.score });
198
+ }
199
+ for (const s of record.slow ?? []) {
200
+ if (seen.has(s.slug))
201
+ continue;
202
+ seen.add(s.slug);
203
+ out.push({ slug: s.slug });
204
+ }
205
+ return out;
206
+ }
207
+ /**
208
+ * Zero-open slug set — the single shared 零打开高频 rule (v5): slugs whose
209
+ * truly-rendered pointer count within the rolling ZERO_OPEN_WINDOW_DAYS
210
+ * reaches ZERO_OPEN_MIN_INJECTIONS while the WHOLE open log has never
211
+ * recorded an open (wide scope — an open long after the last injection
212
+ * still disqualifies). Returns slug → windowed rendered-pointer count for
213
+ * the qualifying slugs; `/topics stats` (the panel's zero-open list) and
214
+ * the retrieval decay (`zeroOpenDecay`) both consume this one
215
+ * implementation. Records with unparsable `at` cannot prove window
216
+ * membership and are skipped (same tolerance as aggregateUsage).
217
+ */
218
+ export function aggregateZeroOpen(injections, opens, nowMs) {
219
+ const cutoff = nowMs - ZERO_OPEN_WINDOW_DAYS * 86_400_000;
220
+ const counts = new Map();
221
+ for (const record of injections) {
222
+ const at = Date.parse(record.at);
223
+ if (!Number.isFinite(at) || at < cutoff)
224
+ continue;
225
+ for (const p of renderedPointers(record)) {
226
+ counts.set(p.slug, (counts.get(p.slug) ?? 0) + 1);
227
+ }
228
+ }
229
+ const opened = new Set(opens.map((o) => o.slug));
230
+ const out = new Map();
231
+ for (const [slug, n] of counts) {
232
+ if (n >= ZERO_OPEN_MIN_INJECTIONS && !opened.has(slug))
233
+ out.set(slug, n);
234
+ }
235
+ return out;
236
+ }
237
+ /**
238
+ * Aggregate the injection-vs-open panel from the two JSONL windows. The
239
+ * per-topic/band denominators count only pointers that truly rendered
240
+ * (deduped/echoed/dropped excluded — same exclusion family as
241
+ * aggregateUsage); the header numerator counts only DISTINCT slugs from
242
+ * that rendered set with an open inside the injection window — a search
243
+ * open or an open of a never-rendered slug cannot enter, and repeat opens
244
+ * of the same slug count once, so the rate can no longer be pinned at
245
+ * 100% by repeated or off-scope opens. Opens per slug stay wide-scope
246
+ * (the model may open a topic long after its last injection). The
247
+ * zero-open list shares the decay's rule: rendered injections counted
248
+ * within the rolling 30-day window (anchored at the newest round), opens
249
+ * wide-scope.
250
+ */
251
+ export function aggregateOpenPanel(injections, opens) {
252
+ // Numerator window: the earliest round represented in the denominator;
253
+ // the zero-open anchor is the newest round — the panel describes the log
254
+ // window it was fed, so a stale log still shows the topics that
255
+ // dominated it. The rendered-slug set below shares that window: only
256
+ // opens of slugs this window rendered can enter the numerator.
257
+ let windowStart = Number.POSITIVE_INFINITY;
258
+ let windowEnd = Number.NEGATIVE_INFINITY;
259
+ for (const r of injections) {
260
+ const atMs = Date.parse(r.at);
261
+ if (!Number.isFinite(atMs))
262
+ continue;
263
+ if (atMs < windowStart)
264
+ windowStart = atMs;
265
+ if (atMs > windowEnd)
266
+ windowEnd = atMs;
267
+ }
268
+ const topicCounts = new Map();
269
+ const bandPointers = new Array(SCORE_BANDS.length).fill(0);
270
+ let pointerEntries = 0;
271
+ const renderedSlugs = new Set();
272
+ for (const r of injections) {
273
+ // renderedPointers keeps the entry/per-topic counts honest: excluded
274
+ // slugs never counted, a fast+slow pair for the same slug counted once.
275
+ for (const p of renderedPointers(r)) {
276
+ pointerEntries += 1;
277
+ renderedSlugs.add(p.slug);
278
+ topicCounts.set(p.slug, (topicCounts.get(p.slug) ?? 0) + 1);
279
+ const score = p.score;
280
+ if (score === undefined)
281
+ continue;
282
+ const bi = SCORE_BANDS.findIndex((b) => score >= b.lo && score < b.hi);
283
+ if (bi >= 0)
284
+ bandPointers[bi] += 1;
285
+ }
286
+ }
287
+ const opensBySlug = new Map();
288
+ const bandOpens = new Array(SCORE_BANDS.length).fill(0);
289
+ let pointerOpens = 0;
290
+ let searchOpens = 0;
291
+ let hasSourceData = false;
292
+ // Numerator: DISTINCT slugs from the window's rendered set with an
293
+ // in-window open — search-sourced opens never count (the model found the
294
+ // topic by asking, not by following a rendered pointer), a never-rendered
295
+ // slug never counts, and repeat opens of the same slug count once.
296
+ const openedSlugsInWindow = new Set();
297
+ for (const o of opens) {
298
+ opensBySlug.set(o.slug, (opensBySlug.get(o.slug) ?? 0) + 1);
299
+ if (o.source === 'pointer' || o.source === 'search')
300
+ hasSourceData = true;
301
+ if (o.source === 'pointer')
302
+ pointerOpens += 1;
303
+ else if (o.source === 'search')
304
+ searchOpens += 1;
305
+ if (o.source === 'pointer' && typeof o.score === 'number') {
306
+ const score = o.score;
307
+ const bi = SCORE_BANDS.findIndex((b) => score >= b.lo && score < b.hi);
308
+ if (bi >= 0)
309
+ bandOpens[bi] += 1;
310
+ }
311
+ if (o.source === 'search')
312
+ continue;
313
+ const atMs = Date.parse(o.at);
314
+ // An open joins the windowed numerator unless it is provably older than
315
+ // the window's earliest round (unparsable stamps cannot prove either
316
+ // way) — and only when the window actually rendered its slug.
317
+ if (Number.isFinite(windowStart) && Number.isFinite(atMs) && atMs < windowStart)
318
+ continue;
319
+ if (!renderedSlugs.has(o.slug))
320
+ continue;
321
+ openedSlugsInWindow.add(o.slug);
322
+ }
323
+ const opensInWindow = openedSlugsInWindow.size;
324
+ const rate = (num, den) => (den === 0 ? 0 : Math.min(1, num / den));
325
+ const topics = [...topicCounts.entries()]
326
+ .map(([slug, injections]) => {
327
+ const opens = opensBySlug.get(slug) ?? 0;
328
+ return { slug, injections, opens, openRate: rate(opens, injections) };
329
+ })
330
+ .sort((a, b) => b.injections - a.injections || a.slug.localeCompare(b.slug));
331
+ // Zero-open cut: the shared 30-day rule (aggregateZeroOpen), anchored at
332
+ // the newest round so window semantics match the decay's rolling window
333
+ // without growing a time-bomb dependency on the wall clock.
334
+ const zeroOpenCounts = windowEnd === Number.NEGATIVE_INFINITY ? new Map() : aggregateZeroOpen(injections, opens, windowEnd);
335
+ const zeroOpenAll = [...zeroOpenCounts.entries()]
336
+ .map(([slug, injections]) => ({ slug, injections }))
337
+ .sort((a, b) => b.injections - a.injections || a.slug.localeCompare(b.slug));
338
+ return {
339
+ pointerEntries,
340
+ opensInWindow,
341
+ openRate: rate(opensInWindow, pointerEntries),
342
+ topics: topics.slice(0, 10),
343
+ zeroOpen: zeroOpenAll.slice(0, 10).map((t) => ({ slug: t.slug, injections: t.injections })),
344
+ zeroOpenTotal: zeroOpenAll.length,
345
+ pointerOpens,
346
+ searchOpens,
347
+ hasSourceData,
348
+ bands: SCORE_BANDS.map((b, i) => ({
349
+ label: b.label,
350
+ pointers: bandPointers[i],
351
+ opens: bandOpens[i],
352
+ openRate: rate(bandOpens[i], bandPointers[i]),
353
+ })),
354
+ };
355
+ }
package/lib/index.d.ts CHANGED
@@ -112,6 +112,7 @@ export declare const Config: import("@deepseek-ai/schemastery").default<Schemast
112
112
  consolidateCadence: import("@deepseek-ai/schemastery").default<string, string, "volatile-defined">;
113
113
  deprecatedTtlDays: import("@deepseek-ai/schemastery").default<number, number, "volatile-defined">;
114
114
  usageBoost: import("@deepseek-ai/schemastery").default<number, number, "volatile-defined">;
115
+ zeroOpenDecay: import("@deepseek-ai/schemastery").default<boolean, boolean, "volatile-defined">;
115
116
  pushDebounceSeconds: import("@deepseek-ai/schemastery").default<number, number, "volatile-defined">;
116
117
  jevEnabled: import("@deepseek-ai/schemastery").default<boolean, boolean, "volatile-defined">;
117
118
  jevBackend: import("@deepseek-ai/schemastery").default<string, string, "volatile-defined">;
@@ -146,6 +147,7 @@ export declare const Config: import("@deepseek-ai/schemastery").default<Schemast
146
147
  consolidateCadence: import("@deepseek-ai/schemastery").default<string, string, "volatile-defined">;
147
148
  deprecatedTtlDays: import("@deepseek-ai/schemastery").default<number, number, "volatile-defined">;
148
149
  usageBoost: import("@deepseek-ai/schemastery").default<number, number, "volatile-defined">;
150
+ zeroOpenDecay: import("@deepseek-ai/schemastery").default<boolean, boolean, "volatile-defined">;
149
151
  pushDebounceSeconds: import("@deepseek-ai/schemastery").default<number, number, "volatile-defined">;
150
152
  jevEnabled: import("@deepseek-ai/schemastery").default<boolean, boolean, "volatile-defined">;
151
153
  jevBackend: import("@deepseek-ai/schemastery").default<string, string, "volatile-defined">;
@@ -48,10 +48,23 @@ export interface RetrievalConfig {
48
48
  hits: number;
49
49
  opens: number;
50
50
  }>;
51
+ /**
52
+ * Zero-open decay (v5, default off — config `zeroOpenDecay`): halve the
53
+ * score of slugs on the zero-open list (≥5 rendered injections in the
54
+ * rolling 30 days, zero opens ever) at score-finalization time, BEFORE
55
+ * the threshold cut — a decayed candidate that drops below the threshold
56
+ * lands in nearMisses with the `zero-open-decay` reason, so the log
57
+ * shows why it stopped injecting. Absent/off = zero behavior change.
58
+ */
59
+ zeroOpenDecay?: boolean;
60
+ /** Zero-open slugs for this round (service-built from the ilog tail). */
61
+ zeroOpenSlugs?: ReadonlySet<string>;
51
62
  }
52
63
  /** Hard cap for the usage bonus — below threshold 0.3 so usage can never
53
64
  * carry a candidate across the gate on its own (ADR 0015). */
54
65
  export declare const USAGE_BOOST_CAP = 0.2;
66
+ /** Score multiplier for zero-open slugs when zeroOpenDecay is on (v5). */
67
+ export declare const ZERO_OPEN_DECAY_FACTOR = 0.5;
55
68
  export declare const DEFAULT_RETRIEVAL: RetrievalConfig;
56
69
  /** Trigger hits outrank every other lexical field (pi-llm-wiki: highest weight). */
57
70
  export declare const TRIGGER_WEIGHT = 5;
package/lib/retrieval.js CHANGED
@@ -15,6 +15,8 @@ import { dependsSlugs } from "./okf.js";
15
15
  /** Hard cap for the usage bonus — below threshold 0.3 so usage can never
16
16
  * carry a candidate across the gate on its own (ADR 0015). */
17
17
  export const USAGE_BOOST_CAP = 0.2;
18
+ /** Score multiplier for zero-open slugs when zeroOpenDecay is on (v5). */
19
+ export const ZERO_OPEN_DECAY_FACTOR = 0.5;
18
20
  export const DEFAULT_RETRIEVAL = {
19
21
  threshold: 0.3,
20
22
  topK: 4,
@@ -149,6 +151,14 @@ export function scoreTopic(queryTokens, topic, cfg) {
149
151
  score *= 0.3;
150
152
  reasons.push('conflicted-demoted');
151
153
  }
154
+ // Zero-open decay (v5): same demotion family as conflicts — applied to
155
+ // the content score BEFORE gateScore is taken, so the threshold cut and
156
+ // the near-miss band both see the halved score; recency still rides
157
+ // outside it as the ranking tiebreaker.
158
+ if (cfg.zeroOpenDecay === true && cfg.zeroOpenSlugs?.has(topic.slug) === true) {
159
+ score *= ZERO_OPEN_DECAY_FACTOR;
160
+ reasons.push('zero-open-decay');
161
+ }
152
162
  const gateScore = score;
153
163
  score += recencyBonus;
154
164
  const bodySet = new Set([...description, ...conclusion]);