dsh-recall-plugin 2.4.8 → 2.4.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  本文件格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本。
4
4
 
5
+ ## [2.4.10] - 2026-10-07
6
+
7
+ ### 修复
8
+
9
+ - **回退恢复侧从「整树解压」改为只处理 diff 变更集(issue #22)**:旧版 `rollbackScript` 的 `$restored` 是目标树全量条数——改 1 个文件也把整棵快照树打 zip/解包一遍(大工作区 GB 级三遍 IO;issue #20 实机 39.2 万条幽灵条目时打出 5.11 GB zip),未变更文件也被 `-Force` 覆写、mtime 全变误触发构建增量/文件监听。现按 sha 求出恢复集(目标有、当前无或内容不同,与 `diffScript` 的 modified+restored 同集合),`git archive <tag> -- <paths>` 分批(100/批)只解压变更项,变更过半时回退整树单遍(N/100 次 archive+解压的启动开销超过单遍);`ROLLBACK_OK` 的 restored 计数随之变成实际恢复条数,与 preview 的 diff 计数逐项相等。pwsh/posix 双侧对偶(POSIX 走 `archive | tar -x -m` 分批 pathspec);archive 失败 pwsh 侧显式 throw、POSIX 侧 pipefail 非零退出——半回退不许报 `ROLLBACK_OK`(F-G2 纪律不变)。实测:6 文件工作区改 1 删 1 增 1 的回退 `ROLLBACK_OK 1 2`(旧版报 7),未变更文件 mtime 不动;变更过半整树路径双侧实测正确。
10
+ - **junction 防护三处评审补强(PR #21 P3 nit)**:① 机器生成的 exclude 行补 gitignore 转义——目录名以 `#`/`!` 开头或含 `[` 的 junction 此前会被解析成注释/反选/字符组而静默漏排除(自引用 `#hash` 金丝雀夹具实测漏排 → 修复后 1 条索引);② 发现行排序,exclude 内容序与 NTFS 枚举序脱钩,消无谓重写;③ 良性 junction 开反选逃生口——exclude 写盘顺序改为 base + 机器行 + 用户行(gitignore 后写胜出),排除配置追加 `!路径/` 即可把指向工作区外的 junction 重新纳入快照(实测默认 1 条 → 反选后 3 条索引),切勿对自引用 junction 使用,README 双语已更新。
11
+
12
+ - **win32 目录重解析点把影子仓库索引撑爆、撤回退化为分钟级(issue #20,PR #21,外部贡献 @PrismScopes)**:git for Windows 把 NTFS junction 当普通目录,`add -A` 递归进入链接目标(`core.symlinks` 取 true / false / 默认实测同样递归,无法配置规避),自引用 junction(`a/link -> a`)按 Windows「路径中最多 31 个重解析点」上限把同一棵子树重复索引——实机病例 1.24 万条 → 39.2 万条、`.git/index` 168 MB,preview/快照/回退退化到分钟级(用户表现为「永久卡在正在计算变更」)。修法=`excludeSyncBlock` 在 `add -A` 之前遍历目录树(不进入链接目标)把目录重解析点按相对 root 路径 + 尾斜杠并入 `info/exclude`,git 根本不进入它们;已跟踪的旧幽灵条目由既有 `ls-files -i -c` 分支一次清掉;`oversizeBlock` 同一次压栈过滤顺带剪掉重解析点。遍历只枚举目录、复用既有目录名剪枝,无重解析点的工作区不产生 exclude 差异,条件化比对跳过重写与清理循环(常态零开销)。POSIX 侧有意不做:git 把指向目录的符号链接记成 `120000` 条目、不递归进目标,照搬反而会把合法快照内容剔出快照。已知限制(README 双语已声明):工作区内的目录重解析点一律不参与快照(含指向工作区外的良性 junction,无 `!` 反选逃生口);POSIX bind mount 环不覆盖。维护者评审实测数据:2 真实文件 + 自引用 junction 夹具,索引 96 条(93 幽灵)→ 3 条 / 0 幽灵,`.git/index` 38 KB → 331 B;门禁 typecheck / 单测 498 / `verify:host` 全绿。
13
+ - **大幽灵存量的首诊清理从「分钟级卡死」改整棵重建(PR #21 评审补强)**:旧清理路径对 37.9 万条幽灵条目走 `ls-files -i -c` + 每批 100 条 `update-index --force-remove`,约 3797 次 git 子进程、每次重写巨型 index(168 MB 渐缩),估 10–60 分钟——升级后第一次快照仍会表现为「卡在正在计算变更」。现按 10000 条阈值切换:`read-tree --empty` 清索引、交给随后的 `add -A` 按新 exclude 整棵重建(`gcScript` 治 issue #18 同款技巧,index 是可重建缓存),实测 1.24 万条幽灵夹具首诊 **2 秒**完成;`read-tree` 失败显式 throw(静默失败会让幽灵条目随 exclude 已重写永久滞留、清理不再触发)。重解析点遍历另加 `if ($root)` 显式守卫:`ensureGitScript` 无 `$root`,原路径靠 `Push($null)` 抛错被 `catch` 吞掉的巧合跳过,现成为显式设计(三处主脚本随后在 `add -A` 之前重做发现,无漏检窗口)。
14
+
15
+ ## [2.4.9] - 2026-10-03
16
+
17
+ ### 变更
18
+
19
+ - **dsh 0.2.1-alpha.1 兼容**(升级核查见 [docs/upgrade-assessments/dsh-0.2.1-alpha.1.md](docs/upgrade-assessments/dsh-0.2.1-alpha.1.md) 与 [docs/compat-audit.md](docs/compat-audit.md) 头部 0.2.1-alpha.1 段):全树内容级 diff(0.2.0-rc.2 整包残留作基线)确认插件注册/消费契约(chat.node 槽位与 props、`ChatNodeKind` 全集、`sessions.fork`/binding、`updateQueue`、设置双 slot)字节级相同——**无需改码**;peer 窗口 6 条 `dsh-*` 追加 `>=0.2.1-alpha.1 <0.3.0` 段(npm prerelease 门槛要求显式同 tuple 段;上限按发版决定放宽,覆盖 0.2 全线正式版),cordis 追加 `>=4.0.5-alpha.1 <4.0.6`、schemastery 追加 `>=3.18.5-alpha.1 <3.18.6`(两者产物除版本号外逐字节相同),`dsh.compatibility.dshReleases` 补 `0.2.1-alpha.1: compatible`;`docs/reference/` 镜像按 tag 重拉(13 源中 4 份实质差异:反向代理发布链接、桌面端系统分配端口 + invariant 移除文案、工具 delta 匹配语义与 `ConversationNodeDefinitionInput`、定时任务投递语义)。正向受益:官方修复插件启停时样式被误移除(`claimStyles`)与队列滞留(惠及 G1 周边时序)。门禁:`test:probe` 52/52、`verify:host` 装配断言全过、`check:dsh` 全绿。
20
+
21
+ ### 文档
22
+
23
+ - README 双语:兼容范围标注与 peer 窗口折叠块同步至 dsh 0.2.1-alpha.1(窗口上限放宽至 `<0.3.0` 的说明一并落地)。
24
+ - `docs/dsh-contract.md`:版本核验记录只保留最新(0.2.1-alpha.1),历次版本记录归口 compat-audit 与 upgrade-assessments,消除双写。
25
+ - 新增待实施计划 `docs/plans/pending/plan-refill-references.md`:引用 chip 回填(撤回后把被撤回消息里的 @文件/目录引用以可点击 chip 形式放回输入框;依赖 dsh 0.2.1-alpha.1 结构化草稿能力)。
26
+
5
27
  ## [2.4.8] - 2026-10-03
6
28
 
7
29
  撤回完成事件/回调(issue #19,方案见 [docs/plans/completed/plan-recall-event.md](docs/plans/completed/plan-recall-event.md)):撤回到达终态时广播 cordis 事件 `dsh-recall/complete` / `dsh-recall/failed`,供同宿主任意插件监听(如长时记忆插件清理被撤回回合的记忆)。纯增量 opt-in:不订阅的插件零感知,撤回链路行为逐字节不变(事件发送失败、监听器抛错、下游慢执行均不影响主流程)。
package/README.en.md CHANGED
@@ -7,7 +7,7 @@
7
7
  ![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-blue)
8
8
  ![Build](https://img.shields.io/badge/pure%20JS-green)
9
9
 
10
- [![DSH](https://img.shields.io/badge/DSH-0.2.0--rc.2-blue)](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.2.0-rc.2)
10
+ [![DSH](https://img.shields.io/badge/DSH-0.2.1--alpha.1-blue)](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.2.1-alpha.1)
11
11
  ![DSH](https://img.shields.io/badge/DSH-Desktop-blue)
12
12
 
13
13
  Under any message you've sent, click "↶ Recall" — **your workspace files and the conversation history roll back to just before that message was sent**.
@@ -63,6 +63,7 @@ Boundaries accepted by design and edge cases not yet covered — worth checking
63
63
  - Recall cannot be initiated while an agent is running in the target workspace (by design — stop the agent first).
64
64
  - Supports Windows (PowerShell 5.1/7 + git CLI) and Linux/macOS (bash + git CLI). Windows is thoroughly verified on real machines; Linux has been fully tested on WSL2 (Ubuntu 26.04, bash 5.3 + git 2.53), including Chinese paths, home fallback, session cleanup, and gc; the macOS side is written to be bash 3.2 compatible but has not been tested on real hardware yet.
65
65
  - Nested git repositories inside the workspace (subdirectories with their own `.git`) cannot be indexed: the snapshot proceeds for everything else (fail-open, with a notice listing the skipped paths), but their contents do not participate in recalls.
66
+ - **Directory reparse points** in the workspace (junctions / directory symlinks) are not snapshotted by default: the subtree they point at is not indexed, so its contents do not participate in recalls. This is required on Windows, where git for Windows treats a junction as an ordinary directory and a self-referencing one inflates the index up to Windows' 31-reparse-point path limit (a two-file workspace measured 64 index entries); on POSIX a symlink still enters the snapshot (git records it as a `120000` entry and never recurses). To deliberately snapshot a **benign** junction (pointing outside the workspace, non-self-referencing), append `!path/` (e.g. `!ext-link/`) in the exclusion settings — user rules take effect after machine-generated ones; **never do this for a self-referencing junction** (the explosion vector).
66
67
  - Extreme cases like filenames containing newlines/TAB are beyond the diff list's parsing capability (negligible probability).
67
68
  - **Interplay with dsh-routing-suite (progressive tool-disclosure router)**: if you also run its router-standard preset, a recall forks a new session via `sessions.fork`, which resets the router's stage to its default (the tool surface temporarily narrows). Symptoms, root cause and the fix are documented in [docs/routing-interplay.md](docs/routing-interplay.md) (Chinese).
68
69
 
@@ -72,7 +73,7 @@ Prerequisites:
72
73
 
73
74
  - git CLI: without it the recall button won't appear (a notice shows at the top of the page); DSH itself keeps running.
74
75
  - Shell: PowerShell 5.1 / 7 on Windows; bash on Linux/macOS.
75
- - DSH version: `0.1.2-alpha.1` through `0.2.0-rc.2`; later versions within a verified minor line are admitted automatically, while unverified new minor lines are blocked by the startup compatibility gate (window mechanics in the fold below).
76
+ - DSH version: `0.1.2-alpha.1` through `0.2.1-alpha.1`; later versions within a verified minor line are admitted automatically, while unverified new minor lines are blocked by the startup compatibility gate (window mechanics in the fold below).
76
77
  - **0.1.7-alpha.1 is a breaking release** (the shell execution and settings interfaces changed); the plugin ships both seams side by side — the same release works on every 0.1.2–0.1.6 line and on 0.1.7+, with unchanged behaviour on older DSH versions.
77
78
  - `0.1.1-rc.2` and earlier are not supported: their client runtime lacks the `sessions`/`workspaces`/`uiWorkspace` services, so the plugin UI silently fails to render.
78
79
 
@@ -82,10 +83,10 @@ Prerequisites:
82
83
  peerDependencies open a window per minor line, consistent with the `dsh.compatibility.dshReleases` declaration:
83
84
 
84
85
  ```
85
- >=0.1.2-alpha.1 <0.1.3 || >=0.1.3-alpha.1 <0.1.4 || >=0.1.5-alpha.1 <0.1.6 || >=0.1.6-alpha.1 <0.1.7 || >=0.1.7-alpha.1 <0.1.8 || >=0.2.0-rc.1 <0.2.1
86
+ >=0.1.2-alpha.1 <0.1.3 || >=0.1.3-alpha.1 <0.1.4 || >=0.1.5-alpha.1 <0.1.6 || >=0.1.6-alpha.1 <0.1.7 || >=0.1.7-alpha.1 <0.1.8 || >=0.2.0-rc.1 <0.2.1 || >=0.2.1-alpha.1 <0.3.0
86
87
  ```
87
88
 
88
- Each line is anchored at its first verified version with an upper bound at the next minor, so later prereleases/releases within a verified line are admitted without touching the peer declaration. The 0.2.0 line needs its own tuple: npm's prerelease gate only admits a prerelease from a comparator with the same tuple, so the existing `<0.1.8` segment cannot admit `0.2.0-rc.1`.
89
+ Each line is anchored at its first verified version with an upper bound at the next minor (the 0.2.1 window's upper bound is widened to `<0.3.0`, admitting every 0.2 release), so later prereleases/releases within a verified line are admitted without touching the peer declaration. Both the 0.2.0 and 0.2.1 lines need their own tuple: npm's prerelease gate only admits a prerelease from a comparator with the same tuple, so the existing `<0.1.8` segment cannot admit `0.2.0-rc.1`, nor `<0.2.1` admit `0.2.1-alpha.1` (prereleases of later tuples such as 0.2.2 obey the same gate and need their own segment once verified).
89
90
 
90
91
  </details>
91
92
 
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![npm version](https://img.shields.io/npm/v/dsh-recall-plugin?style=flat-square&label=npm&color=3178C6)](https://www.npmjs.com/package/dsh-recall-plugin)
6
6
  [![npm downloads](https://img.shields.io/npm/dm/dsh-recall-plugin?style=flat-square&label=downloads&color=1F883D)](https://www.npmjs.com/package/dsh-recall-plugin)
7
7
  ![License](https://img.shields.io/badge/license-MIT-blue)
8
- [![DSH](https://img.shields.io/badge/DSH-0.2.0--rc.2-blue)](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.2.0-rc.2)
8
+ [![DSH](https://img.shields.io/badge/DSH-0.2.1--alpha.1-blue)](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.2.1-alpha.1)
9
9
 
10
10
  在任意一条你发过的消息下方点「↶ 撤回」——**工作区文件和对话历史一起回到那条消息发出之前的状态**。
11
11
 
@@ -60,6 +60,7 @@
60
60
  - 目标工作区的 agent 正在运行时无法发起撤回(防护设计,先停止 agent 再撤回)。
61
61
  - 支持 Windows(PowerShell 5.1/7 + git CLI)与 Linux/macOS(bash + git CLI)。Windows 真机验证充分;Linux 已在 WSL2(Ubuntu 26.04,bash 5.3 + git 2.53)实测全流程(含中文路径、home 降级、会话清理、gc);macOS 侧脚本按 bash 3.2 兼容编写,尚未真机实测。
62
62
  - 工作区内嵌套的其他 git 仓库(子目录自带 `.git`)无法索引:其余部分快照照常(fail-open,页面会提示跳过了哪些路径),但其内容不参与回退。
63
+ - 工作区内的**目录重解析点**(junction / 目录符号链接)默认不参与快照:它们指向的子树不会被索引,因此那部分内容不参与回退。win32 上这是必需的——git for Windows 把 junction 当普通目录递归,自引用 junction 会让索引膨胀到 Windows 的 31 层重解析点上限(实测 2 个文件的工作区产出 64 条索引);POSIX 侧符号链接照常进快照(git 记成 `120000` 条目,不递归进目标)。确需把某个**良性** junction(指向工作区外、非自引用)纳入快照时,可在排除配置里追加 `!路径/`(如 `!ext-link/`)反选恢复——用户排除行在机器生成行之后生效;**切勿对自引用 junction 使用**(爆炸根因)。
63
64
  - 文件名含换行/TAB 的极端情形超出 diff 清单的解析能力(概率可忽略)。
64
65
  - **与 dsh-routing-suite(渐进式工具披露路由)的交互**:同时启用其 router-standard 预设时,撤回经 `sessions.fork` 出新会话会把路由阶段重置为默认(工具面临时收窄)。现象、成因与解决方案见 [docs/routing-interplay.md](docs/routing-interplay.md)。
65
66
 
@@ -69,7 +70,7 @@
69
70
 
70
71
  - git CLI:未安装时撤回按钮不出现(页面顶部会提示安装 git),不影响 DSH 运行。
71
72
  - shell:Windows 上 PowerShell 5.1 / 7 均可;Linux/macOS 需 bash。
72
- - DSH 版本:`0.1.2-alpha.1` 至 `0.2.0-rc.2`;已核验 minor 线内的后续版本自动放行,未核验的新 minor 线会被启动期兼容门禁拦截(开窗机制见下方折叠块)。
73
+ - DSH 版本:`0.1.2-alpha.1` 至 `0.2.1-alpha.1`;已核验 minor 线内的后续版本自动放行,未核验的新 minor 线会被启动期兼容门禁拦截(开窗机制见下方折叠块)。
73
74
  - **0.1.7-alpha.1 是破坏性版本**(shell 执行与 settings 面接口更换),插件内置双分支共存适配:同一发布同时兼容 0.1.2–0.1.6 各线与 0.1.7+,老版本 DSH 上行为不变。
74
75
  - `0.1.1-rc.2` 及更早不支持:那条线的客户端运行时没有 `sessions`/`workspaces`/`uiWorkspace` 服务,插件 UI 会静默不渲染。
75
76
 
@@ -79,10 +80,10 @@
79
80
  peerDependencies 按 minor 版本线开窗,与 `dsh.compatibility.dshReleases` 声明一致:
80
81
 
81
82
  ```
82
- >=0.1.2-alpha.1 <0.1.3 || >=0.1.3-alpha.1 <0.1.4 || >=0.1.5-alpha.1 <0.1.6 || >=0.1.6-alpha.1 <0.1.7 || >=0.1.7-alpha.1 <0.1.8 || >=0.2.0-rc.1 <0.2.1
83
+ >=0.1.2-alpha.1 <0.1.3 || >=0.1.3-alpha.1 <0.1.4 || >=0.1.5-alpha.1 <0.1.6 || >=0.1.6-alpha.1 <0.1.7 || >=0.1.7-alpha.1 <0.1.8 || >=0.2.0-rc.1 <0.2.1 || >=0.2.1-alpha.1 <0.3.0
83
84
  ```
84
85
 
85
- 每条线以首个核验版本为下限、上界开到下一 minor,同线内后续 prerelease/正式版自动放行、无需改 peer 声明。0.2.0 线必须显式开窗:npm 的 prerelease 门槛要求同 tuple 且带 prerelease 的比较器,既有 `<0.1.8` 段放行不了 `0.2.0-rc.1`。
86
+ 每条线以首个核验版本为下限、上界开到下一 minor(0.2.1 段上限放宽到 `<0.3.0`,0.2 全线正式版自动放行),同线内后续 prerelease/正式版自动放行、无需改 peer 声明。0.2.0 与 0.2.1 两线都必须显式开窗:npm 的 prerelease 门槛要求同 tuple 且带 prerelease 的比较器,既有 `<0.1.8` 段放行不了 `0.2.0-rc.1`、`<0.2.1` 段放行不了 `0.2.1-alpha.1`(0.2.2 及以后 tuple 的 prerelease 同样受此门槛约束,待核验后另加段)。
86
87
 
87
88
  </details>
88
89
 
@@ -224,19 +224,44 @@ function rollbackScript(root, store, gitExe, tag, base) {
224
224
  return [
225
225
  "set -e -o pipefail",
226
226
  collectListsBlock(store, gitExe, root, tag, base),
227
- `trap 'rm -f "$tmpc" "$tmpt"' EXIT`,
228
- `restored=$(wc -l < "$tmpt" | tr -d ' ')`,
227
+ "tmpr=" + psq(store.dir + "/diff-restore.$$"),
228
+ `trap 'rm -f "$tmpc" "$tmpt" "$tmpr"' EXIT`,
229
+ // 恢复集 = 目标有、当前无或 sha 不同(awk 结构与 diffScript 同款:
230
+ // cur 侧 "mode sha stage<TAB>path" 取 a[2],target 侧 "mode type sha<TAB>path" 取 a[3])
231
+ "awk -F'\\t' '",
232
+ " FNR==1 { fidx++ }",
233
+ ' fidx==1 { split($1, a, " "); cur[$2]=a[2]; next }',
234
+ ' { split($1, a, " "); tgt[$2]=a[3] }',
235
+ " END { for (p in tgt) if (!(p in cur) || cur[p] != tgt[p]) print p }",
236
+ `' "$tmpc" "$tmpt" > "$tmpr"`,
237
+ `restored=$(wc -l < "$tmpr" | tr -d ' ')`,
238
+ `total=$(wc -l < "$tmpt" | tr -d ' ')`,
229
239
  'if [ "$restored" -gt 0 ]; then',
230
- // -m(--touch):解包不恢复归档成员的 mtime(文件 mtime = 解包时刻)。
231
- // 必须如此:tar 默认保留归档内 mtime,而快照→篡改→回滚常在数秒内
232
- // 完成,恢复出的 mtime 可能与 index 里旧条目的 stat 记录碰撞,下一次
233
- // add -A 的 stat 缓存误判「未变更」跳过 re-hash——工作区内容与快照
234
- // 从此脱钩(实测解包出篡改前内容的间歇性失败)。Windows 版的
240
+ ' if [ $((restored * 2)) -ge "$total" ]; then',
241
+ // -m(--touch,两处 tar 调用同款):解包不恢复归档成员的 mtime(文件
242
+ // mtime = 解包时刻)。必须如此:tar 默认保留归档内 mtime,而快照→篡改→
243
+ // 回滚常在数秒内完成,恢复出的 mtime 可能与 index 里旧条目的 stat 记录
244
+ // 碰撞,下一次 add -A 的 stat 缓存误判「未变更」跳过 re-hash——工作区
245
+ // 内容与快照从此脱钩(实测解包出篡改前内容的间歇性失败)。Windows 版的
235
246
  // Expand-Archive 天然把 mtime 设为解包时刻,无此问题;-m 让 tar 对齐。
236
- ' "$git" --git-dir="$g" archive ' + psq(tag) + ' | tar -x -m -C "$root"',
247
+ ' "$git" --git-dir="$g" archive ' + psq(tag) + ' | tar -x -m -C "$root"',
248
+ " else",
249
+ " batch=()",
250
+ " while IFS= read -r p; do",
251
+ ' if [ -z "$p" ]; then continue; fi',
252
+ ' batch+=("$p")',
253
+ ' if [ "${#batch[@]}" -ge 100 ]; then',
254
+ ' "$git" --literal-pathspecs --git-dir="$g" archive ' + psq(tag) + ' -- "${batch[@]}" | tar -x -m -C "$root"',
255
+ " batch=()",
256
+ " fi",
257
+ ' done < "$tmpr"',
258
+ ' if [ "${#batch[@]}" -gt 0 ]; then',
259
+ ' "$git" --literal-pathspecs --git-dir="$g" archive ' + psq(tag) + ' -- "${batch[@]}" | tar -x -m -C "$root"',
260
+ " fi",
261
+ " fi",
237
262
  "fi",
238
263
  "tmpd=" + psq(store.dir + "/diff-del.$$"),
239
- `trap 'rm -f "$tmpc" "$tmpt" "$tmpd"' EXIT`,
264
+ `trap 'rm -f "$tmpc" "$tmpt" "$tmpr" "$tmpd"' EXIT`,
240
265
  "awk -F'\\t' '",
241
266
  " FNR==1 { fidx++ }",
242
267
  " fidx==1 { cur[$2]=1; next }",
@@ -17,15 +17,48 @@ function dropGitlinksBlock() {
17
17
  "}"
18
18
  ].join("\n");
19
19
  }
20
- function oversizeBlock(maxBytes) {
20
+ function dirSkipSetBlock(name) {
21
21
  return [
22
- "$oversizeSkip = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)",
22
+ "$" + name + " = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)",
23
23
  "foreach ($p in $lines) {",
24
24
  ' $t = "$p".Trim()',
25
25
  " if (-not $t -or $t.StartsWith('#') -or $t.StartsWith('!')) { continue }",
26
26
  " $t = $t -replace '/$', ''",
27
- " if ($t -notmatch '[*?\\[\\]/\\\\]') { [void]$oversizeSkip.Add($t) }",
27
+ " if ($t -notmatch '[*?\\[\\]/\\\\]') { [void]$" + name + ".Add($t) }",
28
+ "}"
29
+ ].join("\n");
30
+ }
31
+ function reparseDirsBlock() {
32
+ return [
33
+ dirSkipSetBlock("reparseSkip"),
34
+ "$reparseRel = [System.Collections.Generic.List[string]]::new()",
35
+ "if ($root) {",
36
+ "$reparseStack = [System.Collections.Generic.Stack[string]]::new()",
37
+ "$reparseStack.Push($root)",
38
+ "while ($reparseStack.Count -gt 0) {",
39
+ " $dir = $reparseStack.Pop()",
40
+ " try {",
41
+ " $di = [System.IO.DirectoryInfo]::new($dir)",
42
+ " foreach ($d in $di.EnumerateDirectories()) {",
43
+ " if ($reparseSkip.Contains($d.Name)) { continue }",
44
+ " if (($d.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) {",
45
+ " $rel = $d.FullName.Substring($root.Length + 1).Replace('\\','/')",
46
+ ' $rel = $rel -replace "\\[", "\\["',
47
+ ' if ($rel.StartsWith("#") -or $rel.StartsWith("!")) { $rel = "\\" + $rel }',
48
+ " $reparseRel.Add($rel + '/')",
49
+ " continue",
50
+ " }",
51
+ " $reparseStack.Push($d.FullName)",
52
+ " }",
53
+ " } catch {}",
54
+ "}",
28
55
  "}",
56
+ "$reparseRel.Sort()"
57
+ ].join("\n");
58
+ }
59
+ function oversizeBlock(maxBytes) {
60
+ return [
61
+ dirSkipSetBlock("oversizeSkip"),
29
62
  "$oversizeStack = [System.Collections.Generic.Stack[string]]::new()",
30
63
  "$oversizeStack.Push($root)",
31
64
  "$oversizeRel = [System.Collections.Generic.List[string]]::new()",
@@ -39,7 +72,9 @@ function oversizeBlock(maxBytes) {
39
72
  " }",
40
73
  " }",
41
74
  " foreach ($d in $di.EnumerateDirectories()) {",
42
- " if (-not $oversizeSkip.Contains($d.Name)) { $oversizeStack.Push($d.FullName) }",
75
+ " if ($oversizeSkip.Contains($d.Name)) { continue }",
76
+ " if (($d.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { continue }",
77
+ " $oversizeStack.Push($d.FullName)",
43
78
  " }",
44
79
  " } catch {}",
45
80
  "}",
@@ -56,6 +91,12 @@ function excludeSyncBlock(excludeFile, base) {
56
91
  "$userPats = @()",
57
92
  "if (Test-Path -LiteralPath $exFile) { $userPats = @(Get-Content -LiteralPath $exFile -Encoding UTF8 -ErrorAction SilentlyContinue | Where-Object { $t = $_.Trim(); $t -and -not $t.StartsWith('#') }) }",
58
93
  "$lines = @('') + @(" + baseList + ") + $userPats",
94
+ reparseDirsBlock(),
95
+ // 写盘顺序 base + reparse + userPats(评审 P3 逃生口):gitignore 后写胜出,
96
+ // 用户行垫底才可用 !path/ 反选重纳良性 junction(指向工作区外、非自引用);
97
+ // 自引用 junction 切勿反选(爆炸根因,见 reparseDirsBlock)。上方 $lines
98
+ //(base+userPats)先供 reparseDirsBlock 构目录名剪枝集,判据语义不变。
99
+ "$lines = @('') + @(" + baseList + ") + @($reparseRel) + $userPats",
59
100
  "$exc = Join-Path $g 'info\\exclude'",
60
101
  "$excOld = @(Get-Content -LiteralPath $exc -Encoding UTF8 -ErrorAction SilentlyContinue)",
61
102
  "$same = ($excOld.Count -eq $lines.Count)",
@@ -67,9 +108,14 @@ function excludeSyncBlock(excludeFile, base) {
67
108
  "if (-not $same) {",
68
109
  " Set-Content -LiteralPath $exc -Value $lines -Encoding utf8",
69
110
  " $hit = @(& $git -c core.quotePath=false --literal-pathspecs --git-dir=$g ls-files -i -c --exclude-from=$exc | Where-Object { $_ })",
70
- " for ($i = 0; $i -lt $hit.Count; $i += 100) {",
71
- " $batch = @($hit[$i..([Math]::Min($i + 99, $hit.Count - 1))])",
72
- " & $git --literal-pathspecs --git-dir=$g update-index --force-remove -- $batch",
111
+ " if ($hit.Count -gt 10000) {",
112
+ " & $git --git-dir=$g read-tree --empty",
113
+ " if ($LASTEXITCODE -ne 0) { throw ('git read-tree --empty failed (exit ' + $LASTEXITCODE + ')') }",
114
+ " } else {",
115
+ " for ($i = 0; $i -lt $hit.Count; $i += 100) {",
116
+ " $batch = @($hit[$i..([Math]::Min($i + 99, $hit.Count - 1))])",
117
+ " & $git --literal-pathspecs --git-dir=$g update-index --force-remove -- $batch",
118
+ " }",
73
119
  " }",
74
120
  "}"
75
121
  ].join("\n");
@@ -250,18 +296,42 @@ function rollbackScript(root, store, gitExe, tag, base) {
250
296
  // 同 diffScript:-z 的 NUL 输出会被 PowerShell 捕获丢弃,改为逐行 + quotePath=false
251
297
  "$curOut = & $git -c core.quotePath=false --git-dir=$g --work-tree=$root ls-files --stage",
252
298
  "$targetOut = @(& $git -c core.quotePath=false --git-dir=$g ls-tree -r " + psq(tag) + " | Where-Object { -not $_.StartsWith('160000') })",
299
+ "$curMap = @{}",
300
+ "foreach ($r in @($curOut)) {",
301
+ " if (-not $r) { continue }",
302
+ ' $tab = $r.IndexOf("`t"); $path = $r.Substring($tab + 1)',
303
+ ' $sha = ($r.Substring(0, $tab) -split " ")[1]',
304
+ " $curMap[$path] = $sha",
305
+ "}",
253
306
  "$targetMap = @{}",
254
307
  "foreach ($r in @($targetOut)) {",
255
308
  " if (-not $r) { continue }",
256
309
  ' $tab = $r.IndexOf("`t"); $path = $r.Substring($tab + 1)',
257
- " $targetMap[$path] = $true",
310
+ ' $sha = ($r.Substring(0, $tab) -split " ")[2]',
311
+ " $targetMap[$path] = $sha",
312
+ "}",
313
+ // 恢复集 = 目标有、当前无或 sha 不同(与 diffScript 的 modified+restored 同集合)
314
+ "$restoreRel = [System.Collections.Generic.List[string]]::new()",
315
+ "foreach ($k in $targetMap.Keys) {",
316
+ " if (-not $curMap.ContainsKey($k) -or $curMap[$k] -ne $targetMap[$k]) { $restoreRel.Add($k) }",
258
317
  "}",
259
- "$restored = $targetMap.Count",
318
+ "$restored = $restoreRel.Count",
260
319
  "if ($restored -gt 0) {",
261
320
  " $zip = " + psq(store.dir + "\\restore-tmp.zip"),
262
- " & $git --git-dir=$g archive --format=zip --output=$zip " + psq(tag),
263
- " Expand-Archive -LiteralPath $zip -DestinationPath $root -Force",
264
- " Remove-Item -LiteralPath $zip -Force",
321
+ " if ($restoreRel.Count * 2 -ge $targetMap.Count) {",
322
+ " & $git --git-dir=$g archive --format=zip --output=$zip " + psq(tag),
323
+ " if ($LASTEXITCODE -ne 0) { throw ('git archive failed (exit ' + $LASTEXITCODE + ')') }",
324
+ " Expand-Archive -LiteralPath $zip -DestinationPath $root -Force",
325
+ " Remove-Item -LiteralPath $zip -Force",
326
+ " } else {",
327
+ " for ($i = 0; $i -lt $restoreRel.Count; $i += 100) {",
328
+ " $batch = $restoreRel.GetRange($i, [Math]::Min(100, $restoreRel.Count - $i))",
329
+ " & $git --literal-pathspecs --git-dir=$g archive --format=zip --output=$zip " + psq(tag) + " -- $batch",
330
+ " if ($LASTEXITCODE -ne 0) { throw ('git archive failed (exit ' + $LASTEXITCODE + ')') }",
331
+ " Expand-Archive -LiteralPath $zip -DestinationPath $root -Force",
332
+ " Remove-Item -LiteralPath $zip -Force",
333
+ " }",
334
+ " }",
265
335
  "}",
266
336
  // 删除失败语义与 POSIX 版对齐(F-G2):本侧 EAP=Stop 下 Remove-Item
267
337
  // 失败直接抛终止错误、pwsh 以非零码退出;POSIX 侧 rm 在 set -e 的 if
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-recall-plugin",
3
- "version": "2.4.8",
3
+ "version": "2.4.10",
4
4
  "description": "在 DSH 中撤回自己发过的消息:工作区文件与对话历史一起回到那条消息发出之前,消息的文本与附件自动放回输入框,改完即可重发。文件走独立影子 git 快照(不碰项目自身的 git),对话走官方 sessions.fork,原会话归档可找回。",
5
5
  "icon": "assets/icon.svg",
6
6
  "type": "module",
@@ -67,19 +67,20 @@
67
67
  "0.1.7-rc.1": "compatible",
68
68
  "0.1.7-rc.2": "compatible",
69
69
  "0.2.0-rc.1": "compatible",
70
- "0.2.0-rc.2": "compatible"
70
+ "0.2.0-rc.2": "compatible",
71
+ "0.2.1-alpha.1": "compatible"
71
72
  }
72
73
  }
73
74
  },
74
75
  "peerDependencies": {
75
- "@deepseek-ai/cordis": "^4.0.1",
76
- "@deepseek-ai/dsh-host-webserver": ">=0.1.2-alpha.1 <0.1.3 || >=0.1.3-alpha.1 <0.1.4 || >=0.1.5-alpha.1 <0.1.6 || >=0.1.6-alpha.1 <0.1.7 || >=0.1.7-alpha.1 <0.1.8 || >=0.2.0-rc.1 <0.2.1",
77
- "@deepseek-ai/dsh-sandbox-policy": ">=0.1.2-alpha.1 <0.1.3 || >=0.1.3-alpha.1 <0.1.4 || >=0.1.5-alpha.1 <0.1.6 || >=0.1.6-alpha.1 <0.1.7 || >=0.1.7-alpha.1 <0.1.8 || >=0.2.0-rc.1 <0.2.1",
78
- "@deepseek-ai/dsh-session": ">=0.1.2-alpha.1 <0.1.3 || >=0.1.3-alpha.1 <0.1.4 || >=0.1.5-alpha.1 <0.1.6 || >=0.1.6-alpha.1 <0.1.7 || >=0.1.7-alpha.1 <0.1.8 || >=0.2.0-rc.1 <0.2.1",
79
- "@deepseek-ai/dsh-session-query": ">=0.1.2-alpha.1 <0.1.3 || >=0.1.3-alpha.1 <0.1.4 || >=0.1.5-alpha.1 <0.1.6 || >=0.1.6-alpha.1 <0.1.7 || >=0.1.7-alpha.1 <0.1.8 || >=0.2.0-rc.1 <0.2.1",
80
- "@deepseek-ai/dsh-settings": ">=0.1.2-alpha.1 <0.1.3 || >=0.1.3-alpha.1 <0.1.4 || >=0.1.5-alpha.1 <0.1.6 || >=0.1.6-alpha.1 <0.1.7 || >=0.1.7-alpha.1 <0.1.8 || >=0.2.0-rc.1 <0.2.1",
81
- "@deepseek-ai/dsh-shell": ">=0.1.2-alpha.1 <0.1.3 || >=0.1.3-alpha.1 <0.1.4 || >=0.1.5-alpha.1 <0.1.6 || >=0.1.6-alpha.1 <0.1.7 || >=0.1.7-alpha.1 <0.1.8 || >=0.2.0-rc.1 <0.2.1",
82
- "@deepseek-ai/schemastery": "^3.18.1",
76
+ "@deepseek-ai/cordis": "^4.0.1 || >=4.0.5-alpha.1 <4.0.6",
77
+ "@deepseek-ai/dsh-host-webserver": ">=0.1.2-alpha.1 <0.1.3 || >=0.1.3-alpha.1 <0.1.4 || >=0.1.5-alpha.1 <0.1.6 || >=0.1.6-alpha.1 <0.1.7 || >=0.1.7-alpha.1 <0.1.8 || >=0.2.0-rc.1 <0.2.1 || >=0.2.1-alpha.1 <0.3.0",
78
+ "@deepseek-ai/dsh-sandbox-policy": ">=0.1.2-alpha.1 <0.1.3 || >=0.1.3-alpha.1 <0.1.4 || >=0.1.5-alpha.1 <0.1.6 || >=0.1.6-alpha.1 <0.1.7 || >=0.1.7-alpha.1 <0.1.8 || >=0.2.0-rc.1 <0.2.1 || >=0.2.1-alpha.1 <0.3.0",
79
+ "@deepseek-ai/dsh-session": ">=0.1.2-alpha.1 <0.1.3 || >=0.1.3-alpha.1 <0.1.4 || >=0.1.5-alpha.1 <0.1.6 || >=0.1.6-alpha.1 <0.1.7 || >=0.1.7-alpha.1 <0.1.8 || >=0.2.0-rc.1 <0.2.1 || >=0.2.1-alpha.1 <0.3.0",
80
+ "@deepseek-ai/dsh-session-query": ">=0.1.2-alpha.1 <0.1.3 || >=0.1.3-alpha.1 <0.1.4 || >=0.1.5-alpha.1 <0.1.6 || >=0.1.6-alpha.1 <0.1.7 || >=0.1.7-alpha.1 <0.1.8 || >=0.2.0-rc.1 <0.2.1 || >=0.2.1-alpha.1 <0.3.0",
81
+ "@deepseek-ai/dsh-settings": ">=0.1.2-alpha.1 <0.1.3 || >=0.1.3-alpha.1 <0.1.4 || >=0.1.5-alpha.1 <0.1.6 || >=0.1.6-alpha.1 <0.1.7 || >=0.1.7-alpha.1 <0.1.8 || >=0.2.0-rc.1 <0.2.1 || >=0.2.1-alpha.1 <0.3.0",
82
+ "@deepseek-ai/dsh-shell": ">=0.1.2-alpha.1 <0.1.3 || >=0.1.3-alpha.1 <0.1.4 || >=0.1.5-alpha.1 <0.1.6 || >=0.1.6-alpha.1 <0.1.7 || >=0.1.7-alpha.1 <0.1.8 || >=0.2.0-rc.1 <0.2.1 || >=0.2.1-alpha.1 <0.3.0",
83
+ "@deepseek-ai/schemastery": "^3.18.1 || >=3.18.5-alpha.1 <3.18.6",
83
84
  "react": "^18.2.0"
84
85
  },
85
86
  "peerDependenciesMeta": {