@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/lib/service.d.ts CHANGED
@@ -38,7 +38,13 @@ export declare class TopicsService {
38
38
  private cache;
39
39
  /** mtime-keyed parse of the observations log, grouped by session. */
40
40
  private echoCache;
41
- private usageCache;
41
+ /** Single shared cache over the two feedback JSONL logs (S2): the key is
42
+ * the (mtime,size) quartet of injections.jsonl + opens.jsonl, one read
43
+ * and one tail parse feed BOTH aggregates — usageSignalsSync and
44
+ * zeroOpenSlugsSync used to each keep a full-parse cache of their own.
45
+ * Invalidation stays append-driven: the store only ever appends, so any
46
+ * write moves mtime+size. Never populated when a read failed (N1). */
47
+ private logSignalsCache;
42
48
  readonly store: BundleStore;
43
49
  private readonly getConfig;
44
50
  readonly sync?: Sync;
@@ -63,10 +69,38 @@ export declare class TopicsService {
63
69
  * Usage signals (ADR 0015): mtime-cached aggregate over the injection log
64
70
  * and the opens log, rolling USAGE_WINDOW_DAYS. Synchronous like
65
71
  * echoSlugsSync — the retrieval hot path is sync — and cheap when warm:
66
- * a fresh cache costs two stat() calls. Unreadable logs fail open to an
72
+ * a fresh cache costs two stat() calls, one read of each log, and feeds
73
+ * the zero-open set from the same parse. Unreadable logs fail open to an
67
74
  * empty map (no boost), never an error.
68
75
  */
69
76
  usageSignalsSync(): Map<string, UsageSignal>;
77
+ /**
78
+ * Zero-open slug set for the decay (v5): the shared aggregateZeroOpen
79
+ * rule over the injection tail and the whole opens log (compacted, so a
80
+ * full read stays small). Shares one cache and one parse with
81
+ * usageSignalsSync; unreadable logs fail open to an empty set (no
82
+ * decay), never an error. Only called when zeroOpenDecay is on.
83
+ */
84
+ zeroOpenSlugsSync(): ReadonlySet<string>;
85
+ /**
86
+ * The one shared feedback-log read behind both signals: stat both files
87
+ * (mtime+size quartet key), parse the injections TAIL only
88
+ * (ZERO_OPEN_TAIL rounds comfortably cover both 30-day windows) and the
89
+ * opens log whole (the zero-open rule is whole-history), then feed the
90
+ * two pure aggregates from that single parse. A failed read still fails
91
+ * open to empty inputs but NEVER populates the cache (N1) — otherwise a
92
+ * stat-success/read-failure race would freeze an empty snapshot until
93
+ * the next append.
94
+ */
95
+ private logSignalsSync;
96
+ /**
97
+ * zeroOpenDecay fields for scoring calls: empty when the gate is off —
98
+ * no file reads, zero behavior change — and the shared mtime-cached set
99
+ * when on. Spread into every searchTopics/scoreTopic cfg that already
100
+ * carries the usage signals (injection paths + explicit topic_search;
101
+ * the slow lane's recall band stays untouched, mirroring usageBoost).
102
+ */
103
+ private zeroOpenCfg;
70
104
  /**
71
105
  * Full retrieval round: search → assemble → log (ADR 0006/0007). The
72
106
  * injection record is written even for zero-hit rounds — the hit-rate
@@ -124,6 +158,13 @@ export declare class TopicsService {
124
158
  openQuestions?: string[];
125
159
  recommendations?: string;
126
160
  }>;
161
+ /**
162
+ * Resolve the open's source attribution: the injections tail (bounded by
163
+ * OPEN_ATTRIBUTION_TAIL) scanned for a same-session hit within the
164
+ * attribution window. Read failures degrade to 'search' — attribution is
165
+ * telemetry, never a blocker.
166
+ */
167
+ private attributeOpen;
127
168
  /** Sync roster read (same mtime cache as roster()). */
128
169
  rosterSync(): RetrievableTopic[];
129
170
  private readDocSync;
package/lib/service.js CHANGED
@@ -11,13 +11,51 @@ import { join } from 'node:path';
11
11
  import * as okf from "./okf.js";
12
12
  import { searchTopics, scoreTopic, passesGate, tokenize } from "./retrieval.js";
13
13
  import { assembleInjection, assemblePointer, POINTER_PER_TOPIC, POINTER_TOTAL } from "./digest.js";
14
- import { aggregateStats, querySample, aggregateUsage, USAGE_WINDOW_DAYS } from "./ilog.js";
14
+ import { aggregateStats, querySample, aggregateUsage, aggregateZeroOpen, ZERO_OPEN_TAIL, USAGE_WINDOW_DAYS, attributeOpen, OPEN_ATTRIBUTION_TAIL } from "./ilog.js";
15
15
  import { fileHistory, fileAtRev } from "./git.js";
16
+ /** Tolerant JSONL line read: undefined = unreadable file. Unlike an empty
17
+ * array, undefined tells the caller to fail open AND skip caching — a
18
+ * successful stat paired with a failed read must not freeze an empty
19
+ * snapshot into the cache until the next append. */
20
+ function readLogLinesSync(file) {
21
+ try {
22
+ return readFileSync(file, 'utf8').split('\n');
23
+ }
24
+ catch {
25
+ return undefined;
26
+ }
27
+ }
28
+ /** Parse already-split lines back-to-front, keeping the last `tail` parsed
29
+ * records in order: blanks and torn JSON lines are skipped (and never
30
+ * consume tail budget), so a torn tail line cannot crowd out a good one.
31
+ * Shared by the usage/zero-open cache — one tail parse feeds both. */
32
+ function parseJsonlLinesSync(lines, tail = Number.POSITIVE_INFINITY) {
33
+ const out = [];
34
+ for (let i = lines.length - 1; i >= 0 && out.length < tail; i -= 1) {
35
+ const line = lines[i];
36
+ if (line.trim() === '')
37
+ continue;
38
+ try {
39
+ out.push(JSON.parse(line));
40
+ }
41
+ catch {
42
+ continue; // torn tail line
43
+ }
44
+ }
45
+ out.reverse();
46
+ return out;
47
+ }
16
48
  export class TopicsService {
17
49
  cache = new Map();
18
50
  /** mtime-keyed parse of the observations log, grouped by session. */
19
51
  echoCache;
20
- usageCache;
52
+ /** Single shared cache over the two feedback JSONL logs (S2): the key is
53
+ * the (mtime,size) quartet of injections.jsonl + opens.jsonl, one read
54
+ * and one tail parse feed BOTH aggregates — usageSignalsSync and
55
+ * zeroOpenSlugsSync used to each keep a full-parse cache of their own.
56
+ * Invalidation stays append-driven: the store only ever appends, so any
57
+ * write moves mtime+size. Never populated when a read failed (N1). */
58
+ logSignalsCache;
21
59
  store;
22
60
  getConfig;
23
61
  sync;
@@ -92,7 +130,7 @@ export class TopicsService {
92
130
  invalidate() {
93
131
  this.cache.clear();
94
132
  this.echoCache = undefined;
95
- this.usageCache = undefined;
133
+ this.logSignalsCache = undefined;
96
134
  }
97
135
  /**
98
136
  * Slugs distilled from THIS session's own turns — the echo set (2026-09
@@ -151,10 +189,34 @@ export class TopicsService {
151
189
  * Usage signals (ADR 0015): mtime-cached aggregate over the injection log
152
190
  * and the opens log, rolling USAGE_WINDOW_DAYS. Synchronous like
153
191
  * echoSlugsSync — the retrieval hot path is sync — and cheap when warm:
154
- * a fresh cache costs two stat() calls. Unreadable logs fail open to an
192
+ * a fresh cache costs two stat() calls, one read of each log, and feeds
193
+ * the zero-open set from the same parse. Unreadable logs fail open to an
155
194
  * empty map (no boost), never an error.
156
195
  */
157
196
  usageSignalsSync() {
197
+ return this.logSignalsSync().signals;
198
+ }
199
+ /**
200
+ * Zero-open slug set for the decay (v5): the shared aggregateZeroOpen
201
+ * rule over the injection tail and the whole opens log (compacted, so a
202
+ * full read stays small). Shares one cache and one parse with
203
+ * usageSignalsSync; unreadable logs fail open to an empty set (no
204
+ * decay), never an error. Only called when zeroOpenDecay is on.
205
+ */
206
+ zeroOpenSlugsSync() {
207
+ return this.logSignalsSync().zeroOpenSlugs;
208
+ }
209
+ /**
210
+ * The one shared feedback-log read behind both signals: stat both files
211
+ * (mtime+size quartet key), parse the injections TAIL only
212
+ * (ZERO_OPEN_TAIL rounds comfortably cover both 30-day windows) and the
213
+ * opens log whole (the zero-open rule is whole-history), then feed the
214
+ * two pure aggregates from that single parse. A failed read still fails
215
+ * open to empty inputs but NEVER populates the cache (N1) — otherwise a
216
+ * stat-success/read-failure race would freeze an empty snapshot until
217
+ * the next append.
218
+ */
219
+ logSignalsSync() {
158
220
  const injFile = join(this.store.metaDir(), 'injections.jsonl');
159
221
  const opensFile = join(this.store.metaDir(), 'opens.jsonl');
160
222
  const statOf = (file) => {
@@ -168,42 +230,44 @@ export class TopicsService {
168
230
  };
169
231
  const injSt = statOf(injFile);
170
232
  const opensSt = statOf(opensFile);
171
- const cached = this.usageCache;
233
+ const cached = this.logSignalsCache;
172
234
  if (cached !== undefined &&
173
235
  cached.injMs === injSt.mtimeMs &&
174
236
  cached.injSize === injSt.size &&
175
237
  cached.opensMs === opensSt.mtimeMs &&
176
238
  cached.opensSize === opensSt.size) {
177
- return cached.signals;
239
+ return cached;
178
240
  }
179
- const parseLines = (file) => {
180
- const out = [];
181
- try {
182
- for (const line of readFileSync(file, 'utf8').split('\n')) {
183
- if (line.trim() === '')
184
- continue;
185
- try {
186
- out.push(JSON.parse(line));
187
- }
188
- catch {
189
- continue; // torn tail line
190
- }
191
- }
192
- }
193
- catch {
194
- // unreadable log: contribute nothing
195
- }
196
- return out;
197
- };
198
- const signals = aggregateUsage(parseLines(injFile), parseLines(opensFile), USAGE_WINDOW_DAYS, Date.now());
199
- this.usageCache = {
200
- injMs: injSt.mtimeMs,
201
- injSize: injSt.size,
202
- opensMs: opensSt.mtimeMs,
203
- opensSize: opensSt.size,
204
- signals,
205
- };
206
- return signals;
241
+ const injLines = readLogLinesSync(injFile);
242
+ const opensLines = readLogLinesSync(opensFile);
243
+ const injections = parseJsonlLinesSync(injLines ?? [], ZERO_OPEN_TAIL);
244
+ const opens = parseJsonlLinesSync(opensLines ?? []);
245
+ const now = Date.now();
246
+ const signals = aggregateUsage(injections, opens, USAGE_WINDOW_DAYS, now);
247
+ const zeroOpenSlugs = new Set(aggregateZeroOpen(injections, opens, now).keys());
248
+ if (injLines !== undefined && opensLines !== undefined) {
249
+ this.logSignalsCache = {
250
+ injMs: injSt.mtimeMs,
251
+ injSize: injSt.size,
252
+ opensMs: opensSt.mtimeMs,
253
+ opensSize: opensSt.size,
254
+ signals,
255
+ zeroOpenSlugs,
256
+ };
257
+ }
258
+ return { signals, zeroOpenSlugs };
259
+ }
260
+ /**
261
+ * zeroOpenDecay fields for scoring calls: empty when the gate is off —
262
+ * no file reads, zero behavior change — and the shared mtime-cached set
263
+ * when on. Spread into every searchTopics/scoreTopic cfg that already
264
+ * carries the usage signals (injection paths + explicit topic_search;
265
+ * the slow lane's recall band stays untouched, mirroring usageBoost).
266
+ */
267
+ zeroOpenCfg() {
268
+ if (this.cfg.zeroOpenDecay !== true)
269
+ return {};
270
+ return { zeroOpenDecay: true, zeroOpenSlugs: this.zeroOpenSlugsSync() };
207
271
  }
208
272
  /**
209
273
  * Full retrieval round: search → assemble → log (ADR 0006/0007). The
@@ -230,6 +294,7 @@ export class TopicsService {
230
294
  conflicts,
231
295
  usageBoost: cfg.usageBoost,
232
296
  usage: this.usageSignalsSync(),
297
+ ...this.zeroOpenCfg(),
233
298
  });
234
299
  const bySlug = new Map(roster.map((r) => [r.slug, r]));
235
300
  const entries = [];
@@ -334,6 +399,7 @@ export class TopicsService {
334
399
  conflicts,
335
400
  usageBoost: cfg.usageBoost,
336
401
  usage: this.usageSignalsSync(),
402
+ ...this.zeroOpenCfg(),
337
403
  });
338
404
  const exclude = dedup?.exclude;
339
405
  const echo = dedup?.echo;
@@ -397,6 +463,7 @@ export class TopicsService {
397
463
  structuralGate: true,
398
464
  usageBoost: cfg.usageBoost,
399
465
  usage: this.usageSignalsSync(),
466
+ ...this.zeroOpenCfg(),
400
467
  });
401
468
  shadow.push({
402
469
  slug: item.slug,
@@ -501,9 +568,14 @@ export class TopicsService {
501
568
  if (doc === undefined)
502
569
  return { found: false, slug };
503
570
  // Awaited (tool path, not hot): a settled write makes the pointer-open
504
- // stat reliable; a failed log write must not fail the tool.
571
+ // stat reliable; a failed log write must not fail the tool. Source
572
+ // attribution rides the same write: a cheap injections-tail scan decides
573
+ // pointer vs search and, for pointers, carries the hit score and the
574
+ // open→injection lag (ilog.attributeOpen).
575
+ const at = new Date().toISOString();
576
+ const attribution = await this.attributeOpen(slug, sessionId, Date.parse(at));
505
577
  await this.store
506
- .appendOpenRecord({ slug, at: new Date().toISOString(), ...(sessionId !== undefined ? { sessionId } : {}) })
578
+ .appendOpenRecord({ slug, at, ...(sessionId !== undefined ? { sessionId } : {}), ...attribution })
507
579
  .catch(() => undefined);
508
580
  // Optional fields are OMITTED, never present-as-undefined: the host's
509
581
  // tool-output validation rejects any object carrying an undefined-valued
@@ -523,6 +595,18 @@ export class TopicsService {
523
595
  found.description = doc.fm.description;
524
596
  return found;
525
597
  }
598
+ /**
599
+ * Resolve the open's source attribution: the injections tail (bounded by
600
+ * OPEN_ATTRIBUTION_TAIL) scanned for a same-session hit within the
601
+ * attribution window. Read failures degrade to 'search' — attribution is
602
+ * telemetry, never a blocker.
603
+ */
604
+ async attributeOpen(slug, sessionId, nowMs) {
605
+ if (sessionId === undefined)
606
+ return { source: 'search' };
607
+ const tail = await this.store.readInjectionRecords(OPEN_ATTRIBUTION_TAIL).catch(() => []);
608
+ return attributeOpen(tail, slug, sessionId, nowMs);
609
+ }
526
610
  /** Sync roster read (same mtime cache as roster()). */
527
611
  rosterSync() {
528
612
  let files;
@@ -656,6 +740,7 @@ export class TopicsService {
656
740
  conflicts: await this.store.getConflicts(),
657
741
  usageBoost: cfg.usageBoost,
658
742
  usage: this.usageSignalsSync(),
743
+ ...this.zeroOpenCfg(),
659
744
  });
660
745
  }
661
746
  async history(slug, limit = 20) {
package/lib/store.d.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * @module store
10
10
  */
11
11
  import * as okf from './okf.ts';
12
- import type { InjectionRecord } from './ilog.ts';
12
+ import type { InjectionRecord, OpenRecord } from './ilog.ts';
13
13
  export interface TopicMeta {
14
14
  slug: string;
15
15
  title: string;
@@ -171,18 +171,12 @@ export declare class BundleStore {
171
171
  private compactInjectionsIfNeeded;
172
172
  readInjectionRecords(limit?: number): Promise<InjectionRecord[]>;
173
173
  private opensPath;
174
- appendOpenRecord(record: {
175
- slug: string;
176
- at: string;
177
- sessionId?: string;
178
- }): Promise<void>;
174
+ appendOpenRecord(record: OpenRecord): Promise<void>;
179
175
  private compactOpensIfNeeded;
180
- /** Opens in the recent window; tolerant of a torn tail like the other JSONLs. */
181
- readOpenRecords(limit?: number): Promise<{
182
- slug: string;
183
- at: string;
184
- sessionId?: string;
185
- }[]>;
176
+ /** Opens in the recent window; tolerant of a torn tail like the other JSONLs.
177
+ * Enrichment fields (sessionId/source/score/sinceInjectionMs) are optional:
178
+ * pre-enrichment lines carry only slug/at and read back without them. */
179
+ readOpenRecords(limit?: number): Promise<OpenRecord[]>;
186
180
  getConflicts(): Promise<Set<string>>;
187
181
  /** Sync variant for the injection hot path. */
188
182
  getConflictsSync(): Set<string>;
package/lib/store.js CHANGED
@@ -544,7 +544,8 @@ export class BundleStore {
544
544
  }
545
545
  // ------------------------------------------------------------------
546
546
  // Pointer-open log (v4 §4.3) — one line per topic_open call; feeds the
547
- // 「指针打开率」 stat. Features only (slug/time), never conversation text.
547
+ // 「指针打开率」 stat. Features only (slug/time + attribution), never
548
+ // conversation text.
548
549
  // ------------------------------------------------------------------
549
550
  opensPath() {
550
551
  return join(this.metaDir(), 'opens.jsonl');
@@ -568,7 +569,9 @@ export class BundleStore {
568
569
  const lines = (await readFile(file, 'utf8')).split('\n').filter((l) => l.trim() !== '');
569
570
  await atomicWrite(file, lines.slice(-Math.max(1, Math.floor(lines.length / 4))).map((l) => `${l}\n`).join(''));
570
571
  }
571
- /** Opens in the recent window; tolerant of a torn tail like the other JSONLs. */
572
+ /** Opens in the recent window; tolerant of a torn tail like the other JSONLs.
573
+ * Enrichment fields (sessionId/source/score/sinceInjectionMs) are optional:
574
+ * pre-enrichment lines carry only slug/at and read back without them. */
572
575
  async readOpenRecords(limit = 2000) {
573
576
  let raw;
574
577
  try {
@@ -587,6 +590,12 @@ export class BundleStore {
587
590
  const rec = { slug: parsed.slug, at: parsed.at };
588
591
  if (typeof parsed.sessionId === 'string')
589
592
  rec.sessionId = parsed.sessionId;
593
+ if (parsed.source === 'pointer' || parsed.source === 'search')
594
+ rec.source = parsed.source;
595
+ if (typeof parsed.score === 'number')
596
+ rec.score = parsed.score;
597
+ if (typeof parsed.sinceInjectionMs === 'number')
598
+ rec.sinceInjectionMs = parsed.sinceInjectionMs;
590
599
  out.push(rec);
591
600
  }
592
601
  }
package/lib/tools.js CHANGED
@@ -108,8 +108,12 @@ export function buildTopicTools(service) {
108
108
  },
109
109
  ],
110
110
  },
111
- async execute(args) {
112
- return await service.openTopic(args.slug);
111
+ async execute(args, exec) {
112
+ // The agent handle on the run context IS the session (Agent.id is the
113
+ // session id — the same key the injection records use). Absent in
114
+ // bare test/host contexts: attribution then degrades to 'search'.
115
+ const sessionId = exec?.agent !== undefined ? String(exec.agent.id) : undefined;
116
+ return await service.openTopic(args.slug, sessionId);
113
117
  },
114
118
  });
115
119
  const topicSearch = defineTool({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwayds/dsh-topics-memory",
3
- "version": "0.20.0",
3
+ "version": "0.22.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,18 +42,18 @@
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",
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",
46
46
  "smoke": "node scripts/smoke-boot.mjs"
47
47
  },
48
48
  "dependencies": {},
49
49
  "peerDependencies": {
50
50
  "@deepseek-ai/cordis": "^4.0.2",
51
- "@deepseek-ai/dsh-tools": ">=0.1.7-rc.1",
52
- "@deepseek-ai/dsh-llm": ">=0.1.7-rc.1",
53
- "@deepseek-ai/dsh-settings": ">=0.1.7-rc.1",
54
- "@deepseek-ai/dsh-skill": ">=0.1.7-rc.1",
55
- "@deepseek-ai/dsh-commands": ">=0.1.7-rc.1",
56
- "@deepseek-ai/dsh-util-values": ">=0.1.7-rc.1",
51
+ "@deepseek-ai/dsh-tools": ">=0.2.0-rc.2",
52
+ "@deepseek-ai/dsh-llm": ">=0.2.0-rc.2",
53
+ "@deepseek-ai/dsh-settings": ">=0.2.0-rc.2",
54
+ "@deepseek-ai/dsh-skill": ">=0.2.0-rc.2",
55
+ "@deepseek-ai/dsh-commands": ">=0.2.0-rc.2",
56
+ "@deepseek-ai/dsh-util-values": ">=0.2.0-rc.2",
57
57
  "@deepseek-ai/schemastery": "^3.18.4"
58
58
  },
59
59
  "peerDependenciesMeta": {
@@ -83,23 +83,23 @@
83
83
  }
84
84
  },
85
85
  "devDependencies": {
86
- "@deepseek-ai/cordis": "4.0.2",
87
- "@deepseek-ai/dsh-agent": "0.1.7-rc.1",
88
- "@deepseek-ai/dsh-attachment": "0.1.7-rc.1",
89
- "@deepseek-ai/dsh-brand": "0.1.7-rc.1",
90
- "@deepseek-ai/dsh-commands": "0.1.7-rc.1",
91
- "@deepseek-ai/dsh-invariants": "0.1.7-rc.1",
92
- "@deepseek-ai/dsh-llm": "0.1.7-rc.1",
93
- "@deepseek-ai/dsh-scope": "0.1.7-rc.1",
94
- "@deepseek-ai/dsh-sandbox": "0.1.7-rc.1",
95
- "@deepseek-ai/dsh-session": "0.1.7-rc.1",
96
- "@deepseek-ai/dsh-settings": "0.1.7-rc.1",
97
- "@deepseek-ai/dsh-skill": "0.1.7-rc.1",
98
- "@deepseek-ai/dsh-system-prompt": "0.1.7-rc.1",
99
- "@deepseek-ai/dsh-timeout": "0.1.7-rc.1",
100
- "@deepseek-ai/dsh-tools": "0.1.7-rc.1",
101
- "@deepseek-ai/dsh-user-approval": "0.1.7-rc.1",
102
- "@deepseek-ai/dsh-util-values": "0.1.7-rc.1",
86
+ "@deepseek-ai/cordis": "4.0.4",
87
+ "@deepseek-ai/dsh-agent": "0.2.0-rc.2",
88
+ "@deepseek-ai/dsh-attachment": "0.2.0-rc.2",
89
+ "@deepseek-ai/dsh-brand": "0.2.0-rc.2",
90
+ "@deepseek-ai/dsh-commands": "0.2.0-rc.2",
91
+ "@deepseek-ai/dsh-invariants": "0.2.0-rc.2",
92
+ "@deepseek-ai/dsh-llm": "0.2.0-rc.2",
93
+ "@deepseek-ai/dsh-scope": "0.2.0-rc.2",
94
+ "@deepseek-ai/dsh-sandbox": "0.2.0-rc.2",
95
+ "@deepseek-ai/dsh-session": "0.2.0-rc.2",
96
+ "@deepseek-ai/dsh-settings": "0.2.0-rc.2",
97
+ "@deepseek-ai/dsh-skill": "0.2.0-rc.2",
98
+ "@deepseek-ai/dsh-system-prompt": "0.2.0-rc.2",
99
+ "@deepseek-ai/dsh-timeout": "0.2.0-rc.2",
100
+ "@deepseek-ai/dsh-tools": "0.2.0-rc.2",
101
+ "@deepseek-ai/dsh-user-approval": "0.2.0-rc.2",
102
+ "@deepseek-ai/dsh-util-values": "0.2.0-rc.2",
103
103
  "@deepseek-ai/schemastery": "3.18.4",
104
104
  "@types/node": "^22.19.0",
105
105
  "typescript": "^5.9.2"
package/src/commands.ts CHANGED
@@ -17,7 +17,7 @@ import { serializeTopicDoc, slugify, firstParagraph } from './okf.ts'
17
17
  import { buildGraph, renderGraphHtml } from './viz.ts'
18
18
  import { CONFIG_KEYS, displayKey, type ConfigKey, type TopicsConfigValue, parseConfigValue } from './config.ts'
19
19
  import { reloadSecrets } from './jev/gate.ts'
20
- import { aggregateStats } from './ilog.ts'
20
+ import { aggregateStats, aggregateOpenPanel, ZERO_OPEN_MIN_INJECTIONS } from './ilog.ts'
21
21
  import {
22
22
  askDistillSingle,
23
23
  createOnboardHandler,
@@ -52,7 +52,7 @@ export const HELP = [
52
52
  ' /topics status bundle 健康:topic 数、观察积压、冲突、同步状态',
53
53
  ' /topics distill 手动触发一次蒸馏(观察池 → Topic,输出 marked/created/updated/gc 摘要)',
54
54
  ' /topics consolidate 手动触发一次整理(合并重复/晋升 stable/废弃过时/刷新元数据,无视 cadence 立即跑)',
55
- ' /topics stats 注入统计:hit rate、top-N、near-miss 分布与调参建议',
55
+ ' /topics stats 注入统计:hit rate、open rate、注入 vs 打开、near-miss 分布与调参建议',
56
56
  ' /topics list 列出全部 Topic',
57
57
  ' /topics show <slug> 查看一个 Topic 全文(含反向引用)',
58
58
  ' /topics history <slug> 一个 Topic 的结论变更史(git log)',
@@ -285,7 +285,7 @@ async function doDistill(service: TopicsService, invocation: CommandInvocation,
285
285
 
286
286
  async function renderStats(service: TopicsService): Promise<string> {
287
287
  const records = await service.store.readInjectionRecords()
288
- const stats = aggregateStats(records as never)
288
+ const stats = aggregateStats(records)
289
289
  if (records.length === 0) return '还没有注入记录 —— 用起来之后这里会有 hit rate / top-N / near-miss 分布。'
290
290
  const lines = [
291
291
  `注入统计(最近 ${records.length} 轮):`,
@@ -310,20 +310,14 @@ async function renderStats(service: TopicsService): Promise<string> {
310
310
  }
311
311
  lines.push(`| 慢道参与轮 | ${slowRounds}(快 ${records.length - slowRounds} / 慢或混合 ${slowRounds}${medianLag}) |`)
312
312
  }
313
- // Pointer open rate (v4 §4.3): topic_open calls vs pointer entries injected.
314
- // Echoed hits ride the retrieval but never became pointers — excluded here.
313
+ // Injection-vs-open metrics (v5): the denominator counts only pointers
314
+ // that truly rendered (deduped/echoed/dropped all excluded, slow pointers
315
+ // included) and the numerator only counts DISTINCT slugs from that
316
+ // rendered set opened inside the injection window — search opens and
317
+ // never-rendered slugs cannot inflate the rate.
315
318
  const opens = await service.store.readOpenRecords()
316
- if (opens.length > 0 || records.some((r) => r.lane !== undefined)) {
317
- const entries = records.reduce(
318
- (acc, r) =>
319
- acc +
320
- r.hits.filter((h) => !(r.deduped ?? []).includes(h.slug) && !(r.echoed ?? []).includes(h.slug)).length +
321
- (r.slow?.length ?? 0),
322
- 0,
323
- )
324
- const rate = entries === 0 ? 0 : Math.min(1, opens.length / entries)
325
- lines.push(`| 指针打开率 | ${(rate * 100).toFixed(1)}%(${opens.length} 次 topic_open / ${entries} 条注入指针) |`)
326
- }
319
+ const panel = aggregateOpenPanel(records, opens)
320
+ lines.push(`| open rate | ${(panel.openRate * 100).toFixed(1)}%(${panel.opensInWindow} 个被打开指针 / ${panel.pointerEntries} 条注入指针) |`)
327
321
  const echoedCount = records.reduce((acc, r) => acc + (r.echoed?.length ?? 0), 0)
328
322
  if (echoedCount > 0) {
329
323
  lines.push(`| 回声抑制 | ${echoedCount} 次(本会话蒸馏出的 topic 不回注) |`)
@@ -332,6 +326,31 @@ async function renderStats(service: TopicsService): Promise<string> {
332
326
  lines.push('', 'Top-N 被注入 Topic:', '', '| Slug | 注入次数 |', '| --- | --- |')
333
327
  for (const t of stats.topTopics.slice(0, 5)) lines.push(`| \`${cell(t.slug)}\` | ${t.count} |`)
334
328
  }
329
+ // ---- 注入 vs 打开 panel ----
330
+ // Per-topic cut: what the header's windowed rate cannot show — which
331
+ // topics the model actually bothers to open (wide-scope slug attribution).
332
+ if (panel.topics.length > 0) {
333
+ lines.push(
334
+ '',
335
+ '注入 vs 打开(Top-10,注入=真正渲染的指针;打开按 slug 归因,全窗口):',
336
+ '',
337
+ '| Topic | 注入 | 打开 | 打开率 |',
338
+ '| --- | --- | --- | --- |',
339
+ ...panel.topics.map((t) => `| \`${cell(t.slug)}\` | ${t.injections} | ${t.opens} | ${(t.openRate * 100).toFixed(1)}% |`),
340
+ )
341
+ }
342
+ if (panel.zeroOpenTotal > 0) {
343
+ const shown = panel.zeroOpen.map((z) => `\`${cell(z.slug)}\``).join('、')
344
+ const more = panel.zeroOpenTotal > panel.zeroOpen.length ? `(共 ${panel.zeroOpenTotal} 个)` : ''
345
+ lines.push('', `零打开高频(注入 ≥${ZERO_OPEN_MIN_INJECTIONS}、打开 0):${shown}${more}`)
346
+ }
347
+ lines.push('', panel.hasSourceData ? `打开来源:pointer ${panel.pointerOpens} / search ${panel.searchOpens}` : '打开来源:暂无来源数据(需新版本 open 记录)')
348
+ if (panel.pointerEntries > 0) {
349
+ lines.push('', '| 分桶 | 指针 | 打开率 |', '| --- | --- | --- |')
350
+ for (const b of panel.bands) {
351
+ lines.push(`| ${b.label} | ${b.pointers} | ${b.pointers === 0 ? '—' : `${(b.openRate * 100).toFixed(1)}%`} |`)
352
+ }
353
+ }
335
354
  if (stats.nearMissHistogram.length > 0) {
336
355
  lines.push(
337
356
  '',
package/src/config.ts CHANGED
@@ -72,6 +72,12 @@ export const TopicsConfig = z.object({
72
72
  * granted to zero-lexical candidates; the structural gate still applies.
73
73
  * 0 = off. */
74
74
  usageBoost: z.number().default(0.15).volatile(),
75
+ /** Zero-open decay (v5, default off): halve the retrieval score of slugs
76
+ * injected ≥5 times in the rolling 30 days with zero opens ever — the
77
+ * 零打开高频 leaderboard stops out-ranking topics the model actually
78
+ * reads. false = zero behavior change; the reason tag `zero-open-decay`
79
+ * rides the hit/near-miss reasons when on. */
80
+ zeroOpenDecay: z.boolean().default(false).volatile(),
75
81
  /** Debounced push delay in GitHub mode. */
76
82
  pushDebounceSeconds: z.number().default(45).volatile(),
77
83
  /** System One decision-model integration (design 2026-09-25), default-off
@@ -127,6 +133,10 @@ export type TopicsConfigValue = {
127
133
  consolidateCadence: string
128
134
  deprecatedTtlDays: number
129
135
  usageBoost: number
136
+ /** Zero-open decay (v5). Optional in this TS view only (same sentinel
137
+ * pattern as the jev keys): bare-harness DEFAULTS predates it —
138
+ * consumers must treat undefined as the documented default (false). */
139
+ zeroOpenDecay?: boolean
130
140
  pushDebounceSeconds: number
131
141
  /** System One integration keys (design 2026-09-25 §4). Optional in this TS
132
142
  * view only: index.ts's bare-harness DEFAULTS literal predates them and
@@ -167,6 +177,7 @@ export const CONFIG_KEYS = [
167
177
  'consolidateCadence',
168
178
  'deprecatedTtlDays',
169
179
  'usageBoost',
180
+ 'zeroOpenDecay',
170
181
  'pushDebounceSeconds',
171
182
  'jevEnabled',
172
183
  'jevBackend',
@@ -199,7 +210,8 @@ export function parseConfigValue(key: ConfigKey, raw: string): boolean | number
199
210
  case 'autoObserve':
200
211
  case 'distillOnSessionEnd':
201
212
  case 'includeSubagents':
202
- case 'jevEnabled': {
213
+ case 'jevEnabled':
214
+ case 'zeroOpenDecay': {
203
215
  if (raw === 'on' || raw === 'true') return true
204
216
  if (raw === 'off' || raw === 'false') return false
205
217
  return { error: `${key} 取值 on|off` }