@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 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 + r.hits.filter((h) => !(r.deduped ?? []).includes(h.slug)).length + (r.slow?.length ?? 0), 0);
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
- 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(' #')}` : ''}`);
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 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;
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.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) => acc + r.hits.filter((h) => !(r.deduped ?? []).includes(h.slug)).length + (r.slow?.length ?? 0),
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
- 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(' #')}` : ''}`)
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 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) => {
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. `included` mirrors the
187
- * fast pointers that entered the context and `slowIncluded` the slow ones
188
- * — only these may be marked as injected by the caller; budget-dropped
189
- * slugs stay injectable later.
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(slug: string, sessionId?: string): Promise<{
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
- const doc = await this.store.readTopic(slug).catch(() => undefined)
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
- return {
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
- return { entries: entries.map((e) => ({ ...e, conclusion: e.conclusion as string | undefined })) }
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