@aiwayds/dsh-topics-memory 0.10.0 → 0.11.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/commands.js +22 -4
- package/lib/config.d.ts +12 -1
- package/lib/config.js +7 -0
- package/lib/ilog.d.ts +2 -0
- package/lib/ilog.js +6 -4
- package/lib/index.js +18 -1
- package/lib/quality.d.ts +8 -0
- package/lib/quality.js +7 -2
- package/lib/service.d.ts +24 -6
- package/lib/service.js +105 -11
- package/lib/tools.js +14 -1
- package/package.json +1 -1
- package/src/commands.ts +24 -4
- package/src/config.ts +8 -0
- package/src/ilog.ts +8 -4
- package/src/index.ts +15 -1
- package/src/quality.ts +15 -2
- package/src/service.ts +108 -12
- package/src/tools.ts +13 -1
package/lib/commands.js
CHANGED
|
@@ -169,12 +169,19 @@ async function renderStats(service) {
|
|
|
169
169
|
lines.push(` 慢道参与轮:${slowRounds}(快 ${(records.length - slowRounds)} / 慢或混合 ${slowRounds}${medianLag})`);
|
|
170
170
|
}
|
|
171
171
|
// Pointer open rate (v4 §4.3): topic_open calls vs pointer entries injected.
|
|
172
|
+
// Echoed hits ride the retrieval but never became pointers — excluded here.
|
|
172
173
|
const opens = await service.store.readOpenRecords();
|
|
173
174
|
if (opens.length > 0 || records.some((r) => r.lane !== undefined)) {
|
|
174
|
-
const entries = records.reduce((acc, r) => acc +
|
|
175
|
+
const entries = records.reduce((acc, r) => acc +
|
|
176
|
+
r.hits.filter((h) => !(r.deduped ?? []).includes(h.slug) && !(r.echoed ?? []).includes(h.slug)).length +
|
|
177
|
+
(r.slow?.length ?? 0), 0);
|
|
175
178
|
const rate = entries === 0 ? 0 : Math.min(1, opens.length / entries);
|
|
176
179
|
lines.push(` 指针打开率:${(rate * 100).toFixed(1)}%(${opens.length} 次 topic_open / ${entries} 条注入指针)`);
|
|
177
180
|
}
|
|
181
|
+
const echoedCount = records.reduce((acc, r) => acc + (r.echoed?.length ?? 0), 0);
|
|
182
|
+
if (echoedCount > 0) {
|
|
183
|
+
lines.push(` 回声抑制:${echoedCount} 次(本会话蒸馏出的 topic 不回注)`);
|
|
184
|
+
}
|
|
178
185
|
if (stats.topTopics.length > 0) {
|
|
179
186
|
lines.push(' Top-N 被注入 Topic:');
|
|
180
187
|
for (const t of stats.topTopics.slice(0, 5))
|
|
@@ -204,12 +211,23 @@ async function renderList(service) {
|
|
|
204
211
|
const metas = await service.store.listTopics();
|
|
205
212
|
if (metas.length === 0)
|
|
206
213
|
return 'Bundle 里还没有 Topic —— 在会话里让我记点什么,或 /topics set 配置好蒸馏。';
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
214
|
+
metas.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
215
|
+
const lines = [
|
|
216
|
+
`共 ${metas.length} 个 Topic:`,
|
|
217
|
+
'',
|
|
218
|
+
'| Slug | 标题 | 状态 | 标签 | 更新 |',
|
|
219
|
+
'| --- | --- | --- | --- | --- |',
|
|
220
|
+
];
|
|
221
|
+
for (const m of metas) {
|
|
222
|
+
const tags = m.tags.length > 0 ? m.tags.map((t) => `#${cell(t)}`).join(' ') : '—';
|
|
223
|
+
lines.push(`| \`${cell(m.slug)}\` | ${cell(m.title)} | ${m.status} | ${tags} | ${cell(m.generatedAt.slice(0, 10))} |`);
|
|
210
224
|
}
|
|
211
225
|
return lines.join('\n');
|
|
212
226
|
}
|
|
227
|
+
/** One markdown table cell: pipes escaped, whitespace flattened. */
|
|
228
|
+
function cell(text) {
|
|
229
|
+
return text.replace(/\|/g, '\\|').replace(/\s*\n\s*/g, ' ');
|
|
230
|
+
}
|
|
213
231
|
async function renderShow(service, slug) {
|
|
214
232
|
if (slug === undefined || slug === '')
|
|
215
233
|
return fail('用法:/topics show <slug>');
|
package/lib/config.d.ts
CHANGED
|
@@ -12,6 +12,11 @@ export declare const TopicsConfig: z<Schemastery.ObjectS<{
|
|
|
12
12
|
autoInject: z<boolean, boolean>;
|
|
13
13
|
/** Skip re-injecting topics already injected earlier in the same session. */
|
|
14
14
|
injectDedup: z<boolean, boolean>;
|
|
15
|
+
/** Skip re-injecting topics distilled from the CURRENT session's own turns
|
|
16
|
+
* (蒸馏回声): the conversation already carries that knowledge, so a
|
|
17
|
+
* pointer is a stale echo at best (2026-09 audit: 3 of 6 useless rounds).
|
|
18
|
+
* Provenance rides the observations log (sessionId → distilledInto). */
|
|
19
|
+
suppressEcho: z<boolean, boolean>;
|
|
15
20
|
/** Max topics injected per round (ADR 0006: ≤4). */
|
|
16
21
|
topK: z<number, number>;
|
|
17
22
|
/** Per-topic digest budget in tokens. */
|
|
@@ -60,6 +65,11 @@ export declare const TopicsConfig: z<Schemastery.ObjectS<{
|
|
|
60
65
|
autoInject: z<boolean, boolean>;
|
|
61
66
|
/** Skip re-injecting topics already injected earlier in the same session. */
|
|
62
67
|
injectDedup: z<boolean, boolean>;
|
|
68
|
+
/** Skip re-injecting topics distilled from the CURRENT session's own turns
|
|
69
|
+
* (蒸馏回声): the conversation already carries that knowledge, so a
|
|
70
|
+
* pointer is a stale echo at best (2026-09 audit: 3 of 6 useless rounds).
|
|
71
|
+
* Provenance rides the observations log (sessionId → distilledInto). */
|
|
72
|
+
suppressEcho: z<boolean, boolean>;
|
|
63
73
|
/** Max topics injected per round (ADR 0006: ≤4). */
|
|
64
74
|
topK: z<number, number>;
|
|
65
75
|
/** Per-topic digest budget in tokens. */
|
|
@@ -106,6 +116,7 @@ export type TopicsConfigValue = {
|
|
|
106
116
|
repo: string;
|
|
107
117
|
autoInject: boolean;
|
|
108
118
|
injectDedup: boolean;
|
|
119
|
+
suppressEcho: boolean;
|
|
109
120
|
topK: number;
|
|
110
121
|
perTopicBudget: number;
|
|
111
122
|
totalBudget: number;
|
|
@@ -126,7 +137,7 @@ export type TopicsConfigValue = {
|
|
|
126
137
|
distillMaxModelCalls: number;
|
|
127
138
|
pushDebounceSeconds: number;
|
|
128
139
|
};
|
|
129
|
-
export declare const CONFIG_KEYS: readonly ["repo", "autoInject", "injectDedup", "topK", "perTopicBudget", "totalBudget", "matchThreshold", "tagBoost", "injectMode", "qualityLane", "graphDepth", "recencyWindowDays", "autoObserve", "includeSubagents", "observationMaxChars", "distillEveryTurns", "distillOnSessionEnd", "distillProvider", "distillModel", "distillBatchSize", "distillMaxModelCalls", "pushDebounceSeconds"];
|
|
140
|
+
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", "pushDebounceSeconds"];
|
|
130
141
|
export type ConfigKey = (typeof CONFIG_KEYS)[number];
|
|
131
142
|
/** CamelCase → dash-display (topK → top-k) for command output. */
|
|
132
143
|
export declare function displayKey(key: string): string;
|
package/lib/config.js
CHANGED
|
@@ -12,6 +12,11 @@ export const TopicsConfig = z.object({
|
|
|
12
12
|
autoInject: z.boolean().default(true),
|
|
13
13
|
/** Skip re-injecting topics already injected earlier in the same session. */
|
|
14
14
|
injectDedup: z.boolean().default(true),
|
|
15
|
+
/** Skip re-injecting topics distilled from the CURRENT session's own turns
|
|
16
|
+
* (蒸馏回声): the conversation already carries that knowledge, so a
|
|
17
|
+
* pointer is a stale echo at best (2026-09 audit: 3 of 6 useless rounds).
|
|
18
|
+
* Provenance rides the observations log (sessionId → distilledInto). */
|
|
19
|
+
suppressEcho: z.boolean().default(true),
|
|
15
20
|
/** Max topics injected per round (ADR 0006: ≤4). */
|
|
16
21
|
topK: z.number().default(4),
|
|
17
22
|
/** Per-topic digest budget in tokens. */
|
|
@@ -58,6 +63,7 @@ export const CONFIG_KEYS = [
|
|
|
58
63
|
'repo',
|
|
59
64
|
'autoInject',
|
|
60
65
|
'injectDedup',
|
|
66
|
+
'suppressEcho',
|
|
61
67
|
'topK',
|
|
62
68
|
'perTopicBudget',
|
|
63
69
|
'totalBudget',
|
|
@@ -93,6 +99,7 @@ export function parseConfigValue(key, raw) {
|
|
|
93
99
|
}
|
|
94
100
|
case 'autoInject':
|
|
95
101
|
case 'injectDedup':
|
|
102
|
+
case 'suppressEcho':
|
|
96
103
|
case 'autoObserve':
|
|
97
104
|
case 'distillOnSessionEnd':
|
|
98
105
|
case 'includeSubagents': {
|
package/lib/ilog.d.ts
CHANGED
|
@@ -51,6 +51,8 @@ export interface InjectionRecord {
|
|
|
51
51
|
}[];
|
|
52
52
|
/** Slugs blocked this round by session-level injection dedup (never assembled). */
|
|
53
53
|
deduped?: string[];
|
|
54
|
+
/** Slugs blocked this round as 蒸馏回声 (distilled from this session's own turns). */
|
|
55
|
+
echoed?: string[];
|
|
54
56
|
usedTokens?: number;
|
|
55
57
|
/** fast = lexical pointers only; slow = async picks only; mixed = both. */
|
|
56
58
|
lane?: 'fast' | 'slow' | 'mixed';
|
package/lib/ilog.js
CHANGED
|
@@ -24,12 +24,14 @@ export function aggregateStats(records) {
|
|
|
24
24
|
budgetSamples += 1;
|
|
25
25
|
budgetSum += r.usedTokens;
|
|
26
26
|
}
|
|
27
|
-
// topTopics feeds 「Top-N 被注入 Topic」— deduped hits were NOT
|
|
28
|
-
// so they must not inflate the per-slug injection counts.
|
|
29
|
-
// metrics above (hits/rounds, zero-hit rounds) keep
|
|
27
|
+
// topTopics feeds 「Top-N 被注入 Topic」— deduped/echoed hits were NOT
|
|
28
|
+
// injected, so they must not inflate the per-slug injection counts.
|
|
29
|
+
// Retrieval-shape metrics above (hits/rounds, zero-hit rounds) keep
|
|
30
|
+
// counting raw hits.
|
|
30
31
|
const deduped = r.deduped === undefined ? undefined : new Set(r.deduped);
|
|
32
|
+
const echoed = r.echoed === undefined ? undefined : new Set(r.echoed);
|
|
31
33
|
for (const h of r.hits) {
|
|
32
|
-
if (deduped?.has(h.slug))
|
|
34
|
+
if (deduped?.has(h.slug) || echoed?.has(h.slug))
|
|
33
35
|
continue;
|
|
34
36
|
topicCounts.set(h.slug, (topicCounts.get(h.slug) ?? 0) + 1);
|
|
35
37
|
}
|
package/lib/index.js
CHANGED
|
@@ -170,6 +170,7 @@ export function apply(ctx) {
|
|
|
170
170
|
return;
|
|
171
171
|
const cfg = cfgNow();
|
|
172
172
|
const seen = cfg.injectDedup ? injectedBySession.get(sessionId) : undefined;
|
|
173
|
+
const echo = cfg.suppressEcho ? service.echoSlugsSync(sessionId) : undefined;
|
|
173
174
|
// Slow-lane consumption point (v4 生命周期表): the pending produced at
|
|
174
175
|
// the previous turn/end rides THIS splice — consumed or expired, the
|
|
175
176
|
// slot is gone either way (消费即清; expiry surfaces in record.why).
|
|
@@ -184,7 +185,7 @@ export function apply(ctx) {
|
|
|
184
185
|
slowExpired = consumed.expired;
|
|
185
186
|
}
|
|
186
187
|
}
|
|
187
|
-
const r = service.retrieveSync(query, sessionId, seen === undefined ? undefined : { exclude: seen }, slow, slowExpired);
|
|
188
|
+
const r = service.retrieveSync(query, sessionId, seen === undefined && echo === undefined ? undefined : { exclude: seen, echo }, slow, slowExpired);
|
|
188
189
|
// Mark only what entered the context this round (hits AND slow picks →
|
|
189
190
|
// dedup filter → assemble → registry mark; budget-dropped slugs stay
|
|
190
191
|
// injectable).
|
|
@@ -235,6 +236,21 @@ export function apply(ctx) {
|
|
|
235
236
|
slowLane.dispatch(sessionId, {
|
|
236
237
|
ring: observer.recentTurns(sessionId),
|
|
237
238
|
turnId: observer.turnCountOf(sessionId),
|
|
239
|
+
// The lane must not spend its rerank on slugs this session already
|
|
240
|
+
// carries (injected earlier / distilled from its own turns) — such a
|
|
241
|
+
// pick is silently swallowed at consumption and the pending dies.
|
|
242
|
+
exclude: (() => {
|
|
243
|
+
const seen = injectedBySession.get(sessionId);
|
|
244
|
+
const echo = cfgNow().suppressEcho ? service.echoSlugsSync(sessionId) : undefined;
|
|
245
|
+
if (seen === undefined || seen.size === 0)
|
|
246
|
+
return echo;
|
|
247
|
+
if (echo === undefined || echo.size === 0)
|
|
248
|
+
return seen;
|
|
249
|
+
const merged = new Set(seen);
|
|
250
|
+
for (const slug of echo)
|
|
251
|
+
merged.add(slug);
|
|
252
|
+
return merged;
|
|
253
|
+
})(),
|
|
238
254
|
});
|
|
239
255
|
}
|
|
240
256
|
catch {
|
|
@@ -582,6 +598,7 @@ const DEFAULTS = {
|
|
|
582
598
|
repo: '',
|
|
583
599
|
autoInject: true,
|
|
584
600
|
injectDedup: true,
|
|
601
|
+
suppressEcho: true,
|
|
585
602
|
topK: 4,
|
|
586
603
|
perTopicBudget: 300,
|
|
587
604
|
totalBudget: 1500,
|
package/lib/quality.d.ts
CHANGED
|
@@ -50,6 +50,14 @@ export type ConsumeResult = {
|
|
|
50
50
|
export interface DispatchInput {
|
|
51
51
|
ring: readonly RingEntry[];
|
|
52
52
|
turnId: number;
|
|
53
|
+
/**
|
|
54
|
+
* Slugs the session already carries — injected earlier this session or
|
|
55
|
+
* distilled from its own turns (echo). The candidate band drops them
|
|
56
|
+
* BEFORE the rerank: a pick the fast-lane dedup would silently swallow at
|
|
57
|
+
* consumption is a wasted aux call and a dead pending (2026-09 audit: 2 of
|
|
58
|
+
* the first 3 slow-lane outcomes died exactly this way).
|
|
59
|
+
*/
|
|
60
|
+
exclude?: ReadonlySet<string>;
|
|
53
61
|
}
|
|
54
62
|
/** Extract the first balanced JSON object from model output (tolerates fences). */
|
|
55
63
|
export declare function parseJsonObject(raw: string): Record<string, unknown>;
|
package/lib/quality.js
CHANGED
|
@@ -29,6 +29,8 @@ export const SAMPLED_EVERY = 3;
|
|
|
29
29
|
const RING_CHARS = 6000;
|
|
30
30
|
/** Candidate band size fed to the rerank. */
|
|
31
31
|
const CANDIDATE_LIMIT = 6;
|
|
32
|
+
/** Over-fetch before the exclude filter, so excluded slugs don't thin the band. */
|
|
33
|
+
const CANDIDATE_OVERFETCH = 6;
|
|
32
34
|
export const QUERY_BUILD_PROMPT = [
|
|
33
35
|
'你是检索查询构建器。输入是最近几轮对话。任务:判断「模型此刻需要什么背景知识」,输出一个用于关键词检索的 query。',
|
|
34
36
|
'只输出一个 JSON 对象,不要任何其他文字:{"needs": true, "query": "3-8 个检索词加一句意图", "ignore": ["被你忽略的内容类型"]}',
|
|
@@ -205,13 +207,16 @@ export class SlowLane {
|
|
|
205
207
|
// candidates = threshold passers + the near-miss band beneath them.
|
|
206
208
|
const band = searchTopics(query, roster, {
|
|
207
209
|
threshold: this.service.cfg.matchThreshold,
|
|
208
|
-
topK: CANDIDATE_LIMIT,
|
|
210
|
+
topK: CANDIDATE_LIMIT + CANDIDATE_OVERFETCH,
|
|
209
211
|
tagBoost: this.service.cfg.tagBoost,
|
|
210
212
|
graphDepth: 0,
|
|
211
213
|
recencyWindowDays: this.service.cfg.recencyWindowDays,
|
|
212
214
|
structuralGate: false,
|
|
213
215
|
});
|
|
214
|
-
const
|
|
216
|
+
const exclude = input.exclude;
|
|
217
|
+
const candidates = [...band.hits, ...band.nearMisses]
|
|
218
|
+
.filter((c) => !exclude?.has(c.slug))
|
|
219
|
+
.slice(0, CANDIDATE_LIMIT);
|
|
215
220
|
if (candidates.length === 0)
|
|
216
221
|
return;
|
|
217
222
|
const bySlug = new Map(roster.map((r) => [r.slug, r]));
|
package/lib/service.d.ts
CHANGED
|
@@ -36,6 +36,8 @@ export interface SlowDelivery {
|
|
|
36
36
|
}
|
|
37
37
|
export declare class TopicsService {
|
|
38
38
|
private cache;
|
|
39
|
+
/** mtime-keyed parse of the observations log, grouped by session. */
|
|
40
|
+
private echoCache;
|
|
39
41
|
readonly store: BundleStore;
|
|
40
42
|
private readonly getConfig;
|
|
41
43
|
readonly sync?: Sync;
|
|
@@ -46,6 +48,16 @@ export declare class TopicsService {
|
|
|
46
48
|
roster(): Promise<RetrievableTopic[]>;
|
|
47
49
|
/** Drop the roster cache (tests, bulk external edits). */
|
|
48
50
|
invalidate(): void;
|
|
51
|
+
/**
|
|
52
|
+
* Slugs distilled from THIS session's own turns — the echo set (2026-09
|
|
53
|
+
* audit finding #3: a topic distilled from in-session knowledge injected
|
|
54
|
+
* back into the same session is a lagging echo of what the model already
|
|
55
|
+
* said, occasionally stale enough to contradict the newer in-session
|
|
56
|
+
* conclusion). Provenance is durable in the observations log: each line
|
|
57
|
+
* carries `sessionId` and, once distilled, `distilledInto`. Sync read on
|
|
58
|
+
* the injection hot path, mtime-cached like the roster.
|
|
59
|
+
*/
|
|
60
|
+
echoSlugsSync(sessionId: string): Set<string>;
|
|
49
61
|
/**
|
|
50
62
|
* Full retrieval round: search → assemble → log (ADR 0006/0007). The
|
|
51
63
|
* injection record is written even for zero-hit rounds — the hit-rate
|
|
@@ -61,22 +73,28 @@ export declare class TopicsService {
|
|
|
61
73
|
*
|
|
62
74
|
* `dedup.exclude` (session-level injection dedup) filters hits AND slow
|
|
63
75
|
* picks before assembly; excluded slugs are reported back as `deduped`
|
|
64
|
-
* (and logged as `record.deduped`), never packed. `
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
76
|
+
* (and logged as `record.deduped`), never packed. `dedup.echo` (蒸馏回声,
|
|
77
|
+
* v4 audit) filters the same way but reports separately as `echoed` /
|
|
78
|
+
* `record.echoed`: slugs distilled from THIS session's own turns.
|
|
79
|
+
* `included` mirrors the fast pointers that entered the context and
|
|
80
|
+
* `slowIncluded` the slow ones — only these may be marked as injected by
|
|
81
|
+
* the caller; budget-dropped slugs stay injectable later.
|
|
68
82
|
*
|
|
69
83
|
* `slow` (v4 §4.2) carries the consumed pending when one exists: picks are
|
|
70
84
|
* packed after the fast pointers under the same budget, take log-only
|
|
71
85
|
* shadow re-gate verdicts against the CURRENT query (B3: record, never
|
|
72
|
-
* block), and fill the record's lane field family.
|
|
86
|
+
* block), and fill the record's lane field family. A pick the fast lane
|
|
87
|
+
* already delivers THIS round is counted as slow-delivered (it entered the
|
|
88
|
+
* context) but not packed twice.
|
|
73
89
|
*/
|
|
74
90
|
retrieveSync(query: string, sessionId?: string, dedup?: {
|
|
75
91
|
exclude?: ReadonlySet<string>;
|
|
92
|
+
echo?: ReadonlySet<string>;
|
|
76
93
|
}, slow?: SlowDelivery, slowExpired?: 'ttl' | 'turn-lag'): {
|
|
77
94
|
text: string;
|
|
78
95
|
outcome: SearchOutcome;
|
|
79
96
|
deduped: string[];
|
|
97
|
+
echoed: string[];
|
|
80
98
|
included: string[];
|
|
81
99
|
slowIncluded: string[];
|
|
82
100
|
};
|
|
@@ -86,7 +104,7 @@ export declare class TopicsService {
|
|
|
86
104
|
* the store stamps it on every write, so it IS the last-updated instant).
|
|
87
105
|
* Logs an open record for the pointer-open-rate stat.
|
|
88
106
|
*/
|
|
89
|
-
openTopic(
|
|
107
|
+
openTopic(rawSlug: string, sessionId?: string): Promise<{
|
|
90
108
|
found: boolean;
|
|
91
109
|
slug: string;
|
|
92
110
|
title?: string;
|
package/lib/service.js
CHANGED
|
@@ -15,6 +15,8 @@ import { aggregateStats, querySample } from "./ilog.js";
|
|
|
15
15
|
import { fileHistory, fileAtRev } from "./git.js";
|
|
16
16
|
export class TopicsService {
|
|
17
17
|
cache = new Map();
|
|
18
|
+
/** mtime-keyed parse of the observations log, grouped by session. */
|
|
19
|
+
echoCache;
|
|
18
20
|
store;
|
|
19
21
|
getConfig;
|
|
20
22
|
sync;
|
|
@@ -82,6 +84,60 @@ export class TopicsService {
|
|
|
82
84
|
/** Drop the roster cache (tests, bulk external edits). */
|
|
83
85
|
invalidate() {
|
|
84
86
|
this.cache.clear();
|
|
87
|
+
this.echoCache = undefined;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Slugs distilled from THIS session's own turns — the echo set (2026-09
|
|
91
|
+
* audit finding #3: a topic distilled from in-session knowledge injected
|
|
92
|
+
* back into the same session is a lagging echo of what the model already
|
|
93
|
+
* said, occasionally stale enough to contradict the newer in-session
|
|
94
|
+
* conclusion). Provenance is durable in the observations log: each line
|
|
95
|
+
* carries `sessionId` and, once distilled, `distilledInto`. Sync read on
|
|
96
|
+
* the injection hot path, mtime-cached like the roster.
|
|
97
|
+
*/
|
|
98
|
+
echoSlugsSync(sessionId) {
|
|
99
|
+
const file = join(this.store.metaDir(), 'observations.jsonl');
|
|
100
|
+
let st;
|
|
101
|
+
try {
|
|
102
|
+
st = statSync(file);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return new Set();
|
|
106
|
+
}
|
|
107
|
+
let bySession = this.echoCache;
|
|
108
|
+
if (bySession === undefined || bySession.mtimeMs !== st.mtimeMs || bySession.size !== st.size) {
|
|
109
|
+
bySession = { mtimeMs: st.mtimeMs, size: st.size, bySession: new Map() };
|
|
110
|
+
try {
|
|
111
|
+
const lines = readFileSync(file, 'utf8').split('\n');
|
|
112
|
+
for (const line of lines) {
|
|
113
|
+
if (line.trim() === '')
|
|
114
|
+
continue;
|
|
115
|
+
let obs;
|
|
116
|
+
try {
|
|
117
|
+
obs = JSON.parse(line);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
continue; // torn tail line
|
|
121
|
+
}
|
|
122
|
+
if (obs.distilled !== true || typeof obs.sessionId !== 'string' || !Array.isArray(obs.distilledInto))
|
|
123
|
+
continue;
|
|
124
|
+
let set = bySession.bySession.get(obs.sessionId);
|
|
125
|
+
if (set === undefined) {
|
|
126
|
+
set = new Set();
|
|
127
|
+
bySession.bySession.set(obs.sessionId, set);
|
|
128
|
+
}
|
|
129
|
+
for (const slug of obs.distilledInto) {
|
|
130
|
+
if (typeof slug === 'string' && slug !== '')
|
|
131
|
+
set.add(okf.slugify(slug));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return new Set(); // unreadable log: fail open (no suppression)
|
|
137
|
+
}
|
|
138
|
+
this.echoCache = bySession;
|
|
139
|
+
}
|
|
140
|
+
return bySession.bySession.get(sessionId) ?? new Set();
|
|
85
141
|
}
|
|
86
142
|
/**
|
|
87
143
|
* Full retrieval round: search → assemble → log (ADR 0006/0007). The
|
|
@@ -151,15 +207,19 @@ export class TopicsService {
|
|
|
151
207
|
*
|
|
152
208
|
* `dedup.exclude` (session-level injection dedup) filters hits AND slow
|
|
153
209
|
* picks before assembly; excluded slugs are reported back as `deduped`
|
|
154
|
-
* (and logged as `record.deduped`), never packed. `
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
210
|
+
* (and logged as `record.deduped`), never packed. `dedup.echo` (蒸馏回声,
|
|
211
|
+
* v4 audit) filters the same way but reports separately as `echoed` /
|
|
212
|
+
* `record.echoed`: slugs distilled from THIS session's own turns.
|
|
213
|
+
* `included` mirrors the fast pointers that entered the context and
|
|
214
|
+
* `slowIncluded` the slow ones — only these may be marked as injected by
|
|
215
|
+
* the caller; budget-dropped slugs stay injectable later.
|
|
158
216
|
*
|
|
159
217
|
* `slow` (v4 §4.2) carries the consumed pending when one exists: picks are
|
|
160
218
|
* packed after the fast pointers under the same budget, take log-only
|
|
161
219
|
* shadow re-gate verdicts against the CURRENT query (B3: record, never
|
|
162
|
-
* block), and fill the record's lane field family.
|
|
220
|
+
* block), and fill the record's lane field family. A pick the fast lane
|
|
221
|
+
* already delivers THIS round is counted as slow-delivered (it entered the
|
|
222
|
+
* context) but not packed twice.
|
|
163
223
|
*/
|
|
164
224
|
retrieveSync(query, sessionId, dedup, slow, slowExpired) {
|
|
165
225
|
const cfg = this.cfg;
|
|
@@ -194,7 +254,7 @@ export class TopicsService {
|
|
|
194
254
|
}
|
|
195
255
|
void this.store.appendInjectionRecord(trace).catch(() => undefined);
|
|
196
256
|
}
|
|
197
|
-
return { text: '', outcome: empty, deduped: [], included: [], slowIncluded: [] };
|
|
257
|
+
return { text: '', outcome: empty, deduped: [], echoed: [], included: [], slowIncluded: [] };
|
|
198
258
|
}
|
|
199
259
|
const conflicts = this.store.getConflictsSync();
|
|
200
260
|
const outcome = searchTopics(query, roster, {
|
|
@@ -206,13 +266,19 @@ export class TopicsService {
|
|
|
206
266
|
conflicts,
|
|
207
267
|
});
|
|
208
268
|
const exclude = dedup?.exclude;
|
|
269
|
+
const echo = dedup?.echo;
|
|
209
270
|
const deduped = exclude === undefined ? [] : outcome.hits.filter((h) => exclude.has(h.slug)).map((h) => h.slug);
|
|
271
|
+
const echoed = [];
|
|
210
272
|
const bySlug = new Map(roster.map((r) => [r.slug, r]));
|
|
211
273
|
const entries = [];
|
|
212
274
|
const unreadable = [];
|
|
213
275
|
for (const hit of outcome.hits) {
|
|
214
276
|
if (exclude?.has(hit.slug))
|
|
215
277
|
continue;
|
|
278
|
+
if (echo?.has(hit.slug)) {
|
|
279
|
+
echoed.push(hit.slug);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
216
282
|
const doc = this.readDocSync(hit.slug);
|
|
217
283
|
if (doc === undefined) {
|
|
218
284
|
// The hit has no readable doc — without this accounting it used to
|
|
@@ -236,9 +302,21 @@ export class TopicsService {
|
|
|
236
302
|
deduped.push(item.slug);
|
|
237
303
|
continue;
|
|
238
304
|
}
|
|
305
|
+
if (echo?.has(item.slug)) {
|
|
306
|
+
if (!echoed.includes(item.slug))
|
|
307
|
+
echoed.push(item.slug);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
239
310
|
const meta = bySlug.get(item.slug);
|
|
240
311
|
if (meta === undefined)
|
|
241
312
|
continue; // vanished between production and consumption
|
|
313
|
+
if (entries.some((e) => e.slug === item.slug)) {
|
|
314
|
+
// The fast lane delivers the same slug THIS round — the content
|
|
315
|
+
// enters the context exactly once (never packed twice), and the
|
|
316
|
+
// pick still counts as delivered for the lane bookkeeping.
|
|
317
|
+
slowDelivered.push({ slug: item.slug, why: item.why });
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
242
320
|
const verdict = scoreTopic(queryTokens, meta, {
|
|
243
321
|
threshold: cfg.matchThreshold,
|
|
244
322
|
topK: cfg.topK,
|
|
@@ -289,6 +367,8 @@ export class TopicsService {
|
|
|
289
367
|
record.sessionId = sessionId;
|
|
290
368
|
if (deduped.length > 0)
|
|
291
369
|
record.deduped = deduped;
|
|
370
|
+
if (echoed.length > 0)
|
|
371
|
+
record.echoed = echoed;
|
|
292
372
|
if (unreadable.length > 0) {
|
|
293
373
|
record.dropped = [...(record.dropped ?? []), ...unreadable.map((slug) => ({ slug, reason: 'doc-unreadable' }))];
|
|
294
374
|
}
|
|
@@ -328,7 +408,7 @@ export class TopicsService {
|
|
|
328
408
|
if (injection.dropped.length > 0)
|
|
329
409
|
record.dropped = [...(record.dropped ?? []), ...injection.dropped];
|
|
330
410
|
void this.store.appendInjectionRecord(record).catch(() => undefined);
|
|
331
|
-
return { text: injection.text, outcome, deduped, included: injection.included, slowIncluded };
|
|
411
|
+
return { text: injection.text, outcome, deduped, echoed, included: injection.included, slowIncluded };
|
|
332
412
|
}
|
|
333
413
|
/**
|
|
334
414
|
* topic_open tool path (v4 §4.1): full conclusion / open questions /
|
|
@@ -336,8 +416,16 @@ export class TopicsService {
|
|
|
336
416
|
* the store stamps it on every write, so it IS the last-updated instant).
|
|
337
417
|
* Logs an open record for the pointer-open-rate stat.
|
|
338
418
|
*/
|
|
339
|
-
async openTopic(
|
|
340
|
-
|
|
419
|
+
async openTopic(rawSlug, sessionId) {
|
|
420
|
+
// Pointers render slugs as `(topics:<slug>)` and models copy that whole
|
|
421
|
+
// token back as the argument — unwrap every `topics:`/`topics/` wrap the
|
|
422
|
+
// same way the depends edges do (audit ⑨: the wrapped form read as a
|
|
423
|
+
// missing topic). Then CANONICALIZE with the save path's own slugify:
|
|
424
|
+
// unwrap alone is case-preserving, and on a case-sensitive filesystem
|
|
425
|
+
// (Linux bundles) an uppercase-carrying argument would miss the file the
|
|
426
|
+
// save path wrote under its folded slug (CI-caught, v0.11.0 round 1).
|
|
427
|
+
const slug = okf.slugify(okf.unwrapTopicRef(rawSlug));
|
|
428
|
+
const doc = slug === '' ? undefined : await this.store.readTopic(slug).catch(() => undefined);
|
|
341
429
|
if (doc === undefined)
|
|
342
430
|
return { found: false, slug };
|
|
343
431
|
// Awaited (tool path, not hot): a settled write makes the pointer-open
|
|
@@ -345,17 +433,23 @@ export class TopicsService {
|
|
|
345
433
|
await this.store
|
|
346
434
|
.appendOpenRecord({ slug, at: new Date().toISOString(), ...(sessionId !== undefined ? { sessionId } : {}) })
|
|
347
435
|
.catch(() => undefined);
|
|
348
|
-
|
|
436
|
+
// Optional fields are OMITTED, never present-as-undefined: the host's
|
|
437
|
+
// tool-output validation rejects any object carrying an undefined-valued
|
|
438
|
+
// key as non-lossless JSON (INVALID_TOOL_OUTPUT; audit ②⑦ — topics
|
|
439
|
+
// without a description made every open of them fail).
|
|
440
|
+
const found = {
|
|
349
441
|
found: true,
|
|
350
442
|
slug,
|
|
351
443
|
title: doc.fm.title,
|
|
352
444
|
status: doc.fm.status,
|
|
353
445
|
updatedAt: doc.fm.generated.at,
|
|
354
|
-
description: doc.fm.description,
|
|
355
446
|
conclusion: okf.sectionOf(doc.body, okf.CONCLUSION_HEADING) ?? '',
|
|
356
447
|
openQuestions: doc.fm.open_questions,
|
|
357
448
|
recommendations: okf.sectionOf(doc.body, okf.RECOMMENDATIONS_HEADING) ?? '',
|
|
358
449
|
};
|
|
450
|
+
if (doc.fm.description !== undefined)
|
|
451
|
+
found.description = doc.fm.description;
|
|
452
|
+
return found;
|
|
359
453
|
}
|
|
360
454
|
/** Sync roster read (same mtime cache as roster()). */
|
|
361
455
|
rosterSync() {
|
package/lib/tools.js
CHANGED
|
@@ -244,7 +244,20 @@ export function buildTopicTools(service) {
|
|
|
244
244
|
},
|
|
245
245
|
async execute(args) {
|
|
246
246
|
const { entries } = await service.history(args.slug, args.limit ?? 20);
|
|
247
|
-
|
|
247
|
+
// conclusion is omitted, never present-as-undefined — the host rejects
|
|
248
|
+
// undefined-valued keys as non-lossless JSON (INVALID_TOOL_OUTPUT).
|
|
249
|
+
return {
|
|
250
|
+
entries: entries.flatMap((e) => {
|
|
251
|
+
const entry = {
|
|
252
|
+
hash: e.hash,
|
|
253
|
+
date: e.date,
|
|
254
|
+
message: e.message,
|
|
255
|
+
};
|
|
256
|
+
if (typeof e.conclusion === 'string')
|
|
257
|
+
entry.conclusion = e.conclusion;
|
|
258
|
+
return [entry];
|
|
259
|
+
}),
|
|
260
|
+
};
|
|
248
261
|
},
|
|
249
262
|
});
|
|
250
263
|
return [topicSave, topicOpen, topicSearch, topicObserve, topicHistory];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiwayds/dsh-topics-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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",
|
package/src/commands.ts
CHANGED
|
@@ -196,15 +196,23 @@ async function renderStats(service: TopicsService): Promise<string> {
|
|
|
196
196
|
lines.push(` 慢道参与轮:${slowRounds}(快 ${(records.length - slowRounds)} / 慢或混合 ${slowRounds}${medianLag})`)
|
|
197
197
|
}
|
|
198
198
|
// Pointer open rate (v4 §4.3): topic_open calls vs pointer entries injected.
|
|
199
|
+
// Echoed hits ride the retrieval but never became pointers — excluded here.
|
|
199
200
|
const opens = await service.store.readOpenRecords()
|
|
200
201
|
if (opens.length > 0 || records.some((r) => r.lane !== undefined)) {
|
|
201
202
|
const entries = records.reduce(
|
|
202
|
-
(acc, r) =>
|
|
203
|
+
(acc, r) =>
|
|
204
|
+
acc +
|
|
205
|
+
r.hits.filter((h) => !(r.deduped ?? []).includes(h.slug) && !(r.echoed ?? []).includes(h.slug)).length +
|
|
206
|
+
(r.slow?.length ?? 0),
|
|
203
207
|
0,
|
|
204
208
|
)
|
|
205
209
|
const rate = entries === 0 ? 0 : Math.min(1, opens.length / entries)
|
|
206
210
|
lines.push(` 指针打开率:${(rate * 100).toFixed(1)}%(${opens.length} 次 topic_open / ${entries} 条注入指针)`)
|
|
207
211
|
}
|
|
212
|
+
const echoedCount = records.reduce((acc, r) => acc + (r.echoed?.length ?? 0), 0)
|
|
213
|
+
if (echoedCount > 0) {
|
|
214
|
+
lines.push(` 回声抑制:${echoedCount} 次(本会话蒸馏出的 topic 不回注)`)
|
|
215
|
+
}
|
|
208
216
|
if (stats.topTopics.length > 0) {
|
|
209
217
|
lines.push(' Top-N 被注入 Topic:')
|
|
210
218
|
for (const t of stats.topTopics.slice(0, 5)) lines.push(` ${t.slug} ×${t.count}`)
|
|
@@ -232,13 +240,25 @@ export function tuningHint(stats: ReturnType<typeof aggregateStats>, threshold:
|
|
|
232
240
|
async function renderList(service: TopicsService): Promise<string> {
|
|
233
241
|
const metas = await service.store.listTopics()
|
|
234
242
|
if (metas.length === 0) return 'Bundle 里还没有 Topic —— 在会话里让我记点什么,或 /topics set 配置好蒸馏。'
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
243
|
+
metas.sort((a, b) => a.slug.localeCompare(b.slug))
|
|
244
|
+
const lines = [
|
|
245
|
+
`共 ${metas.length} 个 Topic:`,
|
|
246
|
+
'',
|
|
247
|
+
'| Slug | 标题 | 状态 | 标签 | 更新 |',
|
|
248
|
+
'| --- | --- | --- | --- | --- |',
|
|
249
|
+
]
|
|
250
|
+
for (const m of metas) {
|
|
251
|
+
const tags = m.tags.length > 0 ? m.tags.map((t) => `#${cell(t)}`).join(' ') : '—'
|
|
252
|
+
lines.push(`| \`${cell(m.slug)}\` | ${cell(m.title)} | ${m.status} | ${tags} | ${cell(m.generatedAt.slice(0, 10))} |`)
|
|
238
253
|
}
|
|
239
254
|
return lines.join('\n')
|
|
240
255
|
}
|
|
241
256
|
|
|
257
|
+
/** One markdown table cell: pipes escaped, whitespace flattened. */
|
|
258
|
+
function cell(text: string): string {
|
|
259
|
+
return text.replace(/\|/g, '\\|').replace(/\s*\n\s*/g, ' ')
|
|
260
|
+
}
|
|
261
|
+
|
|
242
262
|
async function renderShow(service: TopicsService, slug: string | undefined): Promise<CommandResult> {
|
|
243
263
|
if (slug === undefined || slug === '') return fail('用法:/topics show <slug>')
|
|
244
264
|
const doc = await service.store.readTopic(slug)
|
package/src/config.ts
CHANGED
|
@@ -14,6 +14,11 @@ export const TopicsConfig = z.object({
|
|
|
14
14
|
autoInject: z.boolean().default(true),
|
|
15
15
|
/** Skip re-injecting topics already injected earlier in the same session. */
|
|
16
16
|
injectDedup: z.boolean().default(true),
|
|
17
|
+
/** Skip re-injecting topics distilled from the CURRENT session's own turns
|
|
18
|
+
* (蒸馏回声): the conversation already carries that knowledge, so a
|
|
19
|
+
* pointer is a stale echo at best (2026-09 audit: 3 of 6 useless rounds).
|
|
20
|
+
* Provenance rides the observations log (sessionId → distilledInto). */
|
|
21
|
+
suppressEcho: z.boolean().default(true),
|
|
17
22
|
/** Max topics injected per round (ADR 0006: ≤4). */
|
|
18
23
|
topK: z.number().default(4),
|
|
19
24
|
/** Per-topic digest budget in tokens. */
|
|
@@ -61,6 +66,7 @@ export type TopicsConfigValue = {
|
|
|
61
66
|
repo: string
|
|
62
67
|
autoInject: boolean
|
|
63
68
|
injectDedup: boolean
|
|
69
|
+
suppressEcho: boolean
|
|
64
70
|
topK: number
|
|
65
71
|
perTopicBudget: number
|
|
66
72
|
totalBudget: number
|
|
@@ -86,6 +92,7 @@ export const CONFIG_KEYS = [
|
|
|
86
92
|
'repo',
|
|
87
93
|
'autoInject',
|
|
88
94
|
'injectDedup',
|
|
95
|
+
'suppressEcho',
|
|
89
96
|
'topK',
|
|
90
97
|
'perTopicBudget',
|
|
91
98
|
'totalBudget',
|
|
@@ -125,6 +132,7 @@ export function parseConfigValue(key: ConfigKey, raw: string): boolean | number
|
|
|
125
132
|
}
|
|
126
133
|
case 'autoInject':
|
|
127
134
|
case 'injectDedup':
|
|
135
|
+
case 'suppressEcho':
|
|
128
136
|
case 'autoObserve':
|
|
129
137
|
case 'distillOnSessionEnd':
|
|
130
138
|
case 'includeSubagents': {
|
package/src/ilog.ts
CHANGED
|
@@ -41,6 +41,8 @@ export interface InjectionRecord {
|
|
|
41
41
|
dropped?: { slug: string; reason: string }[]
|
|
42
42
|
/** Slugs blocked this round by session-level injection dedup (never assembled). */
|
|
43
43
|
deduped?: string[]
|
|
44
|
+
/** Slugs blocked this round as 蒸馏回声 (distilled from this session's own turns). */
|
|
45
|
+
echoed?: string[]
|
|
44
46
|
usedTokens?: number
|
|
45
47
|
// ---- v4 lane field family (§4.3) — absent on pure fast-lane rounds ----
|
|
46
48
|
/** fast = lexical pointers only; slow = async picks only; mixed = both. */
|
|
@@ -88,12 +90,14 @@ export function aggregateStats(records: readonly InjectionRecord[]): AggregateSt
|
|
|
88
90
|
budgetSamples += 1
|
|
89
91
|
budgetSum += r.usedTokens
|
|
90
92
|
}
|
|
91
|
-
// topTopics feeds 「Top-N 被注入 Topic」— deduped hits were NOT
|
|
92
|
-
// so they must not inflate the per-slug injection counts.
|
|
93
|
-
// metrics above (hits/rounds, zero-hit rounds) keep
|
|
93
|
+
// topTopics feeds 「Top-N 被注入 Topic」— deduped/echoed hits were NOT
|
|
94
|
+
// injected, so they must not inflate the per-slug injection counts.
|
|
95
|
+
// Retrieval-shape metrics above (hits/rounds, zero-hit rounds) keep
|
|
96
|
+
// counting raw hits.
|
|
94
97
|
const deduped = r.deduped === undefined ? undefined : new Set(r.deduped)
|
|
98
|
+
const echoed = r.echoed === undefined ? undefined : new Set(r.echoed)
|
|
95
99
|
for (const h of r.hits) {
|
|
96
|
-
if (deduped?.has(h.slug)) continue
|
|
100
|
+
if (deduped?.has(h.slug) || echoed?.has(h.slug)) continue
|
|
97
101
|
topicCounts.set(h.slug, (topicCounts.get(h.slug) ?? 0) + 1)
|
|
98
102
|
}
|
|
99
103
|
for (const nm of r.nearMisses) {
|
package/src/index.ts
CHANGED
|
@@ -244,6 +244,7 @@ export function apply(ctx: Context): void {
|
|
|
244
244
|
if (state === undefined) return
|
|
245
245
|
const cfg = cfgNow()
|
|
246
246
|
const seen = cfg.injectDedup ? injectedBySession.get(sessionId) : undefined
|
|
247
|
+
const echo = cfg.suppressEcho ? service.echoSlugsSync(sessionId) : undefined
|
|
247
248
|
// Slow-lane consumption point (v4 生命周期表): the pending produced at
|
|
248
249
|
// the previous turn/end rides THIS splice — consumed or expired, the
|
|
249
250
|
// slot is gone either way (消费即清; expiry surfaces in record.why).
|
|
@@ -256,7 +257,7 @@ export function apply(ctx: Context): void {
|
|
|
256
257
|
else slowExpired = consumed.expired
|
|
257
258
|
}
|
|
258
259
|
}
|
|
259
|
-
const r = service.retrieveSync(query, sessionId, seen === undefined ? undefined : { exclude: seen }, slow, slowExpired)
|
|
260
|
+
const r = service.retrieveSync(query, sessionId, seen === undefined && echo === undefined ? undefined : { exclude: seen, echo }, slow, slowExpired)
|
|
260
261
|
// Mark only what entered the context this round (hits AND slow picks →
|
|
261
262
|
// dedup filter → assemble → registry mark; budget-dropped slugs stay
|
|
262
263
|
// injectable).
|
|
@@ -301,6 +302,18 @@ export function apply(ctx: Context): void {
|
|
|
301
302
|
slowLane.dispatch(sessionId, {
|
|
302
303
|
ring: observer.recentTurns(sessionId),
|
|
303
304
|
turnId: observer.turnCountOf(sessionId),
|
|
305
|
+
// The lane must not spend its rerank on slugs this session already
|
|
306
|
+
// carries (injected earlier / distilled from its own turns) — such a
|
|
307
|
+
// pick is silently swallowed at consumption and the pending dies.
|
|
308
|
+
exclude: (() => {
|
|
309
|
+
const seen = injectedBySession.get(sessionId)
|
|
310
|
+
const echo = cfgNow().suppressEcho ? service.echoSlugsSync(sessionId) : undefined
|
|
311
|
+
if (seen === undefined || seen.size === 0) return echo
|
|
312
|
+
if (echo === undefined || echo.size === 0) return seen
|
|
313
|
+
const merged = new Set(seen)
|
|
314
|
+
for (const slug of echo) merged.add(slug)
|
|
315
|
+
return merged
|
|
316
|
+
})(),
|
|
304
317
|
})
|
|
305
318
|
} catch {
|
|
306
319
|
// contained — the lane must never break the event handler
|
|
@@ -646,6 +659,7 @@ const DEFAULTS: TopicsConfigValue = {
|
|
|
646
659
|
repo: '',
|
|
647
660
|
autoInject: true,
|
|
648
661
|
injectDedup: true,
|
|
662
|
+
suppressEcho: true,
|
|
649
663
|
topK: 4,
|
|
650
664
|
perTopicBudget: 300,
|
|
651
665
|
totalBudget: 1500,
|
package/src/quality.ts
CHANGED
|
@@ -36,6 +36,8 @@ export const SAMPLED_EVERY = 3
|
|
|
36
36
|
const RING_CHARS = 6000
|
|
37
37
|
/** Candidate band size fed to the rerank. */
|
|
38
38
|
const CANDIDATE_LIMIT = 6
|
|
39
|
+
/** Over-fetch before the exclude filter, so excluded slugs don't thin the band. */
|
|
40
|
+
const CANDIDATE_OVERFETCH = 6
|
|
39
41
|
|
|
40
42
|
export const QUERY_BUILD_PROMPT = [
|
|
41
43
|
'你是检索查询构建器。输入是最近几轮对话。任务:判断「模型此刻需要什么背景知识」,输出一个用于关键词检索的 query。',
|
|
@@ -70,6 +72,14 @@ export type ConsumeResult = { pending: PendingInjection } | { expired: 'ttl' | '
|
|
|
70
72
|
export interface DispatchInput {
|
|
71
73
|
ring: readonly RingEntry[]
|
|
72
74
|
turnId: number
|
|
75
|
+
/**
|
|
76
|
+
* Slugs the session already carries — injected earlier this session or
|
|
77
|
+
* distilled from its own turns (echo). The candidate band drops them
|
|
78
|
+
* BEFORE the rerank: a pick the fast-lane dedup would silently swallow at
|
|
79
|
+
* consumption is a wasted aux call and a dead pending (2026-09 audit: 2 of
|
|
80
|
+
* the first 3 slow-lane outcomes died exactly this way).
|
|
81
|
+
*/
|
|
82
|
+
exclude?: ReadonlySet<string>
|
|
73
83
|
}
|
|
74
84
|
|
|
75
85
|
/** Race `p` against a timeout; `onTimeout` fires so callers can abort upstream work. */
|
|
@@ -233,13 +243,16 @@ export class SlowLane {
|
|
|
233
243
|
// candidates = threshold passers + the near-miss band beneath them.
|
|
234
244
|
const band = searchTopics(query, roster, {
|
|
235
245
|
threshold: this.service.cfg.matchThreshold,
|
|
236
|
-
topK: CANDIDATE_LIMIT,
|
|
246
|
+
topK: CANDIDATE_LIMIT + CANDIDATE_OVERFETCH,
|
|
237
247
|
tagBoost: this.service.cfg.tagBoost,
|
|
238
248
|
graphDepth: 0,
|
|
239
249
|
recencyWindowDays: this.service.cfg.recencyWindowDays,
|
|
240
250
|
structuralGate: false,
|
|
241
251
|
})
|
|
242
|
-
const
|
|
252
|
+
const exclude = input.exclude
|
|
253
|
+
const candidates = [...band.hits, ...band.nearMisses]
|
|
254
|
+
.filter((c) => !exclude?.has(c.slug))
|
|
255
|
+
.slice(0, CANDIDATE_LIMIT)
|
|
243
256
|
if (candidates.length === 0) return
|
|
244
257
|
const bySlug = new Map(roster.map((r) => [r.slug, r]))
|
|
245
258
|
const payload = candidates.flatMap((c) => {
|
package/src/service.ts
CHANGED
|
@@ -48,6 +48,8 @@ export interface SlowDelivery {
|
|
|
48
48
|
|
|
49
49
|
export class TopicsService {
|
|
50
50
|
private cache = new Map<string, CacheEntry>()
|
|
51
|
+
/** mtime-keyed parse of the observations log, grouped by session. */
|
|
52
|
+
private echoCache: { mtimeMs: number; size: number; bySession: Map<string, Set<string>> } | undefined
|
|
51
53
|
readonly store: BundleStore
|
|
52
54
|
private readonly getConfig: () => TopicsConfigValue
|
|
53
55
|
readonly sync?: Sync
|
|
@@ -116,6 +118,55 @@ export class TopicsService {
|
|
|
116
118
|
/** Drop the roster cache (tests, bulk external edits). */
|
|
117
119
|
invalidate(): void {
|
|
118
120
|
this.cache.clear()
|
|
121
|
+
this.echoCache = undefined
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Slugs distilled from THIS session's own turns — the echo set (2026-09
|
|
126
|
+
* audit finding #3: a topic distilled from in-session knowledge injected
|
|
127
|
+
* back into the same session is a lagging echo of what the model already
|
|
128
|
+
* said, occasionally stale enough to contradict the newer in-session
|
|
129
|
+
* conclusion). Provenance is durable in the observations log: each line
|
|
130
|
+
* carries `sessionId` and, once distilled, `distilledInto`. Sync read on
|
|
131
|
+
* the injection hot path, mtime-cached like the roster.
|
|
132
|
+
*/
|
|
133
|
+
echoSlugsSync(sessionId: string): Set<string> {
|
|
134
|
+
const file = join(this.store.metaDir(), 'observations.jsonl')
|
|
135
|
+
let st
|
|
136
|
+
try {
|
|
137
|
+
st = statSync(file)
|
|
138
|
+
} catch {
|
|
139
|
+
return new Set()
|
|
140
|
+
}
|
|
141
|
+
let bySession = this.echoCache
|
|
142
|
+
if (bySession === undefined || bySession.mtimeMs !== st.mtimeMs || bySession.size !== st.size) {
|
|
143
|
+
bySession = { mtimeMs: st.mtimeMs, size: st.size, bySession: new Map() }
|
|
144
|
+
try {
|
|
145
|
+
const lines = readFileSync(file, 'utf8').split('\n')
|
|
146
|
+
for (const line of lines) {
|
|
147
|
+
if (line.trim() === '') continue
|
|
148
|
+
let obs: { sessionId?: unknown; distilled?: unknown; distilledInto?: unknown }
|
|
149
|
+
try {
|
|
150
|
+
obs = JSON.parse(line)
|
|
151
|
+
} catch {
|
|
152
|
+
continue // torn tail line
|
|
153
|
+
}
|
|
154
|
+
if (obs.distilled !== true || typeof obs.sessionId !== 'string' || !Array.isArray(obs.distilledInto)) continue
|
|
155
|
+
let set = bySession.bySession.get(obs.sessionId)
|
|
156
|
+
if (set === undefined) {
|
|
157
|
+
set = new Set()
|
|
158
|
+
bySession.bySession.set(obs.sessionId, set)
|
|
159
|
+
}
|
|
160
|
+
for (const slug of obs.distilledInto) {
|
|
161
|
+
if (typeof slug === 'string' && slug !== '') set.add(okf.slugify(slug))
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
return new Set() // unreadable log: fail open (no suppression)
|
|
166
|
+
}
|
|
167
|
+
this.echoCache = bySession
|
|
168
|
+
}
|
|
169
|
+
return bySession.bySession.get(sessionId) ?? new Set()
|
|
119
170
|
}
|
|
120
171
|
|
|
121
172
|
/**
|
|
@@ -183,17 +234,21 @@ export class TopicsService {
|
|
|
183
234
|
*
|
|
184
235
|
* `dedup.exclude` (session-level injection dedup) filters hits AND slow
|
|
185
236
|
* picks before assembly; excluded slugs are reported back as `deduped`
|
|
186
|
-
* (and logged as `record.deduped`), never packed. `
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
237
|
+
* (and logged as `record.deduped`), never packed. `dedup.echo` (蒸馏回声,
|
|
238
|
+
* v4 audit) filters the same way but reports separately as `echoed` /
|
|
239
|
+
* `record.echoed`: slugs distilled from THIS session's own turns.
|
|
240
|
+
* `included` mirrors the fast pointers that entered the context and
|
|
241
|
+
* `slowIncluded` the slow ones — only these may be marked as injected by
|
|
242
|
+
* the caller; budget-dropped slugs stay injectable later.
|
|
190
243
|
*
|
|
191
244
|
* `slow` (v4 §4.2) carries the consumed pending when one exists: picks are
|
|
192
245
|
* packed after the fast pointers under the same budget, take log-only
|
|
193
246
|
* shadow re-gate verdicts against the CURRENT query (B3: record, never
|
|
194
|
-
* block), and fill the record's lane field family.
|
|
247
|
+
* block), and fill the record's lane field family. A pick the fast lane
|
|
248
|
+
* already delivers THIS round is counted as slow-delivered (it entered the
|
|
249
|
+
* context) but not packed twice.
|
|
195
250
|
*/
|
|
196
|
-
retrieveSync(query: string, sessionId?: string, dedup?: { exclude?: ReadonlySet<string> }, slow?: SlowDelivery, slowExpired?: 'ttl' | 'turn-lag'): { text: string; outcome: SearchOutcome; deduped: string[]; included: string[]; slowIncluded: string[] } {
|
|
251
|
+
retrieveSync(query: string, sessionId?: string, dedup?: { exclude?: ReadonlySet<string>; echo?: ReadonlySet<string> }, slow?: SlowDelivery, slowExpired?: 'ttl' | 'turn-lag'): { text: string; outcome: SearchOutcome; deduped: string[]; echoed: string[]; included: string[]; slowIncluded: string[] } {
|
|
197
252
|
const cfg = this.cfg
|
|
198
253
|
const roster = this.rosterSync()
|
|
199
254
|
const empty: SearchOutcome = { hits: [], nearMisses: [], rosterSize: roster.length }
|
|
@@ -224,7 +279,7 @@ export class TopicsService {
|
|
|
224
279
|
}
|
|
225
280
|
void this.store.appendInjectionRecord(trace).catch(() => undefined)
|
|
226
281
|
}
|
|
227
|
-
return { text: '', outcome: empty, deduped: [], included: [], slowIncluded: [] }
|
|
282
|
+
return { text: '', outcome: empty, deduped: [], echoed: [], included: [], slowIncluded: [] }
|
|
228
283
|
}
|
|
229
284
|
const conflicts = this.store.getConflictsSync()
|
|
230
285
|
const outcome = searchTopics(query, roster, {
|
|
@@ -236,12 +291,18 @@ export class TopicsService {
|
|
|
236
291
|
conflicts,
|
|
237
292
|
})
|
|
238
293
|
const exclude = dedup?.exclude
|
|
294
|
+
const echo = dedup?.echo
|
|
239
295
|
const deduped = exclude === undefined ? [] : outcome.hits.filter((h) => exclude.has(h.slug)).map((h) => h.slug)
|
|
296
|
+
const echoed: string[] = []
|
|
240
297
|
const bySlug = new Map(roster.map((r) => [r.slug, r]))
|
|
241
298
|
const entries: DigestInput[] = []
|
|
242
299
|
const unreadable: string[] = []
|
|
243
300
|
for (const hit of outcome.hits) {
|
|
244
301
|
if (exclude?.has(hit.slug)) continue
|
|
302
|
+
if (echo?.has(hit.slug)) {
|
|
303
|
+
echoed.push(hit.slug)
|
|
304
|
+
continue
|
|
305
|
+
}
|
|
245
306
|
const doc = this.readDocSync(hit.slug)
|
|
246
307
|
if (doc === undefined) {
|
|
247
308
|
// The hit has no readable doc — without this accounting it used to
|
|
@@ -264,8 +325,19 @@ export class TopicsService {
|
|
|
264
325
|
if (!deduped.includes(item.slug)) deduped.push(item.slug)
|
|
265
326
|
continue
|
|
266
327
|
}
|
|
328
|
+
if (echo?.has(item.slug)) {
|
|
329
|
+
if (!echoed.includes(item.slug)) echoed.push(item.slug)
|
|
330
|
+
continue
|
|
331
|
+
}
|
|
267
332
|
const meta = bySlug.get(item.slug)
|
|
268
333
|
if (meta === undefined) continue // vanished between production and consumption
|
|
334
|
+
if (entries.some((e) => e.slug === item.slug)) {
|
|
335
|
+
// The fast lane delivers the same slug THIS round — the content
|
|
336
|
+
// enters the context exactly once (never packed twice), and the
|
|
337
|
+
// pick still counts as delivered for the lane bookkeeping.
|
|
338
|
+
slowDelivered.push({ slug: item.slug, why: item.why })
|
|
339
|
+
continue
|
|
340
|
+
}
|
|
269
341
|
const verdict = scoreTopic(queryTokens, meta, {
|
|
270
342
|
threshold: cfg.matchThreshold,
|
|
271
343
|
topK: cfg.topK,
|
|
@@ -313,6 +385,7 @@ export class TopicsService {
|
|
|
313
385
|
}
|
|
314
386
|
if (sessionId !== undefined) record.sessionId = sessionId
|
|
315
387
|
if (deduped.length > 0) record.deduped = deduped
|
|
388
|
+
if (echoed.length > 0) record.echoed = echoed
|
|
316
389
|
if (unreadable.length > 0) {
|
|
317
390
|
record.dropped = [...(record.dropped ?? []), ...unreadable.map((slug) => ({ slug, reason: 'doc-unreadable' }))]
|
|
318
391
|
}
|
|
@@ -347,7 +420,7 @@ export class TopicsService {
|
|
|
347
420
|
if (slowExpired !== undefined && injection.text === '') record.lane = 'slow'
|
|
348
421
|
if (injection.dropped.length > 0) record.dropped = [...(record.dropped ?? []), ...injection.dropped]
|
|
349
422
|
void this.store.appendInjectionRecord(record).catch(() => undefined)
|
|
350
|
-
return { text: injection.text, outcome, deduped, included: injection.included, slowIncluded }
|
|
423
|
+
return { text: injection.text, outcome, deduped, echoed, included: injection.included, slowIncluded }
|
|
351
424
|
}
|
|
352
425
|
|
|
353
426
|
/**
|
|
@@ -356,7 +429,7 @@ export class TopicsService {
|
|
|
356
429
|
* the store stamps it on every write, so it IS the last-updated instant).
|
|
357
430
|
* Logs an open record for the pointer-open-rate stat.
|
|
358
431
|
*/
|
|
359
|
-
async openTopic(
|
|
432
|
+
async openTopic(rawSlug: string, sessionId?: string): Promise<{
|
|
360
433
|
found: boolean
|
|
361
434
|
slug: string
|
|
362
435
|
title?: string
|
|
@@ -367,24 +440,47 @@ export class TopicsService {
|
|
|
367
440
|
openQuestions?: string[]
|
|
368
441
|
recommendations?: string
|
|
369
442
|
}> {
|
|
370
|
-
|
|
443
|
+
// Pointers render slugs as `(topics:<slug>)` and models copy that whole
|
|
444
|
+
// token back as the argument — unwrap every `topics:`/`topics/` wrap the
|
|
445
|
+
// same way the depends edges do (audit ⑨: the wrapped form read as a
|
|
446
|
+
// missing topic). Then CANONICALIZE with the save path's own slugify:
|
|
447
|
+
// unwrap alone is case-preserving, and on a case-sensitive filesystem
|
|
448
|
+
// (Linux bundles) an uppercase-carrying argument would miss the file the
|
|
449
|
+
// save path wrote under its folded slug (CI-caught, v0.11.0 round 1).
|
|
450
|
+
const slug = okf.slugify(okf.unwrapTopicRef(rawSlug))
|
|
451
|
+
const doc = slug === '' ? undefined : await this.store.readTopic(slug).catch(() => undefined)
|
|
371
452
|
if (doc === undefined) return { found: false, slug }
|
|
372
453
|
// Awaited (tool path, not hot): a settled write makes the pointer-open
|
|
373
454
|
// stat reliable; a failed log write must not fail the tool.
|
|
374
455
|
await this.store
|
|
375
456
|
.appendOpenRecord({ slug, at: new Date().toISOString(), ...(sessionId !== undefined ? { sessionId } : {}) })
|
|
376
457
|
.catch(() => undefined)
|
|
377
|
-
|
|
458
|
+
// Optional fields are OMITTED, never present-as-undefined: the host's
|
|
459
|
+
// tool-output validation rejects any object carrying an undefined-valued
|
|
460
|
+
// key as non-lossless JSON (INVALID_TOOL_OUTPUT; audit ②⑦ — topics
|
|
461
|
+
// without a description made every open of them fail).
|
|
462
|
+
const found: {
|
|
463
|
+
found: boolean
|
|
464
|
+
slug: string
|
|
465
|
+
title: string
|
|
466
|
+
status: okf.TopicStatus
|
|
467
|
+
updatedAt: string
|
|
468
|
+
description?: string
|
|
469
|
+
conclusion: string
|
|
470
|
+
openQuestions: string[]
|
|
471
|
+
recommendations: string
|
|
472
|
+
} = {
|
|
378
473
|
found: true,
|
|
379
474
|
slug,
|
|
380
475
|
title: doc.fm.title,
|
|
381
476
|
status: doc.fm.status,
|
|
382
477
|
updatedAt: doc.fm.generated.at,
|
|
383
|
-
description: doc.fm.description,
|
|
384
478
|
conclusion: okf.sectionOf(doc.body, okf.CONCLUSION_HEADING) ?? '',
|
|
385
479
|
openQuestions: doc.fm.open_questions,
|
|
386
480
|
recommendations: okf.sectionOf(doc.body, okf.RECOMMENDATIONS_HEADING) ?? '',
|
|
387
481
|
}
|
|
482
|
+
if (doc.fm.description !== undefined) found.description = doc.fm.description
|
|
483
|
+
return found
|
|
388
484
|
}
|
|
389
485
|
|
|
390
486
|
/** Sync roster read (same mtime cache as roster()). */
|
package/src/tools.ts
CHANGED
|
@@ -261,7 +261,19 @@ export function buildTopicTools(service: TopicsService) {
|
|
|
261
261
|
},
|
|
262
262
|
async execute(args) {
|
|
263
263
|
const { entries } = await service.history(args.slug, args.limit ?? 20)
|
|
264
|
-
|
|
264
|
+
// conclusion is omitted, never present-as-undefined — the host rejects
|
|
265
|
+
// undefined-valued keys as non-lossless JSON (INVALID_TOOL_OUTPUT).
|
|
266
|
+
return {
|
|
267
|
+
entries: entries.flatMap((e) => {
|
|
268
|
+
const entry: { hash: string; date: string; message: string; conclusion?: string } = {
|
|
269
|
+
hash: e.hash,
|
|
270
|
+
date: e.date,
|
|
271
|
+
message: e.message,
|
|
272
|
+
}
|
|
273
|
+
if (typeof e.conclusion === 'string') entry.conclusion = e.conclusion
|
|
274
|
+
return [entry]
|
|
275
|
+
}),
|
|
276
|
+
}
|
|
265
277
|
},
|
|
266
278
|
})
|
|
267
279
|
|