dsh-recall-plugin 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +93 -0
- package/cordis.patch.yml +11 -0
- package/lib/client.js +400 -0
- package/lib/index.js +657 -0
- package/package.json +55 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-recall-plugin contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# dsh-recall-plugin
|
|
2
|
+
|
|
3
|
+
> 撤回一条消息,项目文件也一起回去。
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+

|
|
7
|
+

|
|
8
|
+

|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
- 在任意一条你发过的消息下方,点「↶ 撤回」,**工作区文件和对话历史一起回到那条消息发出之前的状态**。
|
|
13
|
+
- 注:安装本插件前的消息无法撤回
|
|
14
|
+
|
|
15
|
+
## 界面预览
|
|
16
|
+
- 撤回按钮位置
|
|
17
|
+
|
|
18
|
+

|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
| 确认面板 · 变更文件清单| |
|
|
22
|
+
| --- | --- |
|
|
23
|
+
|  |  |
|
|
24
|
+
|
|
25
|
+
## 功能亮点
|
|
26
|
+
|
|
27
|
+
- **文件 + 对话,整段回退**:撤回的不只是聊天记录,agent 改过的文件也一并回到原样。
|
|
28
|
+
- **不碰你项目自己的 git**:快照存在独立的影子 git 仓库里,你的分支、暂存区、未提交改动统统不受影响;`.git`、`node_modules` 自动排除。
|
|
29
|
+
- **项目目录保持干净**:快照默认存在 `$DSH_HOME` 下,不会往项目里塞任何东西;受限会话写不了 home 时才临时降级到项目内 `.dsh-recall-snapshots`,权限为 Full access 时自动迁走、清理干净。
|
|
30
|
+
- **可以反复后悔**:快照全量保留、永不修剪。撤回一次后还能再撤到更早;撤回时被覆盖的文件也一直找得回来。
|
|
31
|
+
- **先看清单再动手**:点撤回先弹出将变更的文件清单(修改 / 恢复 / 删除),确认后才执行,不会稀里糊涂覆盖。
|
|
32
|
+
- **磁盘友好**:快照走 git delta 压缩,是增量不是整目录拷贝;超过 100MB 的大文件自动跳过。
|
|
33
|
+
|
|
34
|
+
## 安装
|
|
35
|
+
|
|
36
|
+
前置:Windows + git CLI(未装 git 时插件静默降级,不显示撤回按钮,不影响 DSH 运行);PowerShell 5.1 / 7 均可;DSH 0.1.0-rc.x(依赖版本见 `peerDependencies`)。
|
|
37
|
+
|
|
38
|
+
```powershell
|
|
39
|
+
# DSH 官方插件命令:安装并自动挂载进 web profile
|
|
40
|
+
dsh plugin --profile web add dsh-recall-plugin@<version>
|
|
41
|
+
# 未发布时也可从 git 安装(纯 JS 无构建,免 prepare/allowBuilds):
|
|
42
|
+
# dsh plugin --profile web add github:<user>/dsh-recall-plugin
|
|
43
|
+
|
|
44
|
+
# 重启 DSH 进程(按你的启动方式,任选其一)
|
|
45
|
+
dsh web # 前台直接启动
|
|
46
|
+
pm2 restart <你的dsh进程名> # 若用 pm2 托管
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**验证**:重启后硬刷新页面(Ctrl+Shift+R),悬停任意一条插件启用后发送的用户消息——复制按钮旁出现「↶」即生效。没有按钮?九成是没重启 DSH 进程,或 git CLI 不在 PATH 里。
|
|
50
|
+
|
|
51
|
+
**卸载**:`dsh plugin --profile web remove dsh-recall-plugin`(同时移除依赖与挂载层),快照数据保留在 `$DSH_HOME/dsh-recall-snapshots/`,想彻底清除手动删掉该目录即可。
|
|
52
|
+
|
|
53
|
+
## 使用
|
|
54
|
+
|
|
55
|
+
1. 鼠标悬停任意**插件启用后发送**的用户消息,复制按钮左侧出现「↶ 撤回」。
|
|
56
|
+
2. 点击 → 确认面板展示将变更的文件清单(修改 / 恢复 / 删除)。
|
|
57
|
+
3. 点「确认回退」→ 文件恢复到该消息发送前的状态;视图切到新会话(该消息及之后的对话移除),原会话归档、随时可找回。
|
|
58
|
+
|
|
59
|
+
## 工作原理
|
|
60
|
+
|
|
61
|
+
每条用户消息发送时(agent 动文件之前),工作区被快照进一个独立的影子 git 仓库;撤回时用 `git archive` 恢复文件、通过 DSH 官方 `sessions.fork` 机制把会话切到该消息之前。二进制安全,全程不触碰项目自身的 git 状态。
|
|
62
|
+
|
|
63
|
+
- 快照存储:`$DSH_HOME/dsh-recall-snapshots/<SHA256(项目绝对路径)>/`,内含影子 git 仓库(`git/`,tag 名为 `snap-<消息ID>`)与索引文件 `index.json`(消息 ID → 快照时间 / 会话)。
|
|
64
|
+
- 想直接翻历史快照:
|
|
65
|
+
|
|
66
|
+
```powershell
|
|
67
|
+
git --git-dir="<store>\git\.git" tag -l
|
|
68
|
+
git --git-dir="<store>\git\.git" ls-tree -r --name-only snap-<消息ID>
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## 已知限制
|
|
72
|
+
|
|
73
|
+
- 快照在**消息发送时**创建,插件启用前的历史消息没有快照,不显示撤回按钮。
|
|
74
|
+
- 会话第一条用户消息无法回退对话(仅文件回退),因为 fork 需要更早的 turn 边界。
|
|
75
|
+
- 仅支持 Windows(脚本用 PowerShell + git CLI);Linux/macOS 下插件不报错,但快照不工作、不显示撤回按钮。
|
|
76
|
+
- 工作区内嵌套的其他 git 仓库(子目录自带 `.git`)不进快照,其内容不参与回退。
|
|
77
|
+
|
|
78
|
+
## 本地开发(无需发布)
|
|
79
|
+
|
|
80
|
+
```powershell
|
|
81
|
+
# 把包目录放进 web profile 的 node_modules,并登记到 bundles
|
|
82
|
+
$pkg = '<你的仓库克隆路径>\dsh-recall-plugin'
|
|
83
|
+
$profile = "$env:USERPROFILE\.dsh\profiles\web"
|
|
84
|
+
Copy-Item -Recurse -Force $pkg "$profile\node_modules\dsh-recall-plugin"
|
|
85
|
+
# 手动编辑 $profile\package.json:
|
|
86
|
+
# dependencies 加 "dsh-recall-plugin": "1.0.0"
|
|
87
|
+
# dsh.profile.bundles 加 "dsh-recall-plugin"
|
|
88
|
+
# 然后重启 DSH 并硬刷新页面
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## License
|
|
92
|
+
|
|
93
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# dsh-recall-plugin bundle patch
|
|
2
|
+
#
|
|
3
|
+
# 持久插件挂载层(dsh.bundle.patch):安装后由 DSH CLI 的 bundle 协调
|
|
4
|
+
# 自动把本包加进 profile 的 dsh.profile.bundles,启动时合并这一个
|
|
5
|
+
# insert 行——无需手动编辑任何 profile 文件。
|
|
6
|
+
#
|
|
7
|
+
# 安装(发布到 npm 后):
|
|
8
|
+
# dsh plugin --profile web add dsh-recall-plugin@<version>
|
|
9
|
+
- insert:
|
|
10
|
+
- id: recall
|
|
11
|
+
name: 'dsh-recall-plugin'
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-recall-plugin — Client 半(持久插件形态,浏览器 bundle)
|
|
3
|
+
*
|
|
4
|
+
* 职责:注册 conversation.chat.node 的 user 渲染器,重绘用户消息气泡
|
|
5
|
+
* (文本/图片/JSON 块),在复制按钮旁加「撤回」按钮;二次确认面板展示
|
|
6
|
+
* 文件变更清单,确认后经 /api/recall/* 调用 Host 回退文件,并用官方
|
|
7
|
+
* sessions.fork 把对话一并回退(新会话打开、原会话归档)。
|
|
8
|
+
*
|
|
9
|
+
* 这是 dsh.client bundle(exports["./client"]),由 client-modules 打包成
|
|
10
|
+
* /plugins/<pkg>/client.js 注入页面;factory 内 require("react") 由平台
|
|
11
|
+
* 模块表提供。零构建依赖:纯 JS + React.createElement。
|
|
12
|
+
*/
|
|
13
|
+
window.__ModuleLoader__.load({
|
|
14
|
+
id: "dsh-recall-plugin",
|
|
15
|
+
factory: (require) => {
|
|
16
|
+
const React = require("react")
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
name: "dsh-recall-plugin",
|
|
20
|
+
apply(ctx) {
|
|
21
|
+
const slots = ctx.get('slots')
|
|
22
|
+
if (!slots) return
|
|
23
|
+
// 官方会话服务:fork 到已完成 turn 前缀 + open 切到新会话;
|
|
24
|
+
// workspaces 的归档只是从列表隐藏、可恢复,用来收走回退前的原会话。
|
|
25
|
+
const sessionsSvc = ctx.get('sessions')
|
|
26
|
+
const workspacesSvc = ctx.get('workspaces')
|
|
27
|
+
|
|
28
|
+
// Host HTTP API(动态插件的 harness RPC 在此换成 fetch 调用)
|
|
29
|
+
function api(name, args) {
|
|
30
|
+
return fetch('/api/recall/' + name, {
|
|
31
|
+
method: 'POST',
|
|
32
|
+
headers: { 'content-type': 'application/json' },
|
|
33
|
+
body: JSON.stringify(args || {})
|
|
34
|
+
}).then((r) => r.json())
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const css = [
|
|
38
|
+
'.dsh-recall-row{flex-direction:column;align-items:flex-end;gap:6px;display:flex}',
|
|
39
|
+
'.dsh-recall-stack{flex-direction:column;align-items:flex-end;gap:8px;min-width:0;max-width:min(525px,82%);display:flex}',
|
|
40
|
+
'.dsh-recall-bubble{background:var(--dsw-specific-bubble);max-width:100%;color:var(--dsw-alias-label-primary);border-radius:22px;padding:10px 16px;font-size:16px;line-height:24px;white-space:pre-wrap;word-break:break-word}',
|
|
41
|
+
'.dsh-recall-img{max-width:100%;max-height:320px;border-radius:12px;object-fit:contain}',
|
|
42
|
+
'.dsh-recall-json{margin:0;max-width:100%;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary);white-space:pre-wrap;word-break:break-word;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:8px 10px;background:var(--dsw-alias-markdown-code-block)}',
|
|
43
|
+
'.dsh-recall-actions{align-items:center;gap:10px;height:28px;display:flex}',
|
|
44
|
+
'.dsh-recall-time{color:var(--dsw-alias-label-tertiary);white-space:nowrap;padding-right:12px;font-size:14px;line-height:24px}',
|
|
45
|
+
'.dsh-recall-action{width:28px;height:28px;color:var(--dsw-alias-label-tertiary);cursor:pointer;background:0 0;border:none;border-radius:28px;justify-content:center;align-items:center;padding:6px;display:inline-flex}',
|
|
46
|
+
'.dsh-recall-action:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}',
|
|
47
|
+
'@media (hover:hover){[data-time-hover-root] .dsh-recall-time{opacity:0;transition:opacity 80ms}[data-time-hover-root]:hover .dsh-recall-time,[data-time-hover-root]:focus-within .dsh-recall-time{opacity:1}}',
|
|
48
|
+
'.dsh-recall-panel{width:min(480px,100%);box-sizing:border-box;background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l1);border-radius:12px;padding:12px 14px;display:flex;flex-direction:column;gap:8px;text-align:left;box-shadow:0 8px 28px rgba(0,0,0,.22)}',
|
|
49
|
+
'.dsh-recall-panel-title{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:600;line-height:22px}',
|
|
50
|
+
'.dsh-recall-panel-note{color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px;word-break:break-word}',
|
|
51
|
+
'.dsh-recall-list{max-height:220px;overflow:auto;display:flex;flex-direction:column;gap:2px;padding:4px 0}',
|
|
52
|
+
'.dsh-recall-file{display:flex;gap:8px;align-items:baseline;font-size:12px;line-height:20px}',
|
|
53
|
+
'.dsh-recall-badge{flex:none;font-size:11px;line-height:18px;padding:0 6px;border-radius:6px}',
|
|
54
|
+
'.dsh-recall-badge-modified{color:var(--dsw-alias-state-error-primary);background:var(--dsw-alias-interactive-bg-hover)}',
|
|
55
|
+
'.dsh-recall-badge-restored{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover)}',
|
|
56
|
+
'.dsh-recall-badge-added{color:var(--dsw-alias-label-tertiary);background:var(--dsw-alias-interactive-bg-hover)}',
|
|
57
|
+
'.dsh-recall-rel{min-width:0;color:var(--dsw-alias-label-primary);word-break:break-all;font-family:var(--dsw-font-code, ui-monospace, SFMono-Regular, Consolas, monospace)}',
|
|
58
|
+
'.dsh-recall-panel-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:2px}',
|
|
59
|
+
'.dsh-recall-btn{border:none;border-radius:8px;padding:5px 14px;font-size:13px;line-height:20px;cursor:pointer;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover)}',
|
|
60
|
+
'.dsh-recall-btn:hover{color:var(--dsw-alias-label-primary)}',
|
|
61
|
+
'.dsh-recall-btn-danger{background:var(--dsw-alias-state-error-primary);color:#fff}',
|
|
62
|
+
'.dsh-recall-btn-danger:hover{color:#fff;filter:brightness(1.08)}'
|
|
63
|
+
].join('')
|
|
64
|
+
// 静态 bundle 的 ctx 可能不提供 styles 服务,降级为直接注入 <style>
|
|
65
|
+
const stylesSvc = ctx.get('styles')
|
|
66
|
+
if (stylesSvc && typeof stylesSvc.insert === 'function') {
|
|
67
|
+
stylesSvc.insert(css)
|
|
68
|
+
} else if (typeof document !== 'undefined') {
|
|
69
|
+
const tag = document.createElement('style')
|
|
70
|
+
tag.setAttribute('data-plugin', 'dsh-recall-plugin')
|
|
71
|
+
tag.textContent = css
|
|
72
|
+
document.head.appendChild(tag)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let initedSessionId = null
|
|
76
|
+
let initDone = Promise.resolve()
|
|
77
|
+
|
|
78
|
+
// 每个会话只向 Host 注册一次(预热其根目录解析缓存)。
|
|
79
|
+
// 返回 init 的 promise:Host 端 init 要跑数条 PowerShell(建仓/loadIndex),
|
|
80
|
+
// snapshot-info 必须等它完成后再查,否则冷启动时索引尚未载入会误判
|
|
81
|
+
// has:false 且不再重试,撤回按钮将永不出现。
|
|
82
|
+
function ensureInit(sessionId) {
|
|
83
|
+
if (!sessionId || initedSessionId === sessionId) return initDone
|
|
84
|
+
initedSessionId = sessionId
|
|
85
|
+
initDone = api('init', { sessionId }).catch(() => {})
|
|
86
|
+
return initDone
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 消息时间:当天只显示时分,跨天显示月/日 时分
|
|
90
|
+
function clockText(ms) {
|
|
91
|
+
try {
|
|
92
|
+
const d = new Date(ms)
|
|
93
|
+
const now = new Date()
|
|
94
|
+
const sameDay = d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate()
|
|
95
|
+
const hh = String(d.getHours()).padStart(2, '0')
|
|
96
|
+
const mm = String(d.getMinutes()).padStart(2, '0')
|
|
97
|
+
return sameDay ? hh + ':' + mm : (d.getMonth() + 1) + '/' + d.getDate() + ' ' + hh + ':' + mm
|
|
98
|
+
} catch (e) {
|
|
99
|
+
return ''
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 复制按钮走浏览器剪贴板;无 primitives 依赖,直接调用并带降级
|
|
104
|
+
function writeClipboard(text) {
|
|
105
|
+
try {
|
|
106
|
+
if (navigator && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
|
107
|
+
return navigator.clipboard.writeText(text).then(() => true, () => false)
|
|
108
|
+
}
|
|
109
|
+
} catch (e) { /* fall through */ }
|
|
110
|
+
try {
|
|
111
|
+
if (typeof document !== 'undefined' && typeof document.execCommand === 'function') {
|
|
112
|
+
const el = document.createElement('textarea')
|
|
113
|
+
el.value = text
|
|
114
|
+
el.setAttribute('readonly', '')
|
|
115
|
+
el.style.position = 'fixed'
|
|
116
|
+
el.style.left = '-9999px'
|
|
117
|
+
document.body.appendChild(el)
|
|
118
|
+
el.select()
|
|
119
|
+
try {
|
|
120
|
+
return Promise.resolve(document.execCommand('copy'))
|
|
121
|
+
} finally {
|
|
122
|
+
el.remove()
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
} catch (e) { /* ignore */ }
|
|
126
|
+
return Promise.resolve(false)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function CopyIcon() {
|
|
130
|
+
return React.createElement('svg', { width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, 'aria-hidden': true },
|
|
131
|
+
React.createElement('rect', { x: 5.5, y: 5.5, width: 8, height: 8, rx: 1.5 }),
|
|
132
|
+
React.createElement('path', { d: 'M10.5 5.5V4a1.5 1.5 0 0 0-1.5-1.5H4A1.5 1.5 0 0 0 2.5 4v5A1.5 1.5 0 0 0 4 10.5h1.5' })
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function CheckIcon() {
|
|
137
|
+
return React.createElement('svg', { width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true },
|
|
138
|
+
React.createElement('path', { d: 'm3 8.5 3.2 3.2L13 5' })
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function UndoIcon() {
|
|
143
|
+
return React.createElement('svg', { width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true },
|
|
144
|
+
React.createElement('path', { d: 'M7.5 3.5 3.5 7.5l4 4' }),
|
|
145
|
+
React.createElement('path', { d: 'M4.5 7.5h5a3 3 0 0 1 0 6H8' })
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function useImageSrc(attachment, loadImage) {
|
|
150
|
+
const [src, setSrc] = React.useState(null)
|
|
151
|
+
React.useEffect(() => {
|
|
152
|
+
let alive = true
|
|
153
|
+
if (!attachment || typeof loadImage !== 'function') return undefined
|
|
154
|
+
Promise.resolve(loadImage(attachment)).then((url) => {
|
|
155
|
+
if (alive && url) setSrc(String(url))
|
|
156
|
+
}).catch(() => {})
|
|
157
|
+
return () => { alive = false }
|
|
158
|
+
}, [attachment, loadImage])
|
|
159
|
+
return src
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function ImageBox(props) {
|
|
163
|
+
const src = useImageSrc(props.attachment, props.loadImage)
|
|
164
|
+
if (!src) return null
|
|
165
|
+
return React.createElement('img', { className: 'dsh-recall-img', src, alt: '' })
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const KIND_LABEL = { modified: '修改', restored: '恢复', added: '删除' }
|
|
169
|
+
|
|
170
|
+
function summaryText(counts) {
|
|
171
|
+
const parts = []
|
|
172
|
+
if (counts.modified > 0) parts.push('修改 ' + counts.modified)
|
|
173
|
+
if (counts.restored > 0) parts.push('恢复 ' + counts.restored)
|
|
174
|
+
if (counts.added > 0) parts.push('删除 ' + counts.added)
|
|
175
|
+
return parts.join(' · ')
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function recallPanel(recall, closePanel, executeRecall) {
|
|
179
|
+
if (recall.stage === 'loading') {
|
|
180
|
+
return React.createElement('div', { className: 'dsh-recall-panel' },
|
|
181
|
+
React.createElement('div', { className: 'dsh-recall-panel-title' }, '正在计算变更…')
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
if (recall.stage === 'error') {
|
|
185
|
+
return React.createElement('div', { className: 'dsh-recall-panel' },
|
|
186
|
+
React.createElement('div', { className: 'dsh-recall-panel-title' }, '无法回退'),
|
|
187
|
+
React.createElement('div', { className: 'dsh-recall-panel-note' }, recall.message || ''),
|
|
188
|
+
React.createElement('div', { className: 'dsh-recall-panel-actions' },
|
|
189
|
+
React.createElement('button', { type: 'button', className: 'dsh-recall-btn', onClick: closePanel }, '关闭')
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
if (recall.stage === 'confirm') {
|
|
194
|
+
const changes = recall.changes || []
|
|
195
|
+
const counts = { modified: 0, restored: 0, added: 0 }
|
|
196
|
+
for (const c of changes) {
|
|
197
|
+
if (c && counts[c.kind] !== undefined) counts[c.kind]++
|
|
198
|
+
}
|
|
199
|
+
const rows = changes.map((c, i) => React.createElement('div', { className: 'dsh-recall-file', key: i },
|
|
200
|
+
React.createElement('span', { className: 'dsh-recall-badge dsh-recall-badge-' + (c.kind || '') }, KIND_LABEL[c.kind] || c.kind || ''),
|
|
201
|
+
React.createElement('span', { className: 'dsh-recall-rel' }, c.rel || '')
|
|
202
|
+
))
|
|
203
|
+
// cutSeq 为 null 表示该消息是会话第一条用户消息:文件可回退但对话无从回退
|
|
204
|
+
const canRevertChat = typeof recall.cutSeq === 'number'
|
|
205
|
+
return React.createElement('div', { className: 'dsh-recall-panel' },
|
|
206
|
+
React.createElement('div', { className: 'dsh-recall-panel-title' }, '整段回退'),
|
|
207
|
+
React.createElement('div', { className: 'dsh-recall-panel-note' },
|
|
208
|
+
'将项目恢复到' + (recall.time ? ' ' + clockText(recall.time) + ' ' : ' ') + '发送该消息时的状态。共 ' + changes.length + ' 个文件将变更' + (summaryText(counts) ? '(' + summaryText(counts) + ')' : '') + '。此操作会覆盖当前文件内容,不会自动创建备份。'
|
|
209
|
+
),
|
|
210
|
+
React.createElement('div', { className: 'dsh-recall-panel-note' },
|
|
211
|
+
canRevertChat
|
|
212
|
+
? '对话将一并回退到该消息之前:该消息及之后的全部对话会从当前视图移除,原会话归档保存(可从归档找回)。'
|
|
213
|
+
: '该消息是本会话中第一条用户消息,无法回退对话;确认后仅回退项目文件。'
|
|
214
|
+
),
|
|
215
|
+
changes.length > 0 ? React.createElement('div', { className: 'dsh-recall-list' }, ...rows) : null,
|
|
216
|
+
React.createElement('div', { className: 'dsh-recall-panel-actions' },
|
|
217
|
+
React.createElement('button', { type: 'button', className: 'dsh-recall-btn', onClick: closePanel }, '取消'),
|
|
218
|
+
React.createElement('button', { type: 'button', className: 'dsh-recall-btn dsh-recall-btn-danger', onClick: executeRecall }, '确认回退')
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
if (recall.stage === 'executing') {
|
|
223
|
+
return React.createElement('div', { className: 'dsh-recall-panel' },
|
|
224
|
+
React.createElement('div', { className: 'dsh-recall-panel-title' }, '正在回退…')
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
if (recall.stage === 'done') {
|
|
228
|
+
return React.createElement('div', { className: 'dsh-recall-panel' },
|
|
229
|
+
React.createElement('div', { className: 'dsh-recall-panel-title' }, '回退完成'),
|
|
230
|
+
React.createElement('div', { className: 'dsh-recall-panel-note' },
|
|
231
|
+
recall.chatReverted
|
|
232
|
+
? '项目文件与对话已回退到该消息之前。新会话已打开,原会话已归档(可从归档找回)。'
|
|
233
|
+
: '项目已恢复到发送该消息时的状态。' + (recall.chatError ? ' 对话回退失败:' + recall.chatError : '')
|
|
234
|
+
),
|
|
235
|
+
React.createElement('div', { className: 'dsh-recall-panel-actions' },
|
|
236
|
+
React.createElement('button', { type: 'button', className: 'dsh-recall-btn', onClick: closePanel }, '关闭')
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
return null
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function UserRecallNode(props) {
|
|
244
|
+
const node = props && props.node
|
|
245
|
+
const loadImage = props && props.loadImage
|
|
246
|
+
const sessionId = props && props.sessionId
|
|
247
|
+
const data = node && node.data ? node.data : {}
|
|
248
|
+
// node.id 是会话事件匹配时写入的真实消息 ID;node.key 是位置键(如 13:input),不能用于快照查询
|
|
249
|
+
const messageId = node ? String(node.id || node.key || '') : ''
|
|
250
|
+
const blocks = Array.isArray(data.content) ? data.content : []
|
|
251
|
+
const text = blocks.filter((b) => b && b.type === 'text' && typeof b.text === 'string').map((b) => b.text).join('')
|
|
252
|
+
const images = blocks.filter((b) => b && b.type === 'image' && b.attachment).map((b) => b.attachment)
|
|
253
|
+
const rest = blocks.filter((b) => !b || !(b.type === 'text' && typeof b.text === 'string') && !(b.type === 'image' && b.attachment))
|
|
254
|
+
|
|
255
|
+
const [copied, setCopied] = React.useState(false)
|
|
256
|
+
const [hasSnapshot, setHasSnapshot] = React.useState(false)
|
|
257
|
+
const [recall, setRecall] = React.useState({ stage: 'idle' })
|
|
258
|
+
|
|
259
|
+
React.useEffect(() => {
|
|
260
|
+
let alive = true
|
|
261
|
+
// 先等 init 预热完成再查快照存在性:避免索引未载入时误判 has:false
|
|
262
|
+
ensureInit(sessionId).then(() => {
|
|
263
|
+
if (!messageId || !alive) return
|
|
264
|
+
return api('snapshot-info', { messageId }).then((res) => {
|
|
265
|
+
if (alive && res && res.has) setHasSnapshot(true)
|
|
266
|
+
}).catch(() => {})
|
|
267
|
+
})
|
|
268
|
+
return () => { alive = false }
|
|
269
|
+
}, [messageId, sessionId])
|
|
270
|
+
|
|
271
|
+
const onCopy = () => {
|
|
272
|
+
if (copied) return
|
|
273
|
+
writeClipboard(text).then(() => {
|
|
274
|
+
setCopied(true)
|
|
275
|
+
const timer = ctx.get('timer')
|
|
276
|
+
if (timer && typeof timer.timeout === 'function') {
|
|
277
|
+
timer.timeout(() => setCopied(false), 1200)
|
|
278
|
+
} else {
|
|
279
|
+
setTimeout(() => setCopied(false), 1200)
|
|
280
|
+
}
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const openPreview = () => {
|
|
285
|
+
if (recall.stage === 'loading' || recall.stage === 'executing') return
|
|
286
|
+
setRecall({ stage: 'loading' })
|
|
287
|
+
api('preview', { messageId, sessionId }).then((res) => {
|
|
288
|
+
if (!res || !res.ok) {
|
|
289
|
+
setRecall({ stage: 'error', message: (res && res.error) || '无法获取快照' })
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
setRecall({
|
|
293
|
+
stage: 'confirm',
|
|
294
|
+
changes: res.changes || [],
|
|
295
|
+
time: res.time || null,
|
|
296
|
+
cutSeq: typeof res.cutSeq === 'number' ? res.cutSeq : null
|
|
297
|
+
})
|
|
298
|
+
}).catch((error) => {
|
|
299
|
+
setRecall({ stage: 'error', message: String(error) })
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const executeRecall = () => {
|
|
304
|
+
if (recall.stage !== 'confirm') return
|
|
305
|
+
const changes = recall.changes || []
|
|
306
|
+
const previewCut = typeof recall.cutSeq === 'number' ? recall.cutSeq : null
|
|
307
|
+
setRecall({ stage: 'executing', changes })
|
|
308
|
+
api('execute', { messageId, sessionId }).then(async (res) => {
|
|
309
|
+
if (!res || !res.ok) {
|
|
310
|
+
setRecall({ stage: 'error', message: (res && res.error) || '回退失败' })
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
// 文件已回退;对话回退独立进行,失败只降级为“仅文件回退”而不是整体失败
|
|
314
|
+
const cutSeq = typeof res.cutSeq === 'number' ? res.cutSeq : previewCut
|
|
315
|
+
let chatReverted = false
|
|
316
|
+
let chatError = ''
|
|
317
|
+
if (cutSeq !== null && sessionsSvc && typeof sessionsSvc.fork === 'function') {
|
|
318
|
+
try {
|
|
319
|
+
const childId = await sessionsSvc.fork({ sessionId, atSeq: cutSeq, increaseTitle: true })
|
|
320
|
+
if (childId) {
|
|
321
|
+
if (typeof sessionsSvc.open === 'function') sessionsSvc.open(childId)
|
|
322
|
+
chatReverted = true
|
|
323
|
+
// 回退前的原会话归档:只是从列表隐藏、可恢复,避免侧栏出现两个近似会话
|
|
324
|
+
if (workspacesSvc && typeof workspacesSvc.archiveSession === 'function') {
|
|
325
|
+
workspacesSvc.archiveSession(sessionId).catch(() => {})
|
|
326
|
+
}
|
|
327
|
+
} else {
|
|
328
|
+
chatError = '未返回新会话'
|
|
329
|
+
}
|
|
330
|
+
} catch (error) {
|
|
331
|
+
chatError = String(error)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
setHasSnapshot(false)
|
|
335
|
+
setRecall({ stage: 'done', count: typeof res.count === 'number' ? res.count : changes.length, chatReverted, chatError })
|
|
336
|
+
}).catch((error) => {
|
|
337
|
+
setRecall({ stage: 'error', message: String(error) })
|
|
338
|
+
})
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const closePanel = () => setRecall({ stage: 'idle' })
|
|
342
|
+
|
|
343
|
+
const bubbleChildren = []
|
|
344
|
+
if (text !== '') bubbleChildren.push(React.createElement('div', { className: 'dsh-recall-bubble', key: 'text' }, text))
|
|
345
|
+
for (let i = 0; i < images.length; i++) {
|
|
346
|
+
bubbleChildren.push(React.createElement(ImageBox, { key: 'img-' + i, attachment: images[i], loadImage }))
|
|
347
|
+
}
|
|
348
|
+
for (let i = 0; i < rest.length; i++) {
|
|
349
|
+
bubbleChildren.push(React.createElement('pre', { className: 'dsh-recall-json', key: 'rest-' + i }, JSON.stringify(rest[i], null, 2)))
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const actions = []
|
|
353
|
+
actions.push(React.createElement('span', { className: 'dsh-recall-time', key: 'time' }, clockText(data.time)))
|
|
354
|
+
actions.push(React.createElement('button', {
|
|
355
|
+
key: 'copy',
|
|
356
|
+
type: 'button',
|
|
357
|
+
className: 'dsh-recall-action',
|
|
358
|
+
'aria-label': copied ? '已复制' : '复制',
|
|
359
|
+
title: copied ? '已复制' : '复制',
|
|
360
|
+
onClick: onCopy
|
|
361
|
+
}, copied ? React.createElement(CheckIcon, {}) : React.createElement(CopyIcon, {})))
|
|
362
|
+
if (hasSnapshot) {
|
|
363
|
+
actions.push(React.createElement('button', {
|
|
364
|
+
key: 'recall',
|
|
365
|
+
type: 'button',
|
|
366
|
+
className: 'dsh-recall-action',
|
|
367
|
+
'aria-label': '撤回',
|
|
368
|
+
title: '整段回退:文件与对话一并回到该消息之前',
|
|
369
|
+
onClick: openPreview
|
|
370
|
+
}, React.createElement(UndoIcon, {})))
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return React.createElement('div', { className: 'dsh-recall-row', 'data-time-hover-root': true },
|
|
374
|
+
bubbleChildren.length > 0 ? React.createElement('div', { className: 'dsh-recall-stack', key: 'stack' }, ...bubbleChildren) : null,
|
|
375
|
+
React.createElement('div', { className: 'dsh-recall-actions', key: 'actions' }, ...actions),
|
|
376
|
+
recallPanel(recall, closePanel, executeRecall)
|
|
377
|
+
)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// keyed slot 显式用低于默认 0 的 priority 注册:默认 0 通常被平台/
|
|
381
|
+
// 其他插件的渲染器占据,不指定 priority 会因 keyed slot 冲突拒载
|
|
382
|
+
// 整个插件;lowest renders,负值恰好覆盖默认渲染器实现撤回 UI。
|
|
383
|
+
// priority -1 也可能被别的机器上的插件占用(同样抛冲突),所以
|
|
384
|
+
// 递减重试三次——最坏情况只是撤回按钮不渲染,绝不让插件加载失败。
|
|
385
|
+
let mounted = false
|
|
386
|
+
for (let priority = -1; priority >= -3 && !mounted; priority--) {
|
|
387
|
+
try {
|
|
388
|
+
slots.inject('conversation.chat.node', () => slots.register(
|
|
389
|
+
{ name: 'conversation.chat.node', key: 'user', priority },
|
|
390
|
+
UserRecallNode
|
|
391
|
+
))
|
|
392
|
+
mounted = true
|
|
393
|
+
} catch (error) {
|
|
394
|
+
if (priority === -3) console.error('[dsh-recall-plugin] slot register failed:', error)
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
})
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-recall-plugin — Host 半(持久插件形态,bundle 行挂载)
|
|
3
|
+
*
|
|
4
|
+
* 职责:监听 session/event,为每条用户消息创建项目快照(独立影子 git 仓库,
|
|
5
|
+
* 默认存于 DSH home,受限会话自动降级项目内并可迁回);
|
|
6
|
+
* 通过 webServer 注册 /api/recall/* HTTP API,供 Client 半调用
|
|
7
|
+
* (init / snapshot-info / preview / execute)。
|
|
8
|
+
*
|
|
9
|
+
* 这是持久 npm 插件包的主入口(exports["."]),由 cordis.patch.yml 的
|
|
10
|
+
* insert 行挂载进 profile composition,DSH 重启后自动生效。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const name = 'dsh-recall-plugin'
|
|
14
|
+
|
|
15
|
+
// 硬依赖:shell(PowerShell 执行)、sessions(会话/沙箱策略)、
|
|
16
|
+
// webServer(Client 半的 HTTP API 通道)。其余服务按需 ctx.get。
|
|
17
|
+
export const inject = ['shell', 'sessions', 'webServer']
|
|
18
|
+
|
|
19
|
+
const MAX_FILE_BYTES = 104857600
|
|
20
|
+
const HOME_RETRY_MS = 300000
|
|
21
|
+
|
|
22
|
+
export function apply(ctx) {
|
|
23
|
+
const shell = ctx.shell
|
|
24
|
+
const sessions = ctx.sessions
|
|
25
|
+
const webServer = ctx.webServer
|
|
26
|
+
|
|
27
|
+
const state = {
|
|
28
|
+
roots: new Map(),
|
|
29
|
+
stores: new Map(),
|
|
30
|
+
snapshots: new Map(),
|
|
31
|
+
queue: Promise.resolve(),
|
|
32
|
+
indexLoaded: new Set(),
|
|
33
|
+
gitReady: new Set(),
|
|
34
|
+
cutSeqCache: new Map(),
|
|
35
|
+
homeRetryAt: new Map(),
|
|
36
|
+
gitExe: null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function psq(value) {
|
|
40
|
+
return "'" + String(value).replace(/'/g, "''") + "'"
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 每次 shell 调用都带会话沙箱策略:默认受限模式下不带策略连临时目录都写不了。
|
|
44
|
+
async function runShell(command, opts, sessionId) {
|
|
45
|
+
let policy
|
|
46
|
+
const sp = ctx.get('sandboxPolicy')
|
|
47
|
+
if (sp) {
|
|
48
|
+
const session = sessionId ? sessions.get(sessionId) : undefined
|
|
49
|
+
policy = sp.resolve(session ? { session } : {})
|
|
50
|
+
}
|
|
51
|
+
const spec = shell.resolve({
|
|
52
|
+
command,
|
|
53
|
+
timeoutMs: (opts && opts.timeoutMs) || 300000,
|
|
54
|
+
stdoutMaxBytes: (opts && opts.stdoutMaxBytes) || 4194304,
|
|
55
|
+
...(policy ? { sandboxPolicy: policy } : {})
|
|
56
|
+
})
|
|
57
|
+
const res = await shell.run(spec)
|
|
58
|
+
const out = (res && res.stdout && res.stdout.text) || ''
|
|
59
|
+
if (res && res.exitCode !== 0) {
|
|
60
|
+
const err = ((res && res.stderr && res.stderr.text) || '').trim() || ('exit ' + String(res.exitCode))
|
|
61
|
+
throw new Error(err.slice(0, 1500))
|
|
62
|
+
}
|
|
63
|
+
return out
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function stripBom(text) {
|
|
67
|
+
return text.replace(/^\uFEFF/, '')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function resolveRoot(sessionId) {
|
|
71
|
+
const key = sessionId ? String(sessionId) : 'fallback'
|
|
72
|
+
const cached = state.roots.get(key)
|
|
73
|
+
if (cached) return cached
|
|
74
|
+
let root = null
|
|
75
|
+
if (sessionId) {
|
|
76
|
+
const session = sessions.get(sessionId)
|
|
77
|
+
if (session && session.header && session.header.cwd) root = session.header.cwd
|
|
78
|
+
}
|
|
79
|
+
if (!root) {
|
|
80
|
+
const sp = ctx.get('sandboxPolicy')
|
|
81
|
+
if (sp && sp.workspaceRoot) root = sp.workspaceRoot
|
|
82
|
+
}
|
|
83
|
+
if (root) state.roots.set(key, root)
|
|
84
|
+
return root
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 会话查找(按 cwd 匹配),用于索引读写与 git 操作的沙箱策略
|
|
88
|
+
function sessionForRoot(root) {
|
|
89
|
+
const found = sessions.list().find((s) => s && s.header && s.header.cwd === root)
|
|
90
|
+
return found ? found.id : null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 解析 git 可执行文件路径:DSH 进程 PATH 可能不含 git,
|
|
94
|
+
// 求值一次并缓存,脚本里用绝对路径调用,避免每条命令依赖 PATH。
|
|
95
|
+
async function resolveGit() {
|
|
96
|
+
if (state.gitExe !== null) return state.gitExe
|
|
97
|
+
try {
|
|
98
|
+
let path = stripBom(await runShell("$g = (Get-Command git -ErrorAction SilentlyContinue).Source; if (-not $g -and (Test-Path -LiteralPath 'C:\\Program Files\\Git\\cmd\\git.exe')) { $g = 'C:\\Program Files\\Git\\cmd\\git.exe' }; if ($g) { Write-Output $g }", { stdoutMaxBytes: 4096 }, null)).trim()
|
|
99
|
+
state.gitExe = path || ''
|
|
100
|
+
} catch (error) {
|
|
101
|
+
state.gitExe = ''
|
|
102
|
+
}
|
|
103
|
+
return state.gitExe
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 计算项目对应的 home 存储目录(DSH_HOME 优先,否则 ~/.dsh)。
|
|
107
|
+
// 哈希用 Create()+ComputeHash+BitConverter 而不是 HashData+ToHexString:
|
|
108
|
+
// 后两者是 .NET 5+(仅 PS 7)API,别人机器的 shell 若是 Windows PowerShell
|
|
109
|
+
// 5.1 会抛错,导致 home 存储永远降级到项目内;前者两个版本都可用。
|
|
110
|
+
async function homeDirFor(root, sessionId) {
|
|
111
|
+
const dirScript = [
|
|
112
|
+
'$r = ' + psq(root),
|
|
113
|
+
'$h = if ($env:DSH_HOME) { $env:DSH_HOME } else { Join-Path $env:USERPROFILE ".dsh" }',
|
|
114
|
+
'$sha = [Security.Cryptography.SHA256]::Create()',
|
|
115
|
+
"$hex = ([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($r))) -replace '-','').ToLower()",
|
|
116
|
+
"Write-Output (Join-Path $h ('dsh-recall-snapshots\\' + $hex))"
|
|
117
|
+
].join('\n')
|
|
118
|
+
const text = stripBom(await runShell(dirScript, { stdoutMaxBytes: 4096 }, sessionId)).trim()
|
|
119
|
+
if (!text) return null
|
|
120
|
+
// PS 的 Join-Path 可能带出连续反斜杠(旧版脚本遗留 "…\\<hex>" 形态);
|
|
121
|
+
// 折叠成单反斜杠。Windows 把中间的重复分隔符视为同一个目录,
|
|
122
|
+
// 所以与既有快照数据(index.json、影子 git 仓库)路径完全兼容。
|
|
123
|
+
return text.replace(/\\{2,}/g, '\\')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 存储根:优先放 DSH home(保持项目目录干净);
|
|
127
|
+
// workspace-write 等受限会话写不了 home 时自动降级到项目内(功能优先于干净)。
|
|
128
|
+
// git init <dir> 会把真实 git-dir 建在 <dir>\.git,所以 store.repo 是
|
|
129
|
+
// 仓库工作目录、store.git 是真实 git-dir——冒烟测试踩过的坑。
|
|
130
|
+
async function resolveStore(root, sessionId) {
|
|
131
|
+
const cached = state.stores.get(root)
|
|
132
|
+
if (cached) return cached
|
|
133
|
+
let homeDir = null
|
|
134
|
+
try {
|
|
135
|
+
homeDir = await homeDirFor(root, sessionId)
|
|
136
|
+
} catch (error) {
|
|
137
|
+
homeDir = null
|
|
138
|
+
}
|
|
139
|
+
if (homeDir) {
|
|
140
|
+
try {
|
|
141
|
+
await runShell('New-Item -ItemType Directory -Force -Path ' + psq(homeDir) + ' | Out-Null', { stdoutMaxBytes: 4096 }, sessionId)
|
|
142
|
+
const store = { dir: homeDir, repo: homeDir + '\\git', git: homeDir + '\\git\\.git', home: true }
|
|
143
|
+
state.stores.set(root, store)
|
|
144
|
+
return store
|
|
145
|
+
} catch (error) {
|
|
146
|
+
console.error('recall home store unavailable, falling back to workspace:', String(error))
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const fallback = root + '\\.dsh-recall-snapshots'
|
|
150
|
+
await runShell('New-Item -ItemType Directory -Force -Path ' + psq(fallback) + ' | Out-Null', { stdoutMaxBytes: 4096 }, sessionId)
|
|
151
|
+
const store = { dir: fallback, repo: fallback + '\\git', git: fallback + '\\git\\.git', home: false }
|
|
152
|
+
state.stores.set(root, store)
|
|
153
|
+
return store
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 降级后自动升级:会话权限提升(home 恢复可写)时,把项目内的
|
|
157
|
+
// 影子仓库整体迁回 home 并删除项目内目录,恢复“项目目录干净”。
|
|
158
|
+
// 失败节流 5 分钟,避免受限会话里每条消息都白试一次。
|
|
159
|
+
async function tryUpgradeToHome(root, sessionId) {
|
|
160
|
+
const store = state.stores.get(root)
|
|
161
|
+
if (!store || store.home) return store
|
|
162
|
+
const now = Date.now()
|
|
163
|
+
const last = state.homeRetryAt.get(root) || 0
|
|
164
|
+
if (now - last < HOME_RETRY_MS) return store
|
|
165
|
+
state.homeRetryAt.set(root, now)
|
|
166
|
+
let homeDir = null
|
|
167
|
+
try {
|
|
168
|
+
homeDir = await homeDirFor(root, sessionId)
|
|
169
|
+
} catch (error) {
|
|
170
|
+
homeDir = null
|
|
171
|
+
}
|
|
172
|
+
if (!homeDir) return store
|
|
173
|
+
try {
|
|
174
|
+
await runShell('New-Item -ItemType Directory -Force -Path ' + psq(homeDir) + ' | Out-Null', { stdoutMaxBytes: 4096 }, sessionId)
|
|
175
|
+
const migrate = [
|
|
176
|
+
"$ErrorActionPreference = 'Stop'",
|
|
177
|
+
'$src = ' + psq(store.dir),
|
|
178
|
+
'$dst = ' + psq(homeDir),
|
|
179
|
+
"if (Test-Path -LiteralPath (Join-Path $src 'git')) { Move-Item -LiteralPath (Join-Path $src 'git') -Destination (Join-Path $dst 'git') -Force }",
|
|
180
|
+
"if (Test-Path -LiteralPath (Join-Path $src 'index.json')) { Move-Item -LiteralPath (Join-Path $src 'index.json') -Destination (Join-Path $dst 'index.json') -Force }",
|
|
181
|
+
'Remove-Item -Recurse -Force -LiteralPath $src -ErrorAction SilentlyContinue',
|
|
182
|
+
"Write-Output 'MIGRATE_OK'"
|
|
183
|
+
].join('\n')
|
|
184
|
+
await runShell(migrate, { timeoutMs: 300000, stdoutMaxBytes: 4096 }, sessionId)
|
|
185
|
+
const upgraded = { dir: homeDir, repo: homeDir + '\\git', git: homeDir + '\\git\\.git', home: true }
|
|
186
|
+
state.stores.set(root, upgraded)
|
|
187
|
+
state.gitReady.delete(store.git)
|
|
188
|
+
console.error('recall store upgraded to home:', root)
|
|
189
|
+
return upgraded
|
|
190
|
+
} catch (error) {
|
|
191
|
+
console.error('recall home upgrade failed:', String(error))
|
|
192
|
+
return store
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 建立影子仓库:普通 init(index 留在仓库内跨快照复用,git add 的 stat 缓存
|
|
197
|
+
// 让未变文件近乎零成本),core.longpaths 放开 Windows 深路径,
|
|
198
|
+
// info/exclude 排除 .git(项目是 git 仓库时)、node_modules、降级时的自目录。
|
|
199
|
+
// 幂等:gitReady 命中后直接跳过,省掉每条消息一次的 config/exclude 重写。
|
|
200
|
+
async function ensureGit(root, store, sessionId) {
|
|
201
|
+
if (state.gitReady.has(store.git)) return true
|
|
202
|
+
const gitExe = await resolveGit()
|
|
203
|
+
if (!gitExe) return false
|
|
204
|
+
const script = [
|
|
205
|
+
"$ErrorActionPreference = 'Stop'",
|
|
206
|
+
// 不设 PSNativeCommandUseErrorActionPreference:git 的 stderr 警告(hint 等)
|
|
207
|
+
// 在 DSH shell 注入方式下会被包成 ErrorRecord,配上 EAP=Stop 直接终止脚本;
|
|
208
|
+
// 真正的 git 失败由 runShell 统一按 exitCode 检测抛错,不依赖这里。
|
|
209
|
+
'$git = ' + psq(gitExe),
|
|
210
|
+
'$repo = ' + psq(store.repo),
|
|
211
|
+
'$g = ' + psq(store.git),
|
|
212
|
+
'if (-not (Test-Path -LiteralPath $g)) {',
|
|
213
|
+
' & $git init $repo | Out-Null',
|
|
214
|
+
'}',
|
|
215
|
+
'& $git --git-dir=$g config core.longpaths true',
|
|
216
|
+
// autocrlf=false:按原始字节入快照(回退时逐字节还原),也避免
|
|
217
|
+
// 用户全局 autocrlf=true 时的 LF/CRLF stderr 警告;addEmbeddedRepo=false:
|
|
218
|
+
// 嵌套仓库 hint/warning 走 stderr,在 DSH shell(EAP=Stop)下会让
|
|
219
|
+
// 整条脚本非零退出,必须在仓库级配置里静默掉。
|
|
220
|
+
'& $git --git-dir=$g config core.autocrlf false',
|
|
221
|
+
'& $git --git-dir=$g config advice.addEmbeddedRepo false',
|
|
222
|
+
"Set-Content -LiteralPath (Join-Path $g 'info\\exclude') -Value \"`n.git`nnode_modules/`n.dsh-recall-snapshots/\" -Encoding utf8 -NoNewline",
|
|
223
|
+
"Write-Output 'GIT_OK'"
|
|
224
|
+
].join('\n')
|
|
225
|
+
try {
|
|
226
|
+
await runShell(script, { stdoutMaxBytes: 4096 }, sessionId)
|
|
227
|
+
state.gitReady.add(store.git)
|
|
228
|
+
return true
|
|
229
|
+
} catch (error) {
|
|
230
|
+
console.error('recall ensureGit failed:', String(error))
|
|
231
|
+
return false
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// 快照:git add -A 增量同步 index(.gitignore/exclude 语义由 git 统一处理),
|
|
236
|
+
// 剔除超大文件(参考 TraeWork update_snapshot_file_over_size 的跳过策略),
|
|
237
|
+
// write-tree 生成树、commit-tree 生成无父孤儿提交、tag 保对象可达。
|
|
238
|
+
// 不做 parent 链、不修剪:像 TraeWork 一样保留全量历史,tag 永远可查。
|
|
239
|
+
//
|
|
240
|
+
// 嵌套 git 仓库(工作区里的子项目自带 .git)会被 add -A 记成 gitlink(160000);
|
|
241
|
+
// gitlink 残留在 index 时 add -A 会 fatal "in unpopulated submodule",
|
|
242
|
+
// 且 gitlink 对文件回退毫无意义——所以 add 前后各清一次,子仓库内容不进快照。
|
|
243
|
+
// 依赖外层脚本已定义的 $git/$g;返回的行片段嵌入 snapshot/diff/rollback 三处
|
|
244
|
+
function dropGitlinksScript() {
|
|
245
|
+
return [
|
|
246
|
+
"& $git --git-dir=$g ls-files --stage | Where-Object { $_ -like '160000*' } | ForEach-Object {",
|
|
247
|
+
" $p = ($_ -split \"`t\")[1]",
|
|
248
|
+
' & $git --literal-pathspecs --git-dir=$g update-index --force-remove -- $p',
|
|
249
|
+
'}'
|
|
250
|
+
].join('\n')
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function snapshotScript(root, store, gitExe, messageId) {
|
|
254
|
+
const sync = [
|
|
255
|
+
"$ErrorActionPreference = 'Stop'",
|
|
256
|
+
// 不设 PSNativeCommandUseErrorActionPreference:git 的 stderr 警告(hint 等)
|
|
257
|
+
// 在 DSH shell 注入方式下会被包成 ErrorRecord,配上 EAP=Stop 直接终止脚本;
|
|
258
|
+
// 真正的 git 失败由 runShell 统一按 exitCode 检测抛错,不依赖这里。
|
|
259
|
+
'$git = ' + psq(gitExe),
|
|
260
|
+
'$g = ' + psq(store.git),
|
|
261
|
+
'$root = ' + psq(root),
|
|
262
|
+
dropGitlinksScript(),
|
|
263
|
+
'& $git --git-dir=$g --work-tree=$root add -A',
|
|
264
|
+
dropGitlinksScript(),
|
|
265
|
+
'Get-ChildItem -LiteralPath $root -Recurse -File -Force | Where-Object { $_.Length -gt ' + MAX_FILE_BYTES + ' } | ForEach-Object {',
|
|
266
|
+
" $rel = $_.FullName.Substring($root.Length + 1).Replace('\\','/')",
|
|
267
|
+
' & $git --literal-pathspecs --git-dir=$g update-index --force-remove -- $rel',
|
|
268
|
+
'}',
|
|
269
|
+
'$tree = (& $git --git-dir=$g --work-tree=$root write-tree).Trim()',
|
|
270
|
+
"$commit = (& $git --git-dir=$g -c user.name=dsh-recall -c user.email=recall@dsh.local commit-tree $tree -m ('snapshot ' + " + psq(messageId) + ")).Trim()",
|
|
271
|
+
'& $git --git-dir=$g tag ' + psq('snap-' + messageId) + ' $commit | Out-Null',
|
|
272
|
+
"Write-Output 'SNAP_OK'"
|
|
273
|
+
]
|
|
274
|
+
return sync.join('\n')
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// diff:把当前状态 add 进 index 后用 ls-files --stage 取当前清单,
|
|
278
|
+
// 与目标 tag 的 ls-tree 对比——ignore/exclude 语义两侧一致,不会把
|
|
279
|
+
// node_modules 等误报为“新增”。
|
|
280
|
+
// 不用 -z:PowerShell 捕获原生命令输出会丢弃含 NUL 的行(实测整段变 null),
|
|
281
|
+
// 改用 core.quotePath=false 让非 ASCII 路径原样输出,逐行按 TAB 解析;
|
|
282
|
+
// [Console]::OutputEncoding=UTF8 保证中文路径正确解码。
|
|
283
|
+
// 代价是文件名含换行的极端情况会解析错乱——概率可忽略,记录为已知限制。
|
|
284
|
+
function diffScript(root, store, gitExe, tag) {
|
|
285
|
+
const script = [
|
|
286
|
+
"$ErrorActionPreference = 'Stop'",
|
|
287
|
+
'$OutputEncoding = [Text.UTF8Encoding]::new($false)',
|
|
288
|
+
'try { [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) } catch {}',
|
|
289
|
+
'$git = ' + psq(gitExe),
|
|
290
|
+
'$g = ' + psq(store.git),
|
|
291
|
+
'$root = ' + psq(root),
|
|
292
|
+
dropGitlinksScript(),
|
|
293
|
+
'& $git --git-dir=$g --work-tree=$root add -A',
|
|
294
|
+
dropGitlinksScript(),
|
|
295
|
+
'Get-ChildItem -LiteralPath $root -Recurse -File -Force | Where-Object { $_.Length -gt ' + MAX_FILE_BYTES + ' } | ForEach-Object {',
|
|
296
|
+
" $rel = $_.FullName.Substring($root.Length + 1).Replace('\\','/')",
|
|
297
|
+
' & $git --literal-pathspecs --git-dir=$g update-index --force-remove -- $rel',
|
|
298
|
+
'}',
|
|
299
|
+
'$curOut = & $git -c core.quotePath=false --git-dir=$g --work-tree=$root ls-files --stage',
|
|
300
|
+
// 旧 tag 的树里可能仍有 gitlink(修复前留下的),从目标侧一并剔除,
|
|
301
|
+
// 否则 diff 会报出“恢复 dsh-recall-plugin”这类幻影条目
|
|
302
|
+
"$targetOut = @(& $git -c core.quotePath=false --git-dir=$g ls-tree -r " + psq(tag) + " | Where-Object { -not $_.StartsWith('160000') })",
|
|
303
|
+
'$curMap = @{}',
|
|
304
|
+
'foreach ($r in @($curOut)) {',
|
|
305
|
+
' if (-not $r) { continue }',
|
|
306
|
+
' $tab = $r.IndexOf("`t"); $path = $r.Substring($tab + 1)',
|
|
307
|
+
' $sha = ($r.Substring(0, $tab) -split " ")[1]',
|
|
308
|
+
' $curMap[$path] = $sha',
|
|
309
|
+
'}',
|
|
310
|
+
'$targetMap = @{}',
|
|
311
|
+
'foreach ($r in @($targetOut)) {',
|
|
312
|
+
' if (-not $r) { continue }',
|
|
313
|
+
' $tab = $r.IndexOf("`t"); $path = $r.Substring($tab + 1)',
|
|
314
|
+
' $sha = ($r.Substring(0, $tab) -split " ")[2]',
|
|
315
|
+
' $targetMap[$path] = $sha',
|
|
316
|
+
'}',
|
|
317
|
+
'$result = @()',
|
|
318
|
+
'foreach ($k in $curMap.Keys) {',
|
|
319
|
+
' if (-not $targetMap.ContainsKey($k)) { $result += [pscustomobject]@{ rel = $k; kind = "added" } }',
|
|
320
|
+
' elseif ($targetMap[$k] -ne $curMap[$k]) { $result += [pscustomobject]@{ rel = $k; kind = "modified" } }',
|
|
321
|
+
'}',
|
|
322
|
+
'foreach ($k in $targetMap.Keys) {',
|
|
323
|
+
' if (-not $curMap.ContainsKey($k)) { $result += [pscustomobject]@{ rel = $k; kind = "restored" } }',
|
|
324
|
+
'}',
|
|
325
|
+
'$sorted = @($result | Sort-Object rel)',
|
|
326
|
+
'Write-Output (ConvertTo-Json -InputObject $sorted -Depth 3 -Compress)'
|
|
327
|
+
]
|
|
328
|
+
return script.join('\n')
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// 回退:archive 生成 zip 直接落盘(二进制不经 shell 文本管道),
|
|
332
|
+
// Expand-Archive 覆盖回工作区;再删除“当前有、目标无”的文件。
|
|
333
|
+
// 空树跳过 archive(空 zip 会让 Expand-Archive 报错),只执行删除。
|
|
334
|
+
// 回退后保留快照 tag 与索引:git delta 空间便宜,保留历史可再次
|
|
335
|
+
// 用该快照恢复(幂等),也避免误回退后无法找回。
|
|
336
|
+
function rollbackScript(root, store, gitExe, tag) {
|
|
337
|
+
const script = [
|
|
338
|
+
"$ErrorActionPreference = 'Stop'",
|
|
339
|
+
'$OutputEncoding = [Text.UTF8Encoding]::new($false)',
|
|
340
|
+
'try { [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) } catch {}',
|
|
341
|
+
'$git = ' + psq(gitExe),
|
|
342
|
+
'$g = ' + psq(store.git),
|
|
343
|
+
'$root = ' + psq(root),
|
|
344
|
+
dropGitlinksScript(),
|
|
345
|
+
'& $git --git-dir=$g --work-tree=$root add -A',
|
|
346
|
+
dropGitlinksScript(),
|
|
347
|
+
'Get-ChildItem -LiteralPath $root -Recurse -File -Force | Where-Object { $_.Length -gt ' + MAX_FILE_BYTES + ' } | ForEach-Object {',
|
|
348
|
+
" $rel = $_.FullName.Substring($root.Length + 1).Replace('\\','/')",
|
|
349
|
+
' & $git --literal-pathspecs --git-dir=$g update-index --force-remove -- $rel',
|
|
350
|
+
'}',
|
|
351
|
+
// 同 diffScript:-z 的 NUL 输出会被 PowerShell 捕获丢弃,改为逐行 + quotePath=false
|
|
352
|
+
'$curOut = & $git -c core.quotePath=false --git-dir=$g --work-tree=$root ls-files --stage',
|
|
353
|
+
// 旧 tag 的树里可能仍有 gitlink(修复前留下的),从目标侧一并剔除,
|
|
354
|
+
// 否则 diff 会报出“恢复 dsh-recall-plugin”这类幻影条目
|
|
355
|
+
"$targetOut = @(& $git -c core.quotePath=false --git-dir=$g ls-tree -r " + psq(tag) + " | Where-Object { -not $_.StartsWith('160000') })",
|
|
356
|
+
'$targetMap = @{}',
|
|
357
|
+
'foreach ($r in @($targetOut)) {',
|
|
358
|
+
' if (-not $r) { continue }',
|
|
359
|
+
' $tab = $r.IndexOf("`t"); $path = $r.Substring($tab + 1)',
|
|
360
|
+
' $targetMap[$path] = $true',
|
|
361
|
+
'}',
|
|
362
|
+
'$restored = $targetMap.Count',
|
|
363
|
+
'if ($restored -gt 0) {',
|
|
364
|
+
' $zip = ' + psq(store.dir + '\\restore-tmp.zip'),
|
|
365
|
+
' & $git --git-dir=$g archive --format=zip --output=$zip ' + psq(tag),
|
|
366
|
+
' Expand-Archive -LiteralPath $zip -DestinationPath $root -Force',
|
|
367
|
+
' Remove-Item -LiteralPath $zip -Force',
|
|
368
|
+
'}',
|
|
369
|
+
'$deleted = 0',
|
|
370
|
+
'foreach ($r in @($curOut)) {',
|
|
371
|
+
' if (-not $r) { continue }',
|
|
372
|
+
' $tab = $r.IndexOf("`t"); $path = $r.Substring($tab + 1)',
|
|
373
|
+
' if (-not $targetMap.ContainsKey($path)) {',
|
|
374
|
+
" $full = Join-Path $root ($path.Replace('/','\\'))",
|
|
375
|
+
' if (Test-Path -LiteralPath $full) { Remove-Item -LiteralPath $full -Force; $deleted++ }',
|
|
376
|
+
' }',
|
|
377
|
+
'}',
|
|
378
|
+
"Write-Output ('ROLLBACK_OK ' + $deleted + ' ' + $restored)"
|
|
379
|
+
]
|
|
380
|
+
return script.join('\n')
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function listTagsScript(store, gitExe) {
|
|
384
|
+
return [
|
|
385
|
+
"$ErrorActionPreference = 'Stop'",
|
|
386
|
+
// 不设 PSNativeCommandUseErrorActionPreference:git 的 stderr 警告(hint 等)
|
|
387
|
+
// 在 DSH shell 注入方式下会被包成 ErrorRecord,配上 EAP=Stop 直接终止脚本;
|
|
388
|
+
// 真正的 git 失败由 runShell 统一按 exitCode 检测抛错,不依赖这里。
|
|
389
|
+
'$git = ' + psq(gitExe),
|
|
390
|
+
'$g = ' + psq(store.git),
|
|
391
|
+
'& $git --git-dir=$g tag -l "snap-*"'
|
|
392
|
+
].join('\n')
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// 索引写入合并为单次 shell 调用:pwsh 进程启动是主要耗时,能省一次是一次
|
|
396
|
+
async function saveIndex(root, sessionId) {
|
|
397
|
+
const store = state.stores.get(root)
|
|
398
|
+
if (!store) return
|
|
399
|
+
const entries = Array.from(state.snapshots.entries())
|
|
400
|
+
.filter(([, s]) => s.root === root)
|
|
401
|
+
.map(([id, s]) => ({ id, time: s.time, count: s.count, sessionId: s.sessionId }))
|
|
402
|
+
const json = JSON.stringify(entries)
|
|
403
|
+
try {
|
|
404
|
+
const b64 = Buffer.from(json, 'utf8').toString('base64')
|
|
405
|
+
await runShell("New-Item -ItemType Directory -Force -Path " + psq(store.dir) + " | Out-Null; [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + b64 + "')) | Set-Content -LiteralPath " + psq(store.dir + '\\index.json') + ' -Encoding utf8 -NoNewline', { stdoutMaxBytes: 4096 }, sessionId)
|
|
406
|
+
} catch (error) {
|
|
407
|
+
console.error('recall saveIndex failed:', String(error))
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function loadIndex(root, sessionId) {
|
|
412
|
+
if (state.indexLoaded.has(root)) return
|
|
413
|
+
state.indexLoaded.add(root)
|
|
414
|
+
const store = state.stores.get(root)
|
|
415
|
+
if (!store) return
|
|
416
|
+
try {
|
|
417
|
+
const raw = stripBom(await runShell('Get-Content -LiteralPath ' + psq(store.dir + '\\index.json') + ' -Raw -ErrorAction SilentlyContinue', { stdoutMaxBytes: 4194304 }, sessionId)).trim()
|
|
418
|
+
if (!raw) return
|
|
419
|
+
const entries = JSON.parse(raw)
|
|
420
|
+
if (!Array.isArray(entries)) return
|
|
421
|
+
for (const entry of entries) {
|
|
422
|
+
if (!entry || typeof entry.id !== 'string') continue
|
|
423
|
+
state.snapshots.set(entry.id, {
|
|
424
|
+
root,
|
|
425
|
+
time: typeof entry.time === 'number' ? entry.time : Date.now(),
|
|
426
|
+
count: typeof entry.count === 'number' ? entry.count : 0,
|
|
427
|
+
sessionId: entry.sessionId || sessionId
|
|
428
|
+
})
|
|
429
|
+
}
|
|
430
|
+
} catch (error) {
|
|
431
|
+
/* 索引缺失或损坏时按空历史处理 */
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// 索引丢失时从仓库 tag 重建:tag 名 snap-<messageId> 本身就是快照主键
|
|
436
|
+
async function rebuildOrphans(root, sessionId) {
|
|
437
|
+
const store = state.stores.get(root)
|
|
438
|
+
const gitExe = await resolveGit()
|
|
439
|
+
if (!store || !gitExe) return
|
|
440
|
+
try {
|
|
441
|
+
const listing = stripBom(await runShell(listTagsScript(store, gitExe), { stdoutMaxBytes: 4194304 }, sessionId)).trim()
|
|
442
|
+
if (!listing) return
|
|
443
|
+
for (const name of listing.split(/\r?\n/)) {
|
|
444
|
+
const id = name.trim().replace(/^snap-/, '')
|
|
445
|
+
if (!id || state.snapshots.has(id)) continue
|
|
446
|
+
state.snapshots.set(id, { root, time: 0, count: 0, sessionId })
|
|
447
|
+
}
|
|
448
|
+
await saveIndex(root, sessionId)
|
|
449
|
+
} catch (error) {
|
|
450
|
+
console.error('recall rebuildOrphans failed:', String(error))
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// 迁移收尾:删除旧版 blobs 格式的项目内 .dsh-recall-snapshots 目录,
|
|
455
|
+
// 仅在 home 存储可用时执行——降级场景下该目录就是新 store,不能删。
|
|
456
|
+
function cleanupLegacy(root, sessionId) {
|
|
457
|
+
const store = state.stores.get(root)
|
|
458
|
+
if (!store || !store.home) return
|
|
459
|
+
runShell('Remove-Item -Recurse -Force -LiteralPath ' + psq(root + '\\.dsh-recall-snapshots'), { timeoutMs: 120000, stdoutMaxBytes: 4096 }, sessionId).catch(() => {})
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function captureSnapshot(sessionId, messageId, time) {
|
|
463
|
+
const root = await resolveRoot(sessionId)
|
|
464
|
+
if (!root) return
|
|
465
|
+
let store = await resolveStore(root, sessionId)
|
|
466
|
+
store = await tryUpgradeToHome(root, sessionId)
|
|
467
|
+
const ok = await ensureGit(root, store, sessionId)
|
|
468
|
+
if (!ok) return
|
|
469
|
+
await loadIndex(root, sessionId)
|
|
470
|
+
try {
|
|
471
|
+
await runShell(snapshotScript(root, store, state.gitExe, messageId), { timeoutMs: 600000, stdoutMaxBytes: 65536 }, sessionId)
|
|
472
|
+
state.snapshots.set(String(messageId), { root, time: time || Date.now(), count: 0, sessionId })
|
|
473
|
+
await saveIndex(root, sessionId)
|
|
474
|
+
} catch (error) {
|
|
475
|
+
console.error('recall snapshot failed:', String(error))
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async function diffFor(messageId) {
|
|
480
|
+
const snap = state.snapshots.get(String(messageId))
|
|
481
|
+
if (!snap) return null
|
|
482
|
+
const store = state.stores.get(snap.root)
|
|
483
|
+
if (!store) return null
|
|
484
|
+
const text = stripBom(await runShell(diffScript(snap.root, store, state.gitExe, 'snap-' + messageId), { timeoutMs: 600000, stdoutMaxBytes: 4194304 }, snap.sessionId))
|
|
485
|
+
const trimmed = text.trim()
|
|
486
|
+
if (!trimmed) return []
|
|
487
|
+
const parsed = JSON.parse(trimmed)
|
|
488
|
+
if (Array.isArray(parsed)) return parsed
|
|
489
|
+
if (parsed && typeof parsed === 'object') return [parsed]
|
|
490
|
+
return []
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async function rollbackFor(messageId) {
|
|
494
|
+
const snap = state.snapshots.get(String(messageId))
|
|
495
|
+
if (!snap) return { ok: false, error: '该消息没有可用的项目快照' }
|
|
496
|
+
const store = state.stores.get(snap.root)
|
|
497
|
+
if (!store) return { ok: false, error: '快照存储不可用' }
|
|
498
|
+
const text = stripBom(await runShell(rollbackScript(snap.root, store, state.gitExe, 'snap-' + messageId), { timeoutMs: 600000, stdoutMaxBytes: 65536 }, snap.sessionId))
|
|
499
|
+
const m = text.trim().match(/^ROLLBACK_OK\s+(\d+)\s+(\d+)/)
|
|
500
|
+
const deleted = m ? parseInt(m[1], 10) : 0
|
|
501
|
+
const restored = m ? parseInt(m[2], 10) : 0
|
|
502
|
+
return { ok: true, count: (Number.isNaN(deleted) ? 0 : deleted) + (Number.isNaN(restored) ? 0 : restored) }
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// 在事件序列里找“该消息之前最近一次 turn/end 的 seq”。
|
|
506
|
+
function scanCutSeq(events, messageId) {
|
|
507
|
+
let anchor = -1
|
|
508
|
+
for (let i = 0; i < events.length; i++) {
|
|
509
|
+
const e = events[i]
|
|
510
|
+
if (e && e.type === 'user/message' && e.data && String(e.data.id) === String(messageId)) {
|
|
511
|
+
anchor = i
|
|
512
|
+
break
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
if (anchor < 0) return null
|
|
516
|
+
for (let i = anchor - 1; i >= 0; i--) {
|
|
517
|
+
const e = events[i]
|
|
518
|
+
if (e && e.type === 'turn/end' && typeof e.seq === 'number') return e.seq
|
|
519
|
+
}
|
|
520
|
+
return null
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// 解析“整段回退”的会话切点:优先读 live 会话的内存事件(零 IO、毫秒级),
|
|
524
|
+
// 冷会话回退到 sessionQuery.readSession;结果按 (会话, 消息) 缓存——
|
|
525
|
+
// 消息一旦入日志,其之前的 turn/end 永不变化,缓存终身有效。
|
|
526
|
+
async function resolveCutSeq(sessionId, messageId) {
|
|
527
|
+
if (!sessionId || !messageId) return null
|
|
528
|
+
const cacheKey = String(sessionId) + '\u0000' + String(messageId)
|
|
529
|
+
if (state.cutSeqCache.has(cacheKey)) return state.cutSeqCache.get(cacheKey)
|
|
530
|
+
let result = null
|
|
531
|
+
const live = sessions.get(sessionId)
|
|
532
|
+
if (live && Array.isArray(live.events)) {
|
|
533
|
+
result = scanCutSeq(live.events, messageId)
|
|
534
|
+
} else {
|
|
535
|
+
const query = ctx.get('sessionQuery')
|
|
536
|
+
if (query) {
|
|
537
|
+
try {
|
|
538
|
+
const log = await query.readSession(sessionId)
|
|
539
|
+
result = scanCutSeq(Array.isArray(log && log.events) ? log.events : [], messageId)
|
|
540
|
+
} catch (error) {
|
|
541
|
+
result = null
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
state.cutSeqCache.set(cacheKey, result)
|
|
546
|
+
return result
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ---- HTTP API(Client 半经由 fetch 调用;动态插件的 harness RPC 在此换成 webServer 路由)----
|
|
550
|
+
|
|
551
|
+
async function readJsonBody(req) {
|
|
552
|
+
const chunks = []
|
|
553
|
+
for await (const chunk of req) chunks.push(chunk)
|
|
554
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
555
|
+
if (!text.trim()) return {}
|
|
556
|
+
return JSON.parse(text)
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function sendJson(res, status, body) {
|
|
560
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
561
|
+
res.end(JSON.stringify(body))
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
ctx.effect(() => webServer.register({
|
|
565
|
+
kind: 'prefix',
|
|
566
|
+
path: '/api/recall',
|
|
567
|
+
handler: async (req, res) => {
|
|
568
|
+
const path = (req.url || '').split('?')[0]
|
|
569
|
+
const name = path.replace(/^\/api\/recall\/?/, '').split('/')[0]
|
|
570
|
+
try {
|
|
571
|
+
const args = await readJsonBody(req)
|
|
572
|
+
if (name === 'init') {
|
|
573
|
+
const sessionId = args && args.sessionId ? String(args.sessionId) : null
|
|
574
|
+
const root = await resolveRoot(sessionId)
|
|
575
|
+
if (root) {
|
|
576
|
+
let store = await resolveStore(root, sessionId)
|
|
577
|
+
store = await tryUpgradeToHome(root, sessionId)
|
|
578
|
+
await ensureGit(root, store, sessionId)
|
|
579
|
+
await loadIndex(root, sessionId)
|
|
580
|
+
await rebuildOrphans(root, sessionId)
|
|
581
|
+
cleanupLegacy(root, sessionId)
|
|
582
|
+
}
|
|
583
|
+
sendJson(res, 200, { ok: Boolean(root), root: root || null })
|
|
584
|
+
return
|
|
585
|
+
}
|
|
586
|
+
if (name === 'snapshot-info') {
|
|
587
|
+
await state.queue
|
|
588
|
+
const id = args && args.messageId ? String(args.messageId) : ''
|
|
589
|
+
const snap = state.snapshots.get(id)
|
|
590
|
+
sendJson(res, 200, { has: Boolean(snap), time: snap ? snap.time : null, id })
|
|
591
|
+
return
|
|
592
|
+
}
|
|
593
|
+
if (name === 'preview') {
|
|
594
|
+
const id = args && args.messageId ? String(args.messageId) : ''
|
|
595
|
+
const sessionId = args && args.sessionId ? String(args.sessionId) : null
|
|
596
|
+
try {
|
|
597
|
+
const changes = await diffFor(id)
|
|
598
|
+
if (changes === null) { sendJson(res, 200, { ok: false, error: '该消息没有可用的项目快照' }); return }
|
|
599
|
+
const snap = state.snapshots.get(id)
|
|
600
|
+
const cutSeq = await resolveCutSeq(sessionId, id)
|
|
601
|
+
sendJson(res, 200, { ok: true, changes, time: snap ? snap.time : null, root: snap ? snap.root : null, cutSeq })
|
|
602
|
+
} catch (error) {
|
|
603
|
+
sendJson(res, 200, { ok: false, error: String(error) })
|
|
604
|
+
}
|
|
605
|
+
return
|
|
606
|
+
}
|
|
607
|
+
if (name === 'execute') {
|
|
608
|
+
const id = args && args.messageId ? String(args.messageId) : ''
|
|
609
|
+
const sessionId = args && args.sessionId ? String(args.sessionId) : null
|
|
610
|
+
try {
|
|
611
|
+
const result = await rollbackFor(id)
|
|
612
|
+
if (!result.ok) { sendJson(res, 200, result); return }
|
|
613
|
+
// 文件回退后再解析切点:切点只依赖会话日志,与快照是否删除无关(命中缓存,瞬时)
|
|
614
|
+
const cutSeq = await resolveCutSeq(sessionId, id)
|
|
615
|
+
sendJson(res, 200, { ok: true, count: result.count, cutSeq })
|
|
616
|
+
} catch (error) {
|
|
617
|
+
sendJson(res, 200, { ok: false, error: String(error) })
|
|
618
|
+
}
|
|
619
|
+
return
|
|
620
|
+
}
|
|
621
|
+
sendJson(res, 404, { ok: false, error: 'unknown endpoint: ' + name })
|
|
622
|
+
} catch (error) {
|
|
623
|
+
sendJson(res, 500, { ok: false, error: String(error) })
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}))
|
|
627
|
+
|
|
628
|
+
// 每条用户消息触发快照(子代理会话跳过)
|
|
629
|
+
ctx.on('session/event', (session, event) => {
|
|
630
|
+
if (!event || event.type !== 'user/message') return
|
|
631
|
+
const data = event.data
|
|
632
|
+
if (!data || typeof data.id !== 'string' || !data.id) return
|
|
633
|
+
const source = data.source
|
|
634
|
+
if (!source || source.kind !== 'user') return
|
|
635
|
+
if (session && session.header && session.header.origin === 'subagent') return
|
|
636
|
+
const messageId = data.id
|
|
637
|
+
const time = event.time
|
|
638
|
+
state.queue = state.queue
|
|
639
|
+
.then(() => captureSnapshot(session.id, messageId, time))
|
|
640
|
+
.catch((error) => console.error('recall snapshot error:', String(error)))
|
|
641
|
+
})
|
|
642
|
+
|
|
643
|
+
// 启动预热:所有已存在工作区解析存储、重建索引与孤儿快照,
|
|
644
|
+
// 并清理旧版项目内 blobs 目录(home 可用时)
|
|
645
|
+
for (const session of sessions.list()) {
|
|
646
|
+
const cwd = session && session.header && session.header.cwd
|
|
647
|
+
if (!cwd) continue
|
|
648
|
+
const sessionId = session.id
|
|
649
|
+
Promise.resolve(resolveStore(cwd, sessionId))
|
|
650
|
+
.then(() => tryUpgradeToHome(cwd, sessionId))
|
|
651
|
+
.then((store) => ensureGit(cwd, store, sessionId))
|
|
652
|
+
.then(() => loadIndex(cwd, sessionId))
|
|
653
|
+
.then(() => rebuildOrphans(cwd, sessionId))
|
|
654
|
+
.then(() => cleanupLegacy(cwd, sessionId))
|
|
655
|
+
.catch(() => {})
|
|
656
|
+
}
|
|
657
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-recall-plugin",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "DSH 消息撤回插件:在用户消息气泡旁加「撤回」按钮,把项目文件(独立影子 git 仓库快照)与对话历史(官方 fork)一并回退到该消息发送之前。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/limbo947/dsh-recall-plugin"
|
|
9
|
+
},
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"main": "lib/index.js",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": "./lib/index.js",
|
|
16
|
+
"./client": "./lib/client.js",
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"lib/index.js",
|
|
21
|
+
"lib/client.js",
|
|
22
|
+
"cordis.patch.yml",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=20"
|
|
28
|
+
},
|
|
29
|
+
"dsh": {
|
|
30
|
+
"bundle": {
|
|
31
|
+
"patch": "./cordis.patch.yml"
|
|
32
|
+
},
|
|
33
|
+
"client": {
|
|
34
|
+
"platform": "web",
|
|
35
|
+
"inject": [
|
|
36
|
+
"@deepseek-ai/dsh-client-web-react"
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
42
|
+
"@deepseek-ai/dsh-session": "^0.1.0-rc.6",
|
|
43
|
+
"@deepseek-ai/dsh-shell": "^0.1.0-rc.6",
|
|
44
|
+
"@deepseek-ai/dsh-sandbox-policy": "^0.1.0-rc.6",
|
|
45
|
+
"@deepseek-ai/dsh-session-query": "^0.1.0-rc.6",
|
|
46
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
|
|
47
|
+
"@deepseek-ai/dsh-client-web-react": "^0.1.0-rc.6",
|
|
48
|
+
"cordis": "^4.0.0-rc.7",
|
|
49
|
+
"react": "^18.2.0"
|
|
50
|
+
},
|
|
51
|
+
"peerDependenciesMeta": {
|
|
52
|
+
"cordis": { "optional": true }
|
|
53
|
+
},
|
|
54
|
+
"license": "MIT"
|
|
55
|
+
}
|