@aiwayds/dsh-topics-memory 0.9.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/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/sync.d.ts CHANGED
@@ -32,7 +32,12 @@ export declare class Sync {
32
32
  private readonly urlFor;
33
33
  constructor(store: BundleStore, cfg: () => SyncConfig, token?: TokenResolver, urlFor?: UrlResolver);
34
34
  get active(): boolean;
35
- /** Session start (or `/topics sync pull`): rebase onto remote, mark conflicts. */
35
+ /**
36
+ * Session start (or `/topics sync pull`): rebase onto remote, mark
37
+ * conflicts, then replay any push the previous session's local-only exit
38
+ * deferred. The replay is best-effort — a failure just marks lastError and
39
+ * the debounced flush (or the next boot's pull) retries.
40
+ */
36
41
  pull(): Promise<{
37
42
  ok: boolean;
38
43
  conflicted: string[];
package/lib/sync.js CHANGED
@@ -35,7 +35,12 @@ export class Sync {
35
35
  get active() {
36
36
  return this.cfg().repo !== '';
37
37
  }
38
- /** Session start (or `/topics sync pull`): rebase onto remote, mark conflicts. */
38
+ /**
39
+ * Session start (or `/topics sync pull`): rebase onto remote, mark
40
+ * conflicts, then replay any push the previous session's local-only exit
41
+ * deferred. The replay is best-effort — a failure just marks lastError and
42
+ * the debounced flush (or the next boot's pull) retries.
43
+ */
39
44
  async pull() {
40
45
  if (!this.active)
41
46
  return { ok: true, conflicted: [], message: 'local-only 模式,无同步' };
@@ -52,6 +57,23 @@ export class Sync {
52
57
  const prior = await this.store.getConflicts();
53
58
  if (prior.size > 0)
54
59
  await this.store.setConflicts([]);
60
+ // Deferred-push replay: the previous exit committed locally only, so a
61
+ // successful rebase here is the moment the backlog lands on the remote.
62
+ try {
63
+ if ((await gitmod.unpushedCount(this.store.root)) > 0) {
64
+ const pushed = await gitmod.push(this.store.root, token);
65
+ if (pushed.ok) {
66
+ this.lastPushAt = new Date().toISOString();
67
+ this.lastError = '';
68
+ }
69
+ else {
70
+ this.lastError = pushed.output.slice(-500);
71
+ }
72
+ }
73
+ }
74
+ catch {
75
+ // pull must stay best-effort for the session-start caller
76
+ }
55
77
  return { ok: true, conflicted: [], message: '已与远端同步' };
56
78
  }
57
79
  const conflictedSlugs = outcome.conflicted.map((p) => p.replace(/^topics\//, '').replace(/\.md$/, ''));
@@ -59,7 +81,14 @@ export class Sync {
59
81
  await this.store.setConflicts(outcome.conflicted);
60
82
  }
61
83
  this.lastError = outcome.output.slice(-500);
62
- return { ok: false, conflicted: conflictedSlugs, message: `rebase 冲突:${conflictedSlugs.join('、') || '未知路径'}(已标记降权,待手工解决)` };
84
+ // A rebase can fail without producing conflicted paths (identity missing,
85
+ // fetch failure, corrupt state) — surface the git output tail instead of
86
+ // the misleading 「未知路径」 so a headless failure is diagnosable.
87
+ if (conflictedSlugs.length === 0) {
88
+ const tail = outcome.output.trim().split('\n').slice(-3).join(';').slice(-200);
89
+ return { ok: false, conflicted: [], message: `rebase 失败(无冲突文件):${tail !== '' ? tail : 'git 无输出'}` };
90
+ }
91
+ return { ok: false, conflicted: conflictedSlugs, message: `rebase 冲突:${conflictedSlugs.join('、')}(已标记降权,待手工解决)` };
63
92
  }
64
93
  /** Called after every write-through commit — schedules the debounced push. */
65
94
  schedulePush() {
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,7 +1,7 @@
1
1
  {
2
2
  "name": "@aiwayds/dsh-topics-memory",
3
- "version": "0.9.0",
4
- "description": "dsh-plugin: OKF v0.2 topic memory for dsh - local-first git-tracked bundle, hot-path LLM-free injection, two-stage observer with background distill",
3
+ "version": "0.11.0",
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",
7
7
  "types": "lib/index.d.ts",
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/distill.ts CHANGED
@@ -161,8 +161,10 @@ export class Distiller {
161
161
  * caller can hook post-run cleanup — the per-session llm capture in
162
162
  * index.ts must live exactly as long as a run that may read it. `manual`
163
163
  * is the /topics distill trigger: same lane, same in-flight guard.
164
+ * `boot-replay` is the session-start backlog drain: a previous exit's
165
+ * skipped (or killed-mid-run) distill replays here.
164
166
  */
165
- request(sessionId: string, reason: 'every-n' | 'session-end' | 'manual'): Promise<DistillResult> | undefined {
167
+ request(sessionId: string, reason: 'every-n' | 'session-end' | 'manual' | 'boot-replay'): Promise<DistillResult> | undefined {
166
168
  if (!this.configured) return undefined
167
169
  if (this.inFlight.has(sessionId)) return undefined
168
170
  const run = this.run(sessionId).finally(() => this.inFlight.delete(sessionId))
package/src/git.ts CHANGED
@@ -152,10 +152,13 @@ export interface PullOutcome {
152
152
  * `pull --rebase` with conflict containment: on conflict we abort the rebase
153
153
  * (ADR 0003 — no automatic smart merging) and report the conflicted paths so
154
154
  * the caller can mark those topics. Remote and branch are explicit — the
155
- * bundle never depends on upstream tracking config.
155
+ * bundle never depends on upstream tracking config. IDENTITY rides along:
156
+ * a rebase REPLAYS local commits, which requires a committer identity, and
157
+ * a fresh machine (CI, new host) may have none — without this the pull
158
+ * fails with an empty conflicted list on every session start.
156
159
  */
157
160
  export async function pullRebase(cwd: string, token?: string, remote = 'origin', branch = 'main'): Promise<PullOutcome> {
158
- const r = await runGit(['pull', '--rebase', '--autostash', remote, branch], { cwd, token, mayFail: true, timeoutMs: 60_000 }).catch((e) => {
161
+ const r = await runGit([...IDENTITY, 'pull', '--rebase', '--autostash', remote, branch], { cwd, token, mayFail: true, timeoutMs: 60_000 }).catch((e) => {
159
162
  return { code: -1, stdout: '', stderr: String(e instanceof Error ? e.message : e) } satisfies GitResult
160
163
  })
161
164
  if (r.code === 0) return { ok: true, conflicted: [], output: r.stdout + r.stderr }
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
@@ -39,13 +39,13 @@ import { isDelegated } from './delegation.ts'
39
39
  export const name = 'dsh-topics-memory'
40
40
 
41
41
  /**
42
- * Bounded wait for the process-exit distill. Session teardown is the last
43
- * distill trigger, but the disposer used to fire it fire-and-forget — process
44
- * exit always won the race and the run died unwritten. The disposer now waits
45
- * for the run, capped so a pathological (hanging) model call can never wedge
46
- * the host's exit.
42
+ * Bounded wait for the process-exit meta commit. The exit path is
43
+ * local-only: no pull/push, no model call — the deferred push rides the
44
+ * next boot's pull and the skipped exit distill is replayed there. The
45
+ * window only bounds the local `git commit` against a pathological stall
46
+ * (a wedged index.lock, a hung filesystem), which should never fire.
47
47
  */
48
- export const EXIT_DISTILL_TIMEOUT_MS = 90_000
48
+ export const EXIT_COMMIT_TIMEOUT_MS = 10_000
49
49
 
50
50
  /**
51
51
  * Resolve when `p` settles (rejection swallowed) or after `ms`, whichever
@@ -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
@@ -368,10 +381,10 @@ export function apply(ctx: Context): void {
368
381
  const slowLane = new SlowLane(service, caller, releaseSessionLlm)
369
382
 
370
383
  // ---- Observer (M2) ----
371
- // The process-exit disposer awaits this run (bounded): the callback below
372
- // records it synchronously when the fake 'dispose' session's end trigger
373
- // fires, so the disposer can wait for the last distill to land.
374
- let exitDistill: Promise<DistillResult> | undefined
384
+ // Trigger runs are fire-and-forget everywhere now — including the exit
385
+ // one. Observations are write-through on disk, so a run the process exits
386
+ // under is never lost work: its input is still undistilled and the next
387
+ // boot's replay distills it.
375
388
  const observer = new Observer(service, (sessionId, reason) => {
376
389
  // Trigger-time capture: the agent is still registered here, so its scoped
377
390
  // ctx can hand over the llm instance whose adapters are live.
@@ -387,7 +400,6 @@ export function apply(ctx: Context): void {
387
400
  // and its own settle hook does the release.
388
401
  const run = distiller.request(sessionId, reason)
389
402
  if (run !== undefined) {
390
- if (sessionId === 'dispose') exitDistill = run
391
403
  void run.finally(() => releaseSessionLlm(sessionId)).catch(() => undefined)
392
404
  }
393
405
  })
@@ -508,13 +520,37 @@ export function apply(ctx: Context): void {
508
520
  }) as never)
509
521
  }
510
522
 
511
- // ---- Sync lifecycle: pull on session start, flush on dispose ----
523
+ // ---- Sync lifecycle: pull on session start, local commit on dispose ----
512
524
  ctx.on('session/event' as never, ((session: { id: unknown }, event: SessionEvent) => {
513
525
  if (event.type === 'agent/session-start') {
514
526
  void sync
515
527
  .pull()
516
528
  .catch(() => undefined)
517
529
  .finally(() => store.ensure().catch(() => undefined))
530
+ .then(() => {
531
+ // Boot replay: the observations JSONL is the durable distill queue.
532
+ // Whatever the previous exit skipped (local-only exit since the
533
+ // no-network exit commit) or a killed run left unmarked is still
534
+ // undistilled here. Empty pool and no-model are cheap no-ops inside
535
+ // the distiller; the per-session dedup guards double runs.
536
+ void store
537
+ .undistilledObservations(1)
538
+ .then((pending) => {
539
+ if (pending.length === 0) return
540
+ // Same trigger-time capture the observer callback does: the
541
+ // replay run's caller resolves candidates lazily and a fresh
542
+ // session has no prior capture to lean on.
543
+ try {
544
+ captureFromAgent(agents()?.get(session.id) as unknown)
545
+ } catch {
546
+ // contained — the run below fails with the readable
547
+ // no-adapter detail instead
548
+ }
549
+ const run = distiller.request(String(session.id), 'boot-replay')
550
+ if (run !== undefined) void run.catch(() => undefined)
551
+ })
552
+ .catch(() => undefined)
553
+ })
518
554
  }
519
555
  }) as never)
520
556
 
@@ -522,27 +558,24 @@ export function apply(ctx: Context): void {
522
558
  () => {
523
559
  void store.ensure().catch(() => undefined)
524
560
  // Async disposer: cordis awaits it during unload (Disposable may be
525
- // async), so the exit distill gets its bounded window before the
526
- // process goes away instead of always losing the exit race.
561
+ // async). The exit path is local-only — one git commit of the meta
562
+ // sidecars (observations / injection log / distill state), no pull, no
563
+ // push, no model call. The deferred push rides the next boot's pull
564
+ // (sync.pull replays it) and the skipped exit distill is replayed
565
+ // there too; both only ever needed the on-disk state, which the commit
566
+ // below makes durable before the process goes away.
527
567
  return () => {
528
568
  // A real session's session-end run (agent/disposed trigger, moments
529
569
  // earlier) may still be in flight — and the fake-'dispose' run would
530
570
  // feed the SAME global pool head to the model a second time (double
531
571
  // evaluation, double GC attempts). Skip the exit trigger while any
532
- // run is pending; the bounded wait below then simply has nothing
533
- // extra to await.
572
+ // run is pending; its capture is already write-through on disk.
534
573
  if (!distiller.hasAnyPending()) {
535
574
  observer.onSessionEvent('dispose', 'agent/disposed', undefined)
536
575
  }
537
576
  sync.dispose()
538
- // The exit flush (final meta commit + ledger flush) rides the SAME
539
- // bounded window as the exit distill — fire-and-forget here loses the
540
- // exit race that window was built to win.
541
- const exitFlush = sync.commitMeta().then(() => sync.flush()).catch(() => undefined)
542
- return settleBounded(
543
- exitDistill === undefined ? exitFlush : Promise.allSettled([exitDistill, exitFlush]),
544
- EXIT_DISTILL_TIMEOUT_MS,
545
- )
577
+ const exitFlush = sync.commitMeta().catch(() => undefined)
578
+ return settleBounded(exitFlush, EXIT_COMMIT_TIMEOUT_MS)
546
579
  }
547
580
  },
548
581
  'topics: lifecycle',
@@ -626,6 +659,7 @@ const DEFAULTS: TopicsConfigValue = {
626
659
  repo: '',
627
660
  autoInject: true,
628
661
  injectDedup: true,
662
+ suppressEcho: true,
629
663
  topK: 4,
630
664
  perTopicBudget: 300,
631
665
  totalBudget: 1500,