@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/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. `included` mirrors the
155
- * fast pointers that entered the context and `slowIncluded` the slow ones
156
- * — only these may be marked as injected by the caller; budget-dropped
157
- * slugs stay injectable later.
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(slug, sessionId) {
340
- const doc = await this.store.readTopic(slug).catch(() => undefined);
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
- return {
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
- return { entries: entries.map((e) => ({ ...e, conclusion: e.conclusion })) };
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.10.0",
3
+ "version": "0.12.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
@@ -114,18 +114,17 @@ function fail(text: string): CommandResult {
114
114
  async function renderStatus(service: TopicsService): Promise<string> {
115
115
  const s = await service.store.status()
116
116
  const cfg = service.cfg
117
- const lines = [
118
- `dsh-topics-memory @ ${s.root}`,
119
- ` 模式:${service.githubMode ? `github(${cfg.repo})` : 'local-only'}`,
120
- ` Topics:${s.topicCount}(draft ${s.byStatus.draft} / stable ${s.byStatus.stable} / deprecated ${s.byStatus.deprecated})`,
121
- ` 观察积压:${s.observationsPending} 未蒸馏 / ${s.observationsTotal} 总量`,
122
- ` 冲突:${s.conflicts.length === 0 ? '无' : s.conflicts.join('、')}`,
123
- ` 损坏文件:${s.broken.length === 0 ? '无' : s.broken.join('、')}`,
124
- ` git:${s.git ? `是(HEAD ${s.head?.slice(0, 10) ?? '??'})` : '否'}`,
125
- ` 注入:${cfg.autoInject ? `${cfg.injectMode === 'digest' ? 'digest' : 'pointer'} 模式(topK ${cfg.topK},预算 ${cfg.injectMode === 'digest' ? cfg.totalBudget : Math.min(cfg.totalBudget, 600)} tok,阈值 ${cfg.matchThreshold})` : '关'}`,
126
- ` 去重:${cfg.injectDedup ? '开(同会话已注入的 Topic 不重注)' : '关'}`,
127
- ` 慢道:${cfg.qualityLane === 'off' ? '关' : cfg.qualityLane === 'always' ? `每轮(${cfg.distillProvider}/${cfg.distillModel})` : `采样 1/3(${cfg.distillProvider}/${cfg.distillModel})`}`,
128
- ` 蒸馏:${cfg.distillProvider !== '' && cfg.distillModel !== '' ? `${cfg.distillProvider}/${cfg.distillModel},每 ${cfg.distillEveryTurns} 轮` : '未配置模型(/topics set distill-provider / distill-model)'}`,
117
+ const rows: [string, string][] = [
118
+ ['模式', service.githubMode ? `github(${cell(cfg.repo)})` : 'local-only'],
119
+ ['Topics', `${s.topicCount}(draft ${s.byStatus.draft} / stable ${s.byStatus.stable} / deprecated ${s.byStatus.deprecated})`],
120
+ ['观察积压', `${s.observationsPending} 未蒸馏 / ${s.observationsTotal} 总量`],
121
+ ['冲突', s.conflicts.length === 0 ? '无' : s.conflicts.map((c) => cell(c)).join('、')],
122
+ ['损坏文件', s.broken.length === 0 ? '无' : s.broken.map((b) => cell(b)).join('、')],
123
+ ['git', s.git ? `是(HEAD ${s.head?.slice(0, 10) ?? '??'})` : '否'],
124
+ ['注入', cfg.autoInject ? `${cfg.injectMode === 'digest' ? 'digest' : 'pointer'} 模式(topK ${cfg.topK},预算 ${cfg.injectMode === 'digest' ? cfg.totalBudget : Math.min(cfg.totalBudget, 600)} tok,阈值 ${cfg.matchThreshold})` : '关'],
125
+ ['注入去重', cfg.injectDedup ? '开(同会话已注入的 Topic 不重注)' : '关'],
126
+ ['慢道', cfg.qualityLane === 'off' ? '关' : cfg.qualityLane === 'always' ? `每轮(${cfg.distillProvider}/${cfg.distillModel})` : `采样 1/3(${cfg.distillProvider}/${cfg.distillModel})`],
127
+ ['蒸馏', cfg.distillProvider !== '' && cfg.distillModel !== '' ? `${cfg.distillProvider}/${cfg.distillModel},每 ${cfg.distillEveryTurns} 轮` : '未配置模型(/topics set distill-provider / distill-model)'],
129
128
  ]
130
129
  // Last lane outcome (distill-state summary) — what "checkable via
131
130
  // /topics status" promises; absent until the first run of this bundle.
@@ -136,13 +135,19 @@ async function renderStatus(service: TopicsService): Promise<string> {
136
135
  ? `标记 ${String(lastRun.marked ?? 0)} 条观察`
137
136
  : `失败(${String(lastRun.reason ?? 'unknown')})`
138
137
  const gc = typeof lastRun.gcDropped === 'number' && lastRun.gcDropped > 0 ? `,GC 回收 ${lastRun.gcDropped}` : ''
139
- lines.push(` 最近蒸馏:${outcome}${gc} @ ${String(lastRun.at ?? '').slice(0, 19).replace('T', ' ')}`)
138
+ rows.push(['最近蒸馏', `${outcome}${gc} @ ${cell(String(lastRun.at ?? '').slice(0, 19).replace('T', ' '))}`])
140
139
  }
141
140
  if (service.sync !== undefined) {
142
- lines.push(` 上次推送:${service.sync.lastPushAt ?? '从未'}`)
143
- if (service.sync.lastError !== '') lines.push(` 同步错误:${service.sync.lastError.split('\n').at(-1)}`)
141
+ rows.push(['上次推送', cell(String(service.sync.lastPushAt ?? '从未'))])
142
+ if (service.sync.lastError !== '') rows.push(['同步错误', cell(service.sync.lastError.split('\n').at(-1) ?? '')])
144
143
  }
145
- return lines.join('\n')
144
+ return [
145
+ `dsh-topics-memory @ ${s.root}`,
146
+ '',
147
+ '| 字段 | 值 |',
148
+ '| --- | --- |',
149
+ ...rows.map(([k, v]) => `| ${k} | ${v} |`),
150
+ ].join('\n')
146
151
  }
147
152
 
148
153
  /**
@@ -178,9 +183,13 @@ async function renderStats(service: TopicsService): Promise<string> {
178
183
  if (records.length === 0) return '还没有注入记录 —— 用起来之后这里会有 hit rate / top-N / near-miss 分布。'
179
184
  const lines = [
180
185
  `注入统计(最近 ${records.length} 轮):`,
181
- ` hit rate:${(stats.hitRate * 100).toFixed(1)}%(${stats.injectedRounds}/${stats.rounds} 轮注入)`,
182
- ` 零命中轮:${stats.zeroHitRounds};平均命中 ${stats.avgHitsPerRound} 条/轮`,
183
- ` 平均预算占用:${stats.avgBudgetUtilization} tok`,
186
+ '',
187
+ '| 指标 | 值 |',
188
+ '| --- | --- |',
189
+ `| hit rate | ${(stats.hitRate * 100).toFixed(1)}%(${stats.injectedRounds}/${stats.rounds} 轮注入) |`,
190
+ `| 零命中轮 | ${stats.zeroHitRounds} |`,
191
+ `| 平均命中 | ${stats.avgHitsPerRound} 条/轮 |`,
192
+ `| 平均预算占用 | ${stats.avgBudgetUtilization} tok |`,
184
193
  ]
185
194
  // v4 lane split — how much of the injection traffic each lane carries.
186
195
  const slowRounds = records.filter((r) => r.lane === 'slow' || r.lane === 'mixed').length
@@ -193,27 +202,35 @@ async function renderStats(service: TopicsService): Promise<string> {
193
202
  .sort((a, b) => a - b)
194
203
  medianLag = `,赶上中位时延 ${(lags[Math.floor(lags.length / 2)] / 1000).toFixed(1)}s`
195
204
  }
196
- lines.push(` 慢道参与轮:${slowRounds}(快 ${(records.length - slowRounds)} / 慢或混合 ${slowRounds}${medianLag})`)
205
+ lines.push(`| 慢道参与轮 | ${slowRounds}(快 ${records.length - slowRounds} / 慢或混合 ${slowRounds}${medianLag}) |`)
197
206
  }
198
207
  // Pointer open rate (v4 §4.3): topic_open calls vs pointer entries injected.
208
+ // Echoed hits ride the retrieval but never became pointers — excluded here.
199
209
  const opens = await service.store.readOpenRecords()
200
210
  if (opens.length > 0 || records.some((r) => r.lane !== undefined)) {
201
211
  const entries = records.reduce(
202
- (acc, r) => acc + r.hits.filter((h) => !(r.deduped ?? []).includes(h.slug)).length + (r.slow?.length ?? 0),
212
+ (acc, r) =>
213
+ acc +
214
+ r.hits.filter((h) => !(r.deduped ?? []).includes(h.slug) && !(r.echoed ?? []).includes(h.slug)).length +
215
+ (r.slow?.length ?? 0),
203
216
  0,
204
217
  )
205
218
  const rate = entries === 0 ? 0 : Math.min(1, opens.length / entries)
206
- lines.push(` 指针打开率:${(rate * 100).toFixed(1)}%(${opens.length} 次 topic_open / ${entries} 条注入指针)`)
219
+ lines.push(`| 指针打开率 | ${(rate * 100).toFixed(1)}%(${opens.length} 次 topic_open / ${entries} 条注入指针) |`)
220
+ }
221
+ const echoedCount = records.reduce((acc, r) => acc + (r.echoed?.length ?? 0), 0)
222
+ if (echoedCount > 0) {
223
+ lines.push(`| 回声抑制 | ${echoedCount} 次(本会话蒸馏出的 topic 不回注) |`)
207
224
  }
208
225
  if (stats.topTopics.length > 0) {
209
- lines.push(' Top-N 被注入 Topic:')
210
- for (const t of stats.topTopics.slice(0, 5)) lines.push(` ${t.slug} ×${t.count}`)
226
+ lines.push('', 'Top-N 被注入 Topic:', '', '| Slug | 注入次数 |', '| --- | --- |')
227
+ for (const t of stats.topTopics.slice(0, 5)) lines.push(`| \`${cell(t.slug)}\` | ${t.count} |`)
211
228
  }
212
229
  if (stats.nearMissHistogram.length > 0) {
213
- lines.push(' Near-miss 分布(低于阈值或被结构门挡下):')
214
- for (const b of stats.nearMissHistogram) lines.push(` ${b.bucket}: ${b.count}`)
230
+ lines.push('', 'Near-miss 分布(低于阈值或被结构门挡下):', '', '| 分数段 | 次数 |', '| --- | --- |')
231
+ for (const b of stats.nearMissHistogram) lines.push(`| ${cell(b.bucket)} | ${b.count} |`)
215
232
  const hint = tuningHint(stats, service.cfg.matchThreshold)
216
- if (hint !== undefined) lines.push(` 💡 ${hint}`)
233
+ if (hint !== undefined) lines.push('', `💡 ${hint}`)
217
234
  }
218
235
  return lines.join('\n')
219
236
  }
@@ -229,16 +246,40 @@ export function tuningHint(stats: ReturnType<typeof aggregateStats>, threshold:
229
246
  return undefined
230
247
  }
231
248
 
249
+ /** /topics list caps the table at this many newest topics. */
250
+ const LIST_LIMIT = 100
251
+
232
252
  async function renderList(service: TopicsService): Promise<string> {
233
253
  const metas = await service.store.listTopics()
234
254
  if (metas.length === 0) return 'Bundle 里还没有 Topic —— 在会话里让我记点什么,或 /topics set 配置好蒸馏。'
235
- const lines = [`共 ${metas.length} 个 Topic:`]
236
- for (const m of metas.sort((a, b) => a.slug.localeCompare(b.slug))) {
237
- lines.push(` ${m.slug} [${m.status}] ${m.title}${m.tags.length > 0 ? ` #${m.tags.join(' #')}` : ''}`)
255
+ // Newest first, capped: with a large bundle the table is for scanning
256
+ // recent work, not an exhaustive roster — /topics show <slug> reaches any
257
+ // topic the cap hides. (store.ts re-stamps generated.at on every topic
258
+ // write, so it IS the updated-at time; the slug tiebreak keeps same-stamp
259
+ // rows stable.)
260
+ metas.sort((a, b) => b.generatedAt.localeCompare(a.generatedAt) || a.slug.localeCompare(b.slug))
261
+ const shown = metas.slice(0, LIST_LIMIT)
262
+ const lines = [
263
+ `共 ${metas.length} 个 Topic(按最近更新排序):`,
264
+ '',
265
+ '| # | Slug | 标题 | 状态 | 标签 | 更新 |',
266
+ '| --- | --- | --- | --- | --- | --- |',
267
+ ]
268
+ shown.forEach((m, i) => {
269
+ const tags = m.tags.length > 0 ? m.tags.map((t) => `#${cell(t)}`).join(' ') : '—'
270
+ lines.push(`| ${i + 1} | \`${cell(m.slug)}\` | ${cell(m.title)} | ${m.status} | ${tags} | ${cell(m.generatedAt.slice(0, 10))} |`)
271
+ })
272
+ if (metas.length > shown.length) {
273
+ lines.push('', `… 其余 ${metas.length - shown.length} 个更早的 Topic 未显示(/topics show <slug> 直达)。`)
238
274
  }
239
275
  return lines.join('\n')
240
276
  }
241
277
 
278
+ /** One markdown table cell: pipes escaped, whitespace flattened. */
279
+ function cell(text: string): string {
280
+ return text.replace(/\|/g, '\\|').replace(/\s*\n\s*/g, ' ')
281
+ }
282
+
242
283
  async function renderShow(service: TopicsService, slug: string | undefined): Promise<CommandResult> {
243
284
  if (slug === undefined || slug === '') return fail('用法:/topics show <slug>')
244
285
  const doc = await service.store.readTopic(slug)
@@ -261,10 +302,15 @@ async function renderHistory(service: TopicsService, slug: string | undefined):
261
302
  if (slug === undefined || slug === '') return fail('用法:/topics history <slug>')
262
303
  const { entries } = await service.history(slug, 30)
263
304
  if (entries.length === 0) return fail(`Topic “${slug}” 没有历史(不存在或 bundle 不是 git 仓库)`)
264
- const lines = [`${slug} 的变更史(${entries.length} 条):`]
305
+ const lines = [
306
+ `${slug} 的变更史(${entries.length} 条):`,
307
+ '',
308
+ '| Hash | 时间 | 变更 | 当时的结论 |',
309
+ '| --- | --- | --- | --- |',
310
+ ]
265
311
  for (const e of entries) {
266
- lines.push(` ${e.hash} ${e.date.slice(0, 19).replace('T', ' ')} ${e.message}`)
267
- if (e.conclusion !== undefined && e.conclusion !== '') lines.push(` └ 结论当时:${e.conclusion}`)
312
+ const conclusion = e.conclusion !== undefined && e.conclusion !== '' ? cell(e.conclusion) : '—'
313
+ lines.push(`| \`${cell(e.hash)}\` | ${cell(e.date.slice(0, 19).replace('T', ' '))} | ${cell(e.message)} | ${conclusion} |`)
268
314
  }
269
315
  return ok(lines.join('\n'))
270
316
  }
@@ -321,9 +367,14 @@ async function doSync(service: TopicsService, direction: string | undefined): Pr
321
367
  }
322
368
 
323
369
  function renderConfig(cfg: TopicsConfigValue): string {
324
- const lines = ['当前配置:']
370
+ const lines = [
371
+ '当前配置:',
372
+ '',
373
+ '| 配置项 | 值 |',
374
+ '| --- | --- |',
375
+ ]
325
376
  for (const key of CONFIG_KEYS) {
326
- lines.push(` ${displayKey(key)} = ${String(cfg[key])}`)
377
+ lines.push(`| ${displayKey(key)} | ${cell(String(cfg[key]))} |`)
327
378
  }
328
379
  return lines.join('\n')
329
380
  }
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 injected,
92
- // so they must not inflate the per-slug injection counts. Retrieval-shape
93
- // metrics above (hits/rounds, zero-hit rounds) keep counting raw hits.
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 candidates = [...band.hits, ...band.nearMisses].slice(0, CANDIDATE_LIMIT)
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) => {