@aiwayds/dsh-topics-memory 0.10.0 → 0.12.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 +83 -36
- 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 +86 -35
- 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
|
@@ -86,18 +86,17 @@ function fail(text) {
|
|
|
86
86
|
async function renderStatus(service) {
|
|
87
87
|
const s = await service.store.status();
|
|
88
88
|
const cfg = service.cfg;
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
` 蒸馏:${cfg.distillProvider !== '' && cfg.distillModel !== '' ? `${cfg.distillProvider}/${cfg.distillModel},每 ${cfg.distillEveryTurns} 轮` : '未配置模型(/topics set distill-provider / distill-model)'}`,
|
|
89
|
+
const rows = [
|
|
90
|
+
['模式', service.githubMode ? `github(${cell(cfg.repo)})` : 'local-only'],
|
|
91
|
+
['Topics', `${s.topicCount}(draft ${s.byStatus.draft} / stable ${s.byStatus.stable} / deprecated ${s.byStatus.deprecated})`],
|
|
92
|
+
['观察积压', `${s.observationsPending} 未蒸馏 / ${s.observationsTotal} 总量`],
|
|
93
|
+
['冲突', s.conflicts.length === 0 ? '无' : s.conflicts.map((c) => cell(c)).join('、')],
|
|
94
|
+
['损坏文件', s.broken.length === 0 ? '无' : s.broken.map((b) => cell(b)).join('、')],
|
|
95
|
+
['git', s.git ? `是(HEAD ${s.head?.slice(0, 10) ?? '??'})` : '否'],
|
|
96
|
+
['注入', cfg.autoInject ? `${cfg.injectMode === 'digest' ? 'digest' : 'pointer'} 模式(topK ${cfg.topK},预算 ${cfg.injectMode === 'digest' ? cfg.totalBudget : Math.min(cfg.totalBudget, 600)} tok,阈值 ${cfg.matchThreshold})` : '关'],
|
|
97
|
+
['注入去重', cfg.injectDedup ? '开(同会话已注入的 Topic 不重注)' : '关'],
|
|
98
|
+
['慢道', cfg.qualityLane === 'off' ? '关' : cfg.qualityLane === 'always' ? `每轮(${cfg.distillProvider}/${cfg.distillModel})` : `采样 1/3(${cfg.distillProvider}/${cfg.distillModel})`],
|
|
99
|
+
['蒸馏', cfg.distillProvider !== '' && cfg.distillModel !== '' ? `${cfg.distillProvider}/${cfg.distillModel},每 ${cfg.distillEveryTurns} 轮` : '未配置模型(/topics set distill-provider / distill-model)'],
|
|
101
100
|
];
|
|
102
101
|
// Last lane outcome (distill-state summary) — what "checkable via
|
|
103
102
|
// /topics status" promises; absent until the first run of this bundle.
|
|
@@ -107,14 +106,20 @@ async function renderStatus(service) {
|
|
|
107
106
|
? `标记 ${String(lastRun.marked ?? 0)} 条观察`
|
|
108
107
|
: `失败(${String(lastRun.reason ?? 'unknown')})`;
|
|
109
108
|
const gc = typeof lastRun.gcDropped === 'number' && lastRun.gcDropped > 0 ? `,GC 回收 ${lastRun.gcDropped}` : '';
|
|
110
|
-
|
|
109
|
+
rows.push(['最近蒸馏', `${outcome}${gc} @ ${cell(String(lastRun.at ?? '').slice(0, 19).replace('T', ' '))}`]);
|
|
111
110
|
}
|
|
112
111
|
if (service.sync !== undefined) {
|
|
113
|
-
|
|
112
|
+
rows.push(['上次推送', cell(String(service.sync.lastPushAt ?? '从未'))]);
|
|
114
113
|
if (service.sync.lastError !== '')
|
|
115
|
-
|
|
114
|
+
rows.push(['同步错误', cell(service.sync.lastError.split('\n').at(-1) ?? '')]);
|
|
116
115
|
}
|
|
117
|
-
return
|
|
116
|
+
return [
|
|
117
|
+
`dsh-topics-memory @ ${s.root}`,
|
|
118
|
+
'',
|
|
119
|
+
'| 字段 | 值 |',
|
|
120
|
+
'| --- | --- |',
|
|
121
|
+
...rows.map(([k, v]) => `| ${k} | ${v} |`),
|
|
122
|
+
].join('\n');
|
|
118
123
|
}
|
|
119
124
|
/**
|
|
120
125
|
* `/topics distill` — one manual distill run over the current observation pool.
|
|
@@ -151,9 +156,13 @@ async function renderStats(service) {
|
|
|
151
156
|
return '还没有注入记录 —— 用起来之后这里会有 hit rate / top-N / near-miss 分布。';
|
|
152
157
|
const lines = [
|
|
153
158
|
`注入统计(最近 ${records.length} 轮):`,
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
159
|
+
'',
|
|
160
|
+
'| 指标 | 值 |',
|
|
161
|
+
'| --- | --- |',
|
|
162
|
+
`| hit rate | ${(stats.hitRate * 100).toFixed(1)}%(${stats.injectedRounds}/${stats.rounds} 轮注入) |`,
|
|
163
|
+
`| 零命中轮 | ${stats.zeroHitRounds} |`,
|
|
164
|
+
`| 平均命中 | ${stats.avgHitsPerRound} 条/轮 |`,
|
|
165
|
+
`| 平均预算占用 | ${stats.avgBudgetUtilization} tok |`,
|
|
157
166
|
];
|
|
158
167
|
// v4 lane split — how much of the injection traffic each lane carries.
|
|
159
168
|
const slowRounds = records.filter((r) => r.lane === 'slow' || r.lane === 'mixed').length;
|
|
@@ -166,27 +175,34 @@ async function renderStats(service) {
|
|
|
166
175
|
.sort((a, b) => a - b);
|
|
167
176
|
medianLag = `,赶上中位时延 ${(lags[Math.floor(lags.length / 2)] / 1000).toFixed(1)}s`;
|
|
168
177
|
}
|
|
169
|
-
lines.push(
|
|
178
|
+
lines.push(`| 慢道参与轮 | ${slowRounds}(快 ${records.length - slowRounds} / 慢或混合 ${slowRounds}${medianLag}) |`);
|
|
170
179
|
}
|
|
171
180
|
// Pointer open rate (v4 §4.3): topic_open calls vs pointer entries injected.
|
|
181
|
+
// Echoed hits ride the retrieval but never became pointers — excluded here.
|
|
172
182
|
const opens = await service.store.readOpenRecords();
|
|
173
183
|
if (opens.length > 0 || records.some((r) => r.lane !== undefined)) {
|
|
174
|
-
const entries = records.reduce((acc, r) => acc +
|
|
184
|
+
const entries = records.reduce((acc, r) => acc +
|
|
185
|
+
r.hits.filter((h) => !(r.deduped ?? []).includes(h.slug) && !(r.echoed ?? []).includes(h.slug)).length +
|
|
186
|
+
(r.slow?.length ?? 0), 0);
|
|
175
187
|
const rate = entries === 0 ? 0 : Math.min(1, opens.length / entries);
|
|
176
|
-
lines.push(
|
|
188
|
+
lines.push(`| 指针打开率 | ${(rate * 100).toFixed(1)}%(${opens.length} 次 topic_open / ${entries} 条注入指针) |`);
|
|
189
|
+
}
|
|
190
|
+
const echoedCount = records.reduce((acc, r) => acc + (r.echoed?.length ?? 0), 0);
|
|
191
|
+
if (echoedCount > 0) {
|
|
192
|
+
lines.push(`| 回声抑制 | ${echoedCount} 次(本会话蒸馏出的 topic 不回注) |`);
|
|
177
193
|
}
|
|
178
194
|
if (stats.topTopics.length > 0) {
|
|
179
|
-
lines.push('
|
|
195
|
+
lines.push('', 'Top-N 被注入 Topic:', '', '| Slug | 注入次数 |', '| --- | --- |');
|
|
180
196
|
for (const t of stats.topTopics.slice(0, 5))
|
|
181
|
-
lines.push(
|
|
197
|
+
lines.push(`| \`${cell(t.slug)}\` | ${t.count} |`);
|
|
182
198
|
}
|
|
183
199
|
if (stats.nearMissHistogram.length > 0) {
|
|
184
|
-
lines.push('
|
|
200
|
+
lines.push('', 'Near-miss 分布(低于阈值或被结构门挡下):', '', '| 分数段 | 次数 |', '| --- | --- |');
|
|
185
201
|
for (const b of stats.nearMissHistogram)
|
|
186
|
-
lines.push(
|
|
202
|
+
lines.push(`| ${cell(b.bucket)} | ${b.count} |`);
|
|
187
203
|
const hint = tuningHint(stats, service.cfg.matchThreshold);
|
|
188
204
|
if (hint !== undefined)
|
|
189
|
-
lines.push(
|
|
205
|
+
lines.push('', `💡 ${hint}`);
|
|
190
206
|
}
|
|
191
207
|
return lines.join('\n');
|
|
192
208
|
}
|
|
@@ -200,16 +216,38 @@ export function tuningHint(stats, threshold) {
|
|
|
200
216
|
}
|
|
201
217
|
return undefined;
|
|
202
218
|
}
|
|
219
|
+
/** /topics list caps the table at this many newest topics. */
|
|
220
|
+
const LIST_LIMIT = 100;
|
|
203
221
|
async function renderList(service) {
|
|
204
222
|
const metas = await service.store.listTopics();
|
|
205
223
|
if (metas.length === 0)
|
|
206
224
|
return 'Bundle 里还没有 Topic —— 在会话里让我记点什么,或 /topics set 配置好蒸馏。';
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
225
|
+
// Newest first, capped: with a large bundle the table is for scanning
|
|
226
|
+
// recent work, not an exhaustive roster — /topics show <slug> reaches any
|
|
227
|
+
// topic the cap hides. (store.ts re-stamps generated.at on every topic
|
|
228
|
+
// write, so it IS the updated-at time; the slug tiebreak keeps same-stamp
|
|
229
|
+
// rows stable.)
|
|
230
|
+
metas.sort((a, b) => b.generatedAt.localeCompare(a.generatedAt) || a.slug.localeCompare(b.slug));
|
|
231
|
+
const shown = metas.slice(0, LIST_LIMIT);
|
|
232
|
+
const lines = [
|
|
233
|
+
`共 ${metas.length} 个 Topic(按最近更新排序):`,
|
|
234
|
+
'',
|
|
235
|
+
'| # | Slug | 标题 | 状态 | 标签 | 更新 |',
|
|
236
|
+
'| --- | --- | --- | --- | --- | --- |',
|
|
237
|
+
];
|
|
238
|
+
shown.forEach((m, i) => {
|
|
239
|
+
const tags = m.tags.length > 0 ? m.tags.map((t) => `#${cell(t)}`).join(' ') : '—';
|
|
240
|
+
lines.push(`| ${i + 1} | \`${cell(m.slug)}\` | ${cell(m.title)} | ${m.status} | ${tags} | ${cell(m.generatedAt.slice(0, 10))} |`);
|
|
241
|
+
});
|
|
242
|
+
if (metas.length > shown.length) {
|
|
243
|
+
lines.push('', `… 其余 ${metas.length - shown.length} 个更早的 Topic 未显示(/topics show <slug> 直达)。`);
|
|
210
244
|
}
|
|
211
245
|
return lines.join('\n');
|
|
212
246
|
}
|
|
247
|
+
/** One markdown table cell: pipes escaped, whitespace flattened. */
|
|
248
|
+
function cell(text) {
|
|
249
|
+
return text.replace(/\|/g, '\\|').replace(/\s*\n\s*/g, ' ');
|
|
250
|
+
}
|
|
213
251
|
async function renderShow(service, slug) {
|
|
214
252
|
if (slug === undefined || slug === '')
|
|
215
253
|
return fail('用法:/topics show <slug>');
|
|
@@ -236,11 +274,15 @@ async function renderHistory(service, slug) {
|
|
|
236
274
|
const { entries } = await service.history(slug, 30);
|
|
237
275
|
if (entries.length === 0)
|
|
238
276
|
return fail(`Topic “${slug}” 没有历史(不存在或 bundle 不是 git 仓库)`);
|
|
239
|
-
const lines = [
|
|
277
|
+
const lines = [
|
|
278
|
+
`${slug} 的变更史(${entries.length} 条):`,
|
|
279
|
+
'',
|
|
280
|
+
'| Hash | 时间 | 变更 | 当时的结论 |',
|
|
281
|
+
'| --- | --- | --- | --- |',
|
|
282
|
+
];
|
|
240
283
|
for (const e of entries) {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
lines.push(` └ 结论当时:${e.conclusion}`);
|
|
284
|
+
const conclusion = e.conclusion !== undefined && e.conclusion !== '' ? cell(e.conclusion) : '—';
|
|
285
|
+
lines.push(`| \`${cell(e.hash)}\` | ${cell(e.date.slice(0, 19).replace('T', ' '))} | ${cell(e.message)} | ${conclusion} |`);
|
|
244
286
|
}
|
|
245
287
|
return ok(lines.join('\n'));
|
|
246
288
|
}
|
|
@@ -298,9 +340,14 @@ async function doSync(service, direction) {
|
|
|
298
340
|
return fail('用法:/topics sync [pull|push]');
|
|
299
341
|
}
|
|
300
342
|
function renderConfig(cfg) {
|
|
301
|
-
const lines = [
|
|
343
|
+
const lines = [
|
|
344
|
+
'当前配置:',
|
|
345
|
+
'',
|
|
346
|
+
'| 配置项 | 值 |',
|
|
347
|
+
'| --- | --- |',
|
|
348
|
+
];
|
|
302
349
|
for (const key of CONFIG_KEYS) {
|
|
303
|
-
lines.push(
|
|
350
|
+
lines.push(`| ${displayKey(key)} | ${cell(String(cfg[key]))} |`);
|
|
304
351
|
}
|
|
305
352
|
return lines.join('\n');
|
|
306
353
|
}
|
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;
|