dsh-recall-plugin 2.5.3 → 2.6.1

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,39 @@
2
2
 
3
3
  本文件格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本。
4
4
 
5
- ## [Unreleased]
5
+ ## [2.6.1] - 2026-10-31
6
+
7
+ ### 新增
8
+
9
+ - **「只回退消息」模式(`messageOnly`,默认关闭,issue #31)**:开启后每条用户消息仍登记撤回记录(撤回按钮与对话回退照常),但不再执行文件快照——大仓(gitignore 过滤后仍上千文件 / 上 GB)不必为每条消息写一次全量对象。按消息生效:`index.json` 只给这类条目记 `files: false`,开启前拍的旧快照仍可完整回退文件;确认面板对这类消息隐藏「回退文件与对话」单选,快照管理树标「仅消息」。代价是这类记录没有 tag,索引损坏被隔离后撤回按钮无法从 tag 重建。
10
+
11
+ ### 修复
12
+
13
+ - **磁盘枚举失败不再表现成「没有快照」**:此前快照管理读不到磁盘时会静默返回空列表,界面只剩一棵空树,用户以为快照都没了(issue #30 报告的现场就是这样)。现在失败原因进「最近错误」,列表 / 按工作区或会话批量删除 / 全部删除回 `DUMP_FAILED`,卡片显示可重试的错误条;「全部删除」也不再用「已删除 0 条」掩盖枚举失败。
14
+ - **建过仓但从没拍过快照的空仓不再永久占盘**:dump 脚本为每个 store 增发 `STATE` 段(`present` / `absent` / `quarantined` / `foreign`),「立即 gc」据此把「有 store 布局但从未生成索引」的空壳整目录回收;损坏隔离现场(`index.json.corrupt-*`)、旧版脚本输出与非本插件的目录一律保留。
15
+
16
+ ### 文档
17
+
18
+ - 新增 [plan-dump-visible-and-empty-reclaim.md](docs/plans/completed/plan-dump-visible-and-empty-reclaim.md):两条遗留的问题、判据表与测试清单;AGENTS 已知坑补 I46,compat-audit 补 I46 详情。
19
+ - 新增 [plan-message-only-mode.md](docs/plans/completed/plan-message-only-mode.md):只回退消息的问题、判据、边界与实施记录;smoke checklist 第十一节与 records 同日批次(M1〜M6 实弹)。
20
+
21
+ ## [2.6.0] - 2026-10-30
22
+
23
+ ### 新增
24
+
25
+ - **任务队列按工作区隔离**:快照/preview/回退/删除/gc 原先共用一条全局串行链,一个工作区的慢快照会把**所有会话**的撤回按钮一起堵住。现在同一工作区仍然串行(都在写同一个 git index),不同工作区并行;跨工作区的批量删除与全局 gc 用「等这些工作区的链尾全部落地」保证互斥。win32 下路径大小写归一,避免同一仓库拿到两把锁。
26
+ - **单次快照时限可配置(`snapshotBudgetMs`,默认 3 分钟)**:整条快照脚本(扫描 + `add` + 写索引/打 tag)的兜底超时从硬编码 600 秒改为配置项,设置卡片按秒展示;`0` 表示不限制。
27
+
28
+ ### 修复
29
+
30
+ - **Linux/macOS 上快照管理、批量删除/全部删除与「立即 gc」全部不可用**([issue #30](https://github.com/limbo947/dsh-recall-plugin/issues/30),POSIX dump 脚本的两个缺陷):① 建仓后还没拍过快照的**空仓**没有 `index.json`/`lineage.json`,裸 `cat` 在 `set -e` 下让整条 dump 退出 1,磁盘上的仓一个都枚举不到;② 这两个文件落盘时没有尾换行,`cat` 后紧跟的段尾标记被粘到内容行尾,解析器按整行匹配永远收不到段尾——**即使文件齐全,每个仓也被解析成「无索引」**(报告者的临时处置只修了 ①,树依然是空的)。现在两处改为「缺席放行 + 内容后补一个换行」,逐仓隔离语义不变,存量数据无需迁移。
31
+ - **设置页 exclude 编辑器可能回显垃圾字节**(同一类行边界缺陷):`exclude.txt` 字节数为 3 的倍数时 base64 没有 `=` padding,粘在尾部的段尾标记会被一起解码;回显后再保存即污染文件。
32
+ - **dump 脚本的测试从「字符串断言」升级为「真跑一遍」**:新增 [tests/unit/stores-dump-script.test.js](tests/unit/stores-dump-script.test.js),在 bash 里执行生成的脚本并校验解析结果(CI 的 ubuntu 必然执行,win32 认 Git Bash)。此前只断言脚本文本与解析器行为,这两个只在执行期暴露的缺陷因此全绿漏过。
33
+
34
+ ### 文档
35
+
36
+ - 新增 [plan-stores-dump-posix.md](docs/plans/completed/plan-stores-dump-posix.md):issue #30 的双缺陷分析、实测矩阵与防回归设计;含「不采纳 warmup 落 `index.json = []`」「不采纳脚本保持 fail-fast」的依据,以及两条遗留(dump 失败对用户不可见、空仓无法回收)。
37
+ - 新增 [plan-queue-and-budget.md](docs/plans/completed/plan-queue-and-budget.md):含 web 新会话 cwd 的官方归属核查(`startSession` 取 `workspaceId ?? 当前 ?? 最近`、全链不传 cwd、无配置项/钩子可改写)——该项为官方行为,插件不改代码,只在 README 使用注意与 AGENTS 已知坑里写明。
6
38
 
7
39
  ## [2.5.3] - 2026-10-30
8
40
 
package/README.en.md CHANGED
@@ -158,6 +158,7 @@ All options can be edited visually in the "**Settings → Plugin Config → Reca
158
158
  | `maxFileBytes` | 8388608 (8MB) | Files larger than this are neither snapshotted nor touched by recalls; they are filtered out *before* `git add` from a path list, so no objects are written (older git falls back to add-then-remove) |
159
159
  | `maxWorkspaceBytes` | 268435456 (256MB) | Total snapshot-eligible file size per workspace, measured exactly as `git add` sees it (`.gitignore`, `.git/info/exclude` and the global excludesFile all apply). Exceeding it **disables snapshots for that workspace** (no truncation — a truncated snapshot would make rollback delete the skipped files) and shows a notice on recent messages; raise the limit or add exclusions to recover automatically. 0 = unlimited |
160
160
  | `scanBudgetMs` | 10000 (10s) | Time budget for measuring the snapshot-eligible total. Exceeding it skips the snapshot with a notice (guards against pathological directories stalling startup). 0 = unlimited |
161
+ | `snapshotBudgetMs` | 180000 (3 min) | Total time for one snapshot from scan to write; exceeding it aborts the snapshot and records it under "Recent errors". 0 = unlimited |
161
162
  | `maxSnapshotsPerWorkspace` | 500 | Maximum snapshots kept per workspace; oldest pruned beyond the cap. 0 = unlimited |
162
163
  | `retentionDays` | 0 | Keep snapshots for this many days; older ones are deleted. 0 = disabled (works independently of the cap) |
163
164
  | `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` | Base exclusion list (gitignore syntax, lower priority than exclude.txt); excluded large directories are skipped as whole subtrees during snapshot scans. Directory-form entries (e.g. `target/`) mean one more thing: **when a path segment of the workspace root itself matches, snapshots are disabled for that workspace** — patterns are relative to the root, so they can never exclude the root itself (i.e. a session opened inside a build-output directory), and such directories have no rollback value anyway; remove the entry to re-enable |
package/README.md CHANGED
@@ -155,11 +155,13 @@ peerDependencies 按 minor 版本线开窗,与 `dsh.compatibility.dshReleases`
155
155
  | `maxFileBytes` | 8388608(8MB) | 超过该大小的文件不进快照、不被回退触碰;在 `git add` 之前就按清单剔除,不写对象(旧 git 退回「先加后剔」) |
156
156
  | `maxWorkspaceBytes` | 268435456(256MB) | 单工作区可纳入快照的文件总量上限,口径与 `git add` 一致(`.gitignore`、`.git/info/exclude`、全局 excludesFile 都算数)。超过时**停用该工作区快照**(不截断——截断快照回退时会误删未纳入的文件),并在最近消息上提示;调大该值或补充排除表后自动恢复;0 = 不限制 |
157
157
  | `scanBudgetMs` | 10000(10 秒) | 快照前统计纳入总量的时间预算。超过时停用本次快照并提示(防病态大目录拖死启动);0 = 不限制 |
158
+ | `snapshotBudgetMs` | 180000(3 分钟) | 单次快照从扫描到写入的总时限,超时即中止本次快照并记入「最近错误」;0 = 不限制 |
158
159
  | `maxSnapshotsPerWorkspace` | 500 | 每个工作区保留的最大快照数,超限自动删除最旧的;0 = 不限制 |
159
160
  | `retentionDays` | 0 | 按天数保留快照,超期自动删除;0 = 不启用(与条数上限各自独立生效) |
160
161
  | `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 的),这类目录也没有回退价值;删掉对应项即恢复 |
161
162
  | `refillDraft` | true | 撤回后把被撤回的消息(文本与附件)回填到输入框 |
162
163
  | `snapshotEnabled` | true | 快照总开关(关闭只冻结新建,已有快照仍可撤回) |
164
+ | `messageOnly` | false | 只回退消息:每条用户消息仍登记撤回记录(按钮与对话回退照常),但不写文件快照——适合 gitignore 过滤后仍上 GB 的超大工作区。按消息生效:开启前的旧快照仍可完整回退文件;这类条目在快照管理里标「仅消息」,删除后不可从 tag 恢复 |
163
165
  | `archiveOriginal` | true | 撤回后归档原会话(关闭后原会话保留在会话列表中) |
164
166
  | `editSubmitsImmediately` | true | 「编辑并重发」提交后自动发出改后的消息;关闭则只回填输入框,由你按回车发送 |
165
167
  | `locale` | auto | 界面语言:`auto` 跟随系统(`navigator.language` 以 `zh` 开头用中文,否则英文)、`zh` / `en` 显式锁定。见下方「界面语言」 |
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",
@@ -592,6 +605,9 @@
592
605
  "config.scanBudgetMs.label": "\u626B\u63CF\u65F6\u95F4\u9884\u7B97",
593
606
  "config.scanBudgetMs.hint": "\u5FEB\u7167\u524D\u7EDF\u8BA1\u7EB3\u5165\u603B\u91CF\u8D85\u8FC7\u8BE5\u65F6\u957F\u5373\u505C\u7528\u672C\u6B21\u5FEB\u7167\uFF1B0 \u8868\u793A\u4E0D\u9650\u5236",
594
607
  "config.scanBudgetMs.suffix": "\u79D2",
608
+ "config.snapshotBudgetMs.label": "\u5355\u6B21\u5FEB\u7167\u65F6\u9650",
609
+ "config.snapshotBudgetMs.hint": "\u5355\u6B21\u5FEB\u7167\u4ECE\u626B\u63CF\u5230\u5199\u5165\u7684\u603B\u65F6\u9650\uFF0C\u8D85\u65F6\u5373\u4E2D\u6B62\u672C\u6B21\u5FEB\u7167\u5E76\u8BB0\u5165\u6700\u8FD1\u9519\u8BEF\uFF1B0 \u8868\u793A\u4E0D\u9650\u5236",
610
+ "config.snapshotBudgetMs.suffix": "\u79D2",
595
611
  "config.maxSnapshotsPerWorkspace.label": "\u5FEB\u7167\u603B\u91CF\u4E0A\u9650",
596
612
  "config.maxSnapshotsPerWorkspace.hint": "\u6BCF\u5DE5\u4F5C\u533A\u4FDD\u7559\u7684\u6700\u5927\u5FEB\u7167\u6570\uFF0C\u8D85\u9650\u5220\u9664\u6700\u65E7\u7684\uFF1B0 \u8868\u793A\u4E0D\u9650\u5236",
597
613
  "config.maxSnapshotsPerWorkspace.suffix": "\u6761",
@@ -624,6 +640,7 @@
624
640
  "config.err.maxFileBytes": "\u6587\u4EF6\u5927\u5C0F\u4E0A\u9650\u81F3\u5C11 0.01 MB",
625
641
  "config.err.maxWorkspaceBytes": "\u5DE5\u4F5C\u533A\u603B\u91CF\u4E0A\u9650\u5FC5\u987B\u662F >= 0 \u7684\u6570\u5B57\uFF080 \u8868\u793A\u4E0D\u9650\u5236\uFF09",
626
642
  "config.err.scanBudgetMs": "\u626B\u63CF\u65F6\u95F4\u9884\u7B97\u5FC5\u987B\u662F >= 0 \u7684\u6570\u5B57\uFF080 \u8868\u793A\u4E0D\u9650\u5236\uFF09",
643
+ "config.err.snapshotBudgetMs": "\u5355\u6B21\u5FEB\u7167\u65F6\u9650\u5FC5\u987B\u662F >= 0 \u7684\u6570\u5B57\uFF080 \u8868\u793A\u4E0D\u9650\u5236\uFF09",
627
644
  "config.err.maxSnapshots": "\u5FEB\u7167\u603B\u91CF\u4E0A\u9650\u5FC5\u987B\u662F >= 0 \u7684\u6574\u6570\uFF080 \u8868\u793A\u4E0D\u9650\u5236\uFF09",
628
645
  "config.err.retentionDays": "\u4FDD\u7559\u5929\u6570\u5FC5\u987B\u662F >= 0 \u7684\u6574\u6570\uFF080 \u8868\u793A\u4E0D\u542F\u7528\uFF09",
629
646
  "config.err.locale": "\u754C\u9762\u8BED\u8A00\u5FC5\u987B\u662F auto / zh / en \u4E4B\u4E00",
@@ -679,6 +696,7 @@
679
696
  "tree.family.title": "Version family: {chain}",
680
697
  "tree.switch": "Switch",
681
698
  "tree.switch.title": "Switch to this version session",
699
+ "tree.messageOnly": "Messages only",
682
700
  "tree.delete.snapshot.title": "Delete this snapshot (tag and index entry)",
683
701
  "tree.delete.snapshot.confirm": "Delete this snapshot? This cannot be undone.",
684
702
  "tree.delete.session.title": "Delete all snapshots of this session",
@@ -692,6 +710,8 @@
692
710
  "recall.truncated.files": "\u2026showing the first {shown} of {total} files to change",
693
711
  "recall.truncated.chat": "\u2026showing the first {shown} of {total} differences",
694
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.",
695
715
  "recall.confirm.rollbackAt": "Restore the project to its state at {time}, when this message was sent.",
696
716
  "recall.confirm.rollbackNoTime": "Restore the project to its state when this message was sent.",
697
717
  // 句首空格是有意的:本句与 recall.confirm.rollbackAt 拼接成一段(前句以句点
@@ -765,6 +785,7 @@
765
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.',
766
786
  // ---- 错误码文案 ----
767
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',
768
789
  "err.AGENT_BUSY": "The agent is running; stop it before recalling",
769
790
  "err.NO_SNAPSHOT": "No project snapshot is available for this message",
770
791
  "err.NO_STORE": "Snapshot storage is unavailable",
@@ -807,6 +828,7 @@
807
828
  "manage.search.placeholder": "Search workspace / session title / message / ID",
808
829
  "manage.search.aria": "Search snapshots",
809
830
  "manage.empty": "Send a message in any workspace and its snapshot will appear here.",
831
+ "manage.loadFailed": "Could not read snapshots from disk",
810
832
  "manage.emptyFiltered": "No matching snapshots",
811
833
  "manage.errors.title": "Recent errors ({n})",
812
834
  "manage.errors.collapse": "Collapse",
@@ -825,6 +847,8 @@
825
847
  "config.group.ui": "Interface",
826
848
  "config.snapshotEnabled.label": "Enable snapshots",
827
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",
828
852
  "config.refillDraft.label": "Refill input box",
829
853
  "config.refillDraft.hint": "Put the message text back into the input box after a recall, so you can edit and resend",
830
854
  "config.archiveOriginal.label": "Archive original session",
@@ -846,6 +870,9 @@
846
870
  "config.scanBudgetMs.label": "Scan time budget",
847
871
  "config.scanBudgetMs.hint": "Skip this snapshot when measuring the snapshot-eligible total exceeds this duration; 0 means unlimited",
848
872
  "config.scanBudgetMs.suffix": "s",
873
+ "config.snapshotBudgetMs.label": "Snapshot time limit",
874
+ "config.snapshotBudgetMs.hint": "Total time for one snapshot from scan to write; exceeding it aborts the snapshot and records it under recent errors; 0 means unlimited",
875
+ "config.snapshotBudgetMs.suffix": "s",
849
876
  "config.maxSnapshotsPerWorkspace.label": "Snapshot count limit",
850
877
  "config.maxSnapshotsPerWorkspace.hint": "Maximum snapshots kept per workspace; the oldest are deleted beyond it; 0 means unlimited",
851
878
  "config.maxSnapshotsPerWorkspace.suffix": "snapshots",
@@ -878,6 +905,7 @@
878
905
  "config.err.maxFileBytes": "The file size limit must be at least 0.01 MB",
879
906
  "config.err.maxWorkspaceBytes": "The workspace total limit must be a number >= 0 (0 means unlimited)",
880
907
  "config.err.scanBudgetMs": "The scan time budget must be a number >= 0 (0 means unlimited)",
908
+ "config.err.snapshotBudgetMs": "The snapshot time limit must be a number >= 0 (0 means unlimited)",
881
909
  "config.err.maxSnapshots": "The snapshot count limit must be an integer >= 0 (0 means unlimited)",
882
910
  "config.err.retentionDays": "Retention days must be an integer >= 0 (0 disables it)",
883
911
  "config.err.locale": "Language must be one of auto / zh / en",
@@ -1882,7 +1910,7 @@
1882
1910
  writeIds(rootId, next);
1883
1911
  }
1884
1912
 
1885
- // src/client/recall-node.ts
1913
+ // src/client/message-blocks.ts
1886
1914
  function attachmentRefsFromBlocks(blocks) {
1887
1915
  const refs = [];
1888
1916
  for (const block of blocks) {
@@ -1895,6 +1923,18 @@
1895
1923
  }
1896
1924
  return refs;
1897
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
+ }
1898
1938
  function fileExtOf(name) {
1899
1939
  const dot = name.lastIndexOf(".");
1900
1940
  const raw = dot > 0 && dot < name.length - 1 ? name.slice(dot + 1) : "";
@@ -1915,6 +1955,8 @@
1915
1955
  const name = ref && typeof ref === "object" && typeof ref.name === "string" && ref.name !== "" ? ref.name : t("file.unnamed");
1916
1956
  return { name, ext: fileExtOf(name), size: fileCardSizeText(ref && typeof ref === "object" ? ref.bytes : void 0) };
1917
1957
  }
1958
+
1959
+ // src/client/diff-kinds.ts
1918
1960
  var KIND_INFO = {
1919
1961
  modified: { labelKey: "kind.modified", cls: "modified" },
1920
1962
  restored: { labelKey: "kind.restored", cls: "restored" },
@@ -1930,6 +1972,8 @@
1930
1972
  }
1931
1973
  return parts.join(" \xB7 ");
1932
1974
  }
1975
+
1976
+ // src/client/recall-node.ts
1933
1977
  function buildRecallNode(React, util, ctx, sessionsSvc, workspacesSvc, uiWorkspaceSvc) {
1934
1978
  const { api, ensureInit, showThrottledToast, writeClipboard, clockText: clockText2, pluginConfig, messageFor, t } = util;
1935
1979
  const { EditIcon, EditPanel } = buildEditFlow(React, t);
@@ -1999,7 +2043,9 @@
1999
2043
  );
2000
2044
  });
2001
2045
  const canRevertChat = typeof recall.cutSeq === "number";
2002
- 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;
2003
2049
  if (recall.truncated) {
2004
2050
  rows.push(React.createElement(
2005
2051
  "div",
@@ -2019,11 +2065,11 @@
2019
2065
  React.createElement("div", { className: "dsh-recall-panel-title" }, t("recall.confirm.title")),
2020
2066
  sessionOnly ? (
2021
2067
  // session-only:零文件改动即无不可逆操作缺口——安全快照预告随之隐藏
2022
- //(不打安全快照),主说明换成模式语义
2068
+ //(不打安全快照),主说明换成模式语义;仅消息条目另给专属说明
2023
2069
  React.createElement(
2024
2070
  "div",
2025
2071
  { className: "dsh-recall-panel-note" },
2026
- t("recall.confirm.sessionOnly")
2072
+ t(noFiles ? "recall.confirm.messageOnly" : "recall.confirm.sessionOnly")
2027
2073
  )
2028
2074
  ) : React.createElement(
2029
2075
  "div",
@@ -2045,9 +2091,10 @@
2045
2091
  )
2046
2092
  ) : null,
2047
2093
  changes.length > 0 ? React.createElement("div", { className: "dsh-recall-list" }, ...rows) : null,
2048
- canRevertChat ? (
2094
+ canRevertChat && !noFiles ? (
2049
2095
  // 模式二选一(原生 radio,键盘方向键可达):临场选择不设全局配置项;
2050
- // 默认 both 与现状一致,面板每次打开都复位到默认起点
2096
+ // 默认 both 与现状一致,面板每次打开都复位到默认起点。仅消息条目
2097
+ // 没有可选项(没有文件树),整组隐藏
2051
2098
  React.createElement(
2052
2099
  "div",
2053
2100
  { className: "dsh-recall-scope", role: "radiogroup", "aria-label": t("recall.scope.aria") },
@@ -2065,22 +2112,28 @@
2065
2112
  )
2066
2113
  )
2067
2114
  ) : null,
2115
+ noEffect ? (
2116
+ // 仅消息 + 首条消息:文件无快照、对话无更早切点,提交只会空转
2117
+ React.createElement("div", { className: "dsh-recall-panel-note", key: "no-effect" }, t("recall.confirm.noEffect"))
2118
+ ) : null,
2068
2119
  React.createElement(
2069
2120
  "div",
2070
2121
  { className: "dsh-recall-panel-actions" },
2071
2122
  React.createElement("button", { type: "button", className: "dsh-recall-btn", onClick: closePanel }, t("common.cancel")),
2072
- 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"))
2073
2124
  )
2074
2125
  );
2075
2126
  }
2076
2127
  if (recall.stage === "executing") {
2128
+ const active = recall.scope || scope;
2077
2129
  return React.createElement(
2078
2130
  "div",
2079
2131
  { className: "dsh-recall-panel" },
2080
- 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"))
2081
2133
  );
2082
2134
  }
2083
2135
  if (recall.stage === "done") {
2136
+ const active = recall.scope || scope;
2084
2137
  return React.createElement(
2085
2138
  "div",
2086
2139
  { className: "dsh-recall-panel" },
@@ -2088,7 +2141,7 @@
2088
2141
  React.createElement(
2089
2142
  "div",
2090
2143
  { className: "dsh-recall-panel-note" },
2091
- scope === "session-only" ? (
2144
+ active === "session-only" ? (
2092
2145
  // session-only 文案矩阵:文件侧零改动是确定事实,失败时也只描述对话侧
2093
2146
  recall.chatReverted ? t("recall.done.sessionOnly.ok") : t("recall.done.sessionOnly.fail", { error: recall.chatError || t("common.unknownReason") })
2094
2147
  ) : recall.chatReverted ? t("recall.done.both.ok") : t("recall.done.both.partial") + (recall.chatError ? t("recall.done.both.failChat", { error: recall.chatError }) : "")
@@ -2137,10 +2190,7 @@
2137
2190
  const data = node && node.data ? node.data : {};
2138
2191
  const messageId = node ? String(node.id || node.key || "") : "";
2139
2192
  const blocks = Array.isArray(data.content) ? data.content : [];
2140
- const text2 = blocks.filter((b) => b && b.type === "text" && typeof b.text === "string").map((b) => b.text).join("");
2141
- const imageBlocks = blocks.filter((b) => b && b.type === "image" && b.attachment).map((b) => ({ attachment: b.attachment }));
2142
- const fileBlocks = blocks.filter((b) => b && b.type === "file");
2143
- 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);
2144
2194
  const attachmentRefs = attachmentRefsFromBlocks(blocks);
2145
2195
  activeSession.id = String(sessionId || "");
2146
2196
  const [copied, setCopied] = React.useState(false);
@@ -2467,7 +2517,9 @@
2467
2517
  truncated: Boolean(res.truncated),
2468
2518
  treeId: res.treeId || null,
2469
2519
  time: res.time || null,
2470
- 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
2471
2523
  });
2472
2524
  }).catch((error) => {
2473
2525
  setRecall({ stage: "error", message: String(error) });
@@ -2478,7 +2530,7 @@
2478
2530
  const isEdit = editText !== null;
2479
2531
  if (!isEdit && recall.stage !== "confirm") return;
2480
2532
  const changes = recall.stage === "confirm" ? recall.changes || [] : [];
2481
- 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;
2482
2534
  const previewCut = overrides && overrides.cutSeq !== void 0 ? overrides.cutSeq : recall.stage === "confirm" && typeof recall.cutSeq === "number" ? recall.cutSeq : null;
2483
2535
  const setStage = (stage) => {
2484
2536
  if (!isEdit) {
@@ -2497,7 +2549,7 @@
2497
2549
  const baseFiles = wantAttachments ? preloadAttachmentFiles(sessionId, refsForFiles) : null;
2498
2550
  const inlineFiles = overrides && overrides.editFiles ? overrides.editFiles : null;
2499
2551
  const attachmentFiles = baseFiles !== null && inlineFiles !== null ? Promise.all([baseFiles, inlineFiles]).then(([base, extra]) => base.concat(extra)) : baseFiles !== null ? baseFiles : inlineFiles;
2500
- setStage({ stage: "executing", changes });
2552
+ setStage({ stage: "executing", changes, scope: activeScope });
2501
2553
  api("execute", { messageId, sessionId, previewTotal, previewTreeId: previewTreeId || void 0, scope: activeScope, previewAt: Date.now() }).then(async (res) => {
2502
2554
  if (!res || !res.ok) {
2503
2555
  if (res && res.code === "STALE") {
@@ -2518,7 +2570,8 @@
2518
2570
  truncated: Boolean(res2.truncated),
2519
2571
  treeId: res2.treeId || null,
2520
2572
  time: res2.time || null,
2521
- cutSeq: typeof res2.cutSeq === "number" ? res2.cutSeq : null
2573
+ cutSeq: typeof res2.cutSeq === "number" ? res2.cutSeq : null,
2574
+ files: res2.files !== false
2522
2575
  });
2523
2576
  }).catch((error) => {
2524
2577
  setStage({ stage: "error", message: String(error) });
@@ -2584,7 +2637,7 @@
2584
2637
  fillDraft(fillTarget, text2, attachmentFiles);
2585
2638
  if (fileBlocks.length > 0) showThrottledToast(t("toast.fileAttachRefill"));
2586
2639
  }
2587
- 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 });
2588
2641
  }
2589
2642
  if (chatError === "") {
2590
2643
  api("notify", buildRecallNotify(notifyBase, {
@@ -2762,10 +2815,14 @@
2762
2815
  maxWorkspaceBytes: bytesToMb2(v.maxWorkspaceBytes),
2763
2816
  // 扫描预算持久化为毫秒、展示为秒(整数);0 表示不限制,原样显示 0
2764
2817
  scanBudgetMs: String(v.scanBudgetMs == null ? "" : Math.round(Number(v.scanBudgetMs) / 1e3)),
2818
+ // issue #29:单次快照总时限,同样按秒展示
2819
+ snapshotBudgetMs: String(v.snapshotBudgetMs == null ? "" : Math.round(Number(v.snapshotBudgetMs) / 1e3)),
2765
2820
  maxSnapshotsPerWorkspace: String(v.maxSnapshotsPerWorkspace == null ? "" : v.maxSnapshotsPerWorkspace),
2766
2821
  baseExcludes: Array.isArray(v.baseExcludes) ? v.baseExcludes.join("\n") : "",
2767
2822
  refillDraft: v.refillDraft !== false,
2768
2823
  snapshotEnabled: v.snapshotEnabled !== false,
2824
+ // issue #31:老 Host 无该字段 → 按默认 false(现状)处理
2825
+ messageOnly: v.messageOnly === true,
2769
2826
  archiveOriginal: v.archiveOriginal !== false,
2770
2827
  // issue #26:老 Host 的 values 无该字段时按默认 true(与 util 同口径)
2771
2828
  editSubmitsImmediately: v.editSubmitsImmediately !== false,
@@ -2793,7 +2850,7 @@
2793
2850
  function save() {
2794
2851
  if (state.busy || !draft || !baseline) return;
2795
2852
  const patch = {};
2796
- for (const key of ["gcSnaps", "gcHours", "maxFileBytes", "maxWorkspaceBytes", "scanBudgetMs", "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"]) {
2797
2854
  const value = draft[key];
2798
2855
  if (value !== void 0 && value !== baseline[key]) patch[key] = value;
2799
2856
  }
@@ -2842,6 +2899,14 @@
2842
2899
  }
2843
2900
  clean.scanBudgetMs = Math.round(s * 1e3);
2844
2901
  }
2902
+ if (patch.snapshotBudgetMs !== void 0) {
2903
+ const s = Number(patch.snapshotBudgetMs);
2904
+ if (!Number.isFinite(s) || s < 0) {
2905
+ setState({ busy: false, message: t("config.err.snapshotBudgetMs"), error: true });
2906
+ return;
2907
+ }
2908
+ clean.snapshotBudgetMs = Math.round(s * 1e3);
2909
+ }
2845
2910
  if (patch.maxSnapshotsPerWorkspace !== void 0) {
2846
2911
  const n = parseInt(String(patch.maxSnapshotsPerWorkspace), 10);
2847
2912
  if (!Number.isFinite(n) || n < 0) {
@@ -2852,6 +2917,7 @@
2852
2917
  }
2853
2918
  if (patch.refillDraft !== void 0) clean.refillDraft = Boolean(patch.refillDraft);
2854
2919
  if (patch.snapshotEnabled !== void 0) clean.snapshotEnabled = Boolean(patch.snapshotEnabled);
2920
+ if (patch.messageOnly !== void 0) clean.messageOnly = Boolean(patch.messageOnly);
2855
2921
  if (patch.archiveOriginal !== void 0) clean.archiveOriginal = Boolean(patch.archiveOriginal);
2856
2922
  if (patch.editSubmitsImmediately !== void 0) clean.editSubmitsImmediately = Boolean(patch.editSubmitsImmediately);
2857
2923
  if (patch.retentionDays !== void 0) {
@@ -3009,6 +3075,8 @@
3009
3075
  // 不重复加标题。
3010
3076
  React.createElement("div", { className: "dsh-recall-cfg-group" }, t("config.group.snapshot")),
3011
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")),
3012
3080
  boolRow("refillDraft", t("config.refillDraft.label"), t("config.refillDraft.hint")),
3013
3081
  boolRow("archiveOriginal", t("config.archiveOriginal.label"), t("config.archiveOriginal.hint")),
3014
3082
  boolRow("editSubmitsImmediately", t("config.editSubmitsImmediately.label"), t("config.editSubmitsImmediately.hint")),
@@ -3021,6 +3089,8 @@
3021
3089
  // issue #24 护栏:单文件上限之外的两道闸(总量 + 扫描耗时),0 = 不限制
3022
3090
  numRow("maxWorkspaceBytes", t("config.maxWorkspaceBytes.label"), t("config.maxWorkspaceBytes.hint"), { suffixKey: "config.maxWorkspaceBytes.suffix", min: 0, step: 16 }),
3023
3091
  numRow("scanBudgetMs", t("config.scanBudgetMs.label"), t("config.scanBudgetMs.hint"), { suffixKey: "config.scanBudgetMs.suffix", min: 0, step: 1 }),
3092
+ // issue #29:扫描阶段之外还有 add/写索引——单次快照的总时限兜底
3093
+ numRow("snapshotBudgetMs", t("config.snapshotBudgetMs.label"), t("config.snapshotBudgetMs.hint"), { suffixKey: "config.snapshotBudgetMs.suffix", min: 0, step: 30 }),
3024
3094
  numRow("maxSnapshotsPerWorkspace", t("config.maxSnapshotsPerWorkspace.label"), t("config.maxSnapshotsPerWorkspace.hint"), { suffixKey: "config.maxSnapshotsPerWorkspace.suffix", min: 0, step: 1 }),
3025
3095
  numRow("retentionDays", t("config.retentionDays.label"), t("config.retentionDays.hint"), { suffixKey: "config.retentionDays.suffix", min: 0, step: 1 }),
3026
3096
  React.createElement("div", { className: "dsh-recall-cfg-group" }, t("config.group.ui")),
@@ -3267,7 +3337,7 @@
3267
3337
  return result;
3268
3338
  }
3269
3339
  function buildSnapshotManager(React, util, sessionsSvc, workspacesSvc, uiWorkspaceSvc) {
3270
- const { api, clockText: clockText2, sizeText: sizeText2, buildTree: buildTree2, t } = util;
3340
+ const { api, clockText: clockText2, sizeText: sizeText2, buildTree: buildTree2, messageFor, t } = util;
3271
3341
  function DeleteButton(props) {
3272
3342
  return React.createElement("button", {
3273
3343
  type: "button",
@@ -3306,6 +3376,7 @@
3306
3376
  }
3307
3377
  function ManageCard() {
3308
3378
  const [items, setItems] = React.useState(null);
3379
+ const [listError, setListError] = React.useState(null);
3309
3380
  const [usage, setUsage] = React.useState(null);
3310
3381
  const [errors, setErrors] = React.useState(null);
3311
3382
  const [state, setState] = React.useState({ busy: false, message: "", error: false });
@@ -3349,6 +3420,7 @@
3349
3420
  const useLimit = overLimit || limit;
3350
3421
  api("manage", { op: "list", limit: useLimit }).then((res) => {
3351
3422
  if (res && res.ok) {
3423
+ setListError(null);
3352
3424
  setItems(res.items || []);
3353
3425
  setTotal(typeof res.total === "number" ? res.total : (res.items || []).length);
3354
3426
  fetchTitles(res.items || []);
@@ -3364,6 +3436,10 @@
3364
3436
  }).catch(() => {
3365
3437
  });
3366
3438
  }
3439
+ } else if (res) {
3440
+ setListError(messageFor(res, t("manage.loadFailed")));
3441
+ setItems([]);
3442
+ setTotal(0);
3367
3443
  }
3368
3444
  api("manage", { op: "lineage" }).then((res2) => {
3369
3445
  if (res2 && res2.ok && Array.isArray(res2.lineage)) setLineage(res2.lineage);
@@ -3499,6 +3575,8 @@
3499
3575
  "span",
3500
3576
  { className: "dsh-recall-tree-label" },
3501
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,
3502
3580
  // 删除紧贴本条消息摘要(而非行尾):按行扫读时动作落在「看的那一行」,
3503
3581
  // 不必横跳到右侧同列按钮再回读行号
3504
3582
  React.createElement(DeleteButton, {
@@ -3676,6 +3754,13 @@
3676
3754
  onChange: (e) => setQuery(e.target.value)
3677
3755
  })
3678
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,
3679
3764
  // V3 加载骨架:items===null 表示首查未回——用 5 条 pulse 灰条占位替代
3680
3765
  // 打开快照管理时的一段空白;aria-hidden 纯装饰不打扰读屏
3681
3766
  items === null ? React.createElement(
package/lib/config.js CHANGED
@@ -40,10 +40,17 @@ const Config = Schema.object({
40
40
  // 截断快照回退时,未纳入的文件会命中回退脚本的「当前有、目标无」删除侧。
41
41
  maxWorkspaceBytes: withVolatile(Schema.number().default(268435456).description("\u5355\u5DE5\u4F5C\u533A\u53EF\u7EB3\u5165\u5FEB\u7167\u7684\u6587\u4EF6\u603B\u91CF\u4E0A\u9650\uFF0C\u53E3\u5F84\u4E0E git add \u4E00\u81F4\uFF08\u542B .gitignore\uFF09\uFF0C\u8D85\u8FC7\u5373\u505C\u7528\u8BE5\u5DE5\u4F5C\u533A\u5FEB\u7167\uFF1B0 \u8868\u793A\u4E0D\u9650\u5236")),
42
42
  scanBudgetMs: withVolatile(Schema.number().default(1e4).description("\u5FEB\u7167\u524D\u7EDF\u8BA1\u7EB3\u5165\u603B\u91CF\u7684\u65F6\u95F4\u9884\u7B97\uFF0C\u8D85\u65F6\u5373\u505C\u7528\u672C\u6B21\u5FEB\u7167\uFF1B0 \u8868\u793A\u4E0D\u9650\u5236")),
43
+ // issue #29:scanBudgetMs 只约束「统计阶段」,真正的耗时与写盘在 add。
44
+ // 这条是整条快照脚本的兜底时限——慢盘或病态工作区上它才是把队列拖住的一段。
45
+ snapshotBudgetMs: withVolatile(Schema.number().default(18e4).description("\u5355\u6B21\u5FEB\u7167\u7684\u603B\u65F6\u9650\uFF08\u6BEB\u79D2\uFF0C\u542B\u626B\u63CF\u4E0E\u5199\u5165\uFF09\uFF0C\u8D85\u65F6\u5373\u4E2D\u6B62\u672C\u6B21\u5FEB\u7167\u5E76\u8BB0\u5F55\uFF1B0 \u8868\u793A\u4E0D\u9650\u5236")),
43
46
  maxSnapshotsPerWorkspace: withVolatile(Schema.number().default(500).description("\u6BCF\u4E2A\u5DE5\u4F5C\u533A\u4FDD\u7559\u7684\u6700\u5927\u5FEB\u7167\u6570\uFF0C\u8D85\u9650\u5220\u9664\u6700\u65E7\u7684")),
44
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")),
45
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")),
46
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")),
47
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")),
48
55
  // issue #26:编辑提交后自动发出改后的文本。关闭则退化为「只回填输入框」,
49
56
  // 给「只想改稿、不立刻发」的用户留一条与撤回回填一致的出口
@@ -129,10 +136,12 @@ const DEFAULTS = {
129
136
  maxFileBytes: 8388608,
130
137
  maxWorkspaceBytes: 268435456,
131
138
  scanBudgetMs: 1e4,
139
+ snapshotBudgetMs: 18e4,
132
140
  maxSnapshotsPerWorkspace: 500,
133
141
  baseExcludes: BASE_EXCLUDES,
134
142
  refillDraft: true,
135
143
  snapshotEnabled: true,
144
+ messageOnly: false,
136
145
  archiveOriginal: true,
137
146
  editSubmitsImmediately: true,
138
147
  retentionDays: 0,
@@ -150,17 +159,19 @@ function createConfig(raw) {
150
159
  const maxFileBytes = pickNumber(cfg.maxFileBytes, 8388608, 1024);
151
160
  const maxWorkspaceBytes = pickNumber(cfg.maxWorkspaceBytes, 268435456, 0);
152
161
  const scanBudgetMs = pickNumber(cfg.scanBudgetMs, 1e4, 0);
162
+ const snapshotBudgetMs = pickNumber(cfg.snapshotBudgetMs, 18e4, 0);
153
163
  const rawMax = typeof cfg.maxSnapshotsPerWorkspace === "number" ? cfg.maxSnapshotsPerWorkspace : parseInt(String(cfg.maxSnapshotsPerWorkspace == null ? "" : cfg.maxSnapshotsPerWorkspace), 10);
154
164
  const maxSnapshotsPerWorkspace = Number.isFinite(rawMax) ? Math.max(0, rawMax) : 500;
155
165
  const baseExcludes = Array.isArray(cfg.baseExcludes) && cfg.baseExcludes.length ? cfg.baseExcludes.filter((p) => typeof p === "string" && p.trim()) : BASE_EXCLUDES;
156
166
  const refillDraft = typeof cfg.refillDraft === "boolean" ? cfg.refillDraft : true;
157
167
  const snapshotEnabled = typeof cfg.snapshotEnabled === "boolean" ? cfg.snapshotEnabled : true;
168
+ const messageOnly = cfg.messageOnly === true;
158
169
  const archiveOriginal = typeof cfg.archiveOriginal === "boolean" ? cfg.archiveOriginal : true;
159
170
  const editSubmitsImmediately = cfg.editSubmitsImmediately !== false;
160
171
  const rawDays = typeof cfg.retentionDays === "number" ? cfg.retentionDays : parseInt(String(cfg.retentionDays == null ? "" : cfg.retentionDays), 10);
161
172
  const retentionDays = Number.isFinite(rawDays) ? Math.max(0, rawDays) : 0;
162
173
  const locale = cfg.locale === "zh" || cfg.locale === "en" ? cfg.locale : "auto";
163
- return { gcSnaps, gcHours, maxFileBytes, maxWorkspaceBytes, scanBudgetMs, 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 };
164
175
  }
165
176
  export {
166
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
@@ -9,6 +9,7 @@ import { createRoutesCore } from "./routes-core.js";
9
9
  import { createRoutesManage } from "./routes-manage.js";
10
10
  import { createIntentJournal } from "./intent-journal.js";
11
11
  import { createEnsureRootReady } from "./root-ready.js";
12
+ import { createQueues } from "./queues.js";
12
13
  import * as dshSettings from "@deepseek-ai/dsh-settings";
13
14
  import * as E from "./errors.js";
14
15
  const name = "dsh-recall-plugin";
@@ -55,12 +56,7 @@ function apply(ctx, config) {
55
56
  if (text === E.RECALL_BODY_TOO_LARGE) return { ok: false, code: E.RECALL_BODY_TOO_LARGE, message: "\u8BF7\u6C42\u4F53\u8D85\u8FC7 1MB \u4E0A\u9650" };
56
57
  return { ok: false, code: E.RECALL_ERROR, message: text };
57
58
  }
58
- function enqueue(task) {
59
- const run = state.queue.then(task);
60
- state.queue = run.catch(() => {
61
- });
62
- return run;
63
- }
59
+ const queues = createQueues(process.platform === "win32");
64
60
  async function runLimited(tasks, concurrency) {
65
61
  const limit = concurrency > 0 ? concurrency : 4;
66
62
  let index = 0;
@@ -158,13 +154,20 @@ function apply(ctx, config) {
158
154
  const text = rt.scripts.stripBom(await rt.runShell(rt.scripts.storesDumpScript(container || "", extras), { timeoutMs: 12e4, stdoutMaxBytes: 8388608 }));
159
155
  return parseStoresDump(text);
160
156
  } catch (error) {
161
- console.error("recall stores dump failed:", String(error && error.stack || error));
162
- 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));
163
161
  }
164
162
  }
165
163
  async function locateSnapshotOnDisk(id) {
166
164
  if (!id) return null;
167
- const dump = await dumpStores();
165
+ let dump;
166
+ try {
167
+ dump = await dumpStores();
168
+ } catch (error) {
169
+ return null;
170
+ }
168
171
  const hints = /* @__PURE__ */ new Map();
169
172
  for (const [root, st] of state.stores.entries()) {
170
173
  if (st && st.dir) hints.set(st.dir, root);
@@ -246,7 +249,8 @@ function apply(ctx, config) {
246
249
  state,
247
250
  cfg,
248
251
  supported,
249
- enqueue,
252
+ enqueueFor: queues.enqueueFor,
253
+ enqueueMany: queues.enqueueMany,
250
254
  agentBusy,
251
255
  runLimited,
252
256
  ensureRootReady,
@@ -314,9 +318,14 @@ function apply(ctx, config) {
314
318
  if (session && session.header && session.header.origin === "subagent") return;
315
319
  const messageId = data.id;
316
320
  const time = event.time;
317
- state.queue = state.queue.then(() => cfg.snapshotEnabled ? snaps.captureSnapshot(session.id, messageId, time) : null).then(() => maint.maybeMaintain(session.id)).then(() => {
321
+ void (async () => {
322
+ const root = await rt.resolveRoot(session.id);
323
+ await queues.enqueueFor(root, async () => {
324
+ if (cfg.snapshotEnabled) await snaps.captureSnapshot(session.id, messageId, time);
325
+ });
326
+ await queues.enqueueFor(root, () => maint.maybeMaintain(session.id));
318
327
  listCache.stale = true;
319
- }).catch((error) => rt.recordError("recall snapshot error: " + String(error)));
328
+ })().catch((error) => rt.recordError("recall snapshot error: " + String(error)));
320
329
  });
321
330
  (async () => {
322
331
  let shellReady = false;
@@ -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 {
package/lib/queues.js ADDED
@@ -0,0 +1,30 @@
1
+ function createQueues(isWin) {
2
+ const tails = /* @__PURE__ */ new Map();
3
+ const keyOf = (root) => {
4
+ const k = String(root == null ? "" : root);
5
+ return k !== "" && isWin ? k.toLowerCase() : k;
6
+ };
7
+ function tailFor(key) {
8
+ return tails.get(key) || Promise.resolve();
9
+ }
10
+ function enqueueFor(root, task) {
11
+ const key = keyOf(root);
12
+ const run = tailFor(key).then(task);
13
+ tails.set(key, run.catch(() => {
14
+ }));
15
+ return run;
16
+ }
17
+ function enqueueMany(roots, task) {
18
+ const keys = Array.from(new Set(roots.map(keyOf)));
19
+ if (keys.length === 0) keys.push("");
20
+ const run = Promise.all(keys.map(tailFor)).then(task);
21
+ const tail = run.catch(() => {
22
+ });
23
+ for (const key of keys) tails.set(key, tail);
24
+ return run;
25
+ }
26
+ return { enqueueFor, enqueueMany };
27
+ }
28
+ export {
29
+ createQueues
30
+ };
@@ -4,7 +4,7 @@ import { buildArtifactRootSegment, buildRootNotice } from "./exclude-patterns.js
4
4
  const RECALL_EVENT_COMPLETE_HOST = "dsh-recall/complete";
5
5
  const RECALL_EVENT_FAILED_HOST = "dsh-recall/failed";
6
6
  function createRoutesCore(deps) {
7
- const { rt, snaps, state, cfg, supported, enqueue, agentBusy, rescueRollback, intentJournal, ensureRootReady, emitEvent, E } = deps;
7
+ const { rt, snaps, state, cfg, supported, enqueueFor, agentBusy, rescueRollback, intentJournal, ensureRootReady, emitEvent, E } = deps;
8
8
  return {
9
9
  "init": async (args) => {
10
10
  if (!supported) {
@@ -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
@@ -48,25 +50,27 @@ function createRoutesCore(deps) {
48
50
  const sessionId = args && args.sessionId ? String(args.sessionId) : null;
49
51
  const snap = state.snapshots.get(id);
50
52
  if (agentBusy(sessionId, snap ? snap.root : null)) return { ok: false, code: E.RECALL_AGENT_BUSY, message: "Agent \u6B63\u5728\u8FD0\u884C\u4E2D\uFF0C\u8BF7\u5148\u505C\u6B62\u540E\u518D\u64A4\u56DE" };
51
- const result = await enqueue(() => snaps.diffFor(id));
53
+ const result = await enqueueFor(snap ? snap.root : null, () => snaps.diffFor(id));
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);
66
69
  const staleQueueItemIds2 = await snaps.resolveStaleQueueItemIds(sessionId, cutSeq2);
67
70
  return { ok: true, count: 0, cutSeq: cutSeq2, staleQueueItemIds: staleQueueItemIds2 };
68
71
  }
69
- const result = await enqueue(async () => {
72
+ const initialSnap = state.snapshots.get(id);
73
+ const result = await enqueueFor(initialSnap ? initialSnap.root : null, async () => {
70
74
  const snap = state.snapshots.get(id);
71
75
  if (!snap) return { ok: false, code: E.RECALL_NO_SNAPSHOT, message: "\u8BE5\u6D88\u606F\u6CA1\u6709\u53EF\u7528\u7684\u9879\u76EE\u5FEB\u7167" };
72
76
  const store = state.stores.get(snap.root);
@@ -10,7 +10,8 @@ function createRoutesManage(deps) {
10
10
  state,
11
11
  cfg,
12
12
  supported,
13
- enqueue,
13
+ enqueueFor,
14
+ enqueueMany,
14
15
  runLimited,
15
16
  listExcludeFiles,
16
17
  dumpStores,
@@ -28,6 +29,9 @@ function createRoutesManage(deps) {
28
29
  E
29
30
  } = deps;
30
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
+ }
31
35
  async function buildListItems() {
32
36
  const allItems = [];
33
37
  const dump = await dumpStores();
@@ -36,7 +40,7 @@ function createRoutesManage(deps) {
36
40
  if (st && st.dir) hints.set(st.dir, root);
37
41
  }
38
42
  const byId = /* @__PURE__ */ new Map();
39
- function push(id, time, root, sessionId) {
43
+ function push(id, time, root, sessionId, files) {
40
44
  if (!id || typeof id !== "string") return;
41
45
  if (isSafetySnapshotId(id)) return;
42
46
  const old = byId.get(id);
@@ -51,6 +55,7 @@ function createRoutesManage(deps) {
51
55
  };
52
56
  const liveText = liveMessageTextFast(sessionId || "", id);
53
57
  if (liveText) rec.messageText = liveText;
58
+ if (files === false) rec.files = false;
54
59
  byId.set(id, rec);
55
60
  allItems.push(rec);
56
61
  return;
@@ -68,16 +73,17 @@ function createRoutesManage(deps) {
68
73
  if (t) old.messageText = t;
69
74
  }
70
75
  if (!old.time && time) old.time = time;
76
+ if (files === false) old.files = false;
71
77
  }
72
78
  for (const [dir, info] of dump) {
73
79
  const baseRoot = info.root || hints.get(dir) || null;
74
80
  for (const e of info.entries || []) {
75
81
  if (!e || typeof e.id !== "string") continue;
76
- 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);
77
83
  }
78
84
  }
79
85
  for (const [id, s] of state.snapshots.entries()) {
80
- push(id, s.time, s.root, s.sessionId);
86
+ push(id, s.time, s.root, s.sessionId, s.files);
81
87
  }
82
88
  allItems.sort((a, b) => (b.time || 0) - (a.time || 0));
83
89
  return allItems;
@@ -114,7 +120,7 @@ function createRoutesManage(deps) {
114
120
  else byRoot.set(rec.root, [rec.id]);
115
121
  }
116
122
  let deleted = 0;
117
- await enqueue(async () => {
123
+ await enqueueMany(Array.from(byRoot.keys()), async () => {
118
124
  for (const [root, rootIds] of byRoot) {
119
125
  let store = state.stores.get(root) || null;
120
126
  if (!store) {
@@ -152,7 +158,7 @@ function createRoutesManage(deps) {
152
158
  return deleted;
153
159
  }
154
160
  async function deleteAllSnapshots() {
155
- return enqueue(async () => {
161
+ return enqueueMany(Array.from(state.stores.keys()), async () => {
156
162
  const stores = /* @__PURE__ */ new Map();
157
163
  for (const [root, store] of state.stores.entries()) {
158
164
  if (store && store.dir) stores.set(store.dir, { store, root });
@@ -282,10 +288,14 @@ function createRoutesManage(deps) {
282
288
  maxFileBytes: cfg.maxFileBytes,
283
289
  maxWorkspaceBytes: cfg.maxWorkspaceBytes,
284
290
  scanBudgetMs: cfg.scanBudgetMs,
291
+ // issue #29:单次快照总时限(设置卡片的「单次快照时限」)
292
+ snapshotBudgetMs: cfg.snapshotBudgetMs,
285
293
  maxSnapshotsPerWorkspace: cfg.maxSnapshotsPerWorkspace,
286
294
  baseExcludes: cfg.baseExcludes.slice(),
287
295
  refillDraft: cfg.refillDraft,
288
296
  snapshotEnabled: cfg.snapshotEnabled,
297
+ // issue #31:只回退消息(不写文件快照)
298
+ messageOnly: cfg.messageOnly,
289
299
  archiveOriginal: cfg.archiveOriginal,
290
300
  editSubmitsImmediately: cfg.editSubmitsImmediately,
291
301
  retentionDays: cfg.retentionDays,
@@ -325,6 +335,7 @@ function createRoutesManage(deps) {
325
335
  }
326
336
  if (patch.refillDraft !== void 0) clean.refillDraft = Boolean(patch.refillDraft);
327
337
  if (patch.snapshotEnabled !== void 0) clean.snapshotEnabled = Boolean(patch.snapshotEnabled);
338
+ if (patch.messageOnly !== void 0) clean.messageOnly = Boolean(patch.messageOnly);
328
339
  if (patch.archiveOriginal !== void 0) clean.archiveOriginal = Boolean(patch.archiveOriginal);
329
340
  if (patch.editSubmitsImmediately !== void 0) clean.editSubmitsImmediately = Boolean(patch.editSubmitsImmediately);
330
341
  if (patch.retentionDays !== void 0) {
@@ -374,7 +385,12 @@ function createRoutesManage(deps) {
374
385
  if (stale) refreshListCacheInBackground();
375
386
  return { ok: true, items: listCache.items.slice(0, safeLimit), total: listCache.items.length, stale };
376
387
  }
377
- const allItems = await buildListItems();
388
+ let allItems;
389
+ try {
390
+ allItems = await buildListItems();
391
+ } catch (error) {
392
+ return dumpFailed();
393
+ }
378
394
  listCache.at = Date.now();
379
395
  listCache.items = allItems;
380
396
  listCache.stale = false;
@@ -492,15 +508,25 @@ function createRoutesManage(deps) {
492
508
  const id = args && args.messageId ? String(args.messageId) : "";
493
509
  if (scope === "workspace") {
494
510
  if (!root) return { ok: false, code: E.RECALL_NO_ROOT, message: "\u7F3A\u5C11\u5DE5\u4F5C\u533A\u8DEF\u5F84" };
495
- 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
+ }
496
517
  return { ok: true, deleted };
497
518
  }
498
519
  if (scope === "session") {
499
520
  if (!targetSessionId) return { ok: false, code: E.RECALL_NO_SESSION, message: "\u7F3A\u5C11\u4F1A\u8BDD ID" };
500
- const deleted = await deleteSnapshotsByFilter(
501
- (rec) => rec.sessionId === targetSessionId && (!root || rec.root === root),
502
- sessionId
503
- );
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
+ }
504
530
  return { ok: true, deleted };
505
531
  }
506
532
  let snap = state.snapshots.get(id) || null;
@@ -526,7 +552,7 @@ function createRoutesManage(deps) {
526
552
  }
527
553
  const finalStore = store;
528
554
  const finalRoot = snapRoot;
529
- await enqueue(async () => {
555
+ await enqueueFor(finalRoot, async () => {
530
556
  if (state.gitExe) {
531
557
  await rt.runShell(rt.scripts.purgeTagsScript(finalStore, state.gitExe, ["snap-" + id]), { timeoutMs: 12e4, stdoutMaxBytes: 4096 });
532
558
  }
@@ -544,7 +570,12 @@ function createRoutesManage(deps) {
544
570
  return { ok: true };
545
571
  }
546
572
  if (op === "deleteAll") {
547
- const result = await deleteAllSnapshots();
573
+ let result;
574
+ try {
575
+ result = await deleteAllSnapshots();
576
+ } catch (error) {
577
+ return dumpFailed();
578
+ }
548
579
  if (result.failed > 0) {
549
580
  return {
550
581
  ok: false,
@@ -556,7 +587,7 @@ function createRoutesManage(deps) {
556
587
  return { ok: true, deleted: result.deleted, stores: result.stores };
557
588
  }
558
589
  if (op === "gc") {
559
- const done = sessionId ? await enqueue(() => maint.runGc(sessionId, true)) : await enqueue(() => maint.runGcAll());
590
+ const done = sessionId ? await enqueueFor(await rt.resolveRoot(sessionId), () => maint.runGc(sessionId, true)) : await enqueueMany(Array.from(state.stores.keys()), () => maint.runGcAll());
560
591
  usageCache.payload = null;
561
592
  return { ok: true, gc: Boolean(done) };
562
593
  }
@@ -495,6 +495,10 @@ function excludeDumpScript(files) {
495
495
  lines.push(
496
496
  "printf 'EXCLBEGIN %s\\n' " + q,
497
497
  "if [ -f " + q + " ]; then base64 " + q + " 2>/dev/null | tr -d '\\n'; fi",
498
+ // issue #30 同族:tr -d '\n' 之后内容没有行尾,直接 echo 会把 EXCLEND 粘在
499
+ // base64 尾上;payload 字节数为 3 的倍数(无 '=' padding)时这截尾巴会被一起
500
+ // 解码成垃圾字节,回显到设置页再写回就污染 exclude.txt。
501
+ 'printf "\\n"',
498
502
  "echo 'EXCLEND'"
499
503
  );
500
504
  }
@@ -531,16 +535,34 @@ function storesDumpScript(container, extraDirs) {
531
535
  'for d in "${dirs[@]}"; do',
532
536
  ' [ -d "$d" ] || continue',
533
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",
534
550
  ' if [ -f "$d/root.txt" ]; then',
535
551
  ` printf "ROOT %s\\n" "$(cat "$d/root.txt" 2>/dev/null | tr -d '\\r\\n')"`,
536
552
  " else",
537
553
  ' echo "ROOT "',
538
554
  " fi",
539
555
  " echo INDEXBEGIN",
540
- ' cat "$d/index.json" 2>/dev/null',
556
+ // issue #30:这两个 json 是可选文件(刚建仓没拍过快照就没有),缺席必须放行——
557
+ // set -e 下裸 cat 会把「无文件」升级成整条 dump 失败。另外落盘内容无尾换行
558
+ // (writeTextViaShell = cat > file),cat 后紧接 echo 会把段尾标记粘到内容行尾,
559
+ // JS 侧按整行匹配 INDEXEND 就永远收不到段尾,所以内容后显式补一个换行。
560
+ ' cat "$d/index.json" 2>/dev/null || true',
561
+ ' printf "\\n"',
541
562
  " echo INDEXEND",
542
563
  " echo LINEAGEBEGIN",
543
- ' cat "$d/lineage.json" 2>/dev/null',
564
+ ' cat "$d/lineage.json" 2>/dev/null || true',
565
+ ' printf "\\n"',
544
566
  " echo LINEAGEEND",
545
567
  "done",
546
568
  "exit 0"
@@ -675,6 +675,14 @@ function storesDumpScript(container, extraDirs) {
675
675
  " if (-not $d) { continue }",
676
676
  " if (-not (Test-Path -LiteralPath $d -PathType Container)) { continue }",
677
677
  ' Write-Output ("==DIR " + $d)',
678
+ // STATE 供空仓回收判定(present/absent/quarantined/foreign):判据留在脚本侧
679
+ // 是因为只有这里能看文件系统——absent 是「从没拍过快照的真仓」,foreign 是
680
+ // 别人的目录,两者必须分开(否则会删掉不是我们的东西)。
681
+ " $ix = Join-Path $d 'index.json'",
682
+ " if (Test-Path -LiteralPath $ix -PathType Leaf) { Write-Output 'STATE present' }",
683
+ " elseif (@(Get-ChildItem -LiteralPath $d -Filter 'index.json.corrupt-*' -ErrorAction SilentlyContinue).Count -gt 0) { Write-Output 'STATE quarantined' }",
684
+ " 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' }",
685
+ " else { Write-Output 'STATE foreign' }",
678
686
  " $rt = Join-Path $d 'root.txt'",
679
687
  " if (Test-Path -LiteralPath $rt -PathType Leaf) {",
680
688
  " $rv = (Get-Content -LiteralPath $rt -Raw -Encoding UTF8 -ErrorAction SilentlyContinue)",
@@ -686,7 +694,6 @@ function storesDumpScript(container, extraDirs) {
686
694
  " Write-Output 'ROOT '",
687
695
  " }",
688
696
  " Write-Output 'INDEXBEGIN'",
689
- " $ix = Join-Path $d 'index.json'",
690
697
  " if (Test-Path -LiteralPath $ix -PathType Leaf) {",
691
698
  " $j = Get-Content -LiteralPath $ix -Raw -Encoding UTF8 -ErrorAction SilentlyContinue",
692
699
  " 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);
@@ -441,7 +462,8 @@ function createSnapshots(ctx, rt, config) {
441
462
  await loadIndex(root, sessionId);
442
463
  const captureStartedAt = Date.now();
443
464
  try {
444
- const out = await rt.runShell(S.snapshotScript(root, store, state.gitExe || "", messageId, BASE()), { timeoutMs: 6e5, stdoutMaxBytes: 65536 });
465
+ const budget = config.snapshotBudgetMs > 0 ? config.snapshotBudgetMs : 864e5;
466
+ const out = await rt.runShell(S.snapshotScript(root, store, state.gitExe || "", messageId, BASE()), { timeoutMs: budget, stdoutMaxBytes: 65536 });
445
467
  const limit = parseSnapshotLimit(out);
446
468
  if (limit) {
447
469
  markSnapshotLimited(root, limit);
@@ -518,6 +540,7 @@ function createSnapshots(ctx, rt, config) {
518
540
  async function diffFor(messageId) {
519
541
  const snap = state.snapshots.get(String(messageId));
520
542
  if (!snap) return null;
543
+ if (snap.files === false) return { changes: [], total: 0, truncated: false, treeId: null };
521
544
  const store = state.stores.get(snap.root);
522
545
  if (!store) return null;
523
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/lib/store.js CHANGED
@@ -149,7 +149,6 @@ function createRuntime(ctx, config, deps) {
149
149
  roots: /* @__PURE__ */ new Map(),
150
150
  stores: /* @__PURE__ */ new Map(),
151
151
  snapshots: /* @__PURE__ */ new Map(),
152
- queue: Promise.resolve(),
153
152
  indexLoaded: /* @__PURE__ */ new Set(),
154
153
  // PF-5 索引终态三/四档标记(rebuildOrphans 守卫的数据源):
155
154
  // - indexHealthy:磁盘索引解析成功且在场(loadIndex 正常载入分支)——
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-recall-plugin",
3
- "version": "2.5.3",
3
+ "version": "2.6.1",
4
4
  "description": "在 DSH 中撤回自己发过的消息:工作区文件与对话历史一起回到那条消息发出之前,消息的文本与附件自动放回输入框,改完即可重发。文件走独立影子 git 快照(不碰项目自身的 git),对话走官方 sessions.fork,原会话归档可找回。",
5
5
  "icon": "assets/icon.svg",
6
6
  "type": "module",