dsh-recall-plugin 2.4.6 → 2.4.7

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/config.js CHANGED
@@ -40,7 +40,11 @@ const Config = Schema.object({
40
40
  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")),
41
41
  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")),
42
42
  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")),
43
- retentionDays: withVolatile(Schema.number().default(0).description("\u6309\u5929\u6570\u4FDD\u7559\u5FEB\u7167\uFF0C\u8D85\u671F\u81EA\u52A8\u5220\u9664\uFF1B0 \u8868\u793A\u4E0D\u542F\u7528"))
43
+ retentionDays: withVolatile(Schema.number().default(0).description("\u6309\u5929\u6570\u4FDD\u7559\u5FEB\u7167\uFF0C\u8D85\u671F\u81EA\u52A8\u5220\u9664\uFF1B0 \u8868\u793A\u4E0D\u542F\u7528")),
44
+ // A4:界面语言。union 三值而不是自由字符串——非法配置在加载时响亮失败
45
+ //(合规清单 #3);description 保持中文是 v1 明示限制:它由宿主官方设置
46
+ // 表单在 host 侧渲染,插件无法本地化(自定义配置卡片不受影响,见 README)
47
+ locale: withVolatile(Schema.union(["auto", "zh", "en"]).default("auto").description("\u754C\u9762\u8BED\u8A00\uFF1Aauto \u8DDF\u968F\u7CFB\u7EDF\uFF0C\u53EF\u9009 zh / en\uFF08\u4FDD\u5B58\u540E\u751F\u6548\uFF09"))
44
48
  });
45
49
  function isLegacySettingsFace(settings) {
46
50
  const svc = settings;
@@ -65,7 +69,8 @@ function resolveSettingsNs(ctx, settings) {
65
69
  for (const id of entryIds) {
66
70
  if (known.indexOf(id) >= 0) return id;
67
71
  }
68
- return entryIds.length ? entryIds[0] : null;
72
+ const first = entryIds[0];
73
+ return first !== void 0 ? first : null;
69
74
  }
70
75
  function describeNamespaces(settings) {
71
76
  const svc = settings;
@@ -119,7 +124,8 @@ const DEFAULTS = {
119
124
  refillDraft: true,
120
125
  snapshotEnabled: true,
121
126
  archiveOriginal: true,
122
- retentionDays: 0
127
+ retentionDays: 0,
128
+ locale: "auto"
123
129
  };
124
130
  function createConfig(raw) {
125
131
  const cfg = raw && typeof raw === "object" ? raw : {};
@@ -139,7 +145,8 @@ function createConfig(raw) {
139
145
  const archiveOriginal = typeof cfg.archiveOriginal === "boolean" ? cfg.archiveOriginal : true;
140
146
  const rawDays = typeof cfg.retentionDays === "number" ? cfg.retentionDays : parseInt(String(cfg.retentionDays == null ? "" : cfg.retentionDays), 10);
141
147
  const retentionDays = Number.isFinite(rawDays) ? Math.max(0, rawDays) : 0;
142
- return { gcSnaps, gcHours, maxFileBytes, maxSnapshotsPerWorkspace, baseExcludes, refillDraft, snapshotEnabled, archiveOriginal, retentionDays };
148
+ const locale = cfg.locale === "zh" || cfg.locale === "en" ? cfg.locale : "auto";
149
+ return { gcSnaps, gcHours, maxFileBytes, maxSnapshotsPerWorkspace, baseExcludes, refillDraft, snapshotEnabled, archiveOriginal, retentionDays, locale };
143
150
  }
144
151
  export {
145
152
  Config,
package/lib/errors.js CHANGED
@@ -14,6 +14,7 @@ const RECALL_NO_ROOT = "NO_ROOT";
14
14
  const RECALL_NO_SESSION = "NO_SESSION";
15
15
  const RECALL_PARTIAL_DELETE = "PARTIAL_DELETE";
16
16
  const RECALL_UNKNOWN_OP = "UNKNOWN_OP";
17
+ const RECALL_FORMAT_BLOCKED = "FORMAT_BLOCKED";
17
18
  const RECALL_UNKNOWN_ENDPOINT = "UNKNOWN_ENDPOINT";
18
19
  const RECALL_INDEX_CORRUPT = "INDEX_CORRUPT";
19
20
  const ALL_CODES = Object.freeze([
@@ -33,6 +34,7 @@ const ALL_CODES = Object.freeze([
33
34
  RECALL_NO_SESSION,
34
35
  RECALL_PARTIAL_DELETE,
35
36
  RECALL_UNKNOWN_OP,
37
+ RECALL_FORMAT_BLOCKED,
36
38
  RECALL_UNKNOWN_ENDPOINT,
37
39
  RECALL_INDEX_CORRUPT
38
40
  ]);
@@ -43,6 +45,7 @@ export {
43
45
  RECALL_BODY_TOO_LARGE,
44
46
  RECALL_EMPTY_PATCH,
45
47
  RECALL_ERROR,
48
+ RECALL_FORMAT_BLOCKED,
46
49
  RECALL_INDEX_CORRUPT,
47
50
  RECALL_NO_ROOT,
48
51
  RECALL_NO_SESSION,
@@ -20,7 +20,8 @@ function buildArtifactRootSegment(root, base, isWin) {
20
20
  const pool = isWin ? names.map((n) => n.toLowerCase()) : names;
21
21
  for (const seg of segs) {
22
22
  const i = pool.indexOf(isWin ? seg.toLowerCase() : seg);
23
- if (i >= 0) return names[i];
23
+ const hit = i >= 0 ? names[i] : void 0;
24
+ if (hit !== void 0) return hit;
24
25
  }
25
26
  return null;
26
27
  }
package/lib/index.js CHANGED
@@ -7,6 +7,7 @@ import { createMaintenance } from "./maintenance.js";
7
7
  import { createSessionInfo, titleFromEvents, messageTextFromEvents } from "./session-info.js";
8
8
  import { createRoutesCore } from "./routes-core.js";
9
9
  import { createRoutesManage } from "./routes-manage.js";
10
+ import { createIntentJournal } from "./intent-journal.js";
10
11
  import * as dshSettings from "@deepseek-ai/dsh-settings";
11
12
  import * as E from "./errors.js";
12
13
  const name = "dsh-recall-plugin";
@@ -65,7 +66,7 @@ function apply(ctx, config) {
65
66
  const workers = Array.from({ length: Math.min(limit, tasks.length) }, async () => {
66
67
  while (index < tasks.length) {
67
68
  const task = tasks[index++];
68
- await task();
69
+ if (task) await task();
69
70
  }
70
71
  });
71
72
  await Promise.all(workers);
@@ -215,6 +216,26 @@ function apply(ctx, config) {
215
216
  }
216
217
  return records;
217
218
  }
219
+ const intentJournal = createIntentJournal({
220
+ runShell: rt.runShell,
221
+ writeTextViaShell: rt.writeTextViaShell,
222
+ scripts: rt.scripts,
223
+ isWin: rt.isWin,
224
+ recordError: rt.recordError,
225
+ workspaceMatchesTag: async (messageId) => {
226
+ const d = await snaps.diffFor(messageId);
227
+ return Boolean(d && d.total === 0 && d.changes.length === 0);
228
+ },
229
+ resetToSafety: async (store, safetyId, root) => {
230
+ try {
231
+ const out = await rt.runShell(rt.scripts.rescueScript(root, store, state.gitExe || "", "snap-" + safetyId), { timeoutMs: 6e5, stdoutMaxBytes: 65536 });
232
+ return String(out || "").indexOf("RESCUE_OK") >= 0;
233
+ } catch (error) {
234
+ return false;
235
+ }
236
+ },
237
+ agentBusy: (root) => agentBusy(null, root)
238
+ });
218
239
  const deps = {
219
240
  ctx,
220
241
  rt,
@@ -245,6 +266,7 @@ function apply(ctx, config) {
245
266
  readSettings: () => readSettings(),
246
267
  DEFAULTS,
247
268
  rescueRollback,
269
+ intentJournal,
248
270
  E
249
271
  };
250
272
  const endpoints = {
@@ -252,7 +274,7 @@ function apply(ctx, config) {
252
274
  ...createRoutesManage(deps)
253
275
  };
254
276
  const handleRecall = async (request) => {
255
- const name2 = new URL(request.url).pathname.replace(/^\/api\/recall\/?/, "").split("/")[0];
277
+ const name2 = new URL(request.url).pathname.replace(/^\/api\/recall\/?/, "").split("/")[0] ?? "";
256
278
  const endpoint = endpoints[name2];
257
279
  if (!endpoint) {
258
280
  return jsonResponse({ ok: false, code: E.RECALL_UNKNOWN_ENDPOINT, message: "unknown endpoint: " + name2 });
@@ -319,10 +341,15 @@ function apply(ctx, config) {
319
341
  }
320
342
  }
321
343
  for (const [cwd, sessionId] of warmupRoots) {
322
- Promise.resolve(rt.resolveStore(cwd)).then(() => rt.tryUpgradeToHome(cwd)).then((store) => rt.ensureGit(cwd, store)).then(() => snaps.loadIndex(cwd, sessionId)).then(() => snaps.rebuildOrphans(cwd, sessionId)).then(() => rt.cleanupLegacy(cwd)).catch(() => {
344
+ Promise.resolve(rt.resolveStore(cwd)).then(() => rt.tryUpgradeToHome(cwd)).then((store) => rt.ensureGit(cwd, store)).then(() => snaps.loadIndex(cwd, sessionId)).then(() => snaps.rebuildOrphans(cwd, sessionId)).then(async () => {
345
+ const st = state.stores.get(cwd);
346
+ if (st && await snaps.guardStoreFormat(st)) await intentJournal.recover(st);
347
+ }).then(() => rt.cleanupLegacy(cwd)).catch(() => {
323
348
  });
324
349
  }
325
- })();
350
+ })().catch((error) => {
351
+ console.error("recall warmup skipped:", error);
352
+ });
326
353
  }
327
354
  export {
328
355
  Config,
@@ -0,0 +1,108 @@
1
+ const INTENT_VERSION = 1;
2
+ function describeIntent(intent) {
3
+ return "message=" + intent.messageId + ", phase=" + intent.phase + ", safety=snap-" + intent.safetyId;
4
+ }
5
+ function createIntentJournal(deps) {
6
+ const handled = /* @__PURE__ */ new Set();
7
+ function intentFile(store) {
8
+ return store.dir + (deps.isWin ? "\\" : "/") + "recall-intent.json";
9
+ }
10
+ async function readRaw(store) {
11
+ let raw = "";
12
+ try {
13
+ raw = deps.scripts.stripBom(await deps.runShell(deps.scripts.fileReadCmd(intentFile(store)), { timeoutMs: 3e4, stdoutMaxBytes: 65536 })).trim();
14
+ } catch (error) {
15
+ return { ok: false, intent: null };
16
+ }
17
+ if (!raw) return { ok: true, intent: null };
18
+ try {
19
+ const obj = JSON.parse(raw);
20
+ if (!obj || obj.v !== INTENT_VERSION || obj.op !== "execute" || typeof obj.messageId !== "string" || !obj.messageId || typeof obj.safetyId !== "string" || !obj.safetyId || obj.phase !== "rollback" && obj.phase !== "rescue") {
21
+ return { ok: true, intent: null };
22
+ }
23
+ return {
24
+ ok: true,
25
+ intent: {
26
+ v: INTENT_VERSION,
27
+ op: "execute",
28
+ messageId: obj.messageId,
29
+ root: typeof obj.root === "string" ? obj.root : "",
30
+ safetyId: obj.safetyId,
31
+ safetyOk: obj.safetyOk !== false,
32
+ phase: obj.phase,
33
+ time: typeof obj.time === "number" ? obj.time : 0
34
+ }
35
+ };
36
+ } catch (error) {
37
+ return { ok: true, intent: null };
38
+ }
39
+ }
40
+ async function write(store, text) {
41
+ try {
42
+ await deps.writeTextViaShell(intentFile(store), text);
43
+ } catch (error) {
44
+ deps.recordError("recall intent journal write failed: " + String(error));
45
+ }
46
+ }
47
+ function read(store) {
48
+ return readRaw(store).then((r) => r.intent);
49
+ }
50
+ async function begin(store, root, fields) {
51
+ const intent = {
52
+ v: INTENT_VERSION,
53
+ op: "execute",
54
+ messageId: fields.messageId,
55
+ root,
56
+ safetyId: fields.safetyId,
57
+ safetyOk: fields.safetyOk,
58
+ phase: "rollback",
59
+ time: Date.now()
60
+ };
61
+ await write(store, JSON.stringify(intent));
62
+ }
63
+ async function advance(store, phase) {
64
+ const cur = await read(store);
65
+ if (cur) await write(store, JSON.stringify(Object.assign({}, cur, { phase })));
66
+ }
67
+ function clear(store) {
68
+ return write(store, "");
69
+ }
70
+ async function recover(store) {
71
+ if (handled.has(store.dir)) return false;
72
+ const got = await readRaw(store);
73
+ if (!got.ok) return false;
74
+ const intent = got.intent;
75
+ if (!intent) {
76
+ handled.add(store.dir);
77
+ return false;
78
+ }
79
+ if (await deps.workspaceMatchesTag(intent.messageId)) {
80
+ await clear(store);
81
+ handled.add(store.dir);
82
+ return true;
83
+ }
84
+ if (!intent.safetyOk) {
85
+ deps.recordError("recall interrupted rollback detected, no safety snapshot: " + describeIntent(intent) + "\uFF08\u8BB0\u5F55\u4FDD\u7559\u4E8E " + intentFile(store) + "\uFF09");
86
+ handled.add(store.dir);
87
+ return true;
88
+ }
89
+ if (deps.agentBusy(intent.root)) {
90
+ deps.recordError("recall interrupted rollback deferred (agent busy): " + describeIntent(intent));
91
+ return false;
92
+ }
93
+ const reset = await deps.resetToSafety(store, intent.safetyId, intent.root);
94
+ if (!reset) {
95
+ deps.recordError("recall interrupted rollback rescue failed: " + describeIntent(intent) + "\uFF08\u8BB0\u5F55\u4FDD\u7559\u4E8E " + intentFile(store) + "\uFF09");
96
+ handled.add(store.dir);
97
+ return true;
98
+ }
99
+ await clear(store);
100
+ handled.add(store.dir);
101
+ deps.recordError("recovered interrupted rollback: " + describeIntent(intent) + " \u2014 \u5DE5\u4F5C\u533A\u5DF2\u590D\u4F4D\u5230\u5B89\u5168\u5FEB\u7167");
102
+ return true;
103
+ }
104
+ return { begin, advance, clear, read, file: intentFile, recover };
105
+ }
106
+ export {
107
+ createIntentJournal
108
+ };
@@ -5,8 +5,9 @@ function selectOverLimitVictims(snapshots, limit) {
5
5
  const byRoot = /* @__PURE__ */ new Map();
6
6
  for (const [id, s] of snapshots.entries()) {
7
7
  if (!s || !s.root) continue;
8
- if (!byRoot.has(s.root)) byRoot.set(s.root, []);
9
- byRoot.get(s.root).push({ id, time: s.time });
8
+ const list = byRoot.get(s.root);
9
+ if (list) list.push({ id, time: s.time });
10
+ else byRoot.set(s.root, [{ id, time: s.time }]);
10
11
  }
11
12
  const victims = /* @__PURE__ */ new Map();
12
13
  for (const [root, list] of byRoot) {
@@ -37,8 +38,9 @@ function createMaintenance(ctx, rt, snaps, config, deps = {}) {
37
38
  const byRoot = /* @__PURE__ */ new Map();
38
39
  for (const [id, s] of state.snapshots.entries()) {
39
40
  if (!s || s.sessionId !== sessionId) continue;
40
- if (!byRoot.has(s.root)) byRoot.set(s.root, []);
41
- byRoot.get(s.root).push(id);
41
+ const list = byRoot.get(s.root);
42
+ if (list) list.push(id);
43
+ else byRoot.set(s.root, [id]);
42
44
  }
43
45
  let purged = 0;
44
46
  for (const [root, ids] of byRoot) {
@@ -51,6 +53,7 @@ function createMaintenance(ctx, rt, snaps, config, deps = {}) {
51
53
  }
52
54
  }
53
55
  if (!store || !state.gitExe) continue;
56
+ if (!await snaps.guardStoreFormat(store)) continue;
54
57
  try {
55
58
  for (let i = 0; i < ids.length; i += 100) {
56
59
  await rt.runShell(S.purgeTagsScript(store, state.gitExe, ids.slice(i, i + 100).map((id) => "snap-" + id)), { timeoutMs: 12e4, stdoutMaxBytes: 4096 });
@@ -99,6 +102,7 @@ function createMaintenance(ctx, rt, snaps, config, deps = {}) {
99
102
  }
100
103
  }
101
104
  if (!store || !state.gitExe) continue;
105
+ if (!await snaps.guardStoreFormat(store)) continue;
102
106
  try {
103
107
  for (let i = 0; i < victims.length; i += 100) {
104
108
  await rt.runShell(S.purgeTagsScript(store, state.gitExe, victims.slice(i, i + 100).map((v) => "snap-" + v.id)), { timeoutMs: 12e4, stdoutMaxBytes: 4096 });
@@ -127,6 +131,7 @@ function createMaintenance(ctx, rt, snaps, config, deps = {}) {
127
131
  }
128
132
  }
129
133
  if (!store || !state.gitExe) continue;
134
+ if (!await snaps.guardStoreFormat(store)) continue;
130
135
  try {
131
136
  for (let i = 0; i < victims.length; i += 100) {
132
137
  await rt.runShell(S.purgeTagsScript(store, state.gitExe, victims.slice(i, i + 100).map((v) => "snap-" + v.id)), { timeoutMs: 12e4, stdoutMaxBytes: 4096 });
@@ -2,7 +2,7 @@ import { ENV_HINTS } from "./diagnostics.js";
2
2
  import { parseTreeId } from "./snapshots.js";
3
3
  import { buildArtifactRootSegment, buildRootNotice } from "./exclude-patterns.js";
4
4
  function createRoutesCore(deps) {
5
- const { rt, snaps, state, cfg, supported, enqueue, agentBusy, rescueRollback, E } = deps;
5
+ const { rt, snaps, state, cfg, supported, enqueue, agentBusy, rescueRollback, intentJournal, E } = deps;
6
6
  return {
7
7
  "init": async (args) => {
8
8
  if (!supported) {
@@ -17,15 +17,19 @@ function createRoutesCore(deps) {
17
17
  await rt.ensureGit(root, store);
18
18
  await snaps.loadIndex(root, sessionId);
19
19
  await snaps.rebuildOrphans(root, sessionId);
20
+ if (store && await snaps.guardStoreFormat(store)) await intentJournal.recover(store);
20
21
  rt.cleanupLegacy(root);
21
22
  notice = {
22
23
  gitMissing: state.gitExe === "",
23
24
  homeFallback: store ? !store.home : false
24
25
  };
25
26
  const artifactSeg = buildArtifactRootSegment(root, cfg.baseExcludes, rt.isWin);
26
- if (artifactSeg) notice.buildRootNotice = buildRootNotice(artifactSeg);
27
+ if (artifactSeg) {
28
+ notice.buildRootArtifactSeg = artifactSeg;
29
+ notice.buildRootNotice = buildRootNotice(artifactSeg);
30
+ }
27
31
  }
28
- return { ok: Boolean(root), root: root || null, notice, config: { refillDraft: cfg.refillDraft, archiveOriginal: cfg.archiveOriginal } };
32
+ return { ok: Boolean(root), root: root || null, notice, config: { refillDraft: cfg.refillDraft, archiveOriginal: cfg.archiveOriginal, locale: cfg.locale } };
29
33
  },
30
34
  "snapshot-info": async (args) => {
31
35
  const id = args && args.messageId ? String(args.messageId) : "";
@@ -38,7 +42,9 @@ function createRoutesCore(deps) {
38
42
  time: snap ? snap.time : null,
39
43
  id,
40
44
  ...feedback,
41
- notice: artifactSeg ? buildRootNotice(artifactSeg) : void 0
45
+ // A4:段名与中文 notice 并存下发(client 优先段名本地取词,见 init 同处注释)
46
+ notice: artifactSeg ? buildRootNotice(artifactSeg) : void 0,
47
+ artifactSeg: artifactSeg || void 0
42
48
  };
43
49
  },
44
50
  "preview": async (args) => {
@@ -69,6 +75,9 @@ function createRoutesCore(deps) {
69
75
  if (!snap) return { ok: false, code: E.RECALL_NO_SNAPSHOT, message: "\u8BE5\u6D88\u606F\u6CA1\u6709\u53EF\u7528\u7684\u9879\u76EE\u5FEB\u7167" };
70
76
  const store = state.stores.get(snap.root);
71
77
  if (!store) return { ok: false, code: E.RECALL_NO_STORE, message: "\u5FEB\u7167\u5B58\u50A8\u4E0D\u53EF\u7528" };
78
+ if (!await snaps.guardStoreFormat(store)) {
79
+ return { ok: false, code: E.RECALL_FORMAT_BLOCKED, message: "\u78C1\u76D8\u683C\u5F0F\u4E0D\u53D7\u652F\u6301\uFF0C\u5DF2\u505C\u6B62\u5199\u5165\uFF1B\u8BE6\u89C1\u300C\u6700\u8FD1\u9519\u8BEF\u300D" };
80
+ }
72
81
  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" };
73
82
  const previewTreeId = args && typeof args.previewTreeId === "string" && args.previewTreeId ? args.previewTreeId : null;
74
83
  if (!previewTreeId && args && typeof args.previewTotal === "number") {
@@ -90,12 +99,20 @@ function createRoutesCore(deps) {
90
99
  if (previewTreeId && safetyTreeId && safetyTreeId !== previewTreeId) {
91
100
  return { ok: false, code: E.RECALL_STALE, message: "\u9884\u89C8\u540E\u9879\u76EE\u6587\u4EF6\u53D1\u751F\u4E86\u53D8\u5316\uFF0C\u8BF7\u91CD\u65B0\u9884\u89C8\u786E\u8BA4" };
92
101
  }
102
+ await intentJournal.begin(store, snap.root, { messageId: id, safetyId, safetyOk });
93
103
  const rolled = await snaps.rollbackFor(id);
94
- if (rolled.ok) return rolled;
95
- return rescueRollback(
104
+ if (rolled.ok) {
105
+ await intentJournal.clear(store);
106
+ return rolled;
107
+ }
108
+ await intentJournal.advance(store, "rescue");
109
+ const rescueResult = await rescueRollback(
96
110
  { runShell: rt.runShell, scripts: rt.scripts, gitExe: state.gitExe || "", recordError: rt.recordError },
97
111
  { root: snap.root, store, safetyId, safetyOk, rollbackError: rolled.error }
98
112
  );
113
+ await intentJournal.clear(store);
114
+ if (rescueResult && rescueResult.message) rescueResult.message += "\uFF08\u4E2D\u65AD\u70B9\u8BB0\u5F55\uFF1A" + intentJournal.file(store) + "\uFF09";
115
+ return rescueResult;
99
116
  });
100
117
  if (!result.ok) return result;
101
118
  const cutSeq = await snaps.resolveCutSeq(sessionId, id);
@@ -109,8 +109,9 @@ function createRoutesManage(deps) {
109
109
  const byRoot = /* @__PURE__ */ new Map();
110
110
  for (const rec of records.values()) {
111
111
  if (!match(rec) || !rec.root) continue;
112
- if (!byRoot.has(rec.root)) byRoot.set(rec.root, []);
113
- byRoot.get(rec.root).push(rec.id);
112
+ const list = byRoot.get(rec.root);
113
+ if (list) list.push(rec.id);
114
+ else byRoot.set(rec.root, [rec.id]);
114
115
  }
115
116
  let deleted = 0;
116
117
  await enqueue(async () => {
@@ -124,6 +125,7 @@ function createRoutesManage(deps) {
124
125
  }
125
126
  }
126
127
  if (!store) continue;
128
+ if (!await snaps.guardStoreFormat(store)) continue;
127
129
  try {
128
130
  if (state.gitExe) {
129
131
  const tags = rootIds.map((id) => "snap-" + id);
@@ -181,6 +183,10 @@ function createRoutesManage(deps) {
181
183
  let clearedStores = 0;
182
184
  let failed = 0;
183
185
  for (const { store, root } of stores.values()) {
186
+ if (!await snaps.guardStoreFormat(store)) {
187
+ failed += 1;
188
+ continue;
189
+ }
184
190
  try {
185
191
  const output = await rt.runShell(rt.scripts.listTagsScript(store, gitExe), { timeoutMs: 12e4, stdoutMaxBytes: 4194304 });
186
192
  const tags = rt.scripts.stripBom(output).split(/\r?\n/).map((tag) => tag.trim()).filter((tag) => tag.indexOf("snap-") === 0);
@@ -279,7 +285,9 @@ function createRoutesManage(deps) {
279
285
  refillDraft: cfg.refillDraft,
280
286
  snapshotEnabled: cfg.snapshotEnabled,
281
287
  archiveOriginal: cfg.archiveOriginal,
282
- retentionDays: cfg.retentionDays
288
+ retentionDays: cfg.retentionDays,
289
+ // A4:界面语言偏好随 values 下发(设置卡片据此渲染下拉并即时切语言)
290
+ locale: cfg.locale
283
291
  },
284
292
  overridden,
285
293
  envLocks,
@@ -314,6 +322,11 @@ function createRoutesManage(deps) {
314
322
  if (!Array.isArray(patch.baseExcludes)) return { ok: false, code: E.RECALL_BAD_TYPE, message: "baseExcludes \u5FC5\u987B\u662F\u5B57\u7B26\u4E32\u6570\u7EC4" };
315
323
  clean.baseExcludes = patch.baseExcludes.filter((p) => typeof p === "string" && p.trim());
316
324
  }
325
+ if (patch.locale !== void 0) {
326
+ const v = String(patch.locale);
327
+ if (v !== "auto" && v !== "zh" && v !== "en") return { ok: false, code: E.RECALL_BAD_TYPE, message: "\u754C\u9762\u8BED\u8A00\u5FC5\u987B\u662F auto / zh / en \u4E4B\u4E00" };
328
+ clean.locale = v;
329
+ }
317
330
  if (!Object.keys(clean).length) return { ok: false, code: E.RECALL_EMPTY_PATCH, message: "\u6CA1\u6709\u53EF\u5199\u5165\u7684\u914D\u7F6E\u5B57\u6BB5" };
318
331
  let settings = null;
319
332
  try {
@@ -378,14 +391,16 @@ function createRoutesManage(deps) {
378
391
  return { ok: true, titles: out };
379
392
  }
380
393
  if (op === "messages") {
381
- const reqs = Array.isArray(args && args.requests) ? args.requests.slice(0, 200) : [];
394
+ const reqsRaw = args && args.requests;
395
+ const reqs = Array.isArray(reqsRaw) ? reqsRaw.slice(0, 200) : [];
382
396
  const bySession = /* @__PURE__ */ new Map();
383
397
  for (const r of reqs) {
384
398
  const sid = r && r.sessionId ? String(r.sessionId) : null;
385
399
  const mid = r && r.messageId ? String(r.messageId) : null;
386
400
  if (!sid || !mid) continue;
387
- if (!bySession.has(sid)) bySession.set(sid, []);
388
- bySession.get(sid).push(mid);
401
+ const midList = bySession.get(sid);
402
+ if (midList) midList.push(mid);
403
+ else bySession.set(sid, [mid]);
389
404
  }
390
405
  const texts = {};
391
406
  await runLimited(Array.from(bySession.entries()).map(([sid, mids]) => async () => {
@@ -492,6 +507,9 @@ function createRoutesManage(deps) {
492
507
  }
493
508
  }
494
509
  if (!store) return { ok: false, code: E.RECALL_NO_SNAPSHOT, message: "\u8BE5\u5FEB\u7167\u4E0D\u5B58\u5728" };
510
+ if (!await snaps.guardStoreFormat(store)) {
511
+ return { ok: false, code: E.RECALL_FORMAT_BLOCKED, message: "\u78C1\u76D8\u683C\u5F0F\u4E0D\u53D7\u652F\u6301\uFF0C\u5DF2\u505C\u6B62\u5199\u5165\uFF1B\u8BE6\u89C1\u300C\u6700\u8FD1\u9519\u8BEF\u300D" };
512
+ }
495
513
  const finalStore = store;
496
514
  const finalRoot = snapRoot;
497
515
  await enqueue(async () => {
@@ -363,6 +363,9 @@ function indexReadCmd(dir) {
363
363
  function lineageReadCmd(dir) {
364
364
  return "cat " + psq(dir + "/lineage.json") + " 2>/dev/null || true";
365
365
  }
366
+ function fileReadCmd(file) {
367
+ return "cat " + psq(file) + " 2>/dev/null || true";
368
+ }
366
369
  function legacyRmScript(path) {
367
370
  return "rm -rf -- " + psq(path);
368
371
  }
@@ -441,6 +444,7 @@ export {
441
444
  ensureGitScript,
442
445
  excludeDumpScript,
443
446
  excludeReadCmd,
447
+ fileReadCmd,
444
448
  fileWriteStdinCmd,
445
449
  gcScript,
446
450
  indexReadCmd,
@@ -420,6 +420,9 @@ function indexReadCmd(dir) {
420
420
  function lineageReadCmd(dir) {
421
421
  return "Get-Content -LiteralPath " + psq(dir + "\\lineage.json") + " -Raw -Encoding UTF8 -ErrorAction SilentlyContinue";
422
422
  }
423
+ function fileReadCmd(file) {
424
+ return "if (Test-Path -LiteralPath " + psq(file) + ") { Get-Content -LiteralPath " + psq(file) + " -Raw -Encoding UTF8 -ErrorAction SilentlyContinue }";
425
+ }
423
426
  function legacyRmScript(path) {
424
427
  return "Remove-Item -Recurse -Force -LiteralPath " + psq(path) + " -ErrorAction SilentlyContinue";
425
428
  }
@@ -525,6 +528,7 @@ export {
525
528
  ensureGitScript,
526
529
  excludeDumpScript,
527
530
  excludeReadCmd,
531
+ fileReadCmd,
528
532
  fileWriteStdinCmd,
529
533
  gcScript,
530
534
  homeDirScript,
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,