dsh-plan-completion 0.1.1 → 0.1.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/CHANGELOG.md +39 -0
- package/README.md +25 -5
- package/client.js +146 -4
- package/cordis.patch.yml +16 -13
- package/docs/delivery-notes.md +2 -2
- package/docs/phase-2-plan.md +4 -4
- package/docs/publish-assessment.md +5 -1
- package/docs/security.md +8 -1
- package/host/config.js +2 -0
- package/host/prompt.js +84 -0
- package/host/store.js +28 -7
- package/index.js +206 -15
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,45 @@
|
|
|
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.3] - 2026-11-02
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
- Plan page「核对进度」button (P0-5): `POST /plan-completion/verify {sessionId}` runs one bounded,
|
|
10
|
+
authenticated model call that compares the stored plan with recent evidence and returns
|
|
11
|
+
`{assessment, mismatches[], suggestion}`. It never mutates the plan; results render in the plan
|
|
12
|
+
panel as an assessment list plus next-step suggestion.
|
|
13
|
+
- New config `maxTokensVerify` (default 600); per-session in-flight guard, 8s abort deadline and
|
|
14
|
+
parse-error fallbacks; `modelCalls:false` disables this route's model call.
|
|
15
|
+
- Tests: verify prompt/parser edge cases, authenticated route integration, and a client transport
|
|
16
|
+
test for the new request helper.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- `npm test` now reports **126/126** tests; production routes are `state`/`replan`/`warm`/`verify`.
|
|
20
|
+
|
|
21
|
+
## [0.1.2] - 2026-11-02
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
- Plan persistence: the plan plus the last suggestion are written per session to a local file
|
|
25
|
+
store (`storageDir/plans/<base64url(sessionId)>.json`, atomic temp+rename). After a DSH restart,
|
|
26
|
+
`GET /state` hydrates the persisted plan before responding, so the previous plan/version and
|
|
27
|
+
suggestion are visible immediately (no model call needed).
|
|
28
|
+
- New config: `persistPlans` (default true) and `storageDir` (default `~/.dsh/plan-completion-data`,
|
|
29
|
+
`~` expanded; `DSH_PLAN_COMPLETION_STORE_DIR` also honored). `persistPlans:false` returns to
|
|
30
|
+
memory-only behavior.
|
|
31
|
+
- Persisted writes happen on every committed generation, user edit (`/steps`) and are deleted by
|
|
32
|
+
`/reset`; warm backfill sees the persisted plan and reports `already` instead of re-generating.
|
|
33
|
+
- `store.getState()` / `put(plan, {cas, suggestion})` in both memory and file stores.
|
|
34
|
+
- 9 new tests: store persistence semantics (memory + file, corrupt record tolerance) and a
|
|
35
|
+
restart-simulation integration suite (restore on mount, continue patching after restart, reset
|
|
36
|
+
clears disk).
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
- `npm test` now reports **120/120** tests.
|
|
40
|
+
|
|
41
|
+
### Security
|
|
42
|
+
- Persisted files contain conversation-derived plan/suggestion text (plain JSON, unencrypted, no
|
|
43
|
+
automatic expiry); see `docs/security.md`. `persistPlans:false` disables disk persistence.
|
|
44
|
+
|
|
6
45
|
## [0.1.1] - 2026-11-02
|
|
7
46
|
|
|
8
47
|
### Fixed
|
package/README.md
CHANGED
|
@@ -60,15 +60,31 @@ Client ⇄ Host 唯一通道是本地 HTTP 路由(`ctx.get('webServer').regist
|
|
|
60
60
|
- `GET /plan-completion/state?sessionId=<id>&reason=<kind>&running=<值>`
|
|
61
61
|
- `POST /plan-completion/warm` `{sessionId}`(旧会话回填:从持久化日志重建计划+建议)
|
|
62
62
|
- `POST /plan-completion/replan` `{sessionId, instruction?}`(生产路由,须通过宿主鉴权)
|
|
63
|
+
- `POST /plan-completion/verify` `{sessionId}`(「核对进度」:一次模型调用返回差异清单,不修改计划)
|
|
63
64
|
- `GET /plan-completion/debug?sessionId=<id>`(仅显式启用调试路由后可用)
|
|
64
65
|
|
|
65
|
-
> 生产路由:`state`、`replan`、`warm`;所有路由先调用 `connection.requestRejection(req)`。
|
|
66
|
+
> 生产路由:`state`、`replan`、`warm`、`verify`;所有路由先调用 `connection.requestRejection(req)`。
|
|
66
67
|
> 调试路由:`debug`、`diag`、`theme`、`experiment`、`autojob`、`steps`、`reset` —— 仅当 `config.enableDebugRoutes: true` 时注册。
|
|
67
68
|
|
|
68
69
|
Client 侧用 `fetch(..., {credentials:'same-origin'})`;Electron 的 `dsh-app://app` 页面经主进程转发,响应体可读、自动带 `dsh-auth` cookie。
|
|
69
70
|
|
|
70
71
|
Client 侧关键 API(实测):`inputActions.captureInsertion()` 返回 `{start,end,draftRev}`;`insertText(text, span)`;`setDraft(text)`;`useInput(s => s)` 返回 `{draft, attachmentIds, draftRev, phase, occurrences, queue}`。
|
|
71
72
|
|
|
73
|
+
## 核对进度(v0.1.3)
|
|
74
|
+
|
|
75
|
+
- 计划面板底部「核对进度」按钮 → `POST /plan-completion/verify {sessionId}`(生产路由,需宿主鉴权);
|
|
76
|
+
- Host 用**一次**受控模型调用对照「当前计划 + 最近证据」,返回 `{assessment, mismatches[], suggestion}`,**不修改计划**;
|
|
77
|
+
- 面板内展示结论、差异列表与下一步建议;同一会话同时只允许一个核对请求,8s 超时/解析失败返回降级错误,`modelCalls:false` 时禁用模型调用;
|
|
78
|
+
- 输出预算 `maxTokensVerify`(默认 600);核对结果仅内存展示,不落盘。
|
|
79
|
+
|
|
80
|
+
## 计划持久化(v0.1.2)
|
|
81
|
+
|
|
82
|
+
- 计划 + 最近一条建议按会话持久化到本地文件(`storageDir/plans/<base64url(sessionId)>.json`,原子写);
|
|
83
|
+
- 插件启动/打开会话时按需恢复(`GET /state` 前 hydrate),**DSH 重启后仍能看到原计划与版本**,无需等下一回合;
|
|
84
|
+
- 每次成功提交(含用户编辑、reset)同步落盘;`reset` 会同时删除磁盘记录;
|
|
85
|
+
- 默认目录 `~/.dsh/plan-completion-data`;可用 `storageDir` 改到别处,`persistPlans: false` 退回纯内存;
|
|
86
|
+
- 注意:存储为本地明文 JSON(含计划/建议文本,可能来自会话);不加密、不自动清理,不适合多用户共享机器。
|
|
87
|
+
|
|
72
88
|
## 模型路由与隐私
|
|
73
89
|
|
|
74
90
|
- 仅使用宿主 `agentDefaultModel.currentSelection()` 当前默认的 provider/model,不自动改投其他已配置供应商;无可用路由时使用启发式。该默认值不一定是历史会话原先使用的模型。
|
|
@@ -90,8 +106,9 @@ Client 侧关键 API(实测):`inputActions.captureInsertion()` 返回 `{st
|
|
|
90
106
|
maxTokensRewrite: 1200
|
|
91
107
|
warmBackfill: true
|
|
92
108
|
modelCalls: true # false:本插件仅用启发式,不向模型发送内容
|
|
93
|
-
|
|
94
|
-
|
|
109
|
+
persistPlans: true # false:计划只存内存
|
|
110
|
+
storageDir: '~/.dsh/plan-completion-data'
|
|
111
|
+
enableDebugRoutes: false # 本地调试时才打开 # debugDir: '<本地日志目录>' # 可选;发布版留空
|
|
95
112
|
```
|
|
96
113
|
|
|
97
114
|
| 配置项 | 默认 | 作用 | 依据 |
|
|
@@ -100,6 +117,9 @@ Client 侧关键 API(实测):`inputActions.captureInsertion()` 返回 `{st
|
|
|
100
117
|
| `maxTokensPatch` | 400 | patch 路径输出上限(实测只需 65~120 tok) | 真机 6 样本:输出 75~113 tok |
|
|
101
118
|
| `maxTokensRewrite` | 1200 | rewrite/冷启动输出上限(要输出完整步骤表) | 700 会截断,1200 稳定 |
|
|
102
119
|
| `warmBackfill` | true | 打开「插件从未观察过的旧会话」时,从持久化会话日志回填一次计划+建议 | P0-1;真机老会话回填 2.2s |
|
|
120
|
+
| `persistPlans` | true | 是否把计划+最近建议持久化到本地文件(DSH 重启恢复) | v0.1.2 |
|
|
121
|
+
| `storageDir` | `~/.dsh/plan-completion-data` | 计划文件目录;`~` 会展开为 home;也可用 `DSH_PLAN_COMPLETION_STORE_DIR` | v0.1.2 |
|
|
122
|
+
| `maxTokensVerify` | 600 | 「核对进度」单次模型调用的输出预算 | v0.1.3 |
|
|
103
123
|
| `enableDebugRoutes` | false | 是否注册 `debug/diag/theme/experiment/autojob/steps/reset` 调试路由;仍须鉴权 | 发布版最小攻击面 |
|
|
104
124
|
| `modelCalls` | true | false 时关闭本插件全部模型调用,使用启发式 | 隐私控制 |
|
|
105
125
|
| `debugDir` | 空 | 调试日志/实验结果的落盘目录(也可用 `DSH_PLAN_COMPLETION_DEBUG_DIR`);空则不写文件 | 可移植性 |
|
|
@@ -108,7 +128,7 @@ Client 侧关键 API(实测):`inputActions.captureInsertion()` 返回 `{st
|
|
|
108
128
|
|
|
109
129
|
## 安装 / 卸载
|
|
110
130
|
|
|
111
|
-
> npm
|
|
131
|
+
> npm:[`dsh-plan-completion`](https://www.npmjs.com/package/dsh-plan-completion)(版本见上方 npm badge)。
|
|
112
132
|
> DSH 的 profile 安装仍以仓库 `link:` 或路径安装为准(包内是 `dsh.bundle.patch` + `dsh.client` bundle)。
|
|
113
133
|
|
|
114
134
|
```powershell
|
|
@@ -260,7 +280,7 @@ CI:`.github/workflows/test.yml`(GitHub Actions,Node 20/22 矩阵,push/PR
|
|
|
260
280
|
|
|
261
281
|
## 已知限制(当前)
|
|
262
282
|
|
|
263
|
-
-
|
|
283
|
+
- **计划持久化**(v0.1.2):计划+最近建议按会话落盘到 `storageDir`,DSH 重启后自动恢复(仍保留"本会话内存态"作为快路径);默认目录 `~/.dsh/plan-completion-data`,`persistPlans:false` 可退回纯内存。存储为本地明文 JSON,不加密、不自动清理;
|
|
264
284
|
- 模型选择暂用默认路由推导,未做设置页三选一;
|
|
265
285
|
- 计划页是 Popover,右栏形态二期(`ctx.sidebarRightTabs` 已确认对插件开放);
|
|
266
286
|
- HTTP 路由依赖宿主 `connection.requestRejection`;服务缺失时返回 503,不退回未鉴权访问。已在真实 DSH 完成 v0.1.1 canary(Electron 代理请求正常、直连未认证请求 401);其他宿主/部署形态仍需各自验证;
|
package/client.js
CHANGED
|
@@ -80,6 +80,7 @@ window.__ModuleLoader__.load({
|
|
|
80
80
|
'popover.fill': '填入草稿',
|
|
81
81
|
'popover.sync': '同步给 Agent',
|
|
82
82
|
'popover.close': '关闭',
|
|
83
|
+
'popover.verify': '核对进度',
|
|
83
84
|
'popover.empty': '暂无计划',
|
|
84
85
|
'popover.updated': '更新于 {time}',
|
|
85
86
|
'popover.stale': '计划可能已过期,将在下一回合重建',
|
|
@@ -97,7 +98,14 @@ window.__ModuleLoader__.load({
|
|
|
97
98
|
'sync.todo': '待办:{value}。',
|
|
98
99
|
'sync.reason': '最近更新:{value}。',
|
|
99
100
|
'sync.next': '下一步建议:{value}。',
|
|
100
|
-
'sync.fallback': '请先确认当前进度,再继续推进。'
|
|
101
|
+
'sync.fallback': '请先确认当前进度,再继续推进。',
|
|
102
|
+
'verify.loading': '正在对照计划与实际进度…',
|
|
103
|
+
'verify.assessment': '核对结论',
|
|
104
|
+
'verify.mismatch': '差异',
|
|
105
|
+
'verify.clean': '未发现明显差异。',
|
|
106
|
+
'verify.next': '建议:{value}',
|
|
107
|
+
'verify.failed': '核对失败:{value}',
|
|
108
|
+
'verify.no-session': '当前还没有可用会话'
|
|
101
109
|
},
|
|
102
110
|
en: {
|
|
103
111
|
'entry.aria': 'Open the session plan panel',
|
|
@@ -115,6 +123,7 @@ window.__ModuleLoader__.load({
|
|
|
115
123
|
'popover.fill': 'Fill draft',
|
|
116
124
|
'popover.sync': 'Sync to Agent',
|
|
117
125
|
'popover.close': 'Close',
|
|
126
|
+
'popover.verify': 'Verify progress',
|
|
118
127
|
'popover.empty': 'No plan yet',
|
|
119
128
|
'popover.updated': 'Updated {time}',
|
|
120
129
|
'popover.stale': 'The plan may be stale and will be rebuilt next turn',
|
|
@@ -132,7 +141,14 @@ window.__ModuleLoader__.load({
|
|
|
132
141
|
'sync.todo': 'Pending: {value}.',
|
|
133
142
|
'sync.reason': 'Latest update: {value}.',
|
|
134
143
|
'sync.next': 'Next suggestion: {value}.',
|
|
135
|
-
'sync.fallback': 'Please confirm the current progress before continuing.'
|
|
144
|
+
'sync.fallback': 'Please confirm the current progress before continuing.',
|
|
145
|
+
'verify.loading': 'Comparing the plan against the latest evidence…',
|
|
146
|
+
'verify.assessment': 'Assessment',
|
|
147
|
+
'verify.mismatch': 'Mismatch',
|
|
148
|
+
'verify.clean': 'No obvious mismatches found.',
|
|
149
|
+
'verify.next': 'Next: {value}',
|
|
150
|
+
'verify.failed': 'Verification failed: {value}',
|
|
151
|
+
'verify.no-session': 'No session available yet'
|
|
136
152
|
}
|
|
137
153
|
};
|
|
138
154
|
|
|
@@ -301,6 +317,14 @@ window.__ModuleLoader__.load({
|
|
|
301
317
|
'.dpc-pop-foot .dpc-updated{flex:1 1 auto;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
|
|
302
318
|
'.dpc-actions{padding-top:0;gap:8px;flex:none}',
|
|
303
319
|
'.dpc-btn{border-radius:999px;padding:5px 12px;font-size:12px;line-height:18px}',
|
|
320
|
+
'.dpc-verify-card{gap:6px}',
|
|
321
|
+
'.dpc-verify-loading{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}',
|
|
322
|
+
'.dpc-verify-error{font-size:12px;line-height:18px;color:var(--dsw-alias-state-error-primary, var(--dsw-alias-label-secondary));word-break:break-word}',
|
|
323
|
+
'.dpc-verify-assessment{font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary);white-space:pre-wrap;word-break:break-word}',
|
|
324
|
+
'.dpc-verify-list{margin:0;padding-left:16px;display:flex;flex-direction:column;gap:4px}',
|
|
325
|
+
'.dpc-verify-item{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary);word-break:break-word}',
|
|
326
|
+
'.dpc-verify-clean{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}',
|
|
327
|
+
'.dpc-verify-next{font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary);word-break:break-word}',
|
|
304
328
|
'.dpc-empty{padding:14px;font-size:13px}'
|
|
305
329
|
].join('\n');
|
|
306
330
|
|
|
@@ -729,6 +753,40 @@ window.__ModuleLoader__.load({
|
|
|
729
753
|
});
|
|
730
754
|
}
|
|
731
755
|
|
|
756
|
+
/**
|
|
757
|
+
* P0-5「核对进度」:让 Host 用一次受控模型调用对照计划与实际证据。
|
|
758
|
+
* 返回 Host 的 JSON({ok, assessment, mismatches, suggestion})或 null。
|
|
759
|
+
*/
|
|
760
|
+
function requestVerify(sessionId) {
|
|
761
|
+
if (typeof sessionId !== 'string' || sessionId === '') return Promise.resolve(null);
|
|
762
|
+
let request = null;
|
|
763
|
+
try {
|
|
764
|
+
request = fetch('/plan-completion/verify', {
|
|
765
|
+
method: 'POST',
|
|
766
|
+
credentials: 'same-origin',
|
|
767
|
+
headers: { 'content-type': 'application/json' },
|
|
768
|
+
body: JSON.stringify({ sessionId: sessionId })
|
|
769
|
+
});
|
|
770
|
+
} catch (error) {
|
|
771
|
+
return Promise.resolve(null);
|
|
772
|
+
}
|
|
773
|
+
return Promise.resolve(request)
|
|
774
|
+
.then(function readResponse(response) {
|
|
775
|
+
if (!response || response.ok !== true) return null;
|
|
776
|
+
return Promise.resolve(response.json()).then(
|
|
777
|
+
function parsed(json) {
|
|
778
|
+
return json && typeof json === 'object' ? json : null;
|
|
779
|
+
},
|
|
780
|
+
function badJson() {
|
|
781
|
+
return null;
|
|
782
|
+
}
|
|
783
|
+
);
|
|
784
|
+
})
|
|
785
|
+
.catch(function networkError() {
|
|
786
|
+
return null;
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
|
|
732
790
|
/**
|
|
733
791
|
* P0 boundary: read the current plan state. Keep this signature stable —
|
|
734
792
|
* swapping the transport (HTTP route / projection / wire view) only changes
|
|
@@ -1620,7 +1678,7 @@ window.__ModuleLoader__.load({
|
|
|
1620
1678
|
);
|
|
1621
1679
|
}
|
|
1622
1680
|
|
|
1623
|
-
function renderPopoverContent(plan, inputActions, t) {
|
|
1681
|
+
function renderPopoverContent(plan, inputActions, t, verify, onVerify) {
|
|
1624
1682
|
const children = [];
|
|
1625
1683
|
if (!plan) {
|
|
1626
1684
|
children.push(
|
|
@@ -1732,6 +1790,50 @@ window.__ModuleLoader__.load({
|
|
|
1732
1790
|
}
|
|
1733
1791
|
}
|
|
1734
1792
|
|
|
1793
|
+
// P0-5「核对进度」结果:显示在计划正文里,不改动计划本身。
|
|
1794
|
+
if (verify && (verify.loading || verify.error || verify.result)) {
|
|
1795
|
+
const verifyChildren = [
|
|
1796
|
+
h('div', { key: 'label', className: 'dpc-label' }, t('verify.assessment'))
|
|
1797
|
+
];
|
|
1798
|
+
if (verify.loading) {
|
|
1799
|
+
verifyChildren.push(h('div', { key: 'loading', className: 'dpc-verify-loading' }, t('verify.loading')));
|
|
1800
|
+
} else if (verify.error) {
|
|
1801
|
+
verifyChildren.push(
|
|
1802
|
+
h('div', { key: 'error', className: 'dpc-verify-error' }, t('verify.failed', { value: verify.error }))
|
|
1803
|
+
);
|
|
1804
|
+
} else if (verify.result) {
|
|
1805
|
+
const result = verify.result;
|
|
1806
|
+
if (result.assessment) {
|
|
1807
|
+
verifyChildren.push(h('div', { key: 'assessment', className: 'dpc-verify-assessment' }, result.assessment));
|
|
1808
|
+
}
|
|
1809
|
+
const mismatches = Array.isArray(result.mismatches) ? result.mismatches : [];
|
|
1810
|
+
if (mismatches.length) {
|
|
1811
|
+
verifyChildren.push(
|
|
1812
|
+
h(
|
|
1813
|
+
'ul',
|
|
1814
|
+
{ key: 'mismatches', className: 'dpc-verify-list' },
|
|
1815
|
+
mismatches.map(function renderMismatch(item, index) {
|
|
1816
|
+
const prefix = item && item.stepId ? '[' + item.stepId + '] ' : '';
|
|
1817
|
+
return h(
|
|
1818
|
+
'li',
|
|
1819
|
+
{ key: 'mismatch-' + index, className: 'dpc-verify-item' },
|
|
1820
|
+
prefix + String(item && item.issue ? item.issue : '')
|
|
1821
|
+
);
|
|
1822
|
+
})
|
|
1823
|
+
)
|
|
1824
|
+
);
|
|
1825
|
+
} else {
|
|
1826
|
+
verifyChildren.push(h('div', { key: 'clean', className: 'dpc-verify-clean' }, t('verify.clean')));
|
|
1827
|
+
}
|
|
1828
|
+
if (result.suggestion) {
|
|
1829
|
+
verifyChildren.push(
|
|
1830
|
+
h('div', { key: 'next', className: 'dpc-verify-next' }, t('verify.next', { value: result.suggestion }))
|
|
1831
|
+
);
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
children.push(h('div', { key: 'verify', className: 'dpc-section dpc-verify-card' }, verifyChildren));
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1735
1837
|
if (plan.updatedAt) {
|
|
1736
1838
|
children.push(
|
|
1737
1839
|
h('div', { key: 'updated', className: 'dpc-updated' }, t('popover.updated', { time: formatTime(plan.updatedAt) }))
|
|
@@ -1774,6 +1876,20 @@ window.__ModuleLoader__.load({
|
|
|
1774
1876
|
},
|
|
1775
1877
|
t('popover.sync')
|
|
1776
1878
|
),
|
|
1879
|
+
h(
|
|
1880
|
+
'button',
|
|
1881
|
+
{
|
|
1882
|
+
type: 'button',
|
|
1883
|
+
className: 'dpc-btn',
|
|
1884
|
+
disabled: !!(verify && verify.loading),
|
|
1885
|
+
onMouseDown: preventDefault,
|
|
1886
|
+
onClick: function onClickVerify(event) {
|
|
1887
|
+
stopPropagation(event);
|
|
1888
|
+
if (typeof onVerify === 'function') onVerify();
|
|
1889
|
+
}
|
|
1890
|
+
},
|
|
1891
|
+
t('popover.verify')
|
|
1892
|
+
),
|
|
1777
1893
|
h(
|
|
1778
1894
|
'button',
|
|
1779
1895
|
{
|
|
@@ -1815,6 +1931,32 @@ window.__ModuleLoader__.load({
|
|
|
1815
1931
|
const rootRef = React.useRef(null);
|
|
1816
1932
|
const open = state.overlayOpen === true;
|
|
1817
1933
|
const [heroPos, setHeroPos] = React.useState(null);
|
|
1934
|
+
const [verify, setVerify] = React.useState(null);
|
|
1935
|
+
|
|
1936
|
+
// Ephemeral P0-5 verification result: clear it when the active session changes.
|
|
1937
|
+
React.useEffect(function resetVerify() {
|
|
1938
|
+
setVerify(null);
|
|
1939
|
+
}, [state.sessionId]);
|
|
1940
|
+
|
|
1941
|
+
const onVerify = React.useCallback(function requestVerifyAction() {
|
|
1942
|
+
const sessionId = state.sessionId;
|
|
1943
|
+
if (typeof sessionId !== 'string' || sessionId === '') {
|
|
1944
|
+
setVerify({ loading: false, result: null, error: 'no-session' });
|
|
1945
|
+
return;
|
|
1946
|
+
}
|
|
1947
|
+
setVerify({ loading: true, result: null, error: null });
|
|
1948
|
+
requestVerify(sessionId).then(function applyResult(result) {
|
|
1949
|
+
if (!result) {
|
|
1950
|
+
setVerify({ loading: false, result: null, error: 'request-failed' });
|
|
1951
|
+
return;
|
|
1952
|
+
}
|
|
1953
|
+
if (result.ok !== true) {
|
|
1954
|
+
setVerify({ loading: false, result: null, error: String(result.error || 'failed') });
|
|
1955
|
+
return;
|
|
1956
|
+
}
|
|
1957
|
+
setVerify({ loading: false, result: result, error: null });
|
|
1958
|
+
});
|
|
1959
|
+
}, [state.sessionId]);
|
|
1818
1960
|
|
|
1819
1961
|
// P0-2 hero fallback: when no session-bound dock is mounted (new conversation / hero,
|
|
1820
1962
|
// or a cleared conversation), float a small «▤ 计划» entry above the composer so the
|
|
@@ -1908,7 +2050,7 @@ window.__ModuleLoader__.load({
|
|
|
1908
2050
|
'data-plan-completion': 'popover',
|
|
1909
2051
|
style: position
|
|
1910
2052
|
},
|
|
1911
|
-
renderPopoverContent(state.plan, state.inputActions, t)
|
|
2053
|
+
renderPopoverContent(state.plan, state.inputActions, t, verify, onVerify)
|
|
1912
2054
|
)
|
|
1913
2055
|
);
|
|
1914
2056
|
}
|
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,15 +12,15 @@
|
|
|
12
12
|
|
|
13
13
|
### P0(必须,决定可用性与发布)
|
|
14
14
|
|
|
15
|
-
> 更新(v0.1.
|
|
15
|
+
> 更新(v0.1.3):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
|
-
| P0-5 | **「核对进度」按钮** | 计划页只能看状态,无法对照实际进度 | 计划页底部加「核对进度」→ POST `/plan-completion/verify` → 一次模型调用生成「计划 vs 实际差异 + 建议修正」→ 展示在计划页(可折叠、可采纳) | 点击后 ≤5s 返回差异清单;采纳后生成 patch;失败有降级文案 |
|
|
23
|
+
| P0-5 | ✅ **「核对进度」按钮** | 计划页只能看状态,无法对照实际进度 | 计划页底部加「核对进度」→ POST `/plan-completion/verify` → 一次模型调用生成「计划 vs 实际差异 + 建议修正」→ 展示在计划页(可折叠、可采纳) | 点击后 ≤5s 返回差异清单;采纳后生成 patch;失败有降级文案 | ✅ 已交付(v0.1.3) |
|
|
24
24
|
| P0-6 | **发布工程化** | 无 git 仓库 / LICENSE / CI / 包元数据、README 含本地绝对路径 | 见 `发布评估-GitHub.md` 的 P0 清单 | 全部 P0 项 PASS,`node --test` 绿,仓库可公开 | 1d |
|
|
25
25
|
|
|
26
26
|
### P1(体验增强)
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# GitHub 发布评估 · 会话计划补全插件(2026-11-02)
|
|
2
2
|
|
|
3
|
-
> 历史文档:以下保留 v0.1.0
|
|
3
|
+
> 历史文档:以下保留 v0.1.0 的交付/评估/计划记录。**当前最新为 v0.1.2**(本地计划持久化 + 鉴权/状态一致性/隐私边界修复),详见 [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`)。
|
|
6
|
+
>
|
|
7
|
+
> v0.1.2 发布记录(2026-11-02):tag `v0.1.2` + [Release](https://github.com/Xumi0920/dsh-plan-completion/releases/tag/v0.1.2);GitHub Actions [run 41367719292](https://github.com/Xumi0920/dsh-plan-completion/actions/runs/41367719292)(Ubuntu/Windows × Node20/22 四 job 全绿,120/120 tests);npm `dsh-plan-completion@0.1.2`(31 files,shasum `e9d5d61ae2cb35f043e2eb7d05a38786d2699626`);真机持久化 canary 通过(6 个会话计划落盘、会话文件 `gen5.rev0` 可读回、subagent 真实回合自动写入)。
|
|
4
8
|
|
|
5
9
|
> **2026-11-02 晚更新:P0 清单已全部完成,状态从「不满足全 PASS」→「可按 GitHub 源码仓库发布」。**
|
|
6
10
|
>
|
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/config.js
CHANGED
|
@@ -69,6 +69,8 @@ export const DEFAULTS = Object.freeze({
|
|
|
69
69
|
// 真机实测(Lead index.js,2026-11):rewrite 700 会被截断 → 至少 1200;patch 建议至少 800。
|
|
70
70
|
maxTokensPatch: 800,
|
|
71
71
|
maxTokensRewrite: 1200,
|
|
72
|
+
/** 「核对进度」一次调用的输出预算(差异清单 JSON,不需要完整步骤表)。 */
|
|
73
|
+
maxTokensVerify: 600,
|
|
72
74
|
temperaturePatch: 0.2,
|
|
73
75
|
temperatureRewrite: 0.3,
|
|
74
76
|
});
|
package/host/prompt.js
CHANGED
|
@@ -384,3 +384,87 @@ export function parseModelOutput(text) {
|
|
|
384
384
|
return fail('internal', `模型输出解析异常:${errMsg(err)}`);
|
|
385
385
|
}
|
|
386
386
|
}
|
|
387
|
+
|
|
388
|
+
/* ──────────────────────────────────────────────────────────────
|
|
389
|
+
* 「核对进度」:对照计划与实际证据,输出差异清单(不修改计划)
|
|
390
|
+
* ────────────────────────────────────────────────────────────── */
|
|
391
|
+
|
|
392
|
+
export const VERIFY_SCHEMA = [
|
|
393
|
+
'{"ok":true,',
|
|
394
|
+
'"assessment":"<一句话总体判断>",',
|
|
395
|
+
'"mismatches":[{"stepId":"s2","issue":"<计划状态与证据不一致之处,≤80字>"}],',
|
|
396
|
+
'"suggestion":"<下一步建议,≤40字,可直接发送>"}',
|
|
397
|
+
].join('');
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* 构造「核对进度」prompt:不要求 op/steps,只输出 assessment + mismatches + suggestion。
|
|
401
|
+
* @param {object} evidence 与生成计划相同的证据结构
|
|
402
|
+
*/
|
|
403
|
+
export function buildVerifyPrompt(evidence = {}) {
|
|
404
|
+
const task = [
|
|
405
|
+
'【任务:核对计划进度】',
|
|
406
|
+
'对照下面的计划与最近会话证据,找出计划状态与实际进展不一致的地方:',
|
|
407
|
+
'- 步骤实际已完成/已开始,但计划仍为 todo/doing;',
|
|
408
|
+
'- 计划标记 done,但证据中没有完成依据;',
|
|
409
|
+
'- 步骤被跳过、顺序明显变化,或计划目标与当前工作方向不一致。',
|
|
410
|
+
'不要修改计划,也不要输出 op/steps/changes 字段。',
|
|
411
|
+
'没有明显差异时 mismatches 返回空数组,并给出收尾/推进建议。',
|
|
412
|
+
'严格只输出一个 JSON 对象,schema 如下(字段值用中文):',
|
|
413
|
+
VERIFY_SCHEMA,
|
|
414
|
+
].join('\n')
|
|
415
|
+
return {
|
|
416
|
+
system: `${SYSTEM_PROMPT}\n\n【本次任务】只核对进度并输出差异清单;本次输出 schema 不同于计划更新,不要输出 op 字段。`,
|
|
417
|
+
messages: buildMessages(evidence, task),
|
|
418
|
+
maxTokens: DEFAULTS.maxTokensVerify,
|
|
419
|
+
temperature: 0,
|
|
420
|
+
op: 'verify',
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* 解析「核对进度」输出;永不抛异常。
|
|
426
|
+
* @param {unknown} text
|
|
427
|
+
* @returns {{ok:true, output:{assessment:string, mismatches:Array<{stepId:string, issue:string}>, suggestion:string, verifiedAt:number}}|{ok:false, code:string, error:string}}
|
|
428
|
+
*/
|
|
429
|
+
export function parseVerifyOutput(text) {
|
|
430
|
+
try {
|
|
431
|
+
if (typeof text !== 'string') return fail('not-string', '模型输出不是字符串')
|
|
432
|
+
const cleaned = text.replace(/^\uFEFF/, '').trim()
|
|
433
|
+
if (!cleaned) return fail('empty', '模型输出为空')
|
|
434
|
+
|
|
435
|
+
let parsed = null
|
|
436
|
+
for (const candidate of jsonCandidates(cleaned)) {
|
|
437
|
+
try {
|
|
438
|
+
parsed = JSON.parse(candidate)
|
|
439
|
+
break
|
|
440
|
+
} catch {
|
|
441
|
+
/* try next candidate */
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
if (!isObj(parsed)) return fail('no-json', '未找到合法 JSON 对象')
|
|
445
|
+
|
|
446
|
+
const assessment = isStr(parsed.assessment) ? clampChars(oneLine(parsed.assessment), 200) : ''
|
|
447
|
+
if (!assessment) return fail('missing-assessment', 'assessment 必填且非空')
|
|
448
|
+
|
|
449
|
+
const mismatches = []
|
|
450
|
+
if (Array.isArray(parsed.mismatches)) {
|
|
451
|
+
for (const item of parsed.mismatches.slice(0, 6)) {
|
|
452
|
+
if (!isObj(item)) continue
|
|
453
|
+
const issue = isStr(item.issue) ? clampChars(oneLine(item.issue), 120) : ''
|
|
454
|
+
if (!issue) continue
|
|
455
|
+
const stepId = isStr(item.stepId) ? clampChars(oneLine(item.stepId), 64) : ''
|
|
456
|
+
mismatches.push({ stepId, issue })
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
let suggestion = ''
|
|
461
|
+
if (parsed.suggestion !== undefined) {
|
|
462
|
+
const sanitized = sanitizeSuggestion(parsed.suggestion, DEFAULTS.suggestionMaxChars)
|
|
463
|
+
if (sanitized.ok) suggestion = sanitized.text
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return { ok: true, output: { assessment, mismatches, suggestion, verifiedAt: Date.now() } }
|
|
467
|
+
} catch (err) {
|
|
468
|
+
return fail('internal', `核对解析异常:${errMsg(err)}`)
|
|
469
|
+
}
|
|
470
|
+
}
|
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,15 +12,16 @@
|
|
|
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'
|
|
18
19
|
import { join } from 'node:path'
|
|
19
20
|
import { normalizePlan, patchPlan, rewritePlan, statusFor, applyUserEdit } from './host/plan.js'
|
|
20
|
-
import { buildPatchPrompt, buildRewritePrompt, buildRepairPrompt, parseModelOutput } from './host/prompt.js'
|
|
21
|
+
import { buildPatchPrompt, buildRewritePrompt, buildRepairPrompt, parseModelOutput, buildVerifyPrompt, parseVerifyOutput } 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,9 @@ 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),
|
|
257
|
+
maxTokensVerify: readInt(rawConfig.maxTokensVerify, DEFAULTS.maxTokensVerify, 200, 2000),
|
|
235
258
|
enableDebugRoutes: rawConfig.enableDebugRoutes === true,
|
|
236
259
|
debugDir: typeof rawConfig.debugDir === 'string' && rawConfig.debugDir.trim() !== ''
|
|
237
260
|
? rawConfig.debugDir.trim()
|
|
@@ -275,8 +298,13 @@ export function apply(ctx, config = {}) {
|
|
|
275
298
|
const sessions = shared.sessions // sessionId -> Session
|
|
276
299
|
const warmAttempts = shared.warmAttempts // sessionId -> { at, seq }
|
|
277
300
|
const inflight = new Map() // sessionId -> AbortController
|
|
301
|
+
const verifyInflight = new Set() // sessionId -> 正在核对(同一会话同时只允许一个)
|
|
278
302
|
const gate = createGate(DEFAULTS.globalConcurrency)
|
|
279
|
-
|
|
303
|
+
// Persistence: a file-backed plan store when a directory is configured; otherwise memory.
|
|
304
|
+
// The store keeps the last plan + suggestion per session so a restart can restore the UI.
|
|
305
|
+
const fileStore = settings.persistPlans && settings.storageDir ? createFileStore(settings.storageDir) : null
|
|
306
|
+
const store = fileStore ?? createMemoryStore()
|
|
307
|
+
shared.store = store
|
|
280
308
|
let disposed = false
|
|
281
309
|
const stopGeneration = () => {
|
|
282
310
|
disposed = true
|
|
@@ -313,6 +341,53 @@ export function apply(ctx, config = {}) {
|
|
|
313
341
|
states.set(sessionId, { ...previous, ...patch })
|
|
314
342
|
}
|
|
315
343
|
|
|
344
|
+
// ── Persistence ⇄ in-memory state ────────────────────────────────────────
|
|
345
|
+
const hydration = new Map() // sessionId -> Promise (in-flight disk reads)
|
|
346
|
+
|
|
347
|
+
// Adopt plans retained in memory across a hot reload into the file store. `cas:null` means
|
|
348
|
+
// "only when the file has no plan yet", so a newer on-disk plan is never overwritten.
|
|
349
|
+
if (fileStore) {
|
|
350
|
+
for (const [sessionId, state] of states.entries()) {
|
|
351
|
+
if (!state?.plan) continue
|
|
352
|
+
void fileStore
|
|
353
|
+
.put(state.plan, { cas: null, suggestion: state.suggestion ?? null })
|
|
354
|
+
.catch(() => {})
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Load a persisted plan (+ last suggestion) into the shared in-memory state. The UI can show
|
|
360
|
+
* the previous plan right after a DSH restart, before the next turn runs.
|
|
361
|
+
*/
|
|
362
|
+
async function hydrateSession(sessionId) {
|
|
363
|
+
if (!sessionId) return
|
|
364
|
+
if (states.get(sessionId)?.plan) return
|
|
365
|
+
const pending = hydration.get(sessionId)
|
|
366
|
+
if (pending) return pending
|
|
367
|
+
const task = (async () => {
|
|
368
|
+
try {
|
|
369
|
+
const stored = typeof store.getState === 'function'
|
|
370
|
+
? await store.getState(sessionId)
|
|
371
|
+
: { plan: await store.get(sessionId), suggestion: null }
|
|
372
|
+
if (!stored?.plan || states.get(sessionId)?.plan) return
|
|
373
|
+
const normalized = normalizePlan(stored.plan)
|
|
374
|
+
if (!normalized?.ok || !normalized.plan) return
|
|
375
|
+
states.set(sessionId, {
|
|
376
|
+
plan: normalized.plan,
|
|
377
|
+
suggestion: stored.suggestion && typeof stored.suggestion === 'object' ? stored.suggestion : null,
|
|
378
|
+
stale: false,
|
|
379
|
+
})
|
|
380
|
+
debugLog({ phase: 'state-hydrated', sessionId })
|
|
381
|
+
} catch (error) {
|
|
382
|
+
debugLog({ phase: 'state-hydrate-error', sessionId, error: String(error) })
|
|
383
|
+
} finally {
|
|
384
|
+
hydration.delete(sessionId)
|
|
385
|
+
}
|
|
386
|
+
})()
|
|
387
|
+
hydration.set(sessionId, task)
|
|
388
|
+
return task
|
|
389
|
+
}
|
|
390
|
+
|
|
316
391
|
function seedPlan(sessionId, evidence) {
|
|
317
392
|
const intent = evidence?.intent || evidence?.firstUserMessage || '推进当前会话目标'
|
|
318
393
|
const fallback = (() => {
|
|
@@ -429,6 +504,7 @@ export function apply(ctx, config = {}) {
|
|
|
429
504
|
async function runWarmBackfill(sessionId, options = {}) {
|
|
430
505
|
if (!settings.warmBackfill) return { ok: false, reason: 'disabled' }
|
|
431
506
|
if (!sessionId) return { ok: false, reason: 'no-session-id' }
|
|
507
|
+
await hydrateSession(sessionId)
|
|
432
508
|
if (states.get(sessionId)?.plan) return { ok: true, already: true }
|
|
433
509
|
const now = Date.now()
|
|
434
510
|
const attempt = warmAttempts.get(sessionId)
|
|
@@ -490,6 +566,8 @@ export function apply(ctx, config = {}) {
|
|
|
490
566
|
|
|
491
567
|
await gate.acquire()
|
|
492
568
|
try {
|
|
569
|
+
if (!isCurrent()) return
|
|
570
|
+
await hydrateSession(sessionId)
|
|
493
571
|
if (!isCurrent()) return
|
|
494
572
|
let events = []
|
|
495
573
|
try {
|
|
@@ -499,7 +577,10 @@ export function apply(ctx, config = {}) {
|
|
|
499
577
|
}
|
|
500
578
|
|
|
501
579
|
const basePlan = states.get(sessionId)?.plan ?? null
|
|
502
|
-
const
|
|
580
|
+
const storedPlan = typeof store.getState === 'function'
|
|
581
|
+
? (await store.getState(sessionId))?.plan ?? null
|
|
582
|
+
: await store.get(sessionId)
|
|
583
|
+
const storedVersion = storedPlan?.version ?? null
|
|
503
584
|
const evidence = (() => {
|
|
504
585
|
try {
|
|
505
586
|
const collected = collectFromEvents({ events, plan: basePlan, turn, sessionId })
|
|
@@ -668,16 +749,28 @@ export function apply(ctx, config = {}) {
|
|
|
668
749
|
if (fallbackText) suggestion = { text: fallbackText, alternates: [] }
|
|
669
750
|
}
|
|
670
751
|
|
|
752
|
+
const elapsed = Date.now() - startedAt
|
|
753
|
+
const suggestionDeadline = force ? Number.POSITIVE_INFINITY : Math.max(DEFAULTS.suggestionDeadlineMs, 6000)
|
|
754
|
+
const withinSuggestionDeadline = elapsed <= suggestionDeadline
|
|
755
|
+
const finalSuggestion = withinSuggestionDeadline ? suggestion ?? provisionalSuggestion : provisionalSuggestion
|
|
756
|
+
|
|
671
757
|
if (nextPlan) {
|
|
672
758
|
try {
|
|
673
759
|
// Check CAS even for an unchanged plan; an old snapshot is never an
|
|
674
760
|
// unconditional write. null also protects the first commit from races.
|
|
675
|
-
|
|
761
|
+
// The last suggestion is persisted in the same write so a restart can restore it.
|
|
762
|
+
const committed = await store.put(nextPlan, { cas: storedVersion, suggestion: finalSuggestion })
|
|
676
763
|
if (!committed) {
|
|
677
764
|
debugLog({ phase: 'store-cas-miss', sessionId })
|
|
678
|
-
const
|
|
679
|
-
|
|
680
|
-
|
|
765
|
+
const winner = typeof store.getState === 'function'
|
|
766
|
+
? await store.getState(sessionId)
|
|
767
|
+
: { plan: await store.get(sessionId), suggestion: null }
|
|
768
|
+
publishState(sessionId, {
|
|
769
|
+
plan: winner?.plan ?? null,
|
|
770
|
+
suggestion: null,
|
|
771
|
+
stale: Boolean(winner?.plan),
|
|
772
|
+
})
|
|
773
|
+
debug.lastJob = { sessionId, turn, elapsed, outcome: 'conflict', suggestion: false }
|
|
681
774
|
return
|
|
682
775
|
}
|
|
683
776
|
} catch (error) {
|
|
@@ -686,10 +779,6 @@ export function apply(ctx, config = {}) {
|
|
|
686
779
|
}
|
|
687
780
|
}
|
|
688
781
|
|
|
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
782
|
publishState(sessionId, {
|
|
694
783
|
plan: nextPlan,
|
|
695
784
|
suggestion: finalSuggestion,
|
|
@@ -877,8 +966,99 @@ export function apply(ctx, config = {}) {
|
|
|
877
966
|
jsonResponse(res, 200, outcome)
|
|
878
967
|
return
|
|
879
968
|
}
|
|
969
|
+
if (pathname === `${ROUTE_PREFIX}verify` && method === 'POST') {
|
|
970
|
+
const body = (await readJsonBody(req)) ?? {}
|
|
971
|
+
const sessionId = String(body.sessionId ?? '')
|
|
972
|
+
if (!sessionId) {
|
|
973
|
+
jsonResponse(res, 200, { ok: false, error: 'no-session-id' })
|
|
974
|
+
return
|
|
975
|
+
}
|
|
976
|
+
await hydrateSession(sessionId)
|
|
977
|
+
const plan = states.get(sessionId)?.plan ?? null
|
|
978
|
+
if (!plan) {
|
|
979
|
+
jsonResponse(res, 200, { ok: false, error: 'no-plan' })
|
|
980
|
+
return
|
|
981
|
+
}
|
|
982
|
+
if (!settings.modelCalls) {
|
|
983
|
+
jsonResponse(res, 200, { ok: false, error: 'model-disabled' })
|
|
984
|
+
return
|
|
985
|
+
}
|
|
986
|
+
if (verifyInflight.has(sessionId)) {
|
|
987
|
+
jsonResponse(res, 200, { ok: false, error: 'in-progress' })
|
|
988
|
+
return
|
|
989
|
+
}
|
|
990
|
+
const route = await pickModel(services)
|
|
991
|
+
if (!route) {
|
|
992
|
+
jsonResponse(res, 200, { ok: false, error: 'no-model-route' })
|
|
993
|
+
return
|
|
994
|
+
}
|
|
995
|
+
const session = sessions.get(sessionId) ?? null
|
|
996
|
+
let events = []
|
|
997
|
+
try {
|
|
998
|
+
if (session) {
|
|
999
|
+
events = Array.from(session.snapshotEvents(Math.max(0, Number(session.seq ?? 0) - 300)) ?? [])
|
|
1000
|
+
}
|
|
1001
|
+
} catch {
|
|
1002
|
+
events = []
|
|
1003
|
+
}
|
|
1004
|
+
const evidence = (() => {
|
|
1005
|
+
try {
|
|
1006
|
+
const collected = collectFromEvents({
|
|
1007
|
+
events,
|
|
1008
|
+
plan,
|
|
1009
|
+
turn: Number(plan.watermark?.turn ?? 0),
|
|
1010
|
+
sessionId,
|
|
1011
|
+
})
|
|
1012
|
+
if (collected && collected.ok !== false) return collected
|
|
1013
|
+
} catch {
|
|
1014
|
+
/* fall through to the plan-only payload */
|
|
1015
|
+
}
|
|
1016
|
+
return { plan, turn: Number(plan.watermark?.turn ?? 0), lastAssistantText: '', lastUserMessages: [] }
|
|
1017
|
+
})()
|
|
1018
|
+
verifyInflight.add(sessionId)
|
|
1019
|
+
const controller = new AbortController()
|
|
1020
|
+
const timer = setTimeout(() => controller.abort(new Error('verify-deadline')), 8000)
|
|
1021
|
+
const startedAt = Date.now()
|
|
1022
|
+
try {
|
|
1023
|
+
const prompt = buildVerifyPrompt(evidence)
|
|
1024
|
+
prompt.maxTokens = settings.maxTokensVerify
|
|
1025
|
+
const response = await callModel(services, route, prompt, controller.signal)
|
|
1026
|
+
const text = String(response?.text ?? '')
|
|
1027
|
+
if (!text) {
|
|
1028
|
+
jsonResponse(res, 200, { ok: false, error: 'empty-model-output' })
|
|
1029
|
+
return
|
|
1030
|
+
}
|
|
1031
|
+
const parsed = parseVerifyOutput(text)
|
|
1032
|
+
if (!parsed.ok) {
|
|
1033
|
+
debugLog({ phase: 'verify-parse-failed', sessionId, code: parsed.code })
|
|
1034
|
+
jsonResponse(res, 200, { ok: false, error: 'parse-failed', code: parsed.code })
|
|
1035
|
+
return
|
|
1036
|
+
}
|
|
1037
|
+
debugLog({
|
|
1038
|
+
phase: 'verify-done',
|
|
1039
|
+
sessionId,
|
|
1040
|
+
elapsed: Date.now() - startedAt,
|
|
1041
|
+
mismatches: parsed.output.mismatches.length,
|
|
1042
|
+
})
|
|
1043
|
+
jsonResponse(res, 200, {
|
|
1044
|
+
ok: true,
|
|
1045
|
+
...parsed.output,
|
|
1046
|
+
usage: response?.usage ?? null,
|
|
1047
|
+
elapsedMs: Date.now() - startedAt,
|
|
1048
|
+
})
|
|
1049
|
+
} catch (error) {
|
|
1050
|
+
debugLog({ phase: 'verify-error', sessionId, error: String(error) })
|
|
1051
|
+
jsonResponse(res, 200, { ok: false, error: 'model-error' })
|
|
1052
|
+
} finally {
|
|
1053
|
+
clearTimeout(timer)
|
|
1054
|
+
verifyInflight.delete(sessionId)
|
|
1055
|
+
}
|
|
1056
|
+
return
|
|
1057
|
+
}
|
|
880
1058
|
if (pathname === `${ROUTE_PREFIX}state` && method === 'GET') {
|
|
881
|
-
|
|
1059
|
+
const sessionId = url?.searchParams?.get('sessionId') ?? ''
|
|
1060
|
+
await hydrateSession(sessionId)
|
|
1061
|
+
jsonResponse(res, 200, publicView(sessionId))
|
|
882
1062
|
return
|
|
883
1063
|
}
|
|
884
1064
|
if (pathname === `${ROUTE_PREFIX}debug` && method === 'GET') {
|
|
@@ -972,7 +1152,12 @@ export function apply(ctx, config = {}) {
|
|
|
972
1152
|
const sessionId = String(body.sessionId ?? '')
|
|
973
1153
|
const existed = states.has(sessionId)
|
|
974
1154
|
inflight.get(sessionId)?.abort(new Error('state-reset'))
|
|
975
|
-
|
|
1155
|
+
try {
|
|
1156
|
+
await store.clear(sessionId)
|
|
1157
|
+
} catch (error) {
|
|
1158
|
+
debugLog({ phase: 'reset-store-error', sessionId, error: String(error) })
|
|
1159
|
+
}
|
|
1160
|
+
hydration.delete(sessionId)
|
|
976
1161
|
states.delete(sessionId)
|
|
977
1162
|
warmAttempts.delete(sessionId)
|
|
978
1163
|
handledTurns.delete(sessionId)
|
|
@@ -983,6 +1168,7 @@ export function apply(ctx, config = {}) {
|
|
|
983
1168
|
if (pathname === `${ROUTE_PREFIX}steps` && method === 'POST') {
|
|
984
1169
|
const body = (await readJsonBody(req)) ?? {}
|
|
985
1170
|
const sessionId = String(body.sessionId ?? '')
|
|
1171
|
+
await hydrateSession(sessionId)
|
|
986
1172
|
const plan = states.get(sessionId)?.plan ?? null
|
|
987
1173
|
if (!plan) {
|
|
988
1174
|
jsonResponse(res, 200, { ok: false, error: 'no-plan' })
|
|
@@ -1034,7 +1220,11 @@ export function apply(ctx, config = {}) {
|
|
|
1034
1220
|
return
|
|
1035
1221
|
}
|
|
1036
1222
|
try {
|
|
1037
|
-
store.put(applied.plan, {
|
|
1223
|
+
const committed = await store.put(applied.plan, {
|
|
1224
|
+
cas: plan.version,
|
|
1225
|
+
suggestion: states.get(sessionId)?.suggestion ?? null,
|
|
1226
|
+
})
|
|
1227
|
+
if (!committed) await store.put(applied.plan, { suggestion: states.get(sessionId)?.suggestion ?? null })
|
|
1038
1228
|
} catch (error) {
|
|
1039
1229
|
debugLog({ phase: 'user-edit-store-error', sessionId, error: String(error) })
|
|
1040
1230
|
}
|
|
@@ -1176,6 +1366,7 @@ export function apply(ctx, config = {}) {
|
|
|
1176
1366
|
`${ROUTE_PREFIX}state`,
|
|
1177
1367
|
`${ROUTE_PREFIX}replan`,
|
|
1178
1368
|
`${ROUTE_PREFIX}warm`,
|
|
1369
|
+
`${ROUTE_PREFIX}verify`,
|
|
1179
1370
|
]
|
|
1180
1371
|
if (settings.enableDebugRoutes) {
|
|
1181
1372
|
paths.push(
|