dsh-recall-plugin 2.4.6 → 2.4.8

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/lib/snapshots.js CHANGED
@@ -140,6 +140,20 @@ function parseTagsWithTime(text) {
140
140
  }
141
141
  return out;
142
142
  }
143
+ function judgeStoreFormat(raw, supported) {
144
+ if (raw === null) return { ok: false, v: null, reason: "unreadable" };
145
+ const t = String(raw == null ? "" : raw).trim();
146
+ let v;
147
+ if (!t) {
148
+ v = 1;
149
+ } else {
150
+ if (!/^\d+$/.test(t)) return { ok: false, v: null, reason: "corrupt" };
151
+ v = parseInt(t, 10);
152
+ if (v < 1) return { ok: false, v: null, reason: "corrupt" };
153
+ }
154
+ if (v > supported) return { ok: false, v, reason: "future" };
155
+ return { ok: true, v, reason: t ? "ok" : "absent" };
156
+ }
143
157
  function createSnapshots(ctx, rt, config) {
144
158
  const sessions = ctx.sessions;
145
159
  const state = rt.state;
@@ -149,9 +163,58 @@ function createSnapshots(ctx, rt, config) {
149
163
  const FUSE_BACKOFF_BASE_MS = 5 * 60 * 1e3;
150
164
  const FUSE_BACKOFF_CAP_MS = 60 * 60 * 1e3;
151
165
  const snapFailures = /* @__PURE__ */ new Map();
166
+ const SUPPORTED_FORMAT = 1;
167
+ const FORMAT_OK_TTL_MS = 3e4;
168
+ const formatOkAt = /* @__PURE__ */ new Map();
169
+ const formatBlockedAt = /* @__PURE__ */ new Map();
170
+ function formatFile(store) {
171
+ return store.dir + (rt.isWin ? "\\" : "/") + "format";
172
+ }
173
+ async function readFormatRaw(store) {
174
+ try {
175
+ return S.stripBom(await rt.runShell(S.fileReadCmd(formatFile(store)), { stdoutMaxBytes: 4096 }));
176
+ } catch (error) {
177
+ return null;
178
+ }
179
+ }
180
+ async function storeFormatVerdict(store) {
181
+ const at = formatOkAt.get(store.dir);
182
+ if (at !== void 0 && Date.now() - at < FORMAT_OK_TTL_MS) return { ok: true, v: SUPPORTED_FORMAT, reason: "ok" };
183
+ const verdict = judgeStoreFormat(await readFormatRaw(store), SUPPORTED_FORMAT);
184
+ if (verdict.ok && verdict.reason === "ok") formatOkAt.set(store.dir, Date.now());
185
+ return verdict;
186
+ }
187
+ function formatBlockedThrottled(store, verdict) {
188
+ const last = formatBlockedAt.get(store.dir) || 0;
189
+ if (Date.now() - last < 5 * 60 * 1e3) return;
190
+ formatBlockedAt.set(store.dir, Date.now());
191
+ const detail = verdict.reason === "future" ? "\u78C1\u76D8\u683C\u5F0F\u6807\u8BB0\u4E3A v" + verdict.v + "\uFF08\u672C\u63D2\u4EF6\u652F\u6301 v" + SUPPORTED_FORMAT + "\uFF09\uFF1A\u5199\u5165\u5DF2\u6682\u505C\uFF08\u5FEB\u7167/\u64A4\u56DE/\u6E05\u7406\uFF09\uFF0C\u5217\u8868\u4ECD\u53EF\u67E5\u770B\uFF1B\u8BF7\u5347\u7EA7\u63D2\u4EF6" : verdict.reason === "corrupt" ? "\u78C1\u76D8\u683C\u5F0F\u6807\u8BB0\u5185\u5BB9\u975E\u6CD5\uFF08\u5E94\u4E3A " + SUPPORTED_FORMAT + "\uFF09\uFF1A\u5199\u5165\u5DF2\u6682\u505C\uFF08\u5FEB\u7167/\u64A4\u56DE/\u6E05\u7406\uFF09\uFF1B\u8BF7\u68C0\u67E5\u8BE5 store \u76EE\u5F55\u4E0B\u7684 format \u6587\u4EF6" : "\u8BFB\u4E0D\u5230\u5FEB\u7167\u5E93\u7684\u78C1\u76D8\u683C\u5F0F\u6807\u8BB0\uFF08\u73AF\u5883\u672A\u5C31\u7EEA\u6216\u6587\u4EF6\u4E0D\u53EF\u8BFB\uFF09\uFF1A\u672C\u6B21\u5199\u5165\u5DF2\u6682\u505C\uFF0C\u4E0B\u4E00\u6761\u6D88\u606F\u81EA\u52A8\u91CD\u8BD5\uFF1B\u6301\u7EED\u51FA\u73B0\u8BF7\u68C0\u67E5\u8BE5 store \u76EE\u5F55\u6743\u9650";
192
+ rt.recordError("recall store format blocked: " + detail);
193
+ }
194
+ async function guardStoreFormat(store) {
195
+ const verdict = await storeFormatVerdict(store);
196
+ if (verdict.ok) return true;
197
+ formatBlockedThrottled(store, verdict);
198
+ return false;
199
+ }
200
+ async function stampStoreFormat(store, verdict) {
201
+ if (!verdict.ok || verdict.reason !== "absent") return;
202
+ try {
203
+ await rt.writeTextViaShell(formatFile(store), String(SUPPORTED_FORMAT));
204
+ formatOkAt.set(store.dir, Date.now());
205
+ } catch (error) {
206
+ rt.recordError("recall format stamp failed: " + String(error));
207
+ }
208
+ }
152
209
  async function saveIndex(root, sessionId) {
153
210
  const store = state.stores.get(root);
154
211
  if (!store) return;
212
+ const verdict = await storeFormatVerdict(store);
213
+ if (!verdict.ok) {
214
+ formatBlockedThrottled(store, verdict);
215
+ return;
216
+ }
217
+ await stampStoreFormat(store, verdict);
155
218
  const entries = Array.from(state.snapshots.entries()).filter(([, s]) => s.root === root).map(([id, s]) => {
156
219
  const rec = { id, time: s.time, root: s.root, sessionId: s.sessionId };
157
220
  const fb = state.snapFeedback.get(id);
@@ -168,6 +231,7 @@ function createSnapshots(ctx, rt, config) {
168
231
  if (state.indexLoaded.has(root)) return;
169
232
  const store = state.stores.get(root);
170
233
  if (!store) return;
234
+ if (!await guardStoreFormat(store)) return;
171
235
  let raw = "";
172
236
  let truncated = false;
173
237
  try {
@@ -259,6 +323,7 @@ function createSnapshots(ctx, rt, config) {
259
323
  async function recordLineage(root, childId, parentId) {
260
324
  const store = state.stores.get(root);
261
325
  if (!store) return;
326
+ if (!await guardStoreFormat(store)) return;
262
327
  const sep = rt.isWin ? "\\" : "/";
263
328
  const existing = await loadLineage(root);
264
329
  if (!existing.some((e) => e.childId === childId && e.parentId === parentId)) {
@@ -313,6 +378,7 @@ function createSnapshots(ctx, rt, config) {
313
378
  if (buildArtifactRootSegment(root, BASE(), rt.isWin)) return;
314
379
  let store = await rt.resolveStore(root);
315
380
  store = await rt.tryUpgradeToHome(root);
381
+ if (!await guardStoreFormat(store)) return;
316
382
  const g = await rt.ensureGit(root, store);
317
383
  if (!g.ok) {
318
384
  setFeedback(messageId, { failed: true, ...buildFeedbackError(g.error || "\u672A\u77E5\u539F\u56E0") });
@@ -336,7 +402,10 @@ function createSnapshots(ctx, rt, config) {
336
402
  const keep = rec && (rec.failed || Array.isArray(rec.skipped) && rec.skipped.length);
337
403
  if (keep) state.snapFeedback.set(id, rec);
338
404
  else state.snapFeedback.delete(id);
339
- if (state.snapFeedback.size > 200) state.snapFeedback.delete(state.snapFeedback.keys().next().value);
405
+ if (state.snapFeedback.size > 200) {
406
+ const oldest = state.snapFeedback.keys().next().value;
407
+ if (oldest !== void 0) state.snapFeedback.delete(oldest);
408
+ }
340
409
  }
341
410
  async function feedbackFor(sessionId, messageId) {
342
411
  const rec = state.snapFeedback.get(String(messageId || ""));
@@ -391,8 +460,8 @@ function createSnapshots(ctx, rt, config) {
391
460
  if (!m) {
392
461
  return { ok: false, partial: true, error: "\u56DE\u9000\u811A\u672C\u672A\u6B63\u5E38\u5B8C\u6210\uFF08\u5DE5\u4F5C\u533A\u53EF\u80FD\u5904\u4E8E\u534A\u56DE\u9000\u72B6\u6001\uFF09\uFF1A" + text.slice(0, 300) };
393
462
  }
394
- const deleted = parseInt(m[1], 10);
395
- const restored = parseInt(m[2], 10);
463
+ const deleted = parseInt(m[1] ?? "", 10);
464
+ const restored = parseInt(m[2] ?? "", 10);
396
465
  return { ok: true, count: (Number.isNaN(deleted) ? 0 : deleted) + (Number.isNaN(restored) ? 0 : restored) };
397
466
  } catch (error) {
398
467
  const re = error;
@@ -461,11 +530,12 @@ function createSnapshots(ctx, rt, config) {
461
530
  return [];
462
531
  }
463
532
  }
464
- return { saveIndex, loadIndex, readExclude, writeExclude, rebuildOrphans, captureSnapshot, diffFor, rollbackFor, resolveCutSeq, resolveStaleQueueItemIds, feedbackFor, loadLineage, recordLineage };
533
+ return { saveIndex, loadIndex, readExclude, writeExclude, rebuildOrphans, captureSnapshot, diffFor, rollbackFor, resolveCutSeq, resolveStaleQueueItemIds, feedbackFor, loadLineage, recordLineage, guardStoreFormat };
465
534
  }
466
535
  export {
467
536
  createSnapshots,
468
537
  isSafetySnapshotId,
538
+ judgeStoreFormat,
469
539
  parseChanges,
470
540
  parseDiffOutput,
471
541
  parseSkipped,
package/lib/store.js CHANGED
@@ -29,7 +29,7 @@ async function resolvePosixHomeBase(deps, inputs) {
29
29
  }
30
30
  function parseCleanupResult(out) {
31
31
  const m = String(out || "").match(/CLEANUP_OTHER_INSTANCE\s+(\d+)/);
32
- if (m) return { otherPid: parseInt(m[1], 10), skippedFresh: false };
32
+ if (m) return { otherPid: parseInt(m[1] ?? "", 10), skippedFresh: false };
33
33
  if (String(out || "").indexOf("CLEANUP_SKIPPED_FRESH_LOCK") >= 0) return { otherPid: null, skippedFresh: true };
34
34
  return { otherPid: null, skippedFresh: false };
35
35
  }
@@ -84,8 +84,10 @@ async function runViaSpawn(spawn, req) {
84
84
  const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
85
85
  chunks.push(buf);
86
86
  total += buf.length;
87
- while (chunks.length > 1 && total - chunks[0].length > maxBytes) {
88
- total -= chunks[0].length;
87
+ while (chunks.length > 1) {
88
+ const head = chunks[0];
89
+ if (!head || total - head.length <= maxBytes) break;
90
+ total -= head.length;
89
91
  chunks.shift();
90
92
  }
91
93
  },
@@ -275,7 +277,7 @@ function createRuntime(ctx, config, deps) {
275
277
  }
276
278
  function extractGitDir(command) {
277
279
  const m = String(command).match(/(?:^|\n)[ \t]*(?:\$g|g)[ \t]*=[ \t]*'([^']+)/);
278
- return m ? m[1] : null;
280
+ return m ? m[1] ?? null : null;
279
281
  }
280
282
  async function cleanupAfterGitFailure(command) {
281
283
  if (!command || String(command).indexOf("RECALL_CLEANUP") >= 0) return;
@@ -487,7 +489,7 @@ function createRuntime(ctx, config, deps) {
487
489
  const out = scripts.stripBom(await runShell(scripts.ensureGitScript(store, gitExe, config.baseExcludes), { stdoutMaxBytes: 4096 }));
488
490
  state.gitReady.add(store.git);
489
491
  const m = out.match(/GIT_OK\s+(\d+)/);
490
- state.gcLastAt.set(store.git, m ? parseInt(m[1], 10) * 1e3 : Date.now());
492
+ state.gcLastAt.set(store.git, m ? parseInt(m[1] ?? "", 10) * 1e3 : Date.now());
491
493
  return { ok: true };
492
494
  } catch (error) {
493
495
  recordError("recall ensureGit failed: " + String(error));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-recall-plugin",
3
- "version": "2.4.6",
3
+ "version": "2.4.8",
4
4
  "description": "在 DSH 中撤回自己发过的消息:工作区文件与对话历史一起回到那条消息发出之前,消息的文本与附件自动放回输入框,改完即可重发。文件走独立影子 git 快照(不碰项目自身的 git),对话走官方 sessions.fork,原会话归档可找回。",
5
5
  "icon": "assets/icon.svg",
6
6
  "type": "module",
@@ -34,6 +34,7 @@
34
34
  "check:upgrade": "node scripts/check-upgrade.mjs",
35
35
  "build": "node scripts/build-host.mjs && node scripts/build-client.mjs",
36
36
  "test": "vitest run tests/unit",
37
+ "test:client": "vitest run --config vitest.client.config.ts",
37
38
  "test:probe": "vitest run tests/probe",
38
39
  "verify:host": "node scripts/verify-host.mjs",
39
40
  "typecheck": "tsc --noEmit"
@@ -65,7 +66,8 @@
65
66
  "0.1.7-alpha.2": "compatible",
66
67
  "0.1.7-rc.1": "compatible",
67
68
  "0.1.7-rc.2": "compatible",
68
- "0.2.0-rc.1": "compatible"
69
+ "0.2.0-rc.1": "compatible",
70
+ "0.2.0-rc.2": "compatible"
69
71
  }
70
72
  }
71
73
  },
@@ -90,7 +92,11 @@
90
92
  "@deepseek-ai/schemastery": "^3.18.4",
91
93
  "@types/node": "^20.19.43",
92
94
  "@types/react": "^18.3.31",
95
+ "@types/react-dom": "^18.3.7",
93
96
  "esbuild": "^0.28.1",
97
+ "jsdom": "^29.1.1",
98
+ "react": "^18.3.1",
99
+ "react-dom": "^18.3.1",
94
100
  "typescript": "^5.9.3",
95
101
  "vitest": "^4.1.11"
96
102
  }