@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/README.md CHANGED
@@ -6,10 +6,28 @@ A dsh plugin: maintains "working topic memory" as an [OKF (Open Knowledge Format
6
6
 
7
7
  > **Requires dsh >= 0.1.2-rc.1** — this plugin targets the dsh RC/stable line only (CI and releases resolve the newest of the `latest`/`next` dist-tags at runtime). **The alpha line is no longer supported.**
8
8
 
9
+ ## Demo
10
+
11
+ The full flow in about four minutes: topics captured, distilled, and injected in a real session.
12
+
13
+ https://github.com/user-attachments/assets/8c06cc98-b1ed-402b-9110-4f9a93eb15bc
14
+
9
15
  ## The problem it solves
10
16
 
11
17
  Long sessions forget. Cross-session, even more so. This plugin maintains **structured topic memory**: each Topic records a matter's **name, dependencies, open questions, current conclusion, impact, and recommendations**. When a conclusion changes, edit the file and commit — `git log` directly answers "when, by whom, and why did this conclusion change".
12
18
 
19
+ ## Why this plugin exists: memory is edited, not accumulated
20
+
21
+ The short version: **more memory is not better memory.**
22
+
23
+ Most memory tools assume accumulation — record everything, retrieve broadly. That may work for humans; for LLMs it backfires twice over. Model attention is a finite resource, so a giant memory bank means every turn is spent digging for signal in noise. Worse, process memories hoard intermediate judgments that were right once and wrong later — and they will confidently steer the model into bad decisions.
24
+
25
+ So this plugin takes a hard editorial line on what deserves to be remembered: **a topic records exactly four things — the question that started it, the conclusion it reached, what it impacts, and what it depends on. Everything in between — the discussion, the dead ends, the wrong turns — is deliberately not memory.** Process belongs to the session; when the session ends, it goes. Only conclusions that survive distillation make it into the bundle.
26
+
27
+ Short-term memory is the session's own job — the conversation context already is one, and a plugin that feeds it back is noise. Long-term memory belongs to topics: small, structured, git-traceable, injected in budgeted slices with zero hits meaning zero injection. Every turn hands the model the **minimum high-value context**, not the biggest warehouse.
28
+
29
+ This plugin is not trying to be the model's notebook. It is trying to be the model's editor: deciding what is worth keeping — and, more importantly, what should be forgotten.
30
+
13
31
  ## Core features
14
32
 
15
33
  - **Strict OKF v0.2 compliance**: each Topic is a `markdown + YAML frontmatter` concept document (`type: Topic`) that the whole OKF ecosystem (Obsidian, OKF validators) can consume directly; ships with the provenance (`sources`), trust (`generated`/`verified`), and lifecycle (`status`/`stale_after`) field families.
@@ -30,7 +48,7 @@ Long sessions forget. Cross-session, even more so. This plugin maintains **struc
30
48
 
31
49
  | Model tools | Purpose |
32
50
  |---|---|
33
- | `topic_save` | Distill/revise a Topic (name / dependencies / open questions / conclusion / impact / recommendations) |
51
+ | `topic_save` | Save/revise a Topic (name / dependencies / open questions / conclusion / impact / recommendations) |
34
52
  | `topic_observe` | Jot an atomic observation (decision/finding/constraint/question), pending distill |
35
53
  | `topic_search` | LLM-free keyword search over memory |
36
54
  | `topic_history` | A topic's conclusion change history (git log as a tool) |
@@ -115,7 +133,7 @@ This project's shape is directly inspired and supported by:
115
133
  ## Known boundaries
116
134
 
117
135
  - **Subagents engage memory by default — one switch to opt out**: by default (`include-subagents` on), injection and observation apply to subagent sessions too. `/topics set include-subagents off` skips delegated sessions entirely — no injection, no observation, no distill triggers; the topic tools stay on the global layer, so an explicit `topic_save` from a child still lands. Out-of-process subagents (claude-code/codex providers) never load this plugin anyway.
118
- - **Session-end distill in headless one-shot sessions**: the plugin's disposer now waits (bounded, up to 90s) for the exit-triggered distill to land before unload completes, so a headless process no longer loses that race by default; a hanging model call gives up at the cap and exit proceeds without that run. Within that 90s window the exit run may complete only part of its call budget — the remaining backlog waits for the next trigger. The exit trigger is skipped entirely while a session-end run is still in flight (the same pool head would otherwise be fed to the model twice). Manual `/topics distill` runs inside a live session bypass the race entirely; `meta/distill-state.json` records each lane's outcome, checkable via `/topics status`.
136
+ - **Exit is local-only (0.10.0)**: the plugin's disposer makes one local git commit of the meta sidecars (observations / injections / distill state) and never waits on the network — no pull, no push, no model call, so host exit no longer pays a git round-trip or a bounded distill wait (the old 90s cap is gone; only a 10s guard against a pathological git stall remains). The exit distill trigger is fired fire-and-forget and is skipped entirely while a session-end run is still in flight (the same pool head would otherwise be fed to the model twice). Nothing is lost by skipping: observations are write-through on disk, the deferred push is replayed by the next boot's pull, and the skipped distill is replayed there too (boot-replay). `meta/distill-state.json` records each lane's outcome, checkable via `/topics status`.
119
137
  - **Observation GC (three strikes)**: an observation the model actually evaluated (parseable answer, however useless) but no op consumed accrues one failed attempt; the third failed attempt physically deletes it — explicitly authorized cleanup of raw data the lane demonstrably cannot process. Runs that never evaluated the batch never count: infrastructure failures (network errors, unconfigured distill route → readable `no-model` short-circuit) and unparseable output (`invalid-output`) are exempt, and a batch still mid-shrink on output-limit retries is only counted once a verdict is reached (success, floor stop, stall, or an explicit skip). Deletions are committed immediately (data destruction stays git-traceable); pure attempt counters follow the usual flush cadence.
120
138
  - **Config read timing**: `/topics set` and `settings.yaml` edits take effect most reliably from the next session start.
121
139
  - **Picking a distill model**: `/topics onboard` splits the distill decision into two dependent questions (provider first, then that provider's model catalog), pre-validated with `resolveModelInfo` — a provider with no live route blocks and re-asks, an off-catalog model (a non-NO_ADAPTER failure: outside the advisory catalog, possibly still usable) warns but is allowed; hosts without an ask UI or a usable model route fall back to typed input. The same validation backs the `/topics set` picker panels.
package/README.zh.md CHANGED
@@ -6,10 +6,28 @@
6
6
 
7
7
  > **要求 dsh >= 0.1.2-rc.1** — 本插件只跟随 dsh RC/stable 线(CI 与发版在运行时解析 latest/next 中更新的 dist-tag)。**不再支持 alpha 线。**
8
8
 
9
+ ## 演示
10
+
11
+ 约 4 分钟的完整流程:topic 沉淀、蒸馏、注入在真实会话里跑起来。
12
+
13
+ https://github.com/user-attachments/assets/6e9b346d-2ec9-4148-ae62-7087797d3188
14
+
9
15
  ## 它解决什么问题
10
16
 
11
17
  长会话会失忆,跨会话更会。本插件维护一份**结构化的 topic 记忆**:每个 Topic 记录一件事的**名字、依赖、未决问题、目前结论、影响、建议**。结论变了就改文件、打 commit——`git log` 直接回答「这个结论什么时候、被谁、为什么改的」。
12
18
 
19
+ ## 初衷:记忆是编辑出来的,不是攒出来的
20
+
21
+ 先说结论:**记忆不是越多越好。**
22
+
23
+ 「记忆」类产品的常见思路是多多益善——能记的都记,用时全量检索。这对人也许成立,对 LLM 恰恰相反,而且反在两处:模型的注意力是有限资源,巨型记忆库意味着每一轮对话都在噪音里翻找信号;更危险的是,过程记忆里囤着大量「当时正确、事后失效」的中间判断,它们会理直气壮地把模型引向错误的决定。
24
+
25
+ 所以本插件对「记什么」做了一次苛刻的取舍:**一个 topic 只记四件事——它从什么问题开始、得出了什么结论、影响什么、依赖什么。至于得出结论的过程:讨论、试错、绕过的弯路,一概不记。**过程属于 session,session 结束就该随它去;能留下的,只有经得起蒸馏的结论。
26
+
27
+ 短时记忆交给 session,对话上下文本就是它,不需要插件再回喂一遍;长时记忆交给 topics——小、结构化、git 可追溯,注入按预算切片、零命中零注入,每轮给模型的都是**最小的高价值上下文**,而不是越大越好的仓库。
28
+
29
+ 所以这个插件想做的不是模型的记事本,而是模型的编辑:替它决定什么值得记住,以及——更要紧的——什么应该被忘掉。
30
+
13
31
  ## 核心特性
14
32
 
15
33
  - **OKF v0.2 严格合规**:每个 Topic 是 `markdown + YAML frontmatter` 的 concept 文档(`type: Topic`),可被 Obsidian、OKF 校验器等整个生态直接消费;自带 provenance(`sources`)、trust(`generated`/`verified`)、lifecycle(`status`/`stale_after`)三族字段。
@@ -115,7 +133,7 @@ dsh plugin --profile <name> remove @aiwayds/dsh-topics-memory
115
133
  ## 已知边界
116
134
 
117
135
  - **子代理默认参与记忆,可整体关掉**:默认(`include-subagents` 开)注入与观察同样作用于子代理会话。`/topics set include-subagents off` 后,delegation depth > 0 的子代理会话被整体跳过——不注入、不观察、不触发蒸馏;topic 工具始终在全局层(子代理显式 `topic_save` 不受开关影响)。跨进程子代理(claude-code/codex 等 provider)本就不加载本插件。
118
- - **headless 单发会话里的 session-end 蒸馏**:插件 disposer 现在会等待退出触发的末次蒸馏落盘(有界等待,上限 90s;挂死的模型调用在超时后放弃、退出照常进行),headless 进程默认不再输掉这场竞速。90s 窗口内退出 run 可能只完成部分调用预算,剩余积压留待下次触发。已有 session-end run 在跑时退出触发整体跳过(否则同一全局队头批次会被双份喂给模型)。会话内 `/topics distill` 手动触发则完全绕开竞速;`meta/distill-state.json` 记录每次 lane 的结局,`/topics status` 可查。
136
+ - **退出路径本地化(0.10.0)**:插件 disposer 只做一步本地 git commit(meta 侧车文件:observations / injections / distill state),不再等待任何网络——没有 pull、没有 push、没有模型调用,宿主退出不再支付 git 双程与有界蒸馏等待(旧的 90s 上限移除,仅保留 10s 兜底以防病理性 git 卡死)。退出蒸馏触发改为 fire-and-forget;已有 session-end run 在跑时整体跳过(否则同一全局队头批次会被双份喂给模型)。跳过不丢活:observations 本就 write-through 落盘,推迟的 push 由下次启动的 pull 补推,被跳过的蒸馏同样由下次启动回放(boot-replay)。`meta/distill-state.json` 记录每次 lane 的结局,`/topics status` 可查。
119
137
  - **观察 GC(三振删除)**:被模型实际评估(返回了可解析应答,无论内容有无价值)却未被任何 op 消费的观察记一次 failed attempt,连续 3 次即物理删除(用户已明确授权删除 lane 确实无法处理的原始观察)。模型从未评估过的批次永不计数:基础设施失败(网络错误、蒸馏路由未配置 → 可读 `no-model` 短路)与输出不可解析(`invalid-output`)豁免;输出上限减半重试中的批次只有到达裁决(成功 / 触底 / stalled / 明确跳过)才计一次。删除立即 commit(数据销毁 git 可追溯),纯计数沿用 flush 节奏。
120
138
  - **配置读取时机**:`/topics set` 与 settings.yaml 修改在下次会话启动后生效最稳。
121
139
  - **蒸馏选模型**:`/topics onboard` 的蒸馏一步拆成两问(先 provider 后 model,模型列表取自该 provider 的目录),选完经 `resolveModelInfo` 预校验——provider 无活路由会阻断重选,模型目录校验非 NO_ADAPTER 失败(目录外,可能仍可用)则警告但放行;无 ask UI 或无可用模型路由的环境自动退回文本输入。`/topics set` 的选择面板走同一套校验。
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/distill.d.ts CHANGED
@@ -69,8 +69,10 @@ export declare class Distiller {
69
69
  * caller can hook post-run cleanup — the per-session llm capture in
70
70
  * index.ts must live exactly as long as a run that may read it. `manual`
71
71
  * is the /topics distill trigger: same lane, same in-flight guard.
72
+ * `boot-replay` is the session-start backlog drain: a previous exit's
73
+ * skipped (or killed-mid-run) distill replays here.
72
74
  */
73
- request(sessionId: string, reason: 'every-n' | 'session-end' | 'manual'): Promise<DistillResult> | undefined;
75
+ request(sessionId: string, reason: 'every-n' | 'session-end' | 'manual' | 'boot-replay'): Promise<DistillResult> | undefined;
74
76
  /** True while a run for the session is still in flight (teardown guard). */
75
77
  hasPending(sessionId: string): boolean;
76
78
  /** True while ANY session's run is still in flight (exit-dispose guard). */
package/lib/distill.js CHANGED
@@ -65,6 +65,8 @@ export class Distiller {
65
65
  * caller can hook post-run cleanup — the per-session llm capture in
66
66
  * index.ts must live exactly as long as a run that may read it. `manual`
67
67
  * is the /topics distill trigger: same lane, same in-flight guard.
68
+ * `boot-replay` is the session-start backlog drain: a previous exit's
69
+ * skipped (or killed-mid-run) distill replays here.
68
70
  */
69
71
  request(sessionId, reason) {
70
72
  if (!this.configured)
package/lib/git.d.ts CHANGED
@@ -51,7 +51,10 @@ export interface PullOutcome {
51
51
  * `pull --rebase` with conflict containment: on conflict we abort the rebase
52
52
  * (ADR 0003 — no automatic smart merging) and report the conflicted paths so
53
53
  * the caller can mark those topics. Remote and branch are explicit — the
54
- * bundle never depends on upstream tracking config.
54
+ * bundle never depends on upstream tracking config. IDENTITY rides along:
55
+ * a rebase REPLAYS local commits, which requires a committer identity, and
56
+ * a fresh machine (CI, new host) may have none — without this the pull
57
+ * fails with an empty conflicted list on every session start.
55
58
  */
56
59
  export declare function pullRebase(cwd: string, token?: string, remote?: string, branch?: string): Promise<PullOutcome>;
57
60
  export declare function push(cwd: string, token?: string, remote?: string, branch?: string): Promise<{
package/lib/git.js CHANGED
@@ -117,10 +117,13 @@ export async function fileAtRev(cwd, path, rev) {
117
117
  * `pull --rebase` with conflict containment: on conflict we abort the rebase
118
118
  * (ADR 0003 — no automatic smart merging) and report the conflicted paths so
119
119
  * the caller can mark those topics. Remote and branch are explicit — the
120
- * bundle never depends on upstream tracking config.
120
+ * bundle never depends on upstream tracking config. IDENTITY rides along:
121
+ * a rebase REPLAYS local commits, which requires a committer identity, and
122
+ * a fresh machine (CI, new host) may have none — without this the pull
123
+ * fails with an empty conflicted list on every session start.
121
124
  */
122
125
  export async function pullRebase(cwd, token, remote = 'origin', branch = 'main') {
123
- const r = await runGit(['pull', '--rebase', '--autostash', remote, branch], { cwd, token, mayFail: true, timeoutMs: 60_000 }).catch((e) => {
126
+ const r = await runGit([...IDENTITY, 'pull', '--rebase', '--autostash', remote, branch], { cwd, token, mayFail: true, timeoutMs: 60_000 }).catch((e) => {
124
127
  return { code: -1, stdout: '', stderr: String(e instanceof Error ? e.message : e) };
125
128
  });
126
129
  if (r.code === 0)
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.d.ts CHANGED
@@ -18,13 +18,13 @@
18
18
  import type { Context } from '@deepseek-ai/cordis';
19
19
  export declare const name = "dsh-topics-memory";
20
20
  /**
21
- * Bounded wait for the process-exit distill. Session teardown is the last
22
- * distill trigger, but the disposer used to fire it fire-and-forget — process
23
- * exit always won the race and the run died unwritten. The disposer now waits
24
- * for the run, capped so a pathological (hanging) model call can never wedge
25
- * the host's exit.
21
+ * Bounded wait for the process-exit meta commit. The exit path is
22
+ * local-only: no pull/push, no model call — the deferred push rides the
23
+ * next boot's pull and the skipped exit distill is replayed there. The
24
+ * window only bounds the local `git commit` against a pathological stall
25
+ * (a wedged index.lock, a hung filesystem), which should never fire.
26
26
  */
27
- export declare const EXIT_DISTILL_TIMEOUT_MS = 90000;
27
+ export declare const EXIT_COMMIT_TIMEOUT_MS = 10000;
28
28
  /**
29
29
  * Resolve when `p` settles (rejection swallowed) or after `ms`, whichever
30
30
  * comes first. The cap timer is unref'd: an otherwise-idle event loop exits
package/lib/index.js CHANGED
@@ -11,13 +11,13 @@ import { CONFIG_KEYS, TopicsConfig } from "./config.js";
11
11
  import { isDelegated } from "./delegation.js";
12
12
  export const name = 'dsh-topics-memory';
13
13
  /**
14
- * Bounded wait for the process-exit distill. Session teardown is the last
15
- * distill trigger, but the disposer used to fire it fire-and-forget — process
16
- * exit always won the race and the run died unwritten. The disposer now waits
17
- * for the run, capped so a pathological (hanging) model call can never wedge
18
- * the host's exit.
14
+ * Bounded wait for the process-exit meta commit. The exit path is
15
+ * local-only: no pull/push, no model call — the deferred push rides the
16
+ * next boot's pull and the skipped exit distill is replayed there. The
17
+ * window only bounds the local `git commit` against a pathological stall
18
+ * (a wedged index.lock, a hung filesystem), which should never fire.
19
19
  */
20
- export const EXIT_DISTILL_TIMEOUT_MS = 90_000;
20
+ export const EXIT_COMMIT_TIMEOUT_MS = 10_000;
21
21
  /**
22
22
  * Resolve when `p` settles (rejection swallowed) or after `ms`, whichever
23
23
  * comes first. The cap timer is unref'd: an otherwise-idle event loop exits
@@ -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 {
@@ -301,10 +317,10 @@ export function apply(ctx) {
301
317
  // mid-pipeline deferred to THIS pipeline and never fires again.
302
318
  const slowLane = new SlowLane(service, caller, releaseSessionLlm);
303
319
  // ---- Observer (M2) ----
304
- // The process-exit disposer awaits this run (bounded): the callback below
305
- // records it synchronously when the fake 'dispose' session's end trigger
306
- // fires, so the disposer can wait for the last distill to land.
307
- let exitDistill;
320
+ // Trigger runs are fire-and-forget everywhere now — including the exit
321
+ // one. Observations are write-through on disk, so a run the process exits
322
+ // under is never lost work: its input is still undistilled and the next
323
+ // boot's replay distills it.
308
324
  const observer = new Observer(service, (sessionId, reason) => {
309
325
  // Trigger-time capture: the agent is still registered here, so its scoped
310
326
  // ctx can hand over the llm instance whose adapters are live.
@@ -321,8 +337,6 @@ export function apply(ctx) {
321
337
  // and its own settle hook does the release.
322
338
  const run = distiller.request(sessionId, reason);
323
339
  if (run !== undefined) {
324
- if (sessionId === 'dispose')
325
- exitDistill = run;
326
340
  void run.finally(() => releaseSessionLlm(sessionId)).catch(() => undefined);
327
341
  }
328
342
  });
@@ -445,36 +459,63 @@ export function apply(ctx) {
445
459
  releaseSessionLlm(sessionId);
446
460
  }));
447
461
  }
448
- // ---- Sync lifecycle: pull on session start, flush on dispose ----
462
+ // ---- Sync lifecycle: pull on session start, local commit on dispose ----
449
463
  ctx.on('session/event', ((session, event) => {
450
464
  if (event.type === 'agent/session-start') {
451
465
  void sync
452
466
  .pull()
453
467
  .catch(() => undefined)
454
- .finally(() => store.ensure().catch(() => undefined));
468
+ .finally(() => store.ensure().catch(() => undefined))
469
+ .then(() => {
470
+ // Boot replay: the observations JSONL is the durable distill queue.
471
+ // Whatever the previous exit skipped (local-only exit since the
472
+ // no-network exit commit) or a killed run left unmarked is still
473
+ // undistilled here. Empty pool and no-model are cheap no-ops inside
474
+ // the distiller; the per-session dedup guards double runs.
475
+ void store
476
+ .undistilledObservations(1)
477
+ .then((pending) => {
478
+ if (pending.length === 0)
479
+ return;
480
+ // Same trigger-time capture the observer callback does: the
481
+ // replay run's caller resolves candidates lazily and a fresh
482
+ // session has no prior capture to lean on.
483
+ try {
484
+ captureFromAgent(agents()?.get(session.id));
485
+ }
486
+ catch {
487
+ // contained — the run below fails with the readable
488
+ // no-adapter detail instead
489
+ }
490
+ const run = distiller.request(String(session.id), 'boot-replay');
491
+ if (run !== undefined)
492
+ void run.catch(() => undefined);
493
+ })
494
+ .catch(() => undefined);
495
+ });
455
496
  }
456
497
  }));
457
498
  ctx.effect(() => {
458
499
  void store.ensure().catch(() => undefined);
459
500
  // Async disposer: cordis awaits it during unload (Disposable may be
460
- // async), so the exit distill gets its bounded window before the
461
- // process goes away instead of always losing the exit race.
501
+ // async). The exit path is local-only — one git commit of the meta
502
+ // sidecars (observations / injection log / distill state), no pull, no
503
+ // push, no model call. The deferred push rides the next boot's pull
504
+ // (sync.pull replays it) and the skipped exit distill is replayed
505
+ // there too; both only ever needed the on-disk state, which the commit
506
+ // below makes durable before the process goes away.
462
507
  return () => {
463
508
  // A real session's session-end run (agent/disposed trigger, moments
464
509
  // earlier) may still be in flight — and the fake-'dispose' run would
465
510
  // feed the SAME global pool head to the model a second time (double
466
511
  // evaluation, double GC attempts). Skip the exit trigger while any
467
- // run is pending; the bounded wait below then simply has nothing
468
- // extra to await.
512
+ // run is pending; its capture is already write-through on disk.
469
513
  if (!distiller.hasAnyPending()) {
470
514
  observer.onSessionEvent('dispose', 'agent/disposed', undefined);
471
515
  }
472
516
  sync.dispose();
473
- // The exit flush (final meta commit + ledger flush) rides the SAME
474
- // bounded window as the exit distill — fire-and-forget here loses the
475
- // exit race that window was built to win.
476
- const exitFlush = sync.commitMeta().then(() => sync.flush()).catch(() => undefined);
477
- return settleBounded(exitDistill === undefined ? exitFlush : Promise.allSettled([exitDistill, exitFlush]), EXIT_DISTILL_TIMEOUT_MS);
517
+ const exitFlush = sync.commitMeta().catch(() => undefined);
518
+ return settleBounded(exitFlush, EXIT_COMMIT_TIMEOUT_MS);
478
519
  };
479
520
  }, 'topics: lifecycle');
480
521
  // ---- /topics command (optional peer) ----
@@ -557,6 +598,7 @@ const DEFAULTS = {
557
598
  repo: '',
558
599
  autoInject: true,
559
600
  injectDedup: true,
601
+ suppressEcho: true,
560
602
  topK: 4,
561
603
  perTopicBudget: 300,
562
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;