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 +24 -0
- package/README.md +14 -2
- package/cordis.patch.yml +16 -13
- package/docs/delivery-notes.md +2 -2
- package/docs/phase-2-plan.md +3 -3
- package/docs/publish-assessment.md +3 -1
- package/docs/security.md +8 -1
- package/host/store.js +28 -7
- package/index.js +113 -14
- package/package.json +1 -1
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
|
|
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
|
-
-
|
|
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
|
-
#
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
#
|
|
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>'
|
package/docs/delivery-notes.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 交付说明 · 会话计划补全插件(dsh-plan-completion)v0.1.0
|
|
2
2
|
|
|
3
|
-
> 历史文档:以下保留 v0.1.0
|
|
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.
|
|
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. **无「核对进度」按钮**:计划页当前只展示状态/证据;按需模型核对按钮在二期清单(见下)。
|
package/docs/phase-2-plan.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 会话计划补全插件 · 二期计划
|
|
2
2
|
|
|
3
|
-
> 历史文档:以下保留 v0.1.0
|
|
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.
|
|
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 重启丢失 |
|
|
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
|
|
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.
|
|
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
|
-
*
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
679
|
-
|
|
680
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
}
|