dsh-plan-completion 0.1.1 → 0.1.2

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/CHANGELOG.md CHANGED
@@ -3,6 +3,30 @@
3
3
  All notable changes to this project are documented here.
4
4
  Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versioning: SemVer.
5
5
 
6
+ ## [0.1.2] - 2026-11-02
7
+
8
+ ### Added
9
+ - Plan persistence: the plan plus the last suggestion are written per session to a local file
10
+ store (`storageDir/plans/<base64url(sessionId)>.json`, atomic temp+rename). After a DSH restart,
11
+ `GET /state` hydrates the persisted plan before responding, so the previous plan/version and
12
+ suggestion are visible immediately (no model call needed).
13
+ - New config: `persistPlans` (default true) and `storageDir` (default `~/.dsh/plan-completion-data`,
14
+ `~` expanded; `DSH_PLAN_COMPLETION_STORE_DIR` also honored). `persistPlans:false` returns to
15
+ memory-only behavior.
16
+ - Persisted writes happen on every committed generation, user edit (`/steps`) and are deleted by
17
+ `/reset`; warm backfill sees the persisted plan and reports `already` instead of re-generating.
18
+ - `store.getState()` / `put(plan, {cas, suggestion})` in both memory and file stores.
19
+ - 9 new tests: store persistence semantics (memory + file, corrupt record tolerance) and a
20
+ restart-simulation integration suite (restore on mount, continue patching after restart, reset
21
+ clears disk).
22
+
23
+ ### Changed
24
+ - `npm test` now reports **120/120** tests.
25
+
26
+ ### Security
27
+ - Persisted files contain conversation-derived plan/suggestion text (plain JSON, unencrypted, no
28
+ automatic expiry); see `docs/security.md`. `persistPlans:false` disables disk persistence.
29
+
6
30
  ## [0.1.1] - 2026-11-02
7
31
 
8
32
  ### Fixed
package/README.md CHANGED
@@ -69,6 +69,14 @@ Client 侧用 `fetch(..., {credentials:'same-origin'})`;Electron 的 `dsh-app:
69
69
 
70
70
  Client 侧关键 API(实测):`inputActions.captureInsertion()` 返回 `{start,end,draftRev}`;`insertText(text, span)`;`setDraft(text)`;`useInput(s => s)` 返回 `{draft, attachmentIds, draftRev, phase, occurrences, queue}`。
71
71
 
72
+ ## 计划持久化(v0.1.2)
73
+
74
+ - 计划 + 最近一条建议按会话持久化到本地文件(`storageDir/plans/<base64url(sessionId)>.json`,原子写);
75
+ - 插件启动/打开会话时按需恢复(`GET /state` 前 hydrate),**DSH 重启后仍能看到原计划与版本**,无需等下一回合;
76
+ - 每次成功提交(含用户编辑、reset)同步落盘;`reset` 会同时删除磁盘记录;
77
+ - 默认目录 `~/.dsh/plan-completion-data`;可用 `storageDir` 改到别处,`persistPlans: false` 退回纯内存;
78
+ - 注意:存储为本地明文 JSON(含计划/建议文本,可能来自会话);不加密、不自动清理,不适合多用户共享机器。
79
+
72
80
  ## 模型路由与隐私
73
81
 
74
82
  - 仅使用宿主 `agentDefaultModel.currentSelection()` 当前默认的 provider/model,不自动改投其他已配置供应商;无可用路由时使用启发式。该默认值不一定是历史会话原先使用的模型。
@@ -90,6 +98,8 @@ Client 侧关键 API(实测):`inputActions.captureInsertion()` 返回 `{st
90
98
  maxTokensRewrite: 1200
91
99
  warmBackfill: true
92
100
  modelCalls: true # false:本插件仅用启发式,不向模型发送内容
101
+ persistPlans: true # false:计划只存内存
102
+ storageDir: '~/.dsh/plan-completion-data'
93
103
  enableDebugRoutes: false # 本地调试时才打开
94
104
  # debugDir: '<本地日志目录>' # 可选;发布版留空
95
105
  ```
@@ -100,6 +110,8 @@ Client 侧关键 API(实测):`inputActions.captureInsertion()` 返回 `{st
100
110
  | `maxTokensPatch` | 400 | patch 路径输出上限(实测只需 65~120 tok) | 真机 6 样本:输出 75~113 tok |
101
111
  | `maxTokensRewrite` | 1200 | rewrite/冷启动输出上限(要输出完整步骤表) | 700 会截断,1200 稳定 |
102
112
  | `warmBackfill` | true | 打开「插件从未观察过的旧会话」时,从持久化会话日志回填一次计划+建议 | P0-1;真机老会话回填 2.2s |
113
+ | `persistPlans` | true | 是否把计划+最近建议持久化到本地文件(DSH 重启恢复) | v0.1.2 |
114
+ | `storageDir` | `~/.dsh/plan-completion-data` | 计划文件目录;`~` 会展开为 home;也可用 `DSH_PLAN_COMPLETION_STORE_DIR` | v0.1.2 |
103
115
  | `enableDebugRoutes` | false | 是否注册 `debug/diag/theme/experiment/autojob/steps/reset` 调试路由;仍须鉴权 | 发布版最小攻击面 |
104
116
  | `modelCalls` | true | false 时关闭本插件全部模型调用,使用启发式 | 隐私控制 |
105
117
  | `debugDir` | 空 | 调试日志/实验结果的落盘目录(也可用 `DSH_PLAN_COMPLETION_DEBUG_DIR`);空则不写文件 | 可移植性 |
@@ -108,7 +120,7 @@ Client 侧关键 API(实测):`inputActions.captureInsertion()` 返回 `{st
108
120
 
109
121
  ## 安装 / 卸载
110
122
 
111
- > npm:**`dsh-plan-completion@0.1.0`** 已发布(https://www.npmjs.com/package/dsh-plan-completion)。
123
+ > npm:[`dsh-plan-completion`](https://www.npmjs.com/package/dsh-plan-completion)(版本见上方 npm badge)。
112
124
  > DSH 的 profile 安装仍以仓库 `link:` 或路径安装为准(包内是 `dsh.bundle.patch` + `dsh.client` bundle)。
113
125
 
114
126
  ```powershell
@@ -260,7 +272,7 @@ CI:`.github/workflows/test.yml`(GitHub Actions,Node 20/22 矩阵,push/PR
260
272
 
261
273
  ## 已知限制(当前)
262
274
 
263
- - 计划只存在 Host 内存,重启 DSH 后丢失(二期接 `storageDomain` / 文件存储);旧会话可通过 warm 回填,但热重载外的重启仍会清空状态;
275
+ - **计划持久化**(v0.1.2):计划+最近建议按会话落盘到 `storageDir`,DSH 重启后自动恢复(仍保留"本会话内存态"作为快路径);默认目录 `~/.dsh/plan-completion-data`,`persistPlans:false` 可退回纯内存。存储为本地明文 JSON,不加密、不自动清理;
264
276
  - 模型选择暂用默认路由推导,未做设置页三选一;
265
277
  - 计划页是 Popover,右栏形态二期(`ctx.sidebarRightTabs` 已确认对插件开放);
266
278
  - HTTP 路由依赖宿主 `connection.requestRejection`;服务缺失时返回 503,不退回未鉴权访问。已在真实 DSH 完成 v0.1.1 canary(Electron 代理请求正常、直连未认证请求 401);其他宿主/部署形态仍需各自验证;
package/cordis.patch.yml CHANGED
@@ -1,13 +1,16 @@
1
- - insert:
2
- - id: plan-completion
3
- name: 'dsh-plan-completion'
4
- config:
5
- evidenceChars: 1500
6
- maxTokensPatch: 400
7
- maxTokensRewrite: 1200
8
- warmBackfill: true
9
- modelCalls: true # false: heuristic-only; no plugin model requests
10
- # Debug endpoints (debug/diag/theme/experiment/autojob/steps/reset) stay off in production.
11
- enableDebugRoutes: false
12
- # Optional: set a local directory to persist debug logs / experiment data.
13
- # debugDir: '<local debug directory>'
1
+ - insert:
2
+ - id: plan-completion
3
+ name: 'dsh-plan-completion'
4
+ config:
5
+ evidenceChars: 1500
6
+ maxTokensPatch: 400
7
+ maxTokensRewrite: 1200
8
+ warmBackfill: true
9
+ modelCalls: true # false: heuristic-only; no plugin model requests
10
+ # Plans + last suggestion are persisted per session so a DSH restart restores the UI.
11
+ persistPlans: true
12
+ storageDir: '~/.dsh/plan-completion-data'
13
+ # Debug endpoints (debug/diag/theme/experiment/autojob/steps/reset) stay off in production.
14
+ enableDebugRoutes: false
15
+ # Optional: set a local directory to persist debug logs / experiment data.
16
+ # debugDir: '<local debug directory>'
@@ -1,6 +1,6 @@
1
1
  # 交付说明 · 会话计划补全插件(dsh-plan-completion)v0.1.0
2
2
 
3
- > 历史文档:以下保留 v0.1.0 的交付/评估/计划记录。当前未发布修复已接入鉴权、收紧模型路由及诊断数据,详见 [security.md](security.md) 和 [CHANGELOG](../CHANGELOG.md);旧记录中的“无鉴权”、原始诊断字段与独立评审不代表当前实现。
3
+ > 历史文档:以下保留 v0.1.0 的交付/评估/计划记录。**v0.1.1 已发布**(鉴权、状态一致性、隐私边界修复),详见 [security.md](security.md) 与 [CHANGELOG](../CHANGELOG.md);旧记录中的“无鉴权”、原始诊断字段与独立评审不代表当前实现。
4
4
 
5
5
  > 一期交付 · 2026-11-02 · 真机验收通过
6
6
  > 设计与实现说明见 `README.md`;设计规格见 `docs/design-v1.2.md`
@@ -80,7 +80,7 @@ plugin_manager remove_bundle target='dsh-plan-completion'
80
80
  ## 七、已知限制(v0.1.1 现状)
81
81
 
82
82
  1. **旧会话回填已支持**(v0.1.1):打开插件从未观察过的会话时,Client 会 POST `/plan-completion/warm`,Host 从持久化会话日志重建计划+建议(同一会话 10 分钟内只回填一次;需要一次模型调用,可用 `warmBackfill:false` 关闭)。不触发回填时仍只有 `▤` 入口。
83
- 2. **计划不持久化**:进程内存保存,DSH 重启后计划丢失,下个回合结束重新生成(二期接 `storageDomain`)。
83
+ 2. **计划持久化已支持**(v0.1.2):计划+最近建议写入 `storageDir`(默认 `~/.dsh/plan-completion-data`,明文 JSON),DSH 重启后打开会话即可恢复;`persistPlans:false` 可退回纯内存。存储不加密、不自动清理,多 profile 共用默认目录时可能互相可见(见 `docs/security.md`)。
84
84
  3. **用户编辑锁**:`editedByUser=true` 后模型不能自动 rewrite 覆盖计划,只能 patch;需要整体重构用 `/replan`。
85
85
  4. **调试路由**:`experiment|autojob|steps|theme|diag|reset|debug` 仅在 `enableDebugRoutes:true` 时注册,且与其他路由一样必须通过宿主 `connection.requestRejection`(v0.1.1 起)。
86
86
  5. **无「核对进度」按钮**:计划页当前只展示状态/证据;按需模型核对按钮在二期清单(见下)。
@@ -1,6 +1,6 @@
1
1
  # 会话计划补全插件 · 二期计划
2
2
 
3
- > 历史文档:以下保留 v0.1.0 的交付/评估/计划记录。当前未发布修复已接入鉴权、收紧模型路由及诊断数据,详见 [security.md](security.md) 和 [CHANGELOG](../CHANGELOG.md);旧记录中的“无鉴权”、原始诊断字段与独立评审不代表当前实现。
3
+ > 历史文档:以下保留 v0.1.0 的交付/评估/计划记录。**v0.1.1 已发布**(鉴权、状态一致性、隐私边界修复),详见 [security.md](security.md) 与 [CHANGELOG](../CHANGELOG.md);旧记录中的“无鉴权”、原始诊断字段与独立评审不代表当前实现。
4
4
 
5
5
  > 版本:v2.0 计划草案 · 2026-11-02
6
6
  > 依据:一期交付(见 `dsh-plan-completion/交付说明.md`)+ 本次真机反馈(常驻、计划页视觉、GitHub 发布评估)
@@ -12,13 +12,13 @@
12
12
 
13
13
  ### P0(必须,决定可用性与发布)
14
14
 
15
- > 更新(v0.1.1):P0-1(挂载回填)、P0-2(Hero 常驻)、P0-4(路由鉴权)已交付;以下保留原始规划记录,未完成项为 P0-3 / P0-5 / P0-6。
15
+ > 更新(v0.1.2):P0-1(挂载回填)、P0-2(Hero 常驻)、P0-3(计划持久化)、P0-4(路由鉴权)已交付;以下保留原始规划记录,未完成项为 P0-5 / P0-6。
16
16
 
17
17
  | # | 事项 | 现状问题 | 方案 | 验收标准 | 预估 |
18
18
  |---|---|---|---|---|---|
19
19
  | P0-1 | ✅ **挂载时回填(旧会话也能有建议)** | 插件只处理「激活后观察到 turn/end」的会话;老会话打开只有裸入口 | client 挂载时若 `state.stale && !plan`,POST `/plan-completion/warm {sessionId}`;host 用 `session.snapshotEvents` 回填一次计划+建议(可在会话无新回合时工作) | 打开任意历史会话 ≤5s 出现建议;同一会话只回填一次(按 session+seq 去重) | ✅ 已交付(v0.1.1) |
20
20
  | P0-2 | ✅ **真·常驻(hero / 新会话也有入口)** | `conversation.input.dock` 在无 Session 的 hero 页不渲染;删除全部消息后栏位消失 | 在 `shell.overlay` 增加一个随 composer 定位的轻量 `▤ 计划` 入口(hero 时显示;已有 dock 时隐藏避免重复);无计划时保留虚线提示胶囊(一期已做) | 新会话 hero、删除全部消息、旧会话、正常会话四种状态都有入口且不重叠 | ✅ 已交付(v0.1.1) |
21
- | P0-3 | **计划持久化** | 进程内存,DSH 重启丢失 | `storageDomain` 持久化 plan(按 sessionId 分 key)+ 启动时恢复 + 版本号 `gen.rev` 保留 | 重启 DSH 后同一会话仍显示原计划与版本;schema 变更可迁移 | 1d |
21
+ | P0-3 | ✅ **计划持久化** | 进程内存,DSH 重启丢失 | 本地文件持久化(`storageDir`,按 sessionId 原子写)+ 启动/打开会话时 hydrate + 版本号 `gen.rev` 保留 | 重启 DSH 后同一会话仍显示原计划与版本;schema 变更可迁移 | ✅ 已交付(v0.1.2,文件存储方案) |
22
22
  | P0-4 | ✅ **路由鉴权** | `/plan-completion/*` 调试路由无鉴权(本机回环) | 使用 `connection.requestRejection` 校验来源、或改用 `connection.fetch` 同源校验;调试路由默认仅本机、可配置关闭 | 非本机/非 DSH 页面的请求被拒;客户端不受影响 | ✅ 已交付(v0.1.1) |
23
23
  | P0-5 | **「核对进度」按钮** | 计划页只能看状态,无法对照实际进度 | 计划页底部加「核对进度」→ POST `/plan-completion/verify` → 一次模型调用生成「计划 vs 实际差异 + 建议修正」→ 展示在计划页(可折叠、可采纳) | 点击后 ≤5s 返回差异清单;采纳后生成 patch;失败有降级文案 | 1d |
24
24
  | P0-6 | **发布工程化** | 无 git 仓库 / LICENSE / CI / 包元数据、README 含本地绝对路径 | 见 `发布评估-GitHub.md` 的 P0 清单 | 全部 P0 项 PASS,`node --test` 绿,仓库可公开 | 1d |
@@ -1,6 +1,8 @@
1
1
  # GitHub 发布评估 · 会话计划补全插件(2026-11-02)
2
2
 
3
- > 历史文档:以下保留 v0.1.0 的交付/评估/计划记录。当前未发布修复已接入鉴权、收紧模型路由及诊断数据,详见 [security.md](security.md) 和 [CHANGELOG](../CHANGELOG.md);旧记录中的“无鉴权”、原始诊断字段与独立评审不代表当前实现。
3
+ > 历史文档:以下保留 v0.1.0 的交付/评估/计划记录。**v0.1.1 已发布**(鉴权、状态一致性、隐私边界修复),详见 [security.md](security.md) 与 [CHANGELOG](../CHANGELOG.md);旧记录中的“无鉴权”、原始诊断字段与独立评审不代表当前实现。
4
+ >
5
+ > v0.1.1 发布记录(2026-11-02):tag `v0.1.1` + [Release](https://github.com/Xumi0920/dsh-plan-completion/releases/tag/v0.1.1);GitHub Actions [run 41366651557](https://github.com/Xumi0920/dsh-plan-completion/actions/runs/41366651557)(Ubuntu/Windows × Node20/22 四 job 全绿);npm `dsh-plan-completion@0.1.1`(31 files,shasum `18989b0f50b72d5c8c332f3fdee7f43ccb5fd91a`)。
4
6
 
5
7
  > **2026-11-02 晚更新:P0 清单已全部完成,状态从「不满足全 PASS」→「可按 GitHub 源码仓库发布」。**
6
8
  >
package/docs/security.md CHANGED
@@ -1,6 +1,13 @@
1
1
  # 安全与隐私边界
2
2
 
3
- 本页描述 **v0.1.1** 的安全与隐私边界。npm `0.1.0` 为旧版本(无鉴权、含原始诊断字段),请以 registry 最新版为准。
3
+ 本页描述 **v0.1.2** 的安全与隐私边界。历史版本(npm `0.1.0`/`0.1.1`)不包含本页描述的全部加固,请以 registry 最新版为准。
4
+
5
+ ## 本地计划存储(v0.1.2)
6
+
7
+ - 计划与最近一条建议按会话写入 `storageDir`(默认 `~/.dsh/plan-completion-data`)下的明文 JSON 文件,用于 DSH 重启恢复;
8
+ - 文件包含计划步骤、更新原因、建议文本等**由会话内容派生的数据**;不加密、不自动过期/清理,无文件锁(单进程假设);
9
+ - 同一机器多 profile 共用默认目录时可能互相看到计划;多用户共享机器不应使用默认目录;
10
+ - `persistPlans: false` 完全关闭磁盘持久化;`storageDir` 可指向受控目录。
4
11
 
5
12
  ## 请求访问
6
13
 
package/host/store.js CHANGED
@@ -5,7 +5,9 @@
5
5
  *
6
6
  * 语义(两个 store 一致):
7
7
  * get(sessionId) → plan | null(返回副本,改它不影响存储)
8
- * put(plan, {cas}) → boolean;cas 省略=无条件写,null=仅新建,{gen,rev}=期望当前版本
8
+ * getState(sessionId) → { plan, suggestion } | null(持久化恢复用)
9
+ * put(plan, {cas,suggestion}) → boolean;cas 省略=无条件写,null=仅新建,{gen,rev}=期望当前版本;
10
+ * suggestion 存在时一并保存最近一次建议(undefined=保持不变)
9
11
  * history(sessionId) → plan[](旧 → 新,含当前版本)
10
12
  * prune(n = 5) → 每会话只保留最近 n 版
11
13
  * clear(sessionId?) → boolean | number
@@ -25,6 +27,10 @@ import { validVersion, versionEquals } from './plan.js';
25
27
  const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
26
28
  const isNonEmptyStr = (v) => typeof v === 'string' && v.trim().length > 0;
27
29
 
30
+ function hasOwn(obj, key) {
31
+ return isObj(obj) && Object.prototype.hasOwnProperty.call(obj, key);
32
+ }
33
+
28
34
  function clone(value) {
29
35
  if (value == null) return value;
30
36
  try {
@@ -69,7 +75,7 @@ function pushHistory(state, plan) {
69
75
  * @returns {object}
70
76
  */
71
77
  export function createMemoryStore() {
72
- /** @type {Map<string, {plan: object, history: object[]}>} */
78
+ /** @type {Map<string, {plan: object, suggestion: object|null, history: object[]}>} */
73
79
  const states = new Map();
74
80
 
75
81
  function get(sessionId) {
@@ -78,13 +84,21 @@ export function createMemoryStore() {
78
84
  return state && state.plan ? clone(state.plan) : null;
79
85
  }
80
86
 
87
+ function getState(sessionId) {
88
+ if (!isNonEmptyStr(sessionId)) return null;
89
+ const state = states.get(sessionId);
90
+ if (!state || !state.plan) return null;
91
+ return { plan: clone(state.plan), suggestion: state.suggestion ?? null };
92
+ }
93
+
81
94
  function put(plan, options = {}) {
82
95
  if (!lightValidate(plan)) return false;
83
- const cas = isObj(options) && Object.prototype.hasOwnProperty.call(options, 'cas') ? options.cas : undefined;
96
+ const cas = hasOwn(options, 'cas') ? options.cas : undefined;
84
97
  const sessionId = plan.sessionId;
85
- const state = states.get(sessionId) ?? { plan: null, history: [] };
98
+ const state = states.get(sessionId) ?? { plan: null, suggestion: null, history: [] };
86
99
  if (!casMatches(cas, state)) return false;
87
100
  state.plan = clone(plan);
101
+ if (hasOwn(options, 'suggestion')) state.suggestion = clone(options.suggestion) ?? null;
88
102
  pushHistory(state, plan);
89
103
  states.set(sessionId, state);
90
104
  return true;
@@ -124,7 +138,7 @@ export function createMemoryStore() {
124
138
  return [...states.keys()];
125
139
  }
126
140
 
127
- return { get, put, history, prune, clear, listSessions, size: () => states.size };
141
+ return { get, getState, put, history, prune, clear, listSessions, size: () => states.size };
128
142
  }
129
143
 
130
144
  /* ──────────────────────────────────────────────────────────────
@@ -171,15 +185,22 @@ export function createFileStore(dir) {
171
185
  const data = await readState(sessionId);
172
186
  return data && isObj(data.plan) ? clone(data.plan) : null;
173
187
  },
188
+ async getState(sessionId) {
189
+ if (!enabled || !isNonEmptyStr(sessionId)) return null;
190
+ const data = await readState(sessionId);
191
+ if (!data || !isObj(data.plan)) return null;
192
+ return { plan: clone(data.plan), suggestion: isObj(data.suggestion) ? clone(data.suggestion) : null };
193
+ },
174
194
  async put(plan, options = {}) {
175
195
  if (!enabled || !lightValidate(plan)) return false;
176
196
  try {
177
- const cas = isObj(options) && Object.prototype.hasOwnProperty.call(options, 'cas') ? options.cas : undefined;
197
+ const cas = hasOwn(options, 'cas') ? options.cas : undefined;
178
198
  const sessionId = plan.sessionId;
179
- const data = (await readState(sessionId)) ?? { sessionId, plan: null, history: [] };
199
+ const data = (await readState(sessionId)) ?? { sessionId, plan: null, suggestion: null, history: [] };
180
200
  if (!casMatches(cas, data)) return false;
181
201
  data.plan = clone(plan);
182
202
  data.sessionId = sessionId;
203
+ if (hasOwn(options, 'suggestion')) data.suggestion = clone(options.suggestion) ?? null;
183
204
  if (!Array.isArray(data.history)) data.history = [];
184
205
  const last = data.history[data.history.length - 1];
185
206
  if (last && versionEquals(last.version, plan.version)) data.history[data.history.length - 1] = clone(plan);
package/index.js CHANGED
@@ -12,6 +12,7 @@
12
12
  * (verified contract: same-origin fetch from the Electron page works and the body is readable).
13
13
  */
14
14
  import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs'
15
+ import { homedir } from 'node:os'
15
16
 
16
17
  import { DEFAULTS } from './host/config.js'
17
18
  import { requestRejection, safeDebugRecord, safeDiagnostics } from './host/security.js'
@@ -20,7 +21,7 @@ import { normalizePlan, patchPlan, rewritePlan, statusFor, applyUserEdit } from
20
21
  import { buildPatchPrompt, buildRewritePrompt, buildRepairPrompt, parseModelOutput } from './host/prompt.js'
21
22
  import { heuristicSuggestion, fallbackPlan } from './host/heuristics.js'
22
23
  import { collectFromEvents } from './host/evidence.js'
23
- import { createMemoryStore } from './host/store.js'
24
+ import { createMemoryStore, createFileStore } from './host/store.js'
24
25
  import { runExperiment } from './host/experiment.js'
25
26
  import { sliceEvidence } from './host/experiment.js'
26
27
 
@@ -43,6 +44,25 @@ function debugLog(record) {
43
44
  }
44
45
  }
45
46
 
47
+ /**
48
+ * Resolve the local plan-store directory. `~` is expanded so a shipped config can point at the
49
+ * user's home without hard-coding an absolute path. Empty means "memory only".
50
+ */
51
+ function expandStorageDir(value, envValue) {
52
+ const raw = typeof value === 'string' && value.trim() !== ''
53
+ ? value.trim()
54
+ : (typeof envValue === 'string' ? envValue.trim() : '')
55
+ if (raw === '') return ''
56
+ if (raw === '~' || raw.startsWith('~/') || raw.startsWith('~\\')) {
57
+ try {
58
+ return raw === '~' ? homedir() : join(homedir(), raw.slice(2))
59
+ } catch {
60
+ return ''
61
+ }
62
+ }
63
+ return raw
64
+ }
65
+
46
66
  function readJsonBody(req) {
47
67
  return new Promise(resolve => {
48
68
  let size = 0
@@ -232,6 +252,8 @@ export function apply(ctx, config = {}) {
232
252
  maxTokensRewrite: readInt(rawConfig.maxTokensRewrite, 1200, 400, 8000),
233
253
  warmBackfill: rawConfig.warmBackfill !== false,
234
254
  modelCalls: rawConfig.modelCalls !== false,
255
+ persistPlans: rawConfig.persistPlans !== false,
256
+ storageDir: expandStorageDir(rawConfig.storageDir, process.env.DSH_PLAN_COMPLETION_STORE_DIR),
235
257
  enableDebugRoutes: rawConfig.enableDebugRoutes === true,
236
258
  debugDir: typeof rawConfig.debugDir === 'string' && rawConfig.debugDir.trim() !== ''
237
259
  ? rawConfig.debugDir.trim()
@@ -276,7 +298,11 @@ export function apply(ctx, config = {}) {
276
298
  const warmAttempts = shared.warmAttempts // sessionId -> { at, seq }
277
299
  const inflight = new Map() // sessionId -> AbortController
278
300
  const gate = createGate(DEFAULTS.globalConcurrency)
279
- const store = shared.store
301
+ // Persistence: a file-backed plan store when a directory is configured; otherwise memory.
302
+ // The store keeps the last plan + suggestion per session so a restart can restore the UI.
303
+ const fileStore = settings.persistPlans && settings.storageDir ? createFileStore(settings.storageDir) : null
304
+ const store = fileStore ?? createMemoryStore()
305
+ shared.store = store
280
306
  let disposed = false
281
307
  const stopGeneration = () => {
282
308
  disposed = true
@@ -313,6 +339,53 @@ export function apply(ctx, config = {}) {
313
339
  states.set(sessionId, { ...previous, ...patch })
314
340
  }
315
341
 
342
+ // ── Persistence ⇄ in-memory state ────────────────────────────────────────
343
+ const hydration = new Map() // sessionId -> Promise (in-flight disk reads)
344
+
345
+ // Adopt plans retained in memory across a hot reload into the file store. `cas:null` means
346
+ // "only when the file has no plan yet", so a newer on-disk plan is never overwritten.
347
+ if (fileStore) {
348
+ for (const [sessionId, state] of states.entries()) {
349
+ if (!state?.plan) continue
350
+ void fileStore
351
+ .put(state.plan, { cas: null, suggestion: state.suggestion ?? null })
352
+ .catch(() => {})
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Load a persisted plan (+ last suggestion) into the shared in-memory state. The UI can show
358
+ * the previous plan right after a DSH restart, before the next turn runs.
359
+ */
360
+ async function hydrateSession(sessionId) {
361
+ if (!sessionId) return
362
+ if (states.get(sessionId)?.plan) return
363
+ const pending = hydration.get(sessionId)
364
+ if (pending) return pending
365
+ const task = (async () => {
366
+ try {
367
+ const stored = typeof store.getState === 'function'
368
+ ? await store.getState(sessionId)
369
+ : { plan: await store.get(sessionId), suggestion: null }
370
+ if (!stored?.plan || states.get(sessionId)?.plan) return
371
+ const normalized = normalizePlan(stored.plan)
372
+ if (!normalized?.ok || !normalized.plan) return
373
+ states.set(sessionId, {
374
+ plan: normalized.plan,
375
+ suggestion: stored.suggestion && typeof stored.suggestion === 'object' ? stored.suggestion : null,
376
+ stale: false,
377
+ })
378
+ debugLog({ phase: 'state-hydrated', sessionId })
379
+ } catch (error) {
380
+ debugLog({ phase: 'state-hydrate-error', sessionId, error: String(error) })
381
+ } finally {
382
+ hydration.delete(sessionId)
383
+ }
384
+ })()
385
+ hydration.set(sessionId, task)
386
+ return task
387
+ }
388
+
316
389
  function seedPlan(sessionId, evidence) {
317
390
  const intent = evidence?.intent || evidence?.firstUserMessage || '推进当前会话目标'
318
391
  const fallback = (() => {
@@ -429,6 +502,7 @@ export function apply(ctx, config = {}) {
429
502
  async function runWarmBackfill(sessionId, options = {}) {
430
503
  if (!settings.warmBackfill) return { ok: false, reason: 'disabled' }
431
504
  if (!sessionId) return { ok: false, reason: 'no-session-id' }
505
+ await hydrateSession(sessionId)
432
506
  if (states.get(sessionId)?.plan) return { ok: true, already: true }
433
507
  const now = Date.now()
434
508
  const attempt = warmAttempts.get(sessionId)
@@ -490,6 +564,8 @@ export function apply(ctx, config = {}) {
490
564
 
491
565
  await gate.acquire()
492
566
  try {
567
+ if (!isCurrent()) return
568
+ await hydrateSession(sessionId)
493
569
  if (!isCurrent()) return
494
570
  let events = []
495
571
  try {
@@ -499,7 +575,10 @@ export function apply(ctx, config = {}) {
499
575
  }
500
576
 
501
577
  const basePlan = states.get(sessionId)?.plan ?? null
502
- const storedVersion = store.get(sessionId)?.version ?? null
578
+ const storedPlan = typeof store.getState === 'function'
579
+ ? (await store.getState(sessionId))?.plan ?? null
580
+ : await store.get(sessionId)
581
+ const storedVersion = storedPlan?.version ?? null
503
582
  const evidence = (() => {
504
583
  try {
505
584
  const collected = collectFromEvents({ events, plan: basePlan, turn, sessionId })
@@ -668,16 +747,28 @@ export function apply(ctx, config = {}) {
668
747
  if (fallbackText) suggestion = { text: fallbackText, alternates: [] }
669
748
  }
670
749
 
750
+ const elapsed = Date.now() - startedAt
751
+ const suggestionDeadline = force ? Number.POSITIVE_INFINITY : Math.max(DEFAULTS.suggestionDeadlineMs, 6000)
752
+ const withinSuggestionDeadline = elapsed <= suggestionDeadline
753
+ const finalSuggestion = withinSuggestionDeadline ? suggestion ?? provisionalSuggestion : provisionalSuggestion
754
+
671
755
  if (nextPlan) {
672
756
  try {
673
757
  // Check CAS even for an unchanged plan; an old snapshot is never an
674
758
  // unconditional write. null also protects the first commit from races.
675
- const committed = store.put(nextPlan, { cas: storedVersion })
759
+ // The last suggestion is persisted in the same write so a restart can restore it.
760
+ const committed = await store.put(nextPlan, { cas: storedVersion, suggestion: finalSuggestion })
676
761
  if (!committed) {
677
762
  debugLog({ phase: 'store-cas-miss', sessionId })
678
- const latest = store.get(sessionId)
679
- publishState(sessionId, { plan: latest, suggestion: null, stale: true })
680
- debug.lastJob = { sessionId, turn, elapsed: Date.now() - startedAt, outcome: 'conflict', suggestion: false }
763
+ const winner = typeof store.getState === 'function'
764
+ ? await store.getState(sessionId)
765
+ : { plan: await store.get(sessionId), suggestion: null }
766
+ publishState(sessionId, {
767
+ plan: winner?.plan ?? null,
768
+ suggestion: null,
769
+ stale: Boolean(winner?.plan),
770
+ })
771
+ debug.lastJob = { sessionId, turn, elapsed, outcome: 'conflict', suggestion: false }
681
772
  return
682
773
  }
683
774
  } catch (error) {
@@ -686,10 +777,6 @@ export function apply(ctx, config = {}) {
686
777
  }
687
778
  }
688
779
 
689
- const elapsed = Date.now() - startedAt
690
- const suggestionDeadline = force ? Number.POSITIVE_INFINITY : Math.max(DEFAULTS.suggestionDeadlineMs, 6000)
691
- const withinSuggestionDeadline = elapsed <= suggestionDeadline
692
- const finalSuggestion = withinSuggestionDeadline ? suggestion ?? provisionalSuggestion : provisionalSuggestion
693
780
  publishState(sessionId, {
694
781
  plan: nextPlan,
695
782
  suggestion: finalSuggestion,
@@ -878,7 +965,9 @@ export function apply(ctx, config = {}) {
878
965
  return
879
966
  }
880
967
  if (pathname === `${ROUTE_PREFIX}state` && method === 'GET') {
881
- jsonResponse(res, 200, publicView(url?.searchParams?.get('sessionId') ?? ''))
968
+ const sessionId = url?.searchParams?.get('sessionId') ?? ''
969
+ await hydrateSession(sessionId)
970
+ jsonResponse(res, 200, publicView(sessionId))
882
971
  return
883
972
  }
884
973
  if (pathname === `${ROUTE_PREFIX}debug` && method === 'GET') {
@@ -972,7 +1061,12 @@ export function apply(ctx, config = {}) {
972
1061
  const sessionId = String(body.sessionId ?? '')
973
1062
  const existed = states.has(sessionId)
974
1063
  inflight.get(sessionId)?.abort(new Error('state-reset'))
975
- store.clear(sessionId)
1064
+ try {
1065
+ await store.clear(sessionId)
1066
+ } catch (error) {
1067
+ debugLog({ phase: 'reset-store-error', sessionId, error: String(error) })
1068
+ }
1069
+ hydration.delete(sessionId)
976
1070
  states.delete(sessionId)
977
1071
  warmAttempts.delete(sessionId)
978
1072
  handledTurns.delete(sessionId)
@@ -983,6 +1077,7 @@ export function apply(ctx, config = {}) {
983
1077
  if (pathname === `${ROUTE_PREFIX}steps` && method === 'POST') {
984
1078
  const body = (await readJsonBody(req)) ?? {}
985
1079
  const sessionId = String(body.sessionId ?? '')
1080
+ await hydrateSession(sessionId)
986
1081
  const plan = states.get(sessionId)?.plan ?? null
987
1082
  if (!plan) {
988
1083
  jsonResponse(res, 200, { ok: false, error: 'no-plan' })
@@ -1034,7 +1129,11 @@ export function apply(ctx, config = {}) {
1034
1129
  return
1035
1130
  }
1036
1131
  try {
1037
- store.put(applied.plan, { cas: plan.version })
1132
+ const committed = await store.put(applied.plan, {
1133
+ cas: plan.version,
1134
+ suggestion: states.get(sessionId)?.suggestion ?? null,
1135
+ })
1136
+ if (!committed) await store.put(applied.plan, { suggestion: states.get(sessionId)?.suggestion ?? null })
1038
1137
  } catch (error) {
1039
1138
  debugLog({ phase: 'user-edit-store-error', sessionId, error: String(error) })
1040
1139
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plan-completion",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Implicit session plans with Tab-completable next-message suggestions for DeepSeek Harness",