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/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,14 +266,18 @@ function apply(ctx, config) {
245
266
  readSettings: () => readSettings(),
246
267
  DEFAULTS,
247
268
  rescueRollback,
248
- E
269
+ intentJournal,
270
+ E,
271
+ // issue #19 撤回终态事件广播:闭包注入保持 ctx 不解构纪律,routes 域
272
+ // 不直接持有 ctx;单测可注入桩断言事件名与 payload
273
+ emitEvent: (event, payload) => ctx.emit(event, payload)
249
274
  };
250
275
  const endpoints = {
251
276
  ...createRoutesCore(deps),
252
277
  ...createRoutesManage(deps)
253
278
  };
254
279
  const handleRecall = async (request) => {
255
- const name2 = new URL(request.url).pathname.replace(/^\/api\/recall\/?/, "").split("/")[0];
280
+ const name2 = new URL(request.url).pathname.replace(/^\/api\/recall\/?/, "").split("/")[0] ?? "";
256
281
  const endpoint = endpoints[name2];
257
282
  if (!endpoint) {
258
283
  return jsonResponse({ ok: false, code: E.RECALL_UNKNOWN_ENDPOINT, message: "unknown endpoint: " + name2 });
@@ -319,10 +344,15 @@ function apply(ctx, config) {
319
344
  }
320
345
  }
321
346
  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(() => {
347
+ 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 () => {
348
+ const st = state.stores.get(cwd);
349
+ if (st && await snaps.guardStoreFormat(st)) await intentJournal.recover(st);
350
+ }).then(() => rt.cleanupLegacy(cwd)).catch(() => {
323
351
  });
324
352
  }
325
- })();
353
+ })().catch((error) => {
354
+ console.error("recall warmup skipped:", error);
355
+ });
326
356
  }
327
357
  export {
328
358
  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 });
@@ -1,8 +1,10 @@
1
1
  import { ENV_HINTS } from "./diagnostics.js";
2
2
  import { parseTreeId } from "./snapshots.js";
3
3
  import { buildArtifactRootSegment, buildRootNotice } from "./exclude-patterns.js";
4
+ const RECALL_EVENT_COMPLETE_HOST = "dsh-recall/complete";
5
+ const RECALL_EVENT_FAILED_HOST = "dsh-recall/failed";
4
6
  function createRoutesCore(deps) {
5
- const { rt, snaps, state, cfg, supported, enqueue, agentBusy, rescueRollback, E } = deps;
7
+ const { rt, snaps, state, cfg, supported, enqueue, agentBusy, rescueRollback, intentJournal, emitEvent, E } = deps;
6
8
  return {
7
9
  "init": async (args) => {
8
10
  if (!supported) {
@@ -17,15 +19,19 @@ function createRoutesCore(deps) {
17
19
  await rt.ensureGit(root, store);
18
20
  await snaps.loadIndex(root, sessionId);
19
21
  await snaps.rebuildOrphans(root, sessionId);
22
+ if (store && await snaps.guardStoreFormat(store)) await intentJournal.recover(store);
20
23
  rt.cleanupLegacy(root);
21
24
  notice = {
22
25
  gitMissing: state.gitExe === "",
23
26
  homeFallback: store ? !store.home : false
24
27
  };
25
28
  const artifactSeg = buildArtifactRootSegment(root, cfg.baseExcludes, rt.isWin);
26
- if (artifactSeg) notice.buildRootNotice = buildRootNotice(artifactSeg);
29
+ if (artifactSeg) {
30
+ notice.buildRootArtifactSeg = artifactSeg;
31
+ notice.buildRootNotice = buildRootNotice(artifactSeg);
32
+ }
27
33
  }
28
- return { ok: Boolean(root), root: root || null, notice, config: { refillDraft: cfg.refillDraft, archiveOriginal: cfg.archiveOriginal } };
34
+ return { ok: Boolean(root), root: root || null, notice, config: { refillDraft: cfg.refillDraft, archiveOriginal: cfg.archiveOriginal, locale: cfg.locale } };
29
35
  },
30
36
  "snapshot-info": async (args) => {
31
37
  const id = args && args.messageId ? String(args.messageId) : "";
@@ -38,7 +44,9 @@ function createRoutesCore(deps) {
38
44
  time: snap ? snap.time : null,
39
45
  id,
40
46
  ...feedback,
41
- notice: artifactSeg ? buildRootNotice(artifactSeg) : void 0
47
+ // A4:段名与中文 notice 并存下发(client 优先段名本地取词,见 init 同处注释)
48
+ notice: artifactSeg ? buildRootNotice(artifactSeg) : void 0,
49
+ artifactSeg: artifactSeg || void 0
42
50
  };
43
51
  },
44
52
  "preview": async (args) => {
@@ -69,6 +77,9 @@ function createRoutesCore(deps) {
69
77
  if (!snap) return { ok: false, code: E.RECALL_NO_SNAPSHOT, message: "\u8BE5\u6D88\u606F\u6CA1\u6709\u53EF\u7528\u7684\u9879\u76EE\u5FEB\u7167" };
70
78
  const store = state.stores.get(snap.root);
71
79
  if (!store) return { ok: false, code: E.RECALL_NO_STORE, message: "\u5FEB\u7167\u5B58\u50A8\u4E0D\u53EF\u7528" };
80
+ if (!await snaps.guardStoreFormat(store)) {
81
+ 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" };
82
+ }
72
83
  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
84
  const previewTreeId = args && typeof args.previewTreeId === "string" && args.previewTreeId ? args.previewTreeId : null;
74
85
  if (!previewTreeId && args && typeof args.previewTotal === "number") {
@@ -90,12 +101,20 @@ function createRoutesCore(deps) {
90
101
  if (previewTreeId && safetyTreeId && safetyTreeId !== previewTreeId) {
91
102
  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
103
  }
104
+ await intentJournal.begin(store, snap.root, { messageId: id, safetyId, safetyOk });
93
105
  const rolled = await snaps.rollbackFor(id);
94
- if (rolled.ok) return rolled;
95
- return rescueRollback(
106
+ if (rolled.ok) {
107
+ await intentJournal.clear(store);
108
+ return rolled;
109
+ }
110
+ await intentJournal.advance(store, "rescue");
111
+ const rescueResult = await rescueRollback(
96
112
  { runShell: rt.runShell, scripts: rt.scripts, gitExe: state.gitExe || "", recordError: rt.recordError },
97
113
  { root: snap.root, store, safetyId, safetyOk, rollbackError: rolled.error }
98
114
  );
115
+ await intentJournal.clear(store);
116
+ if (rescueResult && rescueResult.message) rescueResult.message += "\uFF08\u4E2D\u65AD\u70B9\u8BB0\u5F55\uFF1A" + intentJournal.file(store) + "\uFF09";
117
+ return rescueResult;
99
118
  });
100
119
  if (!result.ok) return result;
101
120
  const cutSeq = await snaps.resolveCutSeq(sessionId, id);
@@ -142,9 +161,73 @@ function createRoutesCore(deps) {
142
161
  if (!store) return { ok: false, code: E.RECALL_NO_STORE, message: "\u5FEB\u7167\u5B58\u50A8\u4E0D\u53EF\u7528" };
143
162
  await snaps.recordLineage(root, childId, parentId);
144
163
  return { ok: true };
164
+ },
165
+ // issue #19:client 撤回终态上报 → cordis 事件广播(dsh-recall/complete
166
+ // | dsh-recall/failed)。不进串行队列(零 git 操作)、不依赖 store——
167
+ // lineage-record 的 NO_STORE 早退在这里是缺陷(原会话已归档时解析不到
168
+ // store 会吞掉事件),端点只做 enrich 与转发。恒 { ok: true }:事件
169
+ // 发送失败不影响撤回主流程(client 侧 fire-and-forget,连响应都不等)。
170
+ "notify": async (args) => {
171
+ const status = args && args.status;
172
+ const sessionId = args && args.sessionId ? String(args.sessionId) : "";
173
+ const messageId = args && args.messageId ? String(args.messageId) : "";
174
+ if (status !== "complete" && status !== "failed" || !sessionId || !messageId) {
175
+ return { ok: false, code: E.RECALL_BAD_TYPE, message: "\u64A4\u56DE\u4E0A\u62A5\u7F3A\u5C11\u5FC5\u8981\u53C2\u6570" };
176
+ }
177
+ const scope = args && args.scope === "session-only" ? "session-only" : "both";
178
+ const cutSeq = args && typeof args.cutSeq === "number" ? args.cutSeq : null;
179
+ let root = null;
180
+ try {
181
+ root = await rt.resolveRoot(sessionId) || null;
182
+ } catch {
183
+ root = null;
184
+ }
185
+ let eventName;
186
+ let payload;
187
+ if (status === "complete") {
188
+ eventName = RECALL_EVENT_COMPLETE_HOST;
189
+ payload = {
190
+ version: 1,
191
+ sessionId,
192
+ childSessionId: args && args.childSessionId ? String(args.childSessionId) : null,
193
+ scope,
194
+ cutSeq,
195
+ messageId,
196
+ root,
197
+ count: args && typeof args.count === "number" ? args.count : 0,
198
+ chatReverted: Boolean(args && args.chatReverted),
199
+ archiveRequested: Boolean(args && args.archiveRequested),
200
+ time: Date.now()
201
+ };
202
+ } else {
203
+ eventName = RECALL_EVENT_FAILED_HOST;
204
+ const code = args && typeof args.code === "string" && args.code ? args.code : void 0;
205
+ payload = {
206
+ version: 1,
207
+ stage: args && args.stage === "fork" ? "fork" : "execute",
208
+ sessionId,
209
+ messageId,
210
+ scope,
211
+ cutSeq,
212
+ root,
213
+ ...code ? { code } : {},
214
+ error: args && typeof args.error === "string" && args.error ? args.error : "unknown recall failure",
215
+ time: Date.now()
216
+ };
217
+ }
218
+ queueMicrotask(() => {
219
+ try {
220
+ emitEvent(eventName, payload);
221
+ } catch (error) {
222
+ rt.recordError("recall event emit failed: " + String(error));
223
+ }
224
+ });
225
+ return { ok: true };
145
226
  }
146
227
  };
147
228
  }
148
229
  export {
230
+ RECALL_EVENT_COMPLETE_HOST,
231
+ RECALL_EVENT_FAILED_HOST,
149
232
  createRoutesCore
150
233
  };
@@ -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,