@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 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 lines = [
90
- `dsh-topics-memory @ ${s.root}`,
91
- ` 模式:${service.githubMode ? `github(${cfg.repo})` : 'local-only'}`,
92
- ` Topics:${s.topicCount}(draft ${s.byStatus.draft} / stable ${s.byStatus.stable} / deprecated ${s.byStatus.deprecated})`,
93
- ` 观察积压:${s.observationsPending} 未蒸馏 / ${s.observationsTotal} 总量`,
94
- ` 冲突:${s.conflicts.length === 0 ? '无' : s.conflicts.join('、')}`,
95
- ` 损坏文件:${s.broken.length === 0 ? '无' : s.broken.join('、')}`,
96
- ` git:${s.git ? `是(HEAD ${s.head?.slice(0, 10) ?? '??'})` : '否'}`,
97
- ` 注入:${cfg.autoInject ? `${cfg.injectMode === 'digest' ? 'digest' : 'pointer'} 模式(topK ${cfg.topK},预算 ${cfg.injectMode === 'digest' ? cfg.totalBudget : Math.min(cfg.totalBudget, 600)} tok,阈值 ${cfg.matchThreshold})` : '关'}`,
98
- ` 去重:${cfg.injectDedup ? '开(同会话已注入的 Topic 不重注)' : '关'}`,
99
- ` 慢道:${cfg.qualityLane === 'off' ? '关' : cfg.qualityLane === 'always' ? `每轮(${cfg.distillProvider}/${cfg.distillModel})` : `采样 1/3(${cfg.distillProvider}/${cfg.distillModel})`}`,
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
- lines.push(` 最近蒸馏:${outcome}${gc} @ ${String(lastRun.at ?? '').slice(0, 19).replace('T', ' ')}`);
109
+ rows.push(['最近蒸馏', `${outcome}${gc} @ ${cell(String(lastRun.at ?? '').slice(0, 19).replace('T', ' '))}`]);
111
110
  }
112
111
  if (service.sync !== undefined) {
113
- lines.push(` 上次推送:${service.sync.lastPushAt ?? '从未'}`);
112
+ rows.push(['上次推送', cell(String(service.sync.lastPushAt ?? '从未'))]);
114
113
  if (service.sync.lastError !== '')
115
- lines.push(` 同步错误:${service.sync.lastError.split('\n').at(-1)}`);
114
+ rows.push(['同步错误', cell(service.sync.lastError.split('\n').at(-1) ?? '')]);
116
115
  }
117
- return lines.join('\n');
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
- ` hit rate:${(stats.hitRate * 100).toFixed(1)}%(${stats.injectedRounds}/${stats.rounds} 轮注入)`,
155
- ` 零命中轮:${stats.zeroHitRounds};平均命中 ${stats.avgHitsPerRound} 条/轮`,
156
- ` 平均预算占用:${stats.avgBudgetUtilization} tok`,
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(` 慢道参与轮:${slowRounds}(快 ${(records.length - slowRounds)} / 慢或混合 ${slowRounds}${medianLag})`);
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 + r.hits.filter((h) => !(r.deduped ?? []).includes(h.slug)).length + (r.slow?.length ?? 0), 0);
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(` 指针打开率:${(rate * 100).toFixed(1)}%(${opens.length} 次 topic_open / ${entries} 条注入指针)`);
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(' Top-N 被注入 Topic:');
195
+ lines.push('', 'Top-N 被注入 Topic:', '', '| Slug | 注入次数 |', '| --- | --- |');
180
196
  for (const t of stats.topTopics.slice(0, 5))
181
- lines.push(` ${t.slug} ×${t.count}`);
197
+ lines.push(`| \`${cell(t.slug)}\` | ${t.count} |`);
182
198
  }
183
199
  if (stats.nearMissHistogram.length > 0) {
184
- lines.push(' Near-miss 分布(低于阈值或被结构门挡下):');
200
+ lines.push('', 'Near-miss 分布(低于阈值或被结构门挡下):', '', '| 分数段 | 次数 |', '| --- | --- |');
185
201
  for (const b of stats.nearMissHistogram)
186
- lines.push(` ${b.bucket}: ${b.count}`);
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(` 💡 ${hint}`);
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
- const lines = [`共 ${metas.length} 个 Topic:`];
208
- for (const m of metas.sort((a, b) => a.slug.localeCompare(b.slug))) {
209
- lines.push(` ${m.slug} [${m.status}] ${m.title}${m.tags.length > 0 ? ` #${m.tags.join(' #')}` : ''}`);
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 = [`${slug} 的变更史(${entries.length} 条):`];
277
+ const lines = [
278
+ `${slug} 的变更史(${entries.length} 条):`,
279
+ '',
280
+ '| Hash | 时间 | 变更 | 当时的结论 |',
281
+ '| --- | --- | --- | --- |',
282
+ ];
240
283
  for (const e of entries) {
241
- lines.push(` ${e.hash} ${e.date.slice(0, 19).replace('T', ' ')} ${e.message}`);
242
- if (e.conclusion !== undefined && e.conclusion !== '')
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(` ${displayKey(key)} = ${String(cfg[key])}`);
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 injected,
28
- // so they must not inflate the per-slug injection counts. Retrieval-shape
29
- // metrics above (hits/rounds, zero-hit rounds) keep counting raw hits.
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 candidates = [...band.hits, ...band.nearMisses].slice(0, CANDIDATE_LIMIT);
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. `included` mirrors the
65
- * fast pointers that entered the context and `slowIncluded` the slow ones
66
- * — only these may be marked as injected by the caller; budget-dropped
67
- * slugs stay injectable later.
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(slug: string, sessionId?: string): Promise<{
107
+ openTopic(rawSlug: string, sessionId?: string): Promise<{
90
108
  found: boolean;
91
109
  slug: string;
92
110
  title?: string;