dsh-memoir 0.5.1 → 0.5.3

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.en.md CHANGED
@@ -81,9 +81,11 @@ need long-tail history? memoir_read (local relevance-ranked recall)
81
81
 
82
82
  **Session Snapshot freezing semantics**: one session's injected text is built once and frozen (stable prompt prefix, maximizing prompt-prefix cache hits); the current session does not re-consume memory it just wrote, and a new session rebuilds and sees the latest memory. Since v0.4.2, when there is no unique session identity (session.id / agent.id), freezing is skipped — a cache miss beats wrongly reusing another session's snapshot.
83
83
 
84
- ## v0.5.1 lifecycle completion and rc8 compatibility
84
+ ## v0.5.3 Web-configurable auto-distill and rc2 compatibility
85
85
 
86
- - The development and peer-dependency baseline is `@deepseek-ai/dsh-* 0.1.0-rc.8`.
86
+ - The development and peer-dependency baseline is `@deepseek-ai/dsh-* 0.1.1-rc.2`.
87
+ - Auto-distill now supports per-agent worked-turn intervals, time cooldowns, and tool-call thresholds; defaults `1 / 0 / 1` preserve prior behavior.
88
+ - The Web panel now includes Auto-distill Settings for enabling/disabling and editing those three parameters. Saves apply to subsequent turns immediately and persist in `~/.dsh/dsh-memoir.settings.json`; one action removes the Web override and restores the profile values captured at startup.
87
89
  - Store format v3 migrates v2 entries without changing their `id`, content, or timestamp. The first mutation materializes `importance`, `pinned`, `status`, `supersedes`, and `tags`; startup reads do not rewrite old files.
88
90
  - Retrieval defaults to `active`. Archived and superseded history is retained and can be inspected from the Web panel. Explicit `supersedes` marks its targets as superseded; history is never deleted automatically.
89
91
  - Agents can use `memoir_update` to edit an entry's section, title, content, and lifecycle in place; the Web panel also supports editing, pinning, marking superseded, archiving, and restoring.
@@ -116,11 +118,12 @@ Curated-query Top-5 hit rate: 100% (quality gate ≥ 90%, see `test/recall-quali
116
118
 
117
119
  ## GUI
118
120
 
119
- The v0.4 Project / Global / Search / Add / Delete / Diagnostics architecture is kept; since v0.4.2:
121
+ The v0.4 Project / Global / Search / Add / Delete / Diagnostics architecture is kept and extended:
120
122
 
121
123
  - **Search unified on RetrievalEngine**: a non-empty query calls `GET /api/dsh-memoir/search` — the same BM25 ranking as the agent's `memoir_read` — results ordered by relevance with scores shown
122
124
  - **Hot Memory Inspector**: expand to see the Hot Memory that will actually be injected for the current workspace (Actions / Lessons / Recent state) — i.e. "what exactly the next session inherits"
123
125
  - **Retrieval Diagnostics**: Retrieval Index (docs/terms/epoch), Query Cache (hits/misses/evictions/hit rate/size/capacity), Last Query (latency/returned), Session Snapshot (hash/createdAt/storeRevision)
126
+ - **Auto-distill Settings (v0.5.3)**: enable or disable auto-distill and edit the worked-turn interval, cooldown minutes, and minimum tool calls in the panel; validated saves persist and apply immediately without restarting DSH
124
127
 
125
128
  ## Screenshots
126
129
 
@@ -148,6 +151,7 @@ The v0.4 Project / Global / Search / Add / Delete / Diagnostics architecture is
148
151
 
149
152
  ```text
150
153
  ~/.dsh/dsh-memoir.json ← structured JSON (single source of truth / SSOT)
154
+ ~/.dsh/dsh-memoir.settings.json ← auto-distill overrides saved by the Web panel
151
155
  <workspace>/PROJECT_MEMORY.md ← human-readable projection regenerated from the JSON (git-friendly)
152
156
 
153
157
  No cloud memory DB · No embedding API · No vector DB
@@ -167,6 +171,9 @@ Add a `config` block on the plugin row in `cordis.patch.yml` (all optional; defa
167
171
  enabled: true # master switch (tools, routes, prompt section)
168
172
  announceToAgent: true # system-prompt announcement section
169
173
  autoDistill: true # auto distill reminder after each worked turn
174
+ autoDistillEvery: 1 # remind at most once per N worked turns
175
+ autoDistillCooldownMin: 0 # require M minutes between successful reminders
176
+ autoDistillMinTools: 1 # triggering turn must contain at least K tool calls
170
177
  hotMemoryTokens: 900 # Hot Memory target tokens
171
178
  hotMemoryMaxTokens: 1200 # Hot Memory hard ceiling (never exceeded)
172
179
  readDefaultLimit: 8 # memoir_read default result count
@@ -175,6 +182,10 @@ Add a `config` block on the plugin row in `cordis.patch.yml` (all optional; defa
175
182
  queryCacheSize: 128 # ranked-query LRU cache size
176
183
  ```
177
184
 
185
+ The three auto-distill frequency conditions are combined with AND and isolated per agent. Idle, aborted, subagent, and prior-`memoir_record` turns do not advance the interval. A worked turn below `autoDistillMinTools` advances the interval but cannot trigger by itself. Cooldown changes only after a successful steer.
186
+
187
+ The auto-distill fields in `cordis.patch.yml` are startup defaults. Since v0.5.3, the Auto-distill Settings section at the bottom of the Memory panel can override them: saving atomically writes `~/.dsh/dsh-memoir.settings.json`, and subsequent turns read the new policy immediately. Restore Startup Config removes that override and returns to the profile values resolved when the plugin mounted. Other Hot Memory, recall, and cache settings remain profile-managed.
188
+
178
189
  ## Design Trade-offs
179
190
 
180
191
  - **Bounded vs full injection**: v0.3 injected the full history into the prompt and it kept growing; v0.4+ injects only budgeted Hot Memory, with long-tail history recalled on demand. Token benchmarks below.
@@ -183,6 +194,7 @@ Add a `config` block on the plugin row in `cordis.patch.yml` (all optional; defa
183
194
  - **Multi-process safety**: store record/remove runs inside a cross-process critical section on `~/.dsh/dsh-memoir.lock` (exclusive O_EXCL creation with timeout); the section force-reloads from disk before mutating, so two interleaved DSH processes lose no updates (v0.4.2).
184
195
  - **Windows paths**: canonical keys are fully lowercased (`C:\A` / `c:\a\` / `C:/A` share one bucket) while display paths keep the original casing (v0.4.2).
185
196
  - **GUI and Agent share one engine**: panel search and `memoir_read` use the same RetrievalEngine instead of separate filter logic (v0.4.2).
197
+ - **Auto-distill cadence**: the default still reminds after every worked turn; research-heavy sessions can combine interval, cooldown, and activity thresholds and tune them immediately from the Web panel (v0.5.3).
186
198
 
187
199
  ## Use Cases
188
200
 
@@ -213,7 +225,7 @@ Each plugin has its own focus — pick per need; no "which is stronger" narrativ
213
225
  pnpm install # install devDeps (typescript, esbuild, @deepseek-ai/* type packages)
214
226
  pnpm run build # tsc builds the host + esbuild builds the client bundle
215
227
  pnpm run typecheck # full type check (src + test)
216
- pnpm test # 142 tests: store (incl. multi-process lock) / snapshot / selector / retrieval / tools / routes / auto-distill / integration / client pure logic / bundle protocol & purity / release notes
228
+ pnpm test # 154 tests: store (incl. multi-process lock) / settings / snapshot / selector / retrieval / tools / routes / auto-distill / integration / client pure logic / bundle protocol & purity / release notes
217
229
  npm run bench # benchmark (100/1k/10k/100k entries); results written to bench/report.md
218
230
  ```
219
231
 
@@ -230,7 +242,7 @@ v0.4.2 benchmark summary (node v22.23.2, budget 900/1200 tokens; full report in
230
242
 
231
243
  ## Implementation
232
244
 
233
- - **Full-stack TypeScript**: `src/host/*.ts` (store / tools / retrieval / selector / snapshot / routes / autodistill / index — tsc emits `lib/*.js`) + `src/client/*.ts(x)` (esbuild emits the `lib/client.js` closure-factory bundle).
245
+ - **Full-stack TypeScript**: `src/host/*.ts` (store / settings / tools / retrieval / selector / snapshot / routes / autodistill / index — tsc emits `lib/*.js`) + `src/client/*.ts(x)` (esbuild emits the `lib/client.js` closure-factory bundle).
234
246
  - **Two-sided plugin**: the host half registers the agent tools, `/api/dsh-memoir` routes, the `agent/turn-stopping` auto-distill listener, and the per-project system-prompt injection section; the client half renders the panel. Runtime deps are official NPM SDK packages only.
235
247
  - Mounted via the `dsh.bundle.patch` manifest (`insert` row in `cordis.patch.yml`); no DSH source changes.
236
248
  - Auto-distill safety boundaries: top-level sessions only (subagents / nested delegations skipped), turns with tool activity that haven't recorded yet, aborted turns skipped, at most one steer per turn.
@@ -247,7 +259,7 @@ Bug reports must include screenshot / log evidence, a smoke test, code reference
247
259
 
248
260
  ## Release
249
261
 
250
- Current stable release: **v0.5.1** (2026-08-20) · [GitHub Release](https://github.com/Qinling-Melon-Farmers/dsh-memoir/releases/tag/v0.5.1) · [npm](https://www.npmjs.com/package/dsh-memoir/v/0.5.1). Full history is in [CHANGELOG.md](./CHANGELOG.md).
262
+ Current stable release: **v0.5.3** (2026-08-22) · [GitHub Release](https://github.com/Qinling-Melon-Farmers/dsh-memoir/releases/tag/v0.5.3) · [npm](https://www.npmjs.com/package/dsh-memoir/v/0.5.3). Full history is in [CHANGELOG.md](./CHANGELOG.md).
251
263
 
252
264
  Every version keeps Chinese and English release notes in sync. GitHub Releases show Chinese by default and place the English notes in a collapsible `English` section.
253
265
 
package/README.md CHANGED
@@ -81,9 +81,11 @@ memoir_record 沉淀工作 / 教训 / 下一步
81
81
 
82
82
  **Session Snapshot 冻结语义**:同一 session 的注入文本只构建一次并冻结(prompt 前缀稳定,最大化 prompt-prefix cache 命中);当前 session 不重新消费自己刚写的记忆,新 session 重建并看到最新记忆。v0.4.2 起,没有唯一会话身份(session.id / agent.id)时**不做冻结**——宁可 cache miss,不可跨 session 错复用旧快照。
83
83
 
84
- ## v0.5.1 生命周期完成与 rc8 兼容性
84
+ ## v0.5.3 Web 可调自动收尾与 rc2 兼容性
85
85
 
86
- - 当前开发基线为 `@deepseek-ai/dsh-* 0.1.0-rc.8`;peer dependency 与开发依赖已统一到 rc8。
86
+ - 当前开发基线为 `@deepseek-ai/dsh-* 0.1.1-rc.2`;peer dependency 与开发依赖已统一到 rc2。
87
+ - 自动收尾支持按 agent 配置 worked-turn 间隔、时间冷却和工具调用阈值;默认值 `1 / 0 / 1` 与旧版行为一致。
88
+ - Web 面板新增「自动蒸馏设置」,可启停并修改上述三个参数;保存后立即作用于后续回合,并持久化到 `~/.dsh/dsh-memoir.settings.json`。可一键删除 Web 覆盖并恢复本次启动时的 profile 配置。
87
89
  - 存储格式从 v2 迁移到 v3:旧条目保持原有 `id`、内容和时间,首次变更时补齐 `importance`、`pinned`、`status`、`supersedes` 与 `tags`;启动读取不会重写旧文件。
88
90
  - 默认只召回 `active` 条目;归档和被替代条目保留在历史中,可在 Web 面板切换状态查看。显式 `supersedes` 会把目标条目标记为 `superseded`,不会自动删除历史。
89
91
  - Agent 可用 `memoir_update` 原地编辑条目的分类、标题、正文和生命周期;Web 面板也支持编辑、置顶、标记过时、归档与恢复。
@@ -116,11 +118,12 @@ curated 查询 Top-5 命中率 100%(质量门禁 ≥90%,见 `test/recall-qua
116
118
 
117
119
  ## GUI
118
120
 
119
- 保留 v0.4 的 Project / Global / Search / Add / Delete / Diagnostics 架构,v0.4.2 起:
121
+ 保留 v0.4 的 Project / Global / Search / Add / Delete / Diagnostics 架构,并持续扩展:
120
122
 
121
123
  - **搜索统一走 RetrievalEngine**:query 非空时面板调用 `GET /api/dsh-memoir/search`,与 agent 的 `memoir_read` 共用同一套 BM25 排序,结果按相关性排列并显示分数
122
124
  - **Hot Memory Inspector**:展开查看当前工作区实际会被注入的 Hot Memory(Actions / Lessons / Recent state),即「下一会话到底自动继承什么」
123
125
  - **Retrieval Diagnostics**:Retrieval Index(docs/terms/epoch)、Query Cache(hits/misses/evictions/hit rate/size/capacity)、Last Query(latency/returned)、Session Snapshot(hash/createdAt/storeRevision)
126
+ - **自动蒸馏设置(v0.5.3)**:面板内启停 auto-distill,编辑 worked-turn 间隔、冷却分钟数和最低工具调用数;严格校验后持久化并即时应用,无需重启 DSH
124
127
 
125
128
  ## 界面预览
126
129
 
@@ -148,6 +151,7 @@ curated 查询 Top-5 命中率 100%(质量门禁 ≥90%,见 `test/recall-qua
148
151
 
149
152
  ```text
150
153
  ~/.dsh/dsh-memoir.json ← 结构化 JSON(唯一事实源 / SSOT)
154
+ ~/.dsh/dsh-memoir.settings.json ← Web 面板保存的自动蒸馏覆盖设置
151
155
  <工作区>/PROJECT_MEMORY.md ← 由 JSON 重新生成的人类可读投影(git 友好)
152
156
 
153
157
  No cloud memory DB · No embedding API · No vector DB
@@ -167,6 +171,9 @@ JSON 是 source of truth,Markdown 是 generated projection:面板、工具
167
171
  enabled: true # 总开关(工具、路由、注入段)
168
172
  announceToAgent: true # system prompt 公告段
169
173
  autoDistill: true # 每轮有实际工作的回合结束自动提醒归纳
174
+ autoDistillEvery: 1 # 每 N 个 worked turn 最多提醒一次
175
+ autoDistillCooldownMin: 0 # 两次成功提醒之间至少间隔 M 分钟
176
+ autoDistillMinTools: 1 # 触发提醒的 turn 至少包含 K 次工具调用
170
177
  hotMemoryTokens: 900 # Hot Memory 目标 token 数
171
178
  hotMemoryMaxTokens: 1200 # Hot Memory 硬上限(永不超过)
172
179
  readDefaultLimit: 8 # memoir_read 默认返回条数
@@ -175,6 +182,10 @@ JSON 是 source of truth,Markdown 是 generated projection:面板、工具
175
182
  queryCacheSize: 128 # 排序查询的 LRU 缓存大小
176
183
  ```
177
184
 
185
+ 三个 auto-distill 频率条件按 AND 关系判定并按 agent 隔离。idle、aborted、subagent、已调用 `memoir_record` 的 turn 不推进间隔计数;低于 `autoDistillMinTools` 的 worked turn 会推进间隔,但自身不能触发提醒。冷却只在 steer 成功后更新。
186
+
187
+ `cordis.patch.yml` 中的 auto-distill 字段是启动默认值。v0.5.3 起,可在记忆面板底部展开「自动蒸馏设置」进行修改:保存会原子写入 `~/.dsh/dsh-memoir.settings.json`,后续 turn 立即读取新策略;「恢复启动配置」会删除该覆盖文件并回到本次插件挂载时解析出的 profile 值。其余 Hot Memory、召回和缓存参数仍由 profile 配置。
188
+
178
189
  ## Design Trade-offs
179
190
 
180
191
  - **有界注入 vs 全量注入**:v0.3 把完整历史注入 prompt,越用越膨胀;v0.4+ 只注入预算内的 Hot Memory,长尾历史按需召回。token 基准见下方 Benchmark。
@@ -183,6 +194,7 @@ JSON 是 source of truth,Markdown 是 generated projection:面板、工具
183
194
  - **多进程安全**:store 的 record/remove 走 `~/.dsh/dsh-memoir.lock` 跨进程临界区(O_EXCL 独占创建 + 超时),临界区内强制从磁盘重读再改,两个 DSH 进程交错写入不丢更新(v0.4.2)。
184
195
  - **Windows 路径**:canonical key 全小写(`C:\A` / `c:\a\` / `C:/A` 一个桶),display path 保留原始大小写(v0.4.2)。
185
196
  - **GUI 与 Agent 同源**:面板搜索与 `memoir_read` 共用 RetrievalEngine,不再各写一套过滤逻辑(v0.4.2)。
197
+ - **自动收尾节奏**:默认仍逐 worked turn 提醒;研究型会话可组合轮次间隔、冷却与活动阈值降低打扰,并从 Web 面板即时调节(v0.5.3)。
186
198
 
187
199
  ## Use Cases
188
200
 
@@ -213,7 +225,7 @@ JSON 是 source of truth,Markdown 是 generated projection:面板、工具
213
225
  pnpm install # 安装 devDeps(typescript、esbuild、@deepseek-ai/* 类型包)
214
226
  pnpm run build # tsc 构建 host + esbuild 构建 client bundle
215
227
  pnpm run typecheck # 全量类型检查(src + test)
216
- pnpm test # 142 项测试:store(含多进程锁) / snapshot / selector / retrieval / tools / routes / 自动收尾 / 集成 / client 纯逻辑 / bundle 协议与纯净性 / 发布说明
228
+ pnpm test # 154 项测试:store(含多进程锁) / settings / snapshot / selector / retrieval / tools / routes / 自动收尾 / 集成 / client 纯逻辑 / bundle 协议与纯净性 / 发布说明
217
229
  npm run bench # benchmark(100/1k/10k/100k 条目),结果写入 bench/report.md
218
230
  ```
219
231
 
@@ -230,7 +242,7 @@ v0.4.2 benchmark 摘要(node v22.23.2,budget 900/1200 tokens;完整报告
230
242
 
231
243
  ## 实现说明
232
244
 
233
- - **TypeScript 全栈**:`src/host/*.ts`(store / tools / retrieval / selector / snapshot / routes / autodistill / index,tsc 构建出 `lib/*.js`)+ `src/client/*.ts(x)`(esbuild 打出 `lib/client.js` 闭包工厂 bundle)。
245
+ - **TypeScript 全栈**:`src/host/*.ts`(store / settings / tools / retrieval / selector / snapshot / routes / autodistill / index,tsc 构建出 `lib/*.js`)+ `src/client/*.ts(x)`(esbuild 打出 `lib/client.js` 闭包工厂 bundle)。
234
246
  - **双面插件**:host 半注册 agent 工具、`/api/dsh-memoir` 路由、`agent/turn-stopping` 自动收尾监听与按项目求值的 system prompt 注入段;client 半提供面板。运行时仅依赖官方 NPM SDK。
235
247
  - 通过 `dsh.bundle.patch` manifest(`cordis.patch.yml` 的 `insert` 行)挂载,不改 DSH 源码。
236
248
  - 自动收尾安全边界:仅顶级会话(跳过 subagent / 嵌套委托)、仅「有工具调用且未记录过」的回合、已中止回合不打扰、每回合至多一次。
@@ -248,7 +260,7 @@ PR 请先提 Issue 讨论。
248
260
 
249
261
  ## Release
250
262
 
251
- 当前稳定版:**v0.5.1**(2026-08-20) · [GitHub Release](https://github.com/Qinling-Melon-Farmers/dsh-memoir/releases/tag/v0.5.1) · [npm](https://www.npmjs.com/package/dsh-memoir/v/0.5.1)。完整历史见 [CHANGELOG.md](./CHANGELOG.md)。
263
+ 当前稳定版:**v0.5.3**(2026-08-22) · [GitHub Release](https://github.com/Qinling-Melon-Farmers/dsh-memoir/releases/tag/v0.5.3) · [npm](https://www.npmjs.com/package/dsh-memoir/v/0.5.3)。完整历史见 [CHANGELOG.md](./CHANGELOG.md)。
252
264
 
253
265
  每个版本的更新日志均同步维护中英文;GitHub Release 默认展开中文,英文说明收纳在可折叠的 `English` 区域。
254
266
 
package/cordis.patch.yml CHANGED
@@ -16,6 +16,11 @@
16
16
  # enabled: true # master switch (tools, routes, prompt section)
17
17
  # announceToAgent: true # system-prompt announcement
18
18
  # autoDistill: true # steer a distill prompt after each worked turn
19
+ # autoDistillEvery: 1 # remind at most once per N worked turns
20
+ # autoDistillCooldownMin: 0 # minimum minutes between successful reminders
21
+ # autoDistillMinTools: 1 # minimum tool calls on the triggering turn
22
+ # These auto-distill values are startup defaults. v0.5.3+ can persist a live
23
+ # Web-panel override in ~/.dsh/dsh-memoir.settings.json and restore defaults.
19
24
  - insert:
20
25
  - id: memoir
21
26
  name: dsh-memoir
@@ -19,11 +19,13 @@ export interface TurnEventLike {
19
19
  type: string;
20
20
  data?: unknown;
21
21
  }
22
- /** Scan the tail of a session log for one turn's tool activity. */
23
- export declare function turnActivity(events: readonly TurnEventLike[], turn: number): {
22
+ export interface TurnActivity {
24
23
  worked: boolean;
25
24
  recorded: boolean;
26
- };
25
+ toolCalls: number;
26
+ }
27
+ /** Scan the tail of a session log for one turn's tool activity. */
28
+ export declare function turnActivity(events: readonly TurnEventLike[], turn: number): TurnActivity;
27
29
  /** The agent surface the turn-stopping listener needs. */
28
30
  export interface AutoDistillAgentLike {
29
31
  id: string;
@@ -38,11 +40,21 @@ export interface AutoDistillAgentLike {
38
40
  }
39
41
  /** Subagent sessions (and any nested delegation) never get distilled. */
40
42
  export declare function isSubagentSession(agent: AutoDistillAgentLike): boolean;
41
- /** Per-agent memory of turns already steered (with pruning). */
43
+ export interface AutoDistillPolicy {
44
+ every: number;
45
+ cooldownMs: number;
46
+ minTools: number;
47
+ }
48
+ /** Per-agent frequency, cooldown, and duplicate-turn state (with pruning). */
42
49
  export declare class AutoDistillGate {
43
- private steered;
44
- /** Claim a turn for steering; false when already claimed (or pruned-out). */
45
- consume(agentId: string, turn: number): boolean;
50
+ private states;
51
+ /**
52
+ * Consume one eligible worked turn and decide whether all policy conditions
53
+ * are ready. Duplicate events never advance the worked-turn counter.
54
+ */
55
+ consume(agentId: string, turn: number, toolCalls: number, policy: AutoDistillPolicy, now: number): boolean;
56
+ /** Record a successful steer; failed steer attempts do not start cooldown. */
57
+ recordSteer(agentId: string, now: number): void;
46
58
  /** Drop all state for one agent (disposal hygiene). */
47
59
  forget(agentId: string): void;
48
60
  }
@@ -62,4 +74,14 @@ export interface AutoDistillWire {
62
74
  */
63
75
  export declare function installAutoDistill(wire: AutoDistillWire, options: {
64
76
  enabled: () => boolean;
77
+ every?: number;
78
+ cooldownMin?: number;
79
+ minTools?: number;
80
+ /** Optional live policy source used by the Web settings panel. */
81
+ policy?: () => {
82
+ every?: number;
83
+ cooldownMin?: number;
84
+ minTools?: number;
85
+ };
86
+ now?: () => number;
65
87
  }): () => void;
@@ -19,8 +19,8 @@ export const DISTILL_PROMPT = '(dsh-memoir 自动收尾)本轮工作已结
19
19
  export const AUTO_DISTILL_PLUGIN = 'dsh-memoir';
20
20
  /** Scan the tail of a session log for one turn's tool activity. */
21
21
  export function turnActivity(events, turn) {
22
- let worked = false;
23
22
  let recorded = false;
23
+ let toolCalls = 0;
24
24
  for (let i = events.length - 1; i >= 0; i--) {
25
25
  const event = events[i];
26
26
  const data = event.data;
@@ -31,39 +31,54 @@ export function turnActivity(events, turn) {
31
31
  if (data.turn !== turn)
32
32
  continue;
33
33
  if (event.type === 'tool/call') {
34
- worked = true;
34
+ toolCalls += 1;
35
35
  if (data.name === 'memoir_record')
36
36
  recorded = true;
37
37
  }
38
38
  }
39
- return { worked, recorded };
39
+ return { worked: toolCalls > 0, recorded, toolCalls };
40
40
  }
41
41
  /** Subagent sessions (and any nested delegation) never get distilled. */
42
42
  export function isSubagentSession(agent) {
43
43
  return agent.session.header.origin === 'subagent' || (agent.session.header.delegationDepth ?? 0) > 0;
44
44
  }
45
- /** Per-agent memory of turns already steered (with pruning). */
45
+ /** Per-agent frequency, cooldown, and duplicate-turn state (with pruning). */
46
46
  export class AutoDistillGate {
47
- steered = new Map();
48
- /** Claim a turn for steering; false when already claimed (or pruned-out). */
49
- consume(agentId, turn) {
50
- let set = this.steered.get(agentId);
51
- if (set === undefined) {
52
- set = new Set();
53
- this.steered.set(agentId, set);
47
+ states = new Map();
48
+ /**
49
+ * Consume one eligible worked turn and decide whether all policy conditions
50
+ * are ready. Duplicate events never advance the worked-turn counter.
51
+ */
52
+ consume(agentId, turn, toolCalls, policy, now) {
53
+ let state = this.states.get(agentId);
54
+ if (state === undefined) {
55
+ state = { processedTurns: new Set(), workedSinceSteer: 0 };
56
+ this.states.set(agentId, state);
54
57
  }
55
- if (set.has(turn))
58
+ if (state.processedTurns.has(turn))
56
59
  return false;
57
- set.add(turn);
58
- for (const t of [...set]) {
59
- if (t < turn - 100)
60
- set.delete(t);
60
+ state.processedTurns.add(turn);
61
+ for (const value of [...state.processedTurns]) {
62
+ if (value < turn - 100)
63
+ state.processedTurns.delete(value);
61
64
  }
62
- return true;
65
+ state.workedSinceSteer += 1;
66
+ const intervalReady = state.workedSinceSteer >= policy.every;
67
+ const activityReady = toolCalls >= policy.minTools;
68
+ const cooldownReady = state.lastSteeredAt === undefined || now - state.lastSteeredAt >= policy.cooldownMs;
69
+ return intervalReady && activityReady && cooldownReady;
70
+ }
71
+ /** Record a successful steer; failed steer attempts do not start cooldown. */
72
+ recordSteer(agentId, now) {
73
+ const state = this.states.get(agentId);
74
+ if (state === undefined)
75
+ return;
76
+ state.workedSinceSteer = 0;
77
+ state.lastSteeredAt = now;
63
78
  }
64
79
  /** Drop all state for one agent (disposal hygiene). */
65
80
  forget(agentId) {
66
- this.steered.delete(agentId);
81
+ this.states.delete(agentId);
67
82
  }
68
83
  }
69
84
  /**
@@ -73,6 +88,8 @@ export class AutoDistillGate {
73
88
  */
74
89
  export function installAutoDistill(wire, options) {
75
90
  const gate = new AutoDistillGate();
91
+ const integerAtLeast = (value, fallback, minimum) => typeof value === 'number' && Number.isFinite(value) ? Math.max(minimum, Math.floor(value)) : fallback;
92
+ const numberAtLeast = (value, fallback, minimum) => typeof value === 'number' && Number.isFinite(value) ? Math.max(minimum, value) : fallback;
76
93
  return wire.on('agent/turn-stopping', (payload) => {
77
94
  if (!options.enabled())
78
95
  return;
@@ -81,14 +98,22 @@ export function installAutoDistill(wire, options) {
81
98
  return;
82
99
  if (signal.aborted)
83
100
  return;
84
- const { worked, recorded } = turnActivity(agent.session.events, turn);
101
+ const { worked, recorded, toolCalls } = turnActivity(agent.session.events, turn);
85
102
  if (!worked || recorded)
86
103
  return;
87
- if (!gate.consume(agent.id, turn))
104
+ const live = options.policy?.();
105
+ const policy = {
106
+ every: integerAtLeast(live?.every ?? options.every, 1, 1),
107
+ cooldownMs: numberAtLeast(live?.cooldownMin ?? options.cooldownMin, 0, 0) * 60_000,
108
+ minTools: integerAtLeast(live?.minTools ?? options.minTools, 1, 1),
109
+ };
110
+ const now = options.now?.() ?? Date.now();
111
+ if (!gate.consume(agent.id, turn, toolCalls, policy, now))
88
112
  return;
89
113
  agent.steer(createUserMessage({
90
114
  content: [{ type: 'text', text: DISTILL_PROMPT }],
91
115
  source: { kind: 'plugin', plugin: AUTO_DISTILL_PLUGIN },
92
116
  }));
117
+ gate.recordSteer(agent.id, now);
93
118
  });
94
119
  }
package/lib/client.js CHANGED
@@ -114,6 +114,29 @@ var MemoirApi = class {
114
114
  const response = await this.fetchImpl("/api/dsh-memoir/diagnostics" + query({ path }));
115
115
  return readEnvelope(response);
116
116
  }
117
+ /** Read the live auto-distill policy and whether it comes from Web overrides. */
118
+ async settings() {
119
+ const response = await this.fetchImpl("/api/dsh-memoir/settings");
120
+ return readEnvelope(response);
121
+ }
122
+ /** Persist and immediately apply the auto-distill policy. */
123
+ async updateSettings(settings) {
124
+ const response = await this.fetchImpl("/api/dsh-memoir/settings", {
125
+ method: "PUT",
126
+ headers: { "content-type": "application/json" },
127
+ body: JSON.stringify(settings)
128
+ });
129
+ return readEnvelope(response);
130
+ }
131
+ /** Remove the Web override and restore the profile defaults captured at boot. */
132
+ async resetSettings() {
133
+ const response = await this.fetchImpl("/api/dsh-memoir/settings", {
134
+ method: "DELETE",
135
+ headers: { "content-type": "application/json" },
136
+ body: "{}"
137
+ });
138
+ return readEnvelope(response);
139
+ }
117
140
  /**
118
141
  * Ranked search over the host RetrievalEngine (v0.4.2) — the same ranking
119
142
  * memoir_read uses, so the GUI search and the agent recall never diverge.
@@ -254,7 +277,28 @@ var dictionaries = {
254
277
  "lifecycle.supersede": "\u6807\u8BB0\u5DF2\u66FF\u4EE3",
255
278
  "lifecycle.unsupersede": "\u6062\u590D\u6D3B\u8DC3",
256
279
  "lifecycle.archive": "\u5F52\u6863",
257
- "lifecycle.restore": "\u6062\u590D"
280
+ "lifecycle.restore": "\u6062\u590D",
281
+ "settings.title": "\u81EA\u52A8\u84B8\u998F\u8BBE\u7F6E",
282
+ "settings.description": "\u4FEE\u6539\u540E\u7ACB\u5373\u4F5C\u7528\u4E8E\u540E\u7EED\u56DE\u5408\uFF0C\u5E76\u6301\u4E45\u5316\u5230\u672C\u673A DSH \u7528\u6237\u76EE\u5F55\u3002",
283
+ "settings.enabled": "\u542F\u7528\u81EA\u52A8\u84B8\u998F",
284
+ "settings.enabledHint": "\u5728\u9876\u5C42 agent \u7684\u6709\u6548\u5DE5\u4F5C\u56DE\u5408\u7ED3\u675F\u65F6\u63D0\u9192\u5F52\u7EB3\u8BB0\u5FC6\u3002",
285
+ "settings.every": "\u5DE5\u4F5C\u56DE\u5408\u95F4\u9694",
286
+ "settings.everyHint": "\u6BCF\u7D2F\u8BA1 N \u4E2A worked turn \u6700\u591A\u63D0\u9192\u4E00\u6B21\uFF08\u6700\u5C0F 1\uFF09\u3002",
287
+ "settings.cooldown": "\u51B7\u5374\u65F6\u95F4\uFF08\u5206\u949F\uFF09",
288
+ "settings.cooldownHint": "\u4E24\u6B21\u6210\u529F\u63D0\u9192\u81F3\u5C11\u95F4\u9694 M \u5206\u949F\uFF08\u6700\u5C0F 0\uFF09\u3002",
289
+ "settings.minTools": "\u6700\u4F4E\u5DE5\u5177\u8C03\u7528\u6570",
290
+ "settings.minToolsHint": "\u89E6\u53D1\u56DE\u5408\u81F3\u5C11\u5305\u542B K \u6B21\u5DE5\u5177\u8C03\u7528\uFF08\u6700\u5C0F 1\uFF09\u3002",
291
+ "settings.andHint": "\u4E09\u4E2A\u9891\u7387\u6761\u4EF6\u6309 AND \u5224\u5B9A\uFF1Bidle\u3001aborted\u3001subagent \u548C\u5DF2\u8BB0\u5F55\u8BB0\u5FC6\u7684\u56DE\u5408\u4E0D\u4F1A\u89E6\u53D1\u3002",
292
+ "settings.source": "\u5F53\u524D\u6765\u6E90",
293
+ "settings.source.profile": "\u542F\u52A8\u914D\u7F6E",
294
+ "settings.source.web": "Web \u6301\u4E45\u5316\u8986\u76D6",
295
+ "settings.save": "\u4FDD\u5B58\u5E76\u7ACB\u5373\u5E94\u7528",
296
+ "settings.reset": "\u6062\u590D\u542F\u52A8\u914D\u7F6E",
297
+ "settings.saved": "\u5DF2\u4FDD\u5B58\uFF0C\u540E\u7EED\u56DE\u5408\u7ACB\u5373\u751F\u6548\u3002",
298
+ "settings.resetDone": "\u5DF2\u6062\u590D\u672C\u6B21\u542F\u52A8\u65F6\u7684\u914D\u7F6E\u3002",
299
+ "settings.invalid": "\u8BF7\u8F93\u5165\u6709\u6548\u6570\u503C\uFF1A\u95F4\u9694\u548C\u5DE5\u5177\u6570\u4E3A\u4E0D\u5C0F\u4E8E 1 \u7684\u6574\u6570\uFF0C\u51B7\u5374\u65F6\u95F4\u4E0D\u5C0F\u4E8E 0\u3002",
300
+ "settings.loadFailed": "\u8BBE\u7F6E\u52A0\u8F7D\u5931\u8D25",
301
+ "settings.saveFailed": "\u8BBE\u7F6E\u4FDD\u5B58\u5931\u8D25"
258
302
  },
259
303
  en: {
260
304
  "entry.label": "Memory",
@@ -316,7 +360,28 @@ var dictionaries = {
316
360
  "lifecycle.supersede": "Mark superseded",
317
361
  "lifecycle.unsupersede": "Restore active",
318
362
  "lifecycle.archive": "Archive",
319
- "lifecycle.restore": "Restore"
363
+ "lifecycle.restore": "Restore",
364
+ "settings.title": "Auto-distill settings",
365
+ "settings.description": "Changes apply to subsequent turns immediately and persist in the local DSH user directory.",
366
+ "settings.enabled": "Enable auto-distill",
367
+ "settings.enabledHint": "Prompt top-level agents to distill memory after eligible worked turns.",
368
+ "settings.every": "Worked-turn interval",
369
+ "settings.everyHint": "Remind at most once per N accumulated worked turns (minimum 1).",
370
+ "settings.cooldown": "Cooldown (minutes)",
371
+ "settings.cooldownHint": "Require at least M minutes between successful reminders (minimum 0).",
372
+ "settings.minTools": "Minimum tool calls",
373
+ "settings.minToolsHint": "The triggering turn must contain at least K tool calls (minimum 1).",
374
+ "settings.andHint": "All three frequency conditions use AND semantics; idle, aborted, subagent, and already-recorded turns do not trigger.",
375
+ "settings.source": "Current source",
376
+ "settings.source.profile": "startup profile config",
377
+ "settings.source.web": "persistent Web override",
378
+ "settings.save": "Save and apply now",
379
+ "settings.reset": "Restore startup config",
380
+ "settings.saved": "Saved; subsequent turns use the new policy immediately.",
381
+ "settings.resetDone": "Restored the configuration captured at startup.",
382
+ "settings.invalid": "Enter valid values: interval and tool count are integers of at least 1; cooldown is at least 0.",
383
+ "settings.loadFailed": "Failed to load settings",
384
+ "settings.saveFailed": "Failed to save settings"
320
385
  }
321
386
  };
322
387
  var SECTION_KEYS = ["work", "lessons", "actions", "note"];
@@ -385,6 +450,7 @@ html[data-dsh-memoir-active] [class*="centerCol"] > *:not([data-dsh-memoir-view]
385
450
  border-radius: 6px; padding: 6px 12px; cursor: pointer; font-size: 13px;
386
451
  }
387
452
  .memoir-primary:hover { filter: brightness(1.05); }
453
+ .memoir-primary:disabled, .memoir-iconbtn:disabled { cursor: not-allowed; opacity: .45; }
388
454
 
389
455
  .memoir-body { flex: 1; overflow-y: auto; padding: 4px 14px 16px; }
390
456
  .memoir-section-title {
@@ -434,6 +500,27 @@ html[data-dsh-memoir-active] [class*="centerCol"] > *:not([data-dsh-memoir-view]
434
500
  .memoir-field textarea { min-height: 72px; resize: vertical; }
435
501
  .memoir-form-actions { display: flex; justify-content: flex-end; gap: 8px; }
436
502
 
503
+ /* v0.5.3 live auto-distill settings */
504
+ .memoir-settings { margin: 0 14px; border-top: 1px solid var(--border, rgba(0,0,0,.1)); padding-top: 8px; }
505
+ .memoir-settings-body {
506
+ display: flex; flex-direction: column; gap: 10px; margin-top: 7px; padding: 10px;
507
+ border: 1px solid var(--border, rgba(0,0,0,.1)); border-radius: 8px;
508
+ background: var(--bg-card, rgba(0,0,0,.02)); max-height: min(50vh, 430px); overflow-y: auto;
509
+ }
510
+ .memoir-settings-description, .memoir-settings-note, .memoir-settings-source { font-size: 11px; opacity: .72; line-height: 1.45; }
511
+ .memoir-settings-switch { display: flex; align-items: flex-start; gap: 8px; cursor: pointer; }
512
+ .memoir-settings-switch input { margin-top: 3px; }
513
+ .memoir-settings-switch span { display: flex; flex-direction: column; gap: 2px; }
514
+ .memoir-settings-switch small, .memoir-settings-grid small { font-size: 10px; opacity: .65; line-height: 1.35; }
515
+ .memoir-settings-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
516
+ .memoir-settings-grid .memoir-field { min-width: 0; }
517
+ .memoir-settings-grid .memoir-field > span { font-size: 11px; opacity: .8; }
518
+ .memoir-settings-feedback { padding: 0; }
519
+ .memoir-settings-success { color: var(--success, #15803d); font-size: 12px; }
520
+ @media (max-width: 760px) {
521
+ .memoir-settings-grid { grid-template-columns: 1fr; }
522
+ }
523
+
437
524
  /* Sidebar entry row (plain DOM, matches the shell's nav rows). */
438
525
  .memoir-entry-row {
439
526
  display: flex; align-items: center; gap: 8px; width: 100%;
@@ -723,6 +810,123 @@ function AddForm({ t, onSubmit, onCancel }) {
723
810
  ] })
724
811
  ] });
725
812
  }
813
+ function AutoDistillSettingsPanel({ api, t, refreshKey, onChanged }) {
814
+ const [open, setOpen] = (0, import_react.useState)(false);
815
+ const [settings, setSettings] = (0, import_react.useState)(null);
816
+ const [source, setSource] = (0, import_react.useState)("profile");
817
+ const [every, setEvery] = (0, import_react.useState)("1");
818
+ const [cooldown, setCooldown] = (0, import_react.useState)("0");
819
+ const [minTools, setMinTools] = (0, import_react.useState)("1");
820
+ const [busy, setBusy] = (0, import_react.useState)(false);
821
+ const [message, setMessage] = (0, import_react.useState)(null);
822
+ const [error, setError] = (0, import_react.useState)(null);
823
+ const applySnapshot = (snapshot) => {
824
+ setError(null);
825
+ setSettings(snapshot.settings);
826
+ setSource(snapshot.source);
827
+ setEvery(String(snapshot.settings.autoDistillEvery));
828
+ setCooldown(String(snapshot.settings.autoDistillCooldownMin));
829
+ setMinTools(String(snapshot.settings.autoDistillMinTools));
830
+ };
831
+ (0, import_react.useEffect)(() => {
832
+ let cancelled = false;
833
+ api.settings().then((value) => {
834
+ if (!cancelled) applySnapshot(value);
835
+ }).catch((e) => {
836
+ if (!cancelled) setError(`${t("settings.loadFailed")}: ${e.message}`);
837
+ });
838
+ return () => {
839
+ cancelled = true;
840
+ };
841
+ }, [api, refreshKey]);
842
+ const save = () => {
843
+ if (settings === null) return;
844
+ const parsedEvery = Number(every);
845
+ const parsedCooldown = Number(cooldown);
846
+ const parsedMinTools = Number(minTools);
847
+ if (!Number.isSafeInteger(parsedEvery) || parsedEvery < 1 || !Number.isFinite(parsedCooldown) || parsedCooldown < 0 || !Number.isSafeInteger(parsedMinTools) || parsedMinTools < 1) {
848
+ setMessage(null);
849
+ setError(t("settings.invalid"));
850
+ return;
851
+ }
852
+ setBusy(true);
853
+ setError(null);
854
+ setMessage(null);
855
+ api.updateSettings({
856
+ autoDistill: settings.autoDistill,
857
+ autoDistillEvery: parsedEvery,
858
+ autoDistillCooldownMin: parsedCooldown,
859
+ autoDistillMinTools: parsedMinTools
860
+ }).then((value) => {
861
+ applySnapshot(value);
862
+ setMessage(t("settings.saved"));
863
+ onChanged();
864
+ }).catch((e) => setError(`${t("settings.saveFailed")}: ${e.message}`)).finally(() => setBusy(false));
865
+ };
866
+ const reset = () => {
867
+ setBusy(true);
868
+ setError(null);
869
+ setMessage(null);
870
+ api.resetSettings().then((value) => {
871
+ applySnapshot(value);
872
+ setMessage(t("settings.resetDone"));
873
+ onChanged();
874
+ }).catch((e) => setError(`${t("settings.saveFailed")}: ${e.message}`)).finally(() => setBusy(false));
875
+ };
876
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "memoir-settings", children: [
877
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("button", { type: "button", className: "memoir-diagnostics-toggle", onClick: () => setOpen((value) => !value), children: [
878
+ t("settings.title"),
879
+ " ",
880
+ open ? "\u25BE" : "\u25B8"
881
+ ] }),
882
+ open ? settings === null ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "memoir-settings-body", children: error ?? "\u2026" }) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "memoir-settings-body", children: [
883
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "memoir-settings-description", children: t("settings.description") }),
884
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { className: "memoir-settings-switch", children: [
885
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
886
+ "input",
887
+ {
888
+ type: "checkbox",
889
+ checked: settings.autoDistill,
890
+ onChange: (event) => setSettings({ ...settings, autoDistill: event.target.checked })
891
+ }
892
+ ),
893
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { children: [
894
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("strong", { children: t("settings.enabled") }),
895
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("small", { children: t("settings.enabledHint") })
896
+ ] })
897
+ ] }),
898
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "memoir-settings-grid", children: [
899
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { className: "memoir-field", children: [
900
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: t("settings.every") }),
901
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", { type: "number", min: "1", step: "1", value: every, onChange: (event) => setEvery(event.target.value) }),
902
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("small", { children: t("settings.everyHint") })
903
+ ] }),
904
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { className: "memoir-field", children: [
905
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: t("settings.cooldown") }),
906
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", { type: "number", min: "0", step: "0.1", value: cooldown, onChange: (event) => setCooldown(event.target.value) }),
907
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("small", { children: t("settings.cooldownHint") })
908
+ ] }),
909
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { className: "memoir-field", children: [
910
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: t("settings.minTools") }),
911
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", { type: "number", min: "1", step: "1", value: minTools, onChange: (event) => setMinTools(event.target.value) }),
912
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("small", { children: t("settings.minToolsHint") })
913
+ ] })
914
+ ] }),
915
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "memoir-settings-note", children: t("settings.andHint") }),
916
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "memoir-settings-source", children: [
917
+ t("settings.source"),
918
+ ": ",
919
+ t(`settings.source.${source}`)
920
+ ] }),
921
+ error !== null ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "memoir-error memoir-settings-feedback", children: error }) : null,
922
+ message !== null ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "memoir-settings-success", children: message }) : null,
923
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "memoir-form-actions", children: [
924
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "memoir-iconbtn", disabled: busy || source === "profile", onClick: reset, children: t("settings.reset") }),
925
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "memoir-primary", disabled: busy, onClick: save, children: t("settings.save") })
926
+ ] })
927
+ ] }) : null
928
+ ] });
929
+ }
726
930
  function MemoirPanel({ controller, api, cwdTracker, t }) {
727
931
  const cwd = (0, import_react.useSyncExternalStore)(cwdTracker.subscribe, cwdTracker.getSnapshot);
728
932
  const [tab, setTab] = (0, import_react.useState)("project");
@@ -890,6 +1094,7 @@ function MemoirPanel({ controller, api, cwdTracker, t }) {
890
1094
  ] }, p.key);
891
1095
  }) }),
892
1096
  busy ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "memoir-empty", children: "\u2026" }) : null,
1097
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AutoDistillSettingsPanel, { api, t, refreshKey, onChanged: reload }),
893
1098
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "memoir-inspector", children: [
894
1099
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("button", { type: "button", className: "memoir-diagnostics-toggle", onClick: () => setInspectorOpen((v) => !v), children: [
895
1100
  t("inspector.title"),