dsh-recall-plugin 2.6.0 → 2.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,7 +2,28 @@
2
2
 
3
3
  本文件格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本。
4
4
 
5
- ## [Unreleased]
5
+ ## [2.6.2] - 2026-11-03
6
+
7
+ ### 修复
8
+
9
+ - **Windows 保留设备名不再反复触发快照跳过提示(issue #32)**:`nul`、`aux`、`con`、`prn`、`com1`…、`lpt1`… 及其扩展名和上标数字变体在 `git add` 前默认排除,覆盖任意子目录;规则仅适用于 Windows,不进入构建产物 root 判定,POSIX 合法同名文件不受影响。这些路径保持「不恢复、不删除」的既有回退语义。
10
+ - **0 字节 `nul` 等路径的失败提示不再静默丢失**:`SNAP_SKIP` 同时识别 Git 的 `unable to index file 'X'` 与 `unable to add 'X' to index`,PowerShell 与 bash 模板均已覆盖。
11
+
12
+ ## [2.6.1] - 2026-10-31
13
+
14
+ ### 新增
15
+
16
+ - **「只回退消息」模式(`messageOnly`,默认关闭,issue #31)**:开启后每条用户消息仍登记撤回记录(撤回按钮与对话回退照常),但不再执行文件快照——大仓(gitignore 过滤后仍上千文件 / 上 GB)不必为每条消息写一次全量对象。按消息生效:`index.json` 只给这类条目记 `files: false`,开启前拍的旧快照仍可完整回退文件;确认面板对这类消息隐藏「回退文件与对话」单选,快照管理树标「仅消息」。代价是这类记录没有 tag,索引损坏被隔离后撤回按钮无法从 tag 重建。
17
+
18
+ ### 修复
19
+
20
+ - **磁盘枚举失败不再表现成「没有快照」**:此前快照管理读不到磁盘时会静默返回空列表,界面只剩一棵空树,用户以为快照都没了(issue #30 报告的现场就是这样)。现在失败原因进「最近错误」,列表 / 按工作区或会话批量删除 / 全部删除回 `DUMP_FAILED`,卡片显示可重试的错误条;「全部删除」也不再用「已删除 0 条」掩盖枚举失败。
21
+ - **建过仓但从没拍过快照的空仓不再永久占盘**:dump 脚本为每个 store 增发 `STATE` 段(`present` / `absent` / `quarantined` / `foreign`),「立即 gc」据此把「有 store 布局但从未生成索引」的空壳整目录回收;损坏隔离现场(`index.json.corrupt-*`)、旧版脚本输出与非本插件的目录一律保留。
22
+
23
+ ### 文档
24
+
25
+ - 新增 [plan-dump-visible-and-empty-reclaim.md](docs/plans/completed/plan-dump-visible-and-empty-reclaim.md):两条遗留的问题、判据表与测试清单;AGENTS 已知坑补 I46,compat-audit 补 I46 详情。
26
+ - 新增 [plan-message-only-mode.md](docs/plans/completed/plan-message-only-mode.md):只回退消息的问题、判据、边界与实施记录;smoke checklist 第十一节与 records 同日批次(M1〜M6 实弹)。
6
27
 
7
28
  ## [2.6.0] - 2026-10-30
8
29
 
package/README.en.md CHANGED
@@ -77,6 +77,7 @@ Boundaries accepted by design and edge cases not yet covered — worth checking
77
77
  - Recall cannot be initiated while an agent is running in the target workspace (by design — stop the agent first).
78
78
  - 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.
79
79
  - 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.
80
+ - Windows reserved device names (such as `nul`, `aux`, `con`, and `com1`, including extension forms) cannot be indexed by Git and are excluded from snapshots and recalls by default. These paths are left untouched and do not generate repeated skip notices. This exclusion applies only to Windows.
80
81
  - **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).
81
82
  - Extreme cases like filenames containing newlines/TAB are beyond the diff list's parsing capability (negligible probability).
82
83
  - **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).
@@ -201,7 +202,7 @@ The most common questions and their troubleshooting paths; for the full set of d
201
202
 
202
203
  **No "↶ Recall" button next to a message?** Check in order of likelihood: (1) the DSH process wasn't restarted after installing — restart it and hard-refresh (Ctrl+Shift+R); (2) git CLI isn't on PATH — a notice shows at the top of the page; (3) the message predates the plugin — snapshots are only created when a message is sent; (4) snapshots are disabled for that workspace — it sits under a directory-form exclusion (e.g. opened inside `dist/`), exceeded the `maxWorkspaceBytes` / `scanBudgetMs` guardrails, or the pre-snapshot size scan itself failed (`git ls-files` error; the reason is in "Recent errors" and it retries after 10 minutes) — recent messages carry a recovery notice (raise the limit or extend the exclusion list and it recovers automatically).
203
204
 
204
- **Some files weren't restored by a recall?** They weren't in the snapshot: larger than `maxFileBytes` (8MB by default), matched by the exclusion list, or inside a nested git repository / a win32 reparse point (see "Known Limitations"). The confirmation panel's file list before a recall is the exact rollback scope — trust it.
205
+ **Some files weren't restored by a recall?** They weren't in the snapshot: larger than `maxFileBytes` (8MB by default), matched by the exclusion list (including Windows reserved device names), or inside a nested git repository / a win32 reparse point (see "Known Limitations"). The confirmation panel's file list before a recall is the exact rollback scope — trust it.
205
206
 
206
207
  **Snapshots eating too much disk?** Open "Settings → Plugin Config → Recall Plugin → Snapshot Manager" to see per-workspace usage, run "gc now", or delete by workspace / session / single snapshot; longer-term, lower `maxSnapshotsPerWorkspace`, set `retentionDays`, or add big directories to the exclusion list.
207
208
 
package/README.md CHANGED
@@ -74,6 +74,7 @@
74
74
  - 目标工作区的 agent 正在运行时无法发起撤回(防护设计,先停止 agent 再撤回)。
75
75
  - 支持 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 兼容编写,尚未真机实测。
76
76
  - 工作区内嵌套的其他 git 仓库(子目录自带 `.git`)无法索引:其余部分快照照常(fail-open,页面会提示跳过了哪些路径),但其内容不参与回退。
77
+ - Windows 保留设备名(如 `nul`、`aux`、`con`、`com1`,含扩展名形式)无法由 Git 索引,默认不参与快照与回退;这些路径保持原样,也不会反复产生跳过提示。此排除仅适用于 Windows。
77
78
  - 工作区内的**目录重解析点**(junction / 目录符号链接)默认不参与快照:它们指向的子树不会被索引,因此那部分内容不参与回退。win32 上这是必需的——git for Windows 把 junction 当普通目录递归,自引用 junction 会让索引膨胀到 Windows 的 31 层重解析点上限(实测 2 个文件的工作区产出 64 条索引);POSIX 侧符号链接照常进快照(git 记成 `120000` 条目,不递归进目标)。确需把某个**良性** junction(指向工作区外、非自引用)纳入快照时,可在排除配置里追加 `!路径/`(如 `!ext-link/`)反选恢复——用户排除行在机器生成行之后生效;**切勿对自引用 junction 使用**(爆炸根因)。
78
79
  - 文件名含换行/TAB 的极端情形超出 diff 清单的解析能力(概率可忽略)。
79
80
  - **与 dsh-routing-suite(渐进式工具披露路由)的交互**:同时启用其 router-standard 预设时,撤回经 `sessions.fork` 出新会话会把路由阶段重置为默认(工具面临时收窄)。现象、成因与解决方案见 [docs/routing-interplay.md](docs/routing-interplay.md)。
@@ -161,6 +162,7 @@ peerDependencies 按 minor 版本线开窗,与 `dsh.compatibility.dshReleases`
161
162
  | `baseExcludes` | `.git`、`node_modules/`、`.dsh-recall-snapshots/`、`dsh-recall-snapshots/`、`target/`、`dist/`、`build/`、`out/`、`coverage/`、`.next/`、`.nuxt/`、`.output/`、`.cache/`、`.gradle/`、`*.exe`、`*.dll`、`*.pdb`、`*.so`、`*.dylib`、`*.msi`、`*.zip`、`*.7z`、`*.rar`、`*.tar`、`*.tar.gz`、`*.iso` | 基础排除表(gitignore 语法,优先级低于 exclude.txt);被排除的大目录整棵子树不进快照扫描。其中「目录形态」项(如 `target/`)另有一层含义:**工作区根自身的路径段命中时,该工作区不启用快照**——在构建产物目录里开会话时排除表本来就管不到它自己(模式是相对该 root 的),这类目录也没有回退价值;删掉对应项即恢复 |
162
163
  | `refillDraft` | true | 撤回后把被撤回的消息(文本与附件)回填到输入框 |
163
164
  | `snapshotEnabled` | true | 快照总开关(关闭只冻结新建,已有快照仍可撤回) |
165
+ | `messageOnly` | false | 只回退消息:每条用户消息仍登记撤回记录(按钮与对话回退照常),但不写文件快照——适合 gitignore 过滤后仍上 GB 的超大工作区。按消息生效:开启前的旧快照仍可完整回退文件;这类条目在快照管理里标「仅消息」,删除后不可从 tag 恢复 |
164
166
  | `archiveOriginal` | true | 撤回后归档原会话(关闭后原会话保留在会话列表中) |
165
167
  | `editSubmitsImmediately` | true | 「编辑并重发」提交后自动发出改后的消息;关闭则只回填输入框,由你按回车发送 |
166
168
  | `locale` | auto | 界面语言:`auto` 跟随系统(`navigator.language` 以 `zh` 开头用中文,否则英文)、`zh` / `en` 显式锁定。见下方「界面语言」 |
@@ -198,7 +200,7 @@ peerDependencies 按 minor 版本线开窗,与 `dsh.compatibility.dshReleases`
198
200
 
199
201
  **消息旁没有「↶ 撤回」按钮?** 按概率排查:① 装完没重启 DSH 进程——重启后硬刷新页面(Ctrl+Shift+R);② git CLI 不在 PATH——页面顶部会有安装提示;③ 该消息发送于插件启用之前——快照只在消息发送时创建,历史消息无快照;④ 该工作区被停用快照——工作区开在 `dist/`、`target/` 这类命中基础排除表目录形态的位置,或超出 `maxWorkspaceBytes` / `scanBudgetMs` 护栏,或快照前的范围统计本身失败(`git ls-files` 报错,原因见「最近错误」,10 分钟后自动重试);最近消息上会给出恢复提示(调大上限或补充排除表后自动恢复)。
200
202
 
201
- **撤回后个别文件没有恢复?** 该文件不在快照里:超过 `maxFileBytes`(默认 8MB)、命中排除表,或位于嵌套 git 仓库 / win32 目录重解析点内(见「已知限制」)。撤回前确认面板的清单就是实际回退范围,以它为准。
203
+ **撤回后个别文件没有恢复?** 该文件不在快照里:超过 `maxFileBytes`(默认 8MB)、命中排除表(含 Windows 保留设备名),或位于嵌套 git 仓库 / win32 目录重解析点内(见「已知限制」)。撤回前确认面板的清单就是实际回退范围,以它为准。
202
204
 
203
205
  **快照占用磁盘太多?** 打开「设置 → 插件配置 → 撤回插件 → 快照管理」查看各工作区占用,可「立即 gc」或按工作区 / 会话 / 单条分级删除;长期可压低 `maxSnapshotsPerWorkspace`、设置 `retentionDays`,或把大目录补进排除表。
204
206
 
package/lib/client.js CHANGED
@@ -218,6 +218,10 @@
218
218
  // 对齐;此前复用 ex-note 左对齐正文样式,空列表区显得是「缺了一行」而非
219
219
  // 一个状态
220
220
  ".dsh-recall-empty{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5;text-align:center;padding:24px 0}",
221
+ // 列表读取失败条(issue #30 遗留项):error 前景 + 换行不裁切,右侧带重试 chip。
222
+ // 与空态区分开——「没有快照」和「读不到快照」在用户眼里必须是两件事
223
+ ".dsh-recall-list-error{display:flex;gap:8px;align-items:flex-start;padding:8px 10px;border-radius:8px;background:var(--dsw-alias-interactive-bg-hover-danger);color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:1.5}",
224
+ ".dsh-recall-list-error-text{flex:1;min-width:0;word-break:break-word}",
221
225
  ".dsh-recall-ex-status-success{color:var(--dsw-alias-state-success-primary)}",
222
226
  // V6 健康徽章(git 可用性):pill 配色直接复用官方状态行配对——成功用
223
227
  // success-tertiary 底、失败用 interactive-bg-hover-danger 底(error 无
@@ -242,6 +246,8 @@
242
246
  ".dsh-recall-tree-label{flex:1;min-width:0;display:flex;gap:8px;align-items:baseline;font-size:12px;line-height:1.5;overflow:hidden}",
243
247
  ".dsh-recall-tree-name{flex:none;font-weight:600;color:var(--dsw-alias-label-secondary)}",
244
248
  ".dsh-recall-tree-title{min-width:0;color:var(--dsw-alias-label-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}",
249
+ // issue #31:仅消息条目的静态标记——与 ex-chip 同丸形,但非按钮、无 hover
250
+ ".dsh-recall-tree-tag{flex:none;border-radius:999px;corner-shape:round;padding:0 8px;font-size:11px;line-height:1.6;color:var(--dsw-alias-label-tertiary);background:var(--dsw-alias-bg-module-platform);white-space:nowrap}",
245
251
  // tabular-nums:树 meta 列(「3 会话 / 28 快照」「v2/3」)多行纵向扫描时
246
252
  // 数字等宽,个位对齐不跳动
247
253
  ".dsh-recall-tree-meta{flex:none;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary);white-space:nowrap;font-variant-numeric:tabular-nums}",
@@ -425,6 +431,7 @@
425
431
  "tree.family.title": "\u7248\u672C\u5BB6\u65CF\uFF1A{chain}",
426
432
  "tree.switch": "\u5207\u6362",
427
433
  "tree.switch.title": "\u5207\u6362\u5230\u8BE5\u7248\u672C\u4F1A\u8BDD",
434
+ "tree.messageOnly": "\u4EC5\u6D88\u606F",
428
435
  "tree.delete.snapshot.title": "\u5220\u9664\u8BE5\u5FEB\u7167\uFF08tag \u4E0E\u7D22\u5F15\u6761\u76EE\uFF09",
429
436
  "tree.delete.snapshot.confirm": "\u786E\u8BA4\u5220\u9664\u8BE5\u5FEB\u7167\uFF1F\u6B64\u64CD\u4F5C\u4E0D\u53EF\u6062\u590D\u3002",
430
437
  "tree.delete.session.title": "\u5220\u9664\u8BE5\u4F1A\u8BDD\u5168\u90E8\u5FEB\u7167",
@@ -438,6 +445,8 @@
438
445
  "recall.truncated.files": "\u2026\u4EC5\u663E\u793A\u524D {shown} \u6761\uFF0C\u5171 {total} \u4E2A\u6587\u4EF6\u5C06\u53D8\u66F4",
439
446
  "recall.truncated.chat": "\u2026\u4EC5\u663E\u793A\u524D {shown} \u6761\uFF0C\u5171 {total} \u5904\u5DEE\u5F02",
440
447
  "recall.confirm.sessionOnly": "\u9879\u76EE\u6587\u4EF6\u4FDD\u6301\u5F53\u524D\u72B6\u6001\uFF0C\u4E0D\u4F1A\u88AB\u56DE\u9000\u6216\u5220\u9664\uFF1B\u5BF9\u8BDD\u56DE\u9000\u5230\u8BE5\u6D88\u606F\u4E4B\u524D\u3002",
448
+ "recall.confirm.messageOnly": "\u8FD9\u6761\u6D88\u606F\u53EA\u6709\u5BF9\u8BDD\u56DE\u9000\uFF1A\u4E0D\u4F1A\u6539\u52A8\u9879\u76EE\u6587\u4EF6\uFF0C\u5BF9\u8BDD\u56DE\u9000\u5230\u8BE5\u6D88\u606F\u4E4B\u524D\u3002",
449
+ "recall.confirm.noEffect": "\u8BE5\u6D88\u606F\u6CA1\u6709\u6587\u4EF6\u5FEB\u7167\uFF0C\u4E14\u662F\u4F1A\u8BDD\u7B2C\u4E00\u6761\u6D88\u606F\uFF0C\u4E4B\u524D\u6CA1\u6709\u53EF\u56DE\u9000\u7684\u5BF9\u8BDD\u3002",
441
450
  "recall.confirm.rollbackAt": "\u5C06\u9879\u76EE\u6062\u590D\u5230 {time} \u53D1\u9001\u8BE5\u6D88\u606F\u65F6\u7684\u72B6\u6001\u3002",
442
451
  "recall.confirm.rollbackNoTime": "\u5C06\u9879\u76EE\u6062\u590D\u5230\u53D1\u9001\u8BE5\u6D88\u606F\u65F6\u7684\u72B6\u6001\u3002",
443
452
  "recall.confirm.files": "\u5171 {total} \u4E2A\u6587\u4EF6\u5C06\u53D8\u66F4{summary}\u3002\u6B64\u64CD\u4F5C\u4F1A\u8986\u76D6\u5F53\u524D\u6587\u4EF6\u5185\u5BB9\uFF1B\u56DE\u9000\u524D\u4F1A\u81EA\u52A8\u4FDD\u5B58\u4E00\u4EFD\u5F53\u524D\u72B6\u6001\u7684\u5B89\u5168\u5FEB\u7167\uFF08\u4E0D\u542B\u5728\u4E0B\u65B9\u6E05\u5355\u5185\uFF09\u3002",
@@ -511,6 +520,7 @@
511
520
  "notice.buildRoot": "\u5F53\u524D\u5DE5\u4F5C\u533A\u4F4D\u4E8E\u6784\u5EFA\u4EA7\u7269\u76EE\u5F55\uFF08\u8DEF\u5F84\u6BB5 {seg}\uFF09\uFF0C\u5DF2\u8DF3\u8FC7\u9879\u76EE\u5FEB\u7167\uFF1B\u5982\u9700\u5728\u6B64\u76EE\u5F55\u4F7F\u7528\u64A4\u56DE\uFF0C\u8BF7\u5728\u63D2\u4EF6\u8BBE\u7F6E\u91CC\u4ECE\u300C\u57FA\u7840\u6392\u9664\u8868\u300D\u79FB\u9664\u8BE5\u9879\u3002",
512
521
  // ---- 错误码文案(host 静态 message 的本地化副本;动态细节码不在表内)----
513
522
  "err.STALE": "\u9884\u89C8\u540E\u9879\u76EE\u6587\u4EF6\u53D1\u751F\u4E86\u53D8\u5316\uFF0C\u8BF7\u91CD\u65B0\u9884\u89C8\u786E\u8BA4",
523
+ "err.DUMP_FAILED": "\u8BFB\u53D6\u78C1\u76D8\u5FEB\u7167\u5931\u8D25\uFF0C\u5217\u8868\u4E0E\u6279\u91CF\u5220\u9664\u6682\u4E0D\u53EF\u7528\uFF1B\u8BF7\u67E5\u770B\u300C\u6700\u8FD1\u9519\u8BEF\u300D\u540E\u91CD\u8BD5",
514
524
  "err.AGENT_BUSY": "Agent \u6B63\u5728\u8FD0\u884C\u4E2D\uFF0C\u8BF7\u5148\u505C\u6B62\u540E\u518D\u64A4\u56DE",
515
525
  "err.NO_SNAPSHOT": "\u8BE5\u6D88\u606F\u6CA1\u6709\u53EF\u7528\u7684\u9879\u76EE\u5FEB\u7167",
516
526
  "err.NO_STORE": "\u5FEB\u7167\u5B58\u50A8\u4E0D\u53EF\u7528",
@@ -553,6 +563,7 @@
553
563
  "manage.search.placeholder": "\u641C\u7D22\u5DE5\u4F5C\u533A / \u4F1A\u8BDD\u6807\u9898 / \u6D88\u606F\u5185\u5BB9 / ID",
554
564
  "manage.search.aria": "\u641C\u7D22\u5FEB\u7167",
555
565
  "manage.empty": "\u5728\u4EFB\u610F\u5DE5\u4F5C\u533A\u53D1\u9001\u4E00\u6761\u6D88\u606F\u540E\uFF0C\u8FD9\u91CC\u4F1A\u51FA\u73B0\u5FEB\u7167\u3002",
566
+ "manage.loadFailed": "\u8BFB\u53D6\u78C1\u76D8\u5FEB\u7167\u5931\u8D25",
556
567
  "manage.emptyFiltered": "\u65E0\u5339\u914D\u5FEB\u7167",
557
568
  "manage.errors.title": "\u6700\u8FD1\u9519\u8BEF ({n})",
558
569
  "manage.errors.collapse": "\u6536\u8D77",
@@ -571,6 +582,8 @@
571
582
  "config.group.ui": "\u754C\u9762",
572
583
  "config.snapshotEnabled.label": "\u542F\u7528\u5FEB\u7167",
573
584
  "config.snapshotEnabled.hint": "\u5173\u95ED\u540E\u4E0D\u518D\u65B0\u5EFA\u5FEB\u7167\uFF1B\u5DF2\u6709\u5FEB\u7167\u4ECD\u53EF\u64A4\u56DE",
585
+ "config.messageOnly.label": "\u53EA\u56DE\u9000\u6D88\u606F",
586
+ "config.messageOnly.hint": "\u6BCF\u6761\u6D88\u606F\u53EA\u767B\u8BB0\u64A4\u56DE\u8BB0\u5F55\uFF0C\u4E0D\u5199\u6587\u4EF6\u5FEB\u7167\uFF08\u8D85\u5927\u5DE5\u4F5C\u533A\u7701\u65F6\u7701\u76D8\uFF09\uFF1B\u5DF2\u6709\u5FEB\u7167\u4ECD\u53EF\u56DE\u9000\u6587\u4EF6",
574
587
  "config.refillDraft.label": "\u56DE\u586B\u8F93\u5165\u6846",
575
588
  "config.refillDraft.hint": "\u64A4\u56DE\u540E\u628A\u6D88\u606F\u6587\u672C\u56DE\u586B\u8F93\u5165\u6846\uFF0C\u4FBF\u4E8E\u6539\u5B8C\u91CD\u53D1",
576
589
  "config.archiveOriginal.label": "\u5F52\u6863\u539F\u4F1A\u8BDD",
@@ -683,6 +696,7 @@
683
696
  "tree.family.title": "Version family: {chain}",
684
697
  "tree.switch": "Switch",
685
698
  "tree.switch.title": "Switch to this version session",
699
+ "tree.messageOnly": "Messages only",
686
700
  "tree.delete.snapshot.title": "Delete this snapshot (tag and index entry)",
687
701
  "tree.delete.snapshot.confirm": "Delete this snapshot? This cannot be undone.",
688
702
  "tree.delete.session.title": "Delete all snapshots of this session",
@@ -696,6 +710,8 @@
696
710
  "recall.truncated.files": "\u2026showing the first {shown} of {total} files to change",
697
711
  "recall.truncated.chat": "\u2026showing the first {shown} of {total} differences",
698
712
  "recall.confirm.sessionOnly": "Project files stay as they are \u2014 nothing is reverted or deleted; only the chat rewinds to before this message.",
713
+ "recall.confirm.messageOnly": "This message only rewinds the chat: project files are untouched, and the chat goes back to before this message.",
714
+ "recall.confirm.noEffect": "This message has no file snapshot, and as the first message of the session there is no earlier chat to rewind to.",
699
715
  "recall.confirm.rollbackAt": "Restore the project to its state at {time}, when this message was sent.",
700
716
  "recall.confirm.rollbackNoTime": "Restore the project to its state when this message was sent.",
701
717
  // 句首空格是有意的:本句与 recall.confirm.rollbackAt 拼接成一段(前句以句点
@@ -769,6 +785,7 @@
769
785
  "notice.buildRoot": 'This workspace is a build-artifact directory (path segment {seg}), so project snapshots are skipped; to use recall here, remove that entry from "Base excludes" in the plugin settings.',
770
786
  // ---- 错误码文案 ----
771
787
  "err.STALE": "Project files changed after the preview; preview again to confirm",
788
+ "err.DUMP_FAILED": 'Could not read snapshots from disk; the list and bulk delete are unavailable. Check "Recent errors" and retry',
772
789
  "err.AGENT_BUSY": "The agent is running; stop it before recalling",
773
790
  "err.NO_SNAPSHOT": "No project snapshot is available for this message",
774
791
  "err.NO_STORE": "Snapshot storage is unavailable",
@@ -811,6 +828,7 @@
811
828
  "manage.search.placeholder": "Search workspace / session title / message / ID",
812
829
  "manage.search.aria": "Search snapshots",
813
830
  "manage.empty": "Send a message in any workspace and its snapshot will appear here.",
831
+ "manage.loadFailed": "Could not read snapshots from disk",
814
832
  "manage.emptyFiltered": "No matching snapshots",
815
833
  "manage.errors.title": "Recent errors ({n})",
816
834
  "manage.errors.collapse": "Collapse",
@@ -829,6 +847,8 @@
829
847
  "config.group.ui": "Interface",
830
848
  "config.snapshotEnabled.label": "Enable snapshots",
831
849
  "config.snapshotEnabled.hint": "When off, no new snapshots are created; existing ones can still be recalled",
850
+ "config.messageOnly.label": "Messages only",
851
+ "config.messageOnly.hint": "Register recall records without writing file snapshots (saves time and disk in huge workspaces); existing snapshots can still roll back files",
832
852
  "config.refillDraft.label": "Refill input box",
833
853
  "config.refillDraft.hint": "Put the message text back into the input box after a recall, so you can edit and resend",
834
854
  "config.archiveOriginal.label": "Archive original session",
@@ -1890,7 +1910,7 @@
1890
1910
  writeIds(rootId, next);
1891
1911
  }
1892
1912
 
1893
- // src/client/recall-node.ts
1913
+ // src/client/message-blocks.ts
1894
1914
  function attachmentRefsFromBlocks(blocks) {
1895
1915
  const refs = [];
1896
1916
  for (const block of blocks) {
@@ -1903,6 +1923,18 @@
1903
1923
  }
1904
1924
  return refs;
1905
1925
  }
1926
+ function projectMessageBlocks(blocks) {
1927
+ return {
1928
+ text: blocks.filter((b) => b && b.type === "text" && typeof b.text === "string").map((b) => b.text).join(""),
1929
+ // 官方契约:images 传 image 块数组({attachment}),官方内部取
1930
+ // image.attachment.attachmentId —— 不是裸 attachment 对象
1931
+ images: blocks.filter((b) => b && b.type === "image" && b.attachment).map((b) => ({ attachment: b.attachment })),
1932
+ // file 块(文档/表格等非图片附件):官方渲染成文件卡片(图标 + 文件名 +
1933
+ // 扩展名/大小)——自绘路径必须复刻,否则它落进 rest 的 JSON 兜底
1934
+ files: blocks.filter((b) => b && b.type === "file"),
1935
+ rest: blocks.filter((b) => !b || !(b.type === "text" && typeof b.text === "string") && !(b.type === "image" && b.attachment) && b.type !== "file")
1936
+ };
1937
+ }
1906
1938
  function fileExtOf(name) {
1907
1939
  const dot = name.lastIndexOf(".");
1908
1940
  const raw = dot > 0 && dot < name.length - 1 ? name.slice(dot + 1) : "";
@@ -1923,6 +1955,8 @@
1923
1955
  const name = ref && typeof ref === "object" && typeof ref.name === "string" && ref.name !== "" ? ref.name : t("file.unnamed");
1924
1956
  return { name, ext: fileExtOf(name), size: fileCardSizeText(ref && typeof ref === "object" ? ref.bytes : void 0) };
1925
1957
  }
1958
+
1959
+ // src/client/diff-kinds.ts
1926
1960
  var KIND_INFO = {
1927
1961
  modified: { labelKey: "kind.modified", cls: "modified" },
1928
1962
  restored: { labelKey: "kind.restored", cls: "restored" },
@@ -1938,6 +1972,8 @@
1938
1972
  }
1939
1973
  return parts.join(" \xB7 ");
1940
1974
  }
1975
+
1976
+ // src/client/recall-node.ts
1941
1977
  function buildRecallNode(React, util, ctx, sessionsSvc, workspacesSvc, uiWorkspaceSvc) {
1942
1978
  const { api, ensureInit, showThrottledToast, writeClipboard, clockText: clockText2, pluginConfig, messageFor, t } = util;
1943
1979
  const { EditIcon, EditPanel } = buildEditFlow(React, t);
@@ -2007,7 +2043,9 @@
2007
2043
  );
2008
2044
  });
2009
2045
  const canRevertChat = typeof recall.cutSeq === "number";
2010
- const sessionOnly = canRevertChat && scope === "session-only";
2046
+ const noFiles = recall.files === false;
2047
+ const sessionOnly = noFiles || canRevertChat && scope === "session-only";
2048
+ const noEffect = noFiles && !canRevertChat;
2011
2049
  if (recall.truncated) {
2012
2050
  rows.push(React.createElement(
2013
2051
  "div",
@@ -2027,11 +2065,11 @@
2027
2065
  React.createElement("div", { className: "dsh-recall-panel-title" }, t("recall.confirm.title")),
2028
2066
  sessionOnly ? (
2029
2067
  // session-only:零文件改动即无不可逆操作缺口——安全快照预告随之隐藏
2030
- //(不打安全快照),主说明换成模式语义
2068
+ //(不打安全快照),主说明换成模式语义;仅消息条目另给专属说明
2031
2069
  React.createElement(
2032
2070
  "div",
2033
2071
  { className: "dsh-recall-panel-note" },
2034
- t("recall.confirm.sessionOnly")
2072
+ t(noFiles ? "recall.confirm.messageOnly" : "recall.confirm.sessionOnly")
2035
2073
  )
2036
2074
  ) : React.createElement(
2037
2075
  "div",
@@ -2053,9 +2091,10 @@
2053
2091
  )
2054
2092
  ) : null,
2055
2093
  changes.length > 0 ? React.createElement("div", { className: "dsh-recall-list" }, ...rows) : null,
2056
- canRevertChat ? (
2094
+ canRevertChat && !noFiles ? (
2057
2095
  // 模式二选一(原生 radio,键盘方向键可达):临场选择不设全局配置项;
2058
- // 默认 both 与现状一致,面板每次打开都复位到默认起点
2096
+ // 默认 both 与现状一致,面板每次打开都复位到默认起点。仅消息条目
2097
+ // 没有可选项(没有文件树),整组隐藏
2059
2098
  React.createElement(
2060
2099
  "div",
2061
2100
  { className: "dsh-recall-scope", role: "radiogroup", "aria-label": t("recall.scope.aria") },
@@ -2073,22 +2112,28 @@
2073
2112
  )
2074
2113
  )
2075
2114
  ) : null,
2115
+ noEffect ? (
2116
+ // 仅消息 + 首条消息:文件无快照、对话无更早切点,提交只会空转
2117
+ React.createElement("div", { className: "dsh-recall-panel-note", key: "no-effect" }, t("recall.confirm.noEffect"))
2118
+ ) : null,
2076
2119
  React.createElement(
2077
2120
  "div",
2078
2121
  { className: "dsh-recall-panel-actions" },
2079
2122
  React.createElement("button", { type: "button", className: "dsh-recall-btn", onClick: closePanel }, t("common.cancel")),
2080
- React.createElement("button", { type: "button", className: "dsh-recall-btn dsh-recall-btn-danger", onClick: executeRecall }, t(sessionOnly ? "recall.confirm.submit.sessionOnly" : "recall.confirm.submit.both"))
2123
+ React.createElement("button", { type: "button", className: "dsh-recall-btn dsh-recall-btn-danger", disabled: noEffect, onClick: executeRecall }, t(sessionOnly ? "recall.confirm.submit.sessionOnly" : "recall.confirm.submit.both"))
2081
2124
  )
2082
2125
  );
2083
2126
  }
2084
2127
  if (recall.stage === "executing") {
2128
+ const active = recall.scope || scope;
2085
2129
  return React.createElement(
2086
2130
  "div",
2087
2131
  { className: "dsh-recall-panel" },
2088
- React.createElement("div", { className: "dsh-recall-panel-title" }, t(scope === "session-only" ? "recall.executing.sessionOnly" : "recall.executing.both"))
2132
+ React.createElement("div", { className: "dsh-recall-panel-title" }, t(active === "session-only" ? "recall.executing.sessionOnly" : "recall.executing.both"))
2089
2133
  );
2090
2134
  }
2091
2135
  if (recall.stage === "done") {
2136
+ const active = recall.scope || scope;
2092
2137
  return React.createElement(
2093
2138
  "div",
2094
2139
  { className: "dsh-recall-panel" },
@@ -2096,7 +2141,7 @@
2096
2141
  React.createElement(
2097
2142
  "div",
2098
2143
  { className: "dsh-recall-panel-note" },
2099
- scope === "session-only" ? (
2144
+ active === "session-only" ? (
2100
2145
  // session-only 文案矩阵:文件侧零改动是确定事实,失败时也只描述对话侧
2101
2146
  recall.chatReverted ? t("recall.done.sessionOnly.ok") : t("recall.done.sessionOnly.fail", { error: recall.chatError || t("common.unknownReason") })
2102
2147
  ) : recall.chatReverted ? t("recall.done.both.ok") : t("recall.done.both.partial") + (recall.chatError ? t("recall.done.both.failChat", { error: recall.chatError }) : "")
@@ -2145,10 +2190,7 @@
2145
2190
  const data = node && node.data ? node.data : {};
2146
2191
  const messageId = node ? String(node.id || node.key || "") : "";
2147
2192
  const blocks = Array.isArray(data.content) ? data.content : [];
2148
- const text2 = blocks.filter((b) => b && b.type === "text" && typeof b.text === "string").map((b) => b.text).join("");
2149
- const imageBlocks = blocks.filter((b) => b && b.type === "image" && b.attachment).map((b) => ({ attachment: b.attachment }));
2150
- const fileBlocks = blocks.filter((b) => b && b.type === "file");
2151
- const rest = blocks.filter((b) => !b || !(b.type === "text" && typeof b.text === "string") && !(b.type === "image" && b.attachment) && b.type !== "file");
2193
+ const { text: text2, images: imageBlocks, files: fileBlocks, rest } = projectMessageBlocks(blocks);
2152
2194
  const attachmentRefs = attachmentRefsFromBlocks(blocks);
2153
2195
  activeSession.id = String(sessionId || "");
2154
2196
  const [copied, setCopied] = React.useState(false);
@@ -2475,7 +2517,9 @@
2475
2517
  truncated: Boolean(res.truncated),
2476
2518
  treeId: res.treeId || null,
2477
2519
  time: res.time || null,
2478
- cutSeq: typeof res.cutSeq === "number" ? res.cutSeq : null
2520
+ cutSeq: typeof res.cutSeq === "number" ? res.cutSeq : null,
2521
+ // issue #31:仅消息条目没有文件树(老 Host 无该字段 → 按 true)
2522
+ files: res.files !== false
2479
2523
  });
2480
2524
  }).catch((error) => {
2481
2525
  setRecall({ stage: "error", message: String(error) });
@@ -2486,7 +2530,7 @@
2486
2530
  const isEdit = editText !== null;
2487
2531
  if (!isEdit && recall.stage !== "confirm") return;
2488
2532
  const changes = recall.stage === "confirm" ? recall.changes || [] : [];
2489
- const activeScope = overrides && overrides.scope === "session-only" ? "session-only" : overrides && overrides.scope === "both" ? "both" : scope;
2533
+ const activeScope = overrides && overrides.scope === "session-only" ? "session-only" : overrides && overrides.scope === "both" ? "both" : recall.stage === "confirm" && recall.files === false ? "session-only" : scope;
2490
2534
  const previewCut = overrides && overrides.cutSeq !== void 0 ? overrides.cutSeq : recall.stage === "confirm" && typeof recall.cutSeq === "number" ? recall.cutSeq : null;
2491
2535
  const setStage = (stage) => {
2492
2536
  if (!isEdit) {
@@ -2505,7 +2549,7 @@
2505
2549
  const baseFiles = wantAttachments ? preloadAttachmentFiles(sessionId, refsForFiles) : null;
2506
2550
  const inlineFiles = overrides && overrides.editFiles ? overrides.editFiles : null;
2507
2551
  const attachmentFiles = baseFiles !== null && inlineFiles !== null ? Promise.all([baseFiles, inlineFiles]).then(([base, extra]) => base.concat(extra)) : baseFiles !== null ? baseFiles : inlineFiles;
2508
- setStage({ stage: "executing", changes });
2552
+ setStage({ stage: "executing", changes, scope: activeScope });
2509
2553
  api("execute", { messageId, sessionId, previewTotal, previewTreeId: previewTreeId || void 0, scope: activeScope, previewAt: Date.now() }).then(async (res) => {
2510
2554
  if (!res || !res.ok) {
2511
2555
  if (res && res.code === "STALE") {
@@ -2526,7 +2570,8 @@
2526
2570
  truncated: Boolean(res2.truncated),
2527
2571
  treeId: res2.treeId || null,
2528
2572
  time: res2.time || null,
2529
- cutSeq: typeof res2.cutSeq === "number" ? res2.cutSeq : null
2573
+ cutSeq: typeof res2.cutSeq === "number" ? res2.cutSeq : null,
2574
+ files: res2.files !== false
2530
2575
  });
2531
2576
  }).catch((error) => {
2532
2577
  setStage({ stage: "error", message: String(error) });
@@ -2592,7 +2637,7 @@
2592
2637
  fillDraft(fillTarget, text2, attachmentFiles);
2593
2638
  if (fileBlocks.length > 0) showThrottledToast(t("toast.fileAttachRefill"));
2594
2639
  }
2595
- setRecall({ stage: "done", count: typeof res.count === "number" ? res.count : changes.length, chatReverted, chatError });
2640
+ setRecall({ stage: "done", count: typeof res.count === "number" ? res.count : changes.length, chatReverted, chatError, scope: activeScope });
2596
2641
  }
2597
2642
  if (chatError === "") {
2598
2643
  api("notify", buildRecallNotify(notifyBase, {
@@ -2776,6 +2821,8 @@
2776
2821
  baseExcludes: Array.isArray(v.baseExcludes) ? v.baseExcludes.join("\n") : "",
2777
2822
  refillDraft: v.refillDraft !== false,
2778
2823
  snapshotEnabled: v.snapshotEnabled !== false,
2824
+ // issue #31:老 Host 无该字段 → 按默认 false(现状)处理
2825
+ messageOnly: v.messageOnly === true,
2779
2826
  archiveOriginal: v.archiveOriginal !== false,
2780
2827
  // issue #26:老 Host 的 values 无该字段时按默认 true(与 util 同口径)
2781
2828
  editSubmitsImmediately: v.editSubmitsImmediately !== false,
@@ -2803,7 +2850,7 @@
2803
2850
  function save() {
2804
2851
  if (state.busy || !draft || !baseline) return;
2805
2852
  const patch = {};
2806
- for (const key of ["gcSnaps", "gcHours", "maxFileBytes", "maxWorkspaceBytes", "scanBudgetMs", "snapshotBudgetMs", "maxSnapshotsPerWorkspace", "baseExcludes", "refillDraft", "snapshotEnabled", "archiveOriginal", "editSubmitsImmediately", "retentionDays", "locale"]) {
2853
+ for (const key of ["gcSnaps", "gcHours", "maxFileBytes", "maxWorkspaceBytes", "scanBudgetMs", "snapshotBudgetMs", "maxSnapshotsPerWorkspace", "baseExcludes", "refillDraft", "snapshotEnabled", "messageOnly", "archiveOriginal", "editSubmitsImmediately", "retentionDays", "locale"]) {
2807
2854
  const value = draft[key];
2808
2855
  if (value !== void 0 && value !== baseline[key]) patch[key] = value;
2809
2856
  }
@@ -2870,6 +2917,7 @@
2870
2917
  }
2871
2918
  if (patch.refillDraft !== void 0) clean.refillDraft = Boolean(patch.refillDraft);
2872
2919
  if (patch.snapshotEnabled !== void 0) clean.snapshotEnabled = Boolean(patch.snapshotEnabled);
2920
+ if (patch.messageOnly !== void 0) clean.messageOnly = Boolean(patch.messageOnly);
2873
2921
  if (patch.archiveOriginal !== void 0) clean.archiveOriginal = Boolean(patch.archiveOriginal);
2874
2922
  if (patch.editSubmitsImmediately !== void 0) clean.editSubmitsImmediately = Boolean(patch.editSubmitsImmediately);
2875
2923
  if (patch.retentionDays !== void 0) {
@@ -3027,6 +3075,8 @@
3027
3075
  // 不重复加标题。
3028
3076
  React.createElement("div", { className: "dsh-recall-cfg-group" }, t("config.group.snapshot")),
3029
3077
  boolRow("snapshotEnabled", t("config.snapshotEnabled.label"), t("config.snapshotEnabled.hint")),
3078
+ // issue #31:只回退消息——紧挨总开关,语义上是「快照写什么」的下一层
3079
+ boolRow("messageOnly", t("config.messageOnly.label"), t("config.messageOnly.hint")),
3030
3080
  boolRow("refillDraft", t("config.refillDraft.label"), t("config.refillDraft.hint")),
3031
3081
  boolRow("archiveOriginal", t("config.archiveOriginal.label"), t("config.archiveOriginal.hint")),
3032
3082
  boolRow("editSubmitsImmediately", t("config.editSubmitsImmediately.label"), t("config.editSubmitsImmediately.hint")),
@@ -3287,7 +3337,7 @@
3287
3337
  return result;
3288
3338
  }
3289
3339
  function buildSnapshotManager(React, util, sessionsSvc, workspacesSvc, uiWorkspaceSvc) {
3290
- const { api, clockText: clockText2, sizeText: sizeText2, buildTree: buildTree2, t } = util;
3340
+ const { api, clockText: clockText2, sizeText: sizeText2, buildTree: buildTree2, messageFor, t } = util;
3291
3341
  function DeleteButton(props) {
3292
3342
  return React.createElement("button", {
3293
3343
  type: "button",
@@ -3326,6 +3376,7 @@
3326
3376
  }
3327
3377
  function ManageCard() {
3328
3378
  const [items, setItems] = React.useState(null);
3379
+ const [listError, setListError] = React.useState(null);
3329
3380
  const [usage, setUsage] = React.useState(null);
3330
3381
  const [errors, setErrors] = React.useState(null);
3331
3382
  const [state, setState] = React.useState({ busy: false, message: "", error: false });
@@ -3369,6 +3420,7 @@
3369
3420
  const useLimit = overLimit || limit;
3370
3421
  api("manage", { op: "list", limit: useLimit }).then((res) => {
3371
3422
  if (res && res.ok) {
3423
+ setListError(null);
3372
3424
  setItems(res.items || []);
3373
3425
  setTotal(typeof res.total === "number" ? res.total : (res.items || []).length);
3374
3426
  fetchTitles(res.items || []);
@@ -3384,6 +3436,10 @@
3384
3436
  }).catch(() => {
3385
3437
  });
3386
3438
  }
3439
+ } else if (res) {
3440
+ setListError(messageFor(res, t("manage.loadFailed")));
3441
+ setItems([]);
3442
+ setTotal(0);
3387
3443
  }
3388
3444
  api("manage", { op: "lineage" }).then((res2) => {
3389
3445
  if (res2 && res2.ok && Array.isArray(res2.lineage)) setLineage(res2.lineage);
@@ -3519,6 +3575,8 @@
3519
3575
  "span",
3520
3576
  { className: "dsh-recall-tree-label" },
3521
3577
  React.createElement("span", { className: "dsh-recall-tree-title" }, label),
3578
+ // issue #31:仅消息条目没有文件树,标出来——否则用户会以为这条能回退文件
3579
+ it.files === false ? React.createElement("span", { className: "dsh-recall-tree-tag", key: "message-only" }, t("tree.messageOnly")) : null,
3522
3580
  // 删除紧贴本条消息摘要(而非行尾):按行扫读时动作落在「看的那一行」,
3523
3581
  // 不必横跳到右侧同列按钮再回读行号
3524
3582
  React.createElement(DeleteButton, {
@@ -3696,6 +3754,13 @@
3696
3754
  onChange: (e) => setQuery(e.target.value)
3697
3755
  })
3698
3756
  ),
3757
+ // 读取失败提示:role=alert 让读屏立即播报;「重试」复用 refresh,不新造入口
3758
+ listError ? React.createElement(
3759
+ "div",
3760
+ { key: "list-error", className: "dsh-recall-list-error", role: "alert" },
3761
+ React.createElement("span", { className: "dsh-recall-list-error-text" }, listError),
3762
+ React.createElement("button", { type: "button", className: "dsh-recall-ex-chip", onClick: () => refresh() }, t("common.retry"))
3763
+ ) : null,
3699
3764
  // V3 加载骨架:items===null 表示首查未回——用 5 条 pulse 灰条占位替代
3700
3765
  // 打开快照管理时的一段空白;aria-hidden 纯装饰不打扰读屏
3701
3766
  items === null ? React.createElement(
package/lib/config.js CHANGED
@@ -47,6 +47,10 @@ const Config = Schema.object({
47
47
  baseExcludes: withVolatile(Schema.array(Schema.string()).default(BASE_EXCLUDES).description("\u57FA\u7840\u6392\u9664\u8868\uFF08gitignore \u8BED\u6CD5\uFF0C\u4F18\u5148\u7EA7\u4F4E\u4E8E exclude.txt\uFF09")),
48
48
  refillDraft: withVolatile(Schema.boolean().default(true).description("\u64A4\u56DE\u540E\u628A\u88AB\u64A4\u56DE\u7684\u6D88\u606F\uFF08\u6587\u672C\u4E0E\u9644\u4EF6\uFF09\u56DE\u586B\u5230\u8F93\u5165\u6846")),
49
49
  snapshotEnabled: withVolatile(Schema.boolean().default(true).description("\u542F\u7528\u6D88\u606F\u5FEB\u7167\uFF08\u5173\u95ED\u540E\u4E0D\u518D\u65B0\u5EFA\uFF0C\u5DF2\u6709\u5FEB\u7167\u4ECD\u53EF\u64A4\u56DE\uFF09")),
50
+ // issue #31:超大工作区(gitignore 过滤后仍上 GB)每条消息写一次全量对象
51
+ // 成本过高。开启后仍登记撤回记录(按钮与对话回退照常),但跳过文件快照;
52
+ // 按条落 files:false,老条目仍可完整回退文件
53
+ messageOnly: withVolatile(Schema.boolean().default(false).description("\u53EA\u56DE\u9000\u6D88\u606F\uFF1A\u6BCF\u6761\u6D88\u606F\u53EA\u767B\u8BB0\u64A4\u56DE\u8BB0\u5F55\uFF0C\u4E0D\u5199\u6587\u4EF6\u5FEB\u7167\uFF08\u9002\u5408\u8D85\u5927\u5DE5\u4F5C\u533A\uFF09")),
50
54
  archiveOriginal: withVolatile(Schema.boolean().default(true).description("\u64A4\u56DE\u540E\u5F52\u6863\u539F\u4F1A\u8BDD\uFF08\u5173\u95ED\u540E\u539F\u4F1A\u8BDD\u4FDD\u7559\u5728\u5217\u8868\u4E2D\uFF09")),
51
55
  // issue #26:编辑提交后自动发出改后的文本。关闭则退化为「只回填输入框」,
52
56
  // 给「只想改稿、不立刻发」的用户留一条与撤回回填一致的出口
@@ -137,6 +141,7 @@ const DEFAULTS = {
137
141
  baseExcludes: BASE_EXCLUDES,
138
142
  refillDraft: true,
139
143
  snapshotEnabled: true,
144
+ messageOnly: false,
140
145
  archiveOriginal: true,
141
146
  editSubmitsImmediately: true,
142
147
  retentionDays: 0,
@@ -160,12 +165,13 @@ function createConfig(raw) {
160
165
  const baseExcludes = Array.isArray(cfg.baseExcludes) && cfg.baseExcludes.length ? cfg.baseExcludes.filter((p) => typeof p === "string" && p.trim()) : BASE_EXCLUDES;
161
166
  const refillDraft = typeof cfg.refillDraft === "boolean" ? cfg.refillDraft : true;
162
167
  const snapshotEnabled = typeof cfg.snapshotEnabled === "boolean" ? cfg.snapshotEnabled : true;
168
+ const messageOnly = cfg.messageOnly === true;
163
169
  const archiveOriginal = typeof cfg.archiveOriginal === "boolean" ? cfg.archiveOriginal : true;
164
170
  const editSubmitsImmediately = cfg.editSubmitsImmediately !== false;
165
171
  const rawDays = typeof cfg.retentionDays === "number" ? cfg.retentionDays : parseInt(String(cfg.retentionDays == null ? "" : cfg.retentionDays), 10);
166
172
  const retentionDays = Number.isFinite(rawDays) ? Math.max(0, rawDays) : 0;
167
173
  const locale = cfg.locale === "zh" || cfg.locale === "en" ? cfg.locale : "auto";
168
- return { gcSnaps, gcHours, maxFileBytes, maxWorkspaceBytes, scanBudgetMs, snapshotBudgetMs, maxSnapshotsPerWorkspace, baseExcludes, refillDraft, snapshotEnabled, archiveOriginal, editSubmitsImmediately, retentionDays, locale };
174
+ return { gcSnaps, gcHours, maxFileBytes, maxWorkspaceBytes, scanBudgetMs, snapshotBudgetMs, maxSnapshotsPerWorkspace, baseExcludes, refillDraft, snapshotEnabled, messageOnly, archiveOriginal, editSubmitsImmediately, retentionDays, locale };
169
175
  }
170
176
  export {
171
177
  Config,
package/lib/dump-parse.js CHANGED
@@ -31,7 +31,7 @@ function parseStoresDump(text) {
31
31
  for (const line of String(text).split(/\r?\n/)) {
32
32
  if (line.indexOf("==DIR ") === 0) {
33
33
  flush();
34
- cur = { dir: line.slice(6).trim(), root: null, entries: null, lineage: null };
34
+ cur = { dir: line.slice(6).trim(), root: null, entries: null, lineage: null, indexState: "unknown" };
35
35
  inIndex = false;
36
36
  inLineage = false;
37
37
  indexLines = [];
@@ -39,6 +39,11 @@ function parseStoresDump(text) {
39
39
  continue;
40
40
  }
41
41
  if (!cur) continue;
42
+ if (line.indexOf("STATE ") === 0) {
43
+ const v = line.slice(6).trim();
44
+ if (v === "present" || v === "absent" || v === "quarantined" || v === "foreign") cur.indexState = v;
45
+ continue;
46
+ }
42
47
  if (line.indexOf("ROOT ") === 0) {
43
48
  const v = line.slice(5).trim();
44
49
  cur.root = v || null;
package/lib/errors.js CHANGED
@@ -16,6 +16,7 @@ const RECALL_PARTIAL_DELETE = "PARTIAL_DELETE";
16
16
  const RECALL_UNKNOWN_OP = "UNKNOWN_OP";
17
17
  const RECALL_FORMAT_BLOCKED = "FORMAT_BLOCKED";
18
18
  const RECALL_UNKNOWN_ENDPOINT = "UNKNOWN_ENDPOINT";
19
+ const RECALL_DUMP_FAILED = "DUMP_FAILED";
19
20
  const RECALL_INDEX_CORRUPT = "INDEX_CORRUPT";
20
21
  const ALL_CODES = Object.freeze([
21
22
  RECALL_STALE,
@@ -36,6 +37,7 @@ const ALL_CODES = Object.freeze([
36
37
  RECALL_UNKNOWN_OP,
37
38
  RECALL_FORMAT_BLOCKED,
38
39
  RECALL_UNKNOWN_ENDPOINT,
40
+ RECALL_DUMP_FAILED,
39
41
  RECALL_INDEX_CORRUPT
40
42
  ]);
41
43
  export {
@@ -43,6 +45,7 @@ export {
43
45
  RECALL_AGENT_BUSY,
44
46
  RECALL_BAD_TYPE,
45
47
  RECALL_BODY_TOO_LARGE,
48
+ RECALL_DUMP_FAILED,
46
49
  RECALL_EMPTY_PATCH,
47
50
  RECALL_ERROR,
48
51
  RECALL_FORMAT_BLOCKED,
package/lib/index.js CHANGED
@@ -154,13 +154,20 @@ function apply(ctx, config) {
154
154
  const text = rt.scripts.stripBom(await rt.runShell(rt.scripts.storesDumpScript(container || "", extras), { timeoutMs: 12e4, stdoutMaxBytes: 8388608 }));
155
155
  return parseStoresDump(text);
156
156
  } catch (error) {
157
- console.error("recall stores dump failed:", String(error && error.stack || error));
158
- return /* @__PURE__ */ new Map();
157
+ const detail = String(error && error.stack || error);
158
+ console.error("recall stores dump failed:", detail);
159
+ rt.recordError("recall stores dump failed: " + detail);
160
+ throw error instanceof Error ? error : new Error(String(error));
159
161
  }
160
162
  }
161
163
  async function locateSnapshotOnDisk(id) {
162
164
  if (!id) return null;
163
- const dump = await dumpStores();
165
+ let dump;
166
+ try {
167
+ dump = await dumpStores();
168
+ } catch (error) {
169
+ return null;
170
+ }
164
171
  const hints = /* @__PURE__ */ new Map();
165
172
  for (const [root, st] of state.stores.entries()) {
166
173
  if (st && st.dir) hints.set(st.dir, root);
@@ -205,7 +205,9 @@ function createMaintenance(ctx, rt, snaps, config, deps = {}) {
205
205
  let removed = 0;
206
206
  for (const [dir, info] of disk) {
207
207
  if (!dir || !info) continue;
208
- if (!Array.isArray(info.entries) || info.entries.length) continue;
208
+ const emptyIndex = Array.isArray(info.entries) && info.entries.length === 0;
209
+ const neverCaptured = info.entries === null && info.indexState === "absent";
210
+ if (!emptyIndex && !neverCaptured) continue;
209
211
  if (memorySnapshotCount(dir, info.root) > 0) continue;
210
212
  const store = rt.storeFromDir(dir, false);
211
213
  try {
@@ -38,6 +38,8 @@ function createRoutesCore(deps) {
38
38
  time: snap ? snap.time : null,
39
39
  id,
40
40
  ...feedback,
41
+ // issue #31:按钮照常出现,但界面要知道这条没有文件树(隐藏范围单选)
42
+ files: snap ? snap.files !== false : void 0,
41
43
  // A4:段名与中文 notice 并存下发(client 优先段名本地取词,见 init 同处注释)
42
44
  notice: artifactSeg ? buildRootNotice(artifactSeg) : void 0,
43
45
  artifactSeg: artifactSeg || void 0
@@ -52,14 +54,15 @@ function createRoutesCore(deps) {
52
54
  if (result === null) return { ok: false, code: E.RECALL_NO_SNAPSHOT, message: "\u8BE5\u6D88\u606F\u6CA1\u6709\u53EF\u7528\u7684\u9879\u76EE\u5FEB\u7167" };
53
55
  const snap2 = state.snapshots.get(id);
54
56
  const cutSeq = await snaps.resolveCutSeq(sessionId, id);
55
- return { ok: true, changes: result.changes, total: result.total, truncated: result.truncated, treeId: result.treeId || null, time: snap2 ? snap2.time : null, root: snap2 ? snap2.root : null, cutSeq };
57
+ return { ok: true, changes: result.changes, total: result.total, truncated: result.truncated, treeId: result.treeId || null, time: snap2 ? snap2.time : null, root: snap2 ? snap2.root : null, cutSeq, files: snap2 ? snap2.files !== false : void 0 };
56
58
  },
57
59
  "execute": async (args) => {
58
60
  const id = args && args.messageId ? String(args.messageId) : "";
59
61
  const sessionId = args && args.sessionId ? String(args.sessionId) : null;
60
- const scope = args && args.scope === "session-only" ? "session-only" : "both";
62
+ const entry = state.snapshots.get(id);
63
+ const scope = args && args.scope === "session-only" || entry !== void 0 && entry.files === false ? "session-only" : "both";
61
64
  if (scope === "session-only") {
62
- const snap = state.snapshots.get(id);
65
+ const snap = entry;
63
66
  if (!snap) return { ok: false, code: E.RECALL_NO_SNAPSHOT, message: "\u8BE5\u6D88\u606F\u6CA1\u6709\u53EF\u7528\u7684\u9879\u76EE\u5FEB\u7167" };
64
67
  if (agentBusy(sessionId, snap.root)) return { ok: false, code: E.RECALL_AGENT_BUSY, message: "Agent \u6B63\u5728\u8FD0\u884C\u4E2D\uFF0C\u8BF7\u5148\u505C\u6B62\u540E\u518D\u64A4\u56DE" };
65
68
  const cutSeq2 = await snaps.resolveCutSeq(sessionId, id);
@@ -29,6 +29,9 @@ function createRoutesManage(deps) {
29
29
  E
30
30
  } = deps;
31
31
  const { sessionTitles, messageTexts, liveTitleFast, liveMessageTextFast } = sessionInfo;
32
+ function dumpFailed() {
33
+ return { ok: false, code: E.RECALL_DUMP_FAILED, message: "\u8BFB\u53D6\u78C1\u76D8\u5FEB\u7167\u5931\u8D25\uFF0C\u5217\u8868\u4E0E\u6279\u91CF\u5220\u9664\u4E0D\u53EF\u7528\uFF1B\u8BF7\u67E5\u770B\u300C\u6700\u8FD1\u9519\u8BEF\u300D\u4E2D\u7684\u539F\u56E0\u540E\u91CD\u8BD5" };
34
+ }
32
35
  async function buildListItems() {
33
36
  const allItems = [];
34
37
  const dump = await dumpStores();
@@ -37,7 +40,7 @@ function createRoutesManage(deps) {
37
40
  if (st && st.dir) hints.set(st.dir, root);
38
41
  }
39
42
  const byId = /* @__PURE__ */ new Map();
40
- function push(id, time, root, sessionId) {
43
+ function push(id, time, root, sessionId, files) {
41
44
  if (!id || typeof id !== "string") return;
42
45
  if (isSafetySnapshotId(id)) return;
43
46
  const old = byId.get(id);
@@ -52,6 +55,7 @@ function createRoutesManage(deps) {
52
55
  };
53
56
  const liveText = liveMessageTextFast(sessionId || "", id);
54
57
  if (liveText) rec.messageText = liveText;
58
+ if (files === false) rec.files = false;
55
59
  byId.set(id, rec);
56
60
  allItems.push(rec);
57
61
  return;
@@ -69,16 +73,17 @@ function createRoutesManage(deps) {
69
73
  if (t) old.messageText = t;
70
74
  }
71
75
  if (!old.time && time) old.time = time;
76
+ if (files === false) old.files = false;
72
77
  }
73
78
  for (const [dir, info] of dump) {
74
79
  const baseRoot = info.root || hints.get(dir) || null;
75
80
  for (const e of info.entries || []) {
76
81
  if (!e || typeof e.id !== "string") continue;
77
- push(e.id, e.time, baseRoot || typeof e.root === "string" && e.root || null, e.sessionId);
82
+ push(e.id, e.time, baseRoot || typeof e.root === "string" && e.root || null, e.sessionId, e.files);
78
83
  }
79
84
  }
80
85
  for (const [id, s] of state.snapshots.entries()) {
81
- push(id, s.time, s.root, s.sessionId);
86
+ push(id, s.time, s.root, s.sessionId, s.files);
82
87
  }
83
88
  allItems.sort((a, b) => (b.time || 0) - (a.time || 0));
84
89
  return allItems;
@@ -289,6 +294,8 @@ function createRoutesManage(deps) {
289
294
  baseExcludes: cfg.baseExcludes.slice(),
290
295
  refillDraft: cfg.refillDraft,
291
296
  snapshotEnabled: cfg.snapshotEnabled,
297
+ // issue #31:只回退消息(不写文件快照)
298
+ messageOnly: cfg.messageOnly,
292
299
  archiveOriginal: cfg.archiveOriginal,
293
300
  editSubmitsImmediately: cfg.editSubmitsImmediately,
294
301
  retentionDays: cfg.retentionDays,
@@ -328,6 +335,7 @@ function createRoutesManage(deps) {
328
335
  }
329
336
  if (patch.refillDraft !== void 0) clean.refillDraft = Boolean(patch.refillDraft);
330
337
  if (patch.snapshotEnabled !== void 0) clean.snapshotEnabled = Boolean(patch.snapshotEnabled);
338
+ if (patch.messageOnly !== void 0) clean.messageOnly = Boolean(patch.messageOnly);
331
339
  if (patch.archiveOriginal !== void 0) clean.archiveOriginal = Boolean(patch.archiveOriginal);
332
340
  if (patch.editSubmitsImmediately !== void 0) clean.editSubmitsImmediately = Boolean(patch.editSubmitsImmediately);
333
341
  if (patch.retentionDays !== void 0) {
@@ -377,7 +385,12 @@ function createRoutesManage(deps) {
377
385
  if (stale) refreshListCacheInBackground();
378
386
  return { ok: true, items: listCache.items.slice(0, safeLimit), total: listCache.items.length, stale };
379
387
  }
380
- const allItems = await buildListItems();
388
+ let allItems;
389
+ try {
390
+ allItems = await buildListItems();
391
+ } catch (error) {
392
+ return dumpFailed();
393
+ }
381
394
  listCache.at = Date.now();
382
395
  listCache.items = allItems;
383
396
  listCache.stale = false;
@@ -495,15 +508,25 @@ function createRoutesManage(deps) {
495
508
  const id = args && args.messageId ? String(args.messageId) : "";
496
509
  if (scope === "workspace") {
497
510
  if (!root) return { ok: false, code: E.RECALL_NO_ROOT, message: "\u7F3A\u5C11\u5DE5\u4F5C\u533A\u8DEF\u5F84" };
498
- const deleted = await deleteSnapshotsByFilter((rec) => rec.root === root, sessionId);
511
+ let deleted;
512
+ try {
513
+ deleted = await deleteSnapshotsByFilter((rec) => rec.root === root, sessionId);
514
+ } catch (error) {
515
+ return dumpFailed();
516
+ }
499
517
  return { ok: true, deleted };
500
518
  }
501
519
  if (scope === "session") {
502
520
  if (!targetSessionId) return { ok: false, code: E.RECALL_NO_SESSION, message: "\u7F3A\u5C11\u4F1A\u8BDD ID" };
503
- const deleted = await deleteSnapshotsByFilter(
504
- (rec) => rec.sessionId === targetSessionId && (!root || rec.root === root),
505
- sessionId
506
- );
521
+ let deleted;
522
+ try {
523
+ deleted = await deleteSnapshotsByFilter(
524
+ (rec) => rec.sessionId === targetSessionId && (!root || rec.root === root),
525
+ sessionId
526
+ );
527
+ } catch (error) {
528
+ return dumpFailed();
529
+ }
507
530
  return { ok: true, deleted };
508
531
  }
509
532
  let snap = state.snapshots.get(id) || null;
@@ -547,7 +570,12 @@ function createRoutesManage(deps) {
547
570
  return { ok: true };
548
571
  }
549
572
  if (op === "deleteAll") {
550
- const result = await deleteAllSnapshots();
573
+ let result;
574
+ try {
575
+ result = await deleteAllSnapshots();
576
+ } catch (error) {
577
+ return dumpFailed();
578
+ }
551
579
  if (result.failed > 0) {
552
580
  return {
553
581
  ok: false,
@@ -246,7 +246,7 @@ function snapshotScript(root, store, gitExe, messageId, base) {
246
246
  "addrc=0",
247
247
  'add_log=$("$git" --git-dir="$g" --work-tree="$root" add -A --ignore-errors "${pa_args[@]}" 2>&1) || addrc=$?',
248
248
  `if [ "$addrc" -ge 2 ]; then printf '%s\\n' "$add_log" >&2; exit "$addrc"; fi`,
249
- `printf '%s\\n' "$add_log" | sed -n "s/^error: unable to index file '\\(.*\\)'$/\\1/p" | sort -u | while IFS= read -r sk; do`,
249
+ `printf '%s\\n' "$add_log" | sed -n -e "s/^error: unable to index file '\\(.*\\)'$/\\1/p" -e "s/^error: unable to add '\\(.*\\)' to index$/\\1/p" | sort -u | while IFS= read -r sk; do`,
250
250
  // 循环体用 if/fi 而非 && 列表:&& 列表条件为假时整条管道退出码为 1,
251
251
  // set -e 会把脚本杀掉(if 语句天然豁免)
252
252
  ` if [ -n "$sk" ]; then printf 'SNAP_SKIP %s\\n' "$sk"; fi`,
@@ -535,6 +535,18 @@ function storesDumpScript(container, extraDirs) {
535
535
  'for d in "${dirs[@]}"; do',
536
536
  ' [ -d "$d" ] || continue',
537
537
  ' echo "==DIR $d"',
538
+ // STATE 供空仓回收判定(present/absent/quarantined/foreign):判据留在
539
+ // 脚本侧是因为只有这里能看文件系统——absent 表示「从没拍过快照的真仓」
540
+ // (git/ 或 root.txt 在),foreign 表示不是我们的目录,两者不能混。
541
+ ' if [ -f "$d/index.json" ]; then',
542
+ " echo 'STATE present'",
543
+ ' elif ls "$d"/index.json.corrupt-* >/dev/null 2>&1; then',
544
+ " echo 'STATE quarantined'",
545
+ ' elif [ -d "$d/git" ] || [ -f "$d/root.txt" ]; then',
546
+ " echo 'STATE absent'",
547
+ " else",
548
+ " echo 'STATE foreign'",
549
+ " fi",
538
550
  ' if [ -f "$d/root.txt" ]; then',
539
551
  ` printf "ROOT %s\\n" "$(cat "$d/root.txt" 2>/dev/null | tr -d '\\r\\n')"`,
540
552
  " else",
@@ -5,6 +5,32 @@ const UTF8_PRELUDE = "$OutputEncoding = [Text.UTF8Encoding]::new($false)\ntry {
5
5
  const MAX_FILE_BYTES = 8388608;
6
6
  const STALE_LOCK_MIN = 5;
7
7
  const HEARTBEAT_TTL_S = 900;
8
+ const WIN_RESERVED_EXCLUDES = [
9
+ "nul",
10
+ "nul.*",
11
+ "aux",
12
+ "aux.*",
13
+ "con",
14
+ "con.*",
15
+ "prn",
16
+ "prn.*",
17
+ "com[1-9]",
18
+ "com[1-9].*",
19
+ "lpt[1-9]",
20
+ "lpt[1-9].*",
21
+ "com\xB9",
22
+ "com\xB9.*",
23
+ "com\xB2",
24
+ "com\xB2.*",
25
+ "com\xB3",
26
+ "com\xB3.*",
27
+ "lpt\xB9",
28
+ "lpt\xB9.*",
29
+ "lpt\xB2",
30
+ "lpt\xB2.*",
31
+ "lpt\xB3",
32
+ "lpt\xB3.*"
33
+ ];
8
34
  const FIDELITY_ATTRS = "* -text -filter -ident -export-ignore -export-subst -working-tree-encoding";
9
35
  function stripBom(text) {
10
36
  return text.replace(/^\uFEFF/, "");
@@ -180,7 +206,8 @@ function limitScanBlock(maxBytes, fileMaxBytes, budgetMs) {
180
206
  ].join("\n");
181
207
  }
182
208
  function excludeSyncBlock(excludeFile, base) {
183
- const baseList = (Array.isArray(base) && base.length ? base : [".git", "node_modules/", ".dsh-recall-snapshots/", "dsh-recall-snapshots/"]).map(psq).join(",");
209
+ const configuredBase = Array.isArray(base) && base.length ? base : [".git", "node_modules/", ".dsh-recall-snapshots/", "dsh-recall-snapshots/"];
210
+ const baseList = [...configuredBase, ...WIN_RESERVED_EXCLUDES].map(psq).join(",");
184
211
  return [
185
212
  "$exFile = " + psq(excludeFile),
186
213
  "$userPats = @()",
@@ -317,14 +344,14 @@ function snapshotScript(root, store, gitExe, messageId, base) {
317
344
  // native stderr 包装成 ErrorRecord,EAP=Stop 下直接抛 NativeCommandError,
318
345
  // 而 PS 5.1 的 SilentlyContinue 会把合并流里的记录整个丢弃(实测 LOG
319
346
  // 为空)——Continue 是两个版本下唯一都能拿到 stderr 文本的取值。捕获
320
- // 后按 "unable to index file 'X'" 提取被跳过的路径,以 SNAP_SKIP 行
321
- // 回传 JS 侧做用户可见提示。
347
+ // 后按 "unable to index file 'X'" / "unable to add 'X' to index" 提取
348
+ // 被跳过的路径,以 SNAP_SKIP 行回传 JS 侧做用户可见提示。
322
349
  "$ErrorActionPreference = 'Continue'",
323
350
  '$addLog = (@(& $git --git-dir=$g --work-tree=$root add -A --ignore-errors @paArgs 2>&1) | ForEach-Object { [string]$_ }) -join "`n"',
324
351
  "$addRc = $LASTEXITCODE",
325
352
  "$ErrorActionPreference = 'Stop'",
326
353
  'if ($addRc -ge 2) { throw ("git add fatal (exit " + $addRc + "): " + $addLog) }',
327
- `foreach ($m in [regex]::Matches($addLog, "unable to index file '([^']+)'") ) { Write-Output ('SNAP_SKIP ' + $m.Groups[1].Value) }`,
354
+ `foreach ($m in [regex]::Matches($addLog, "unable to (?:index file|add) '([^']+)'") ) { Write-Output ('SNAP_SKIP ' + $m.Groups[1].Value) }`,
328
355
  dropGitlinksBlock(),
329
356
  // 兜底:旧 git 走裸 add 时大文件仍会被重新索引,这里按清单再清一次;
330
357
  // pathspec 生效时它是空操作(清单条目本就不在 index)
@@ -675,6 +702,14 @@ function storesDumpScript(container, extraDirs) {
675
702
  " if (-not $d) { continue }",
676
703
  " if (-not (Test-Path -LiteralPath $d -PathType Container)) { continue }",
677
704
  ' Write-Output ("==DIR " + $d)',
705
+ // STATE 供空仓回收判定(present/absent/quarantined/foreign):判据留在脚本侧
706
+ // 是因为只有这里能看文件系统——absent 是「从没拍过快照的真仓」,foreign 是
707
+ // 别人的目录,两者必须分开(否则会删掉不是我们的东西)。
708
+ " $ix = Join-Path $d 'index.json'",
709
+ " if (Test-Path -LiteralPath $ix -PathType Leaf) { Write-Output 'STATE present' }",
710
+ " elseif (@(Get-ChildItem -LiteralPath $d -Filter 'index.json.corrupt-*' -ErrorAction SilentlyContinue).Count -gt 0) { Write-Output 'STATE quarantined' }",
711
+ " elseif ((Test-Path -LiteralPath (Join-Path $d 'git') -PathType Container) -or (Test-Path -LiteralPath (Join-Path $d 'root.txt') -PathType Leaf)) { Write-Output 'STATE absent' }",
712
+ " else { Write-Output 'STATE foreign' }",
678
713
  " $rt = Join-Path $d 'root.txt'",
679
714
  " if (Test-Path -LiteralPath $rt -PathType Leaf) {",
680
715
  " $rv = (Get-Content -LiteralPath $rt -Raw -Encoding UTF8 -ErrorAction SilentlyContinue)",
@@ -686,7 +721,6 @@ function storesDumpScript(container, extraDirs) {
686
721
  " Write-Output 'ROOT '",
687
722
  " }",
688
723
  " Write-Output 'INDEXBEGIN'",
689
- " $ix = Join-Path $d 'index.json'",
690
724
  " if (Test-Path -LiteralPath $ix -PathType Leaf) {",
691
725
  " $j = Get-Content -LiteralPath $ix -Raw -Encoding UTF8 -ErrorAction SilentlyContinue",
692
726
  " if ($j) { Write-Output $j.TrimEnd() }",
package/lib/snapshots.js CHANGED
@@ -269,6 +269,7 @@ function createSnapshots(ctx, rt, config) {
269
269
  await stampStoreFormat(store, verdict);
270
270
  const entries = Array.from(state.snapshots.entries()).filter(([, s]) => s.root === root).map(([id, s]) => {
271
271
  const rec = { id, time: s.time, root: s.root, sessionId: s.sessionId };
272
+ if (s.files === false) rec.files = false;
272
273
  const fb = state.snapFeedback.get(id);
273
274
  if (fb && (fb.failed || Array.isArray(fb.skipped) && fb.skipped.length)) rec.feedback = fb;
274
275
  return rec;
@@ -320,11 +321,13 @@ function createSnapshots(ctx, rt, config) {
320
321
  invalid++;
321
322
  continue;
322
323
  }
323
- state.snapshots.set(entry.id, {
324
+ const info = {
324
325
  root,
325
326
  time: typeof entry.time === "number" ? entry.time : Date.now(),
326
327
  sessionId: entry.sessionId || sessionId || ""
327
- });
328
+ };
329
+ if (entry.files === false) info.files = false;
330
+ state.snapshots.set(entry.id, info);
328
331
  const fb = entry.feedback;
329
332
  if (fb && typeof fb === "object") {
330
333
  const rec = {};
@@ -422,9 +425,27 @@ function createSnapshots(ctx, rt, config) {
422
425
  rt.recordError("recall rebuildOrphans failed: " + String(error));
423
426
  }
424
427
  }
428
+ async function captureMessageOnly(root, sessionId, messageId, time) {
429
+ let store = await rt.resolveStore(root);
430
+ store = await rt.tryUpgradeToHome(root);
431
+ if (!await guardStoreFormat(store)) return;
432
+ const g = await rt.ensureGit(root, store);
433
+ if (!g.ok) {
434
+ setFeedback(messageId, { failed: true, ...buildFeedbackError(g.error || "\u672A\u77E5\u539F\u56E0") });
435
+ return;
436
+ }
437
+ await loadIndex(root, sessionId);
438
+ state.snapshots.set(String(messageId), { root, time: time || Date.now(), sessionId, files: false });
439
+ await saveIndex(root, sessionId);
440
+ setFeedback(messageId, { skipped: [] });
441
+ }
425
442
  async function captureSnapshot(sessionId, messageId, time) {
426
443
  const root = await rt.resolveRoot(sessionId);
427
444
  if (!root) return;
445
+ if (config.messageOnly) {
446
+ await captureMessageOnly(root, sessionId, messageId, time);
447
+ return;
448
+ }
428
449
  const fused = snapFailures.get(root);
429
450
  if (fused && Date.now() < fused.skipUntil) return;
430
451
  const limited = snapLimits.get(root);
@@ -519,6 +540,7 @@ function createSnapshots(ctx, rt, config) {
519
540
  async function diffFor(messageId) {
520
541
  const snap = state.snapshots.get(String(messageId));
521
542
  if (!snap) return null;
543
+ if (snap.files === false) return { changes: [], total: 0, truncated: false, treeId: null };
522
544
  const store = state.stores.get(snap.root);
523
545
  if (!store) return null;
524
546
  const text = S.stripBom(await rt.runShell(S.diffScript(snap.root, store, state.gitExe || "", "snap-" + messageId, BASE(), MAX_CHANGES), { timeoutMs: 6e5, stdoutMaxBytes: 8388608 }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-recall-plugin",
3
- "version": "2.6.0",
3
+ "version": "2.6.2",
4
4
  "description": "在 DSH 中撤回自己发过的消息:工作区文件与对话历史一起回到那条消息发出之前,消息的文本与附件自动放回输入框,改完即可重发。文件走独立影子 git 快照(不碰项目自身的 git),对话走官方 sessions.fork,原会话归档可找回。",
5
5
  "icon": "assets/icon.svg",
6
6
  "type": "module",