@aiwayds/dsh-topics-memory 0.21.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 +5 -0
- package/README.zh.md +5 -0
- package/lib/commands.js +27 -11
- package/lib/config.d.ts +17 -1
- package/lib/config.js +9 -1
- package/lib/ilog.d.ts +129 -0
- package/lib/ilog.js +236 -0
- package/lib/index.d.ts +2 -0
- package/lib/retrieval.d.ts +13 -0
- package/lib/retrieval.js +10 -0
- package/lib/service.d.ts +43 -2
- package/lib/service.js +121 -36
- package/lib/store.d.ts +6 -12
- package/lib/store.js +11 -2
- package/lib/tools.js +6 -2
- package/package.json +2 -2
- package/src/commands.ts +35 -16
- package/src/config.ts +13 -1
- package/src/ilog.ts +289 -0
- package/src/retrieval.ts +22 -0
- package/src/service.ts +130 -43
- package/src/store.ts +14 -8
- package/src/tools.ts +6 -2
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
239
|
+
return cached;
|
|
178
240
|
}
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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,7 +42,7 @@
|
|
|
42
42
|
"build": "tsc -p tsconfig.json",
|
|
43
43
|
"check": "tsc --noEmit -p tsconfig.json",
|
|
44
44
|
"pretest": "tsc -p tsconfig.json",
|
|
45
|
-
"test": "node --test test/yaml.test.mjs test/okf.test.mjs test/retrieval.test.mjs test/digest.test.mjs test/ilog.test.mjs test/store.test.mjs test/paths.test.mjs test/git.test.mjs test/sync.test.mjs test/tools.test.mjs test/dedup.test.mjs test/commands.test.mjs test/onboard.test.mjs test/delegation.test.mjs test/observer.test.mjs test/distill.test.mjs test/consolidate.test.mjs test/viz.test.mjs test/corpus.test.mjs test/llm-scope.test.mjs test/quality.test.mjs test/replay-gate.test.mjs test/depends-repair.test.mjs test/skill.test.mjs test/fast-lane.test.mjs test/legacy-import.test.mjs test/jev-config.test.mjs test/jev-gate.test.mjs test/jev-client.test.mjs test/jev-log.test.mjs test/jev-dual.test.mjs test/jev-fastgate.test.mjs",
|
|
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": {},
|
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、
|
|
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
|
|
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
|
-
//
|
|
314
|
-
//
|
|
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
|
-
|
|
317
|
-
|
|
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` }
|