dsh-rewind-plugin 0.6.3 → 0.7.0

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/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // src/index.ts
2
2
  import { createAssistantMessage } from "@deepseek-ai/dsh-llm";
3
- import { unlink } from "node:fs/promises";
3
+ import { unlink as unlink2 } from "node:fs/promises";
4
4
  import * as dshSettings from "@deepseek-ai/dsh-settings";
5
+ import "@deepseek-ai/schemastery";
5
6
 
6
7
  // src/locales.ts
7
8
  var en = {
@@ -33,11 +34,9 @@ var en = {
33
34
  "command.description": "Rewind the conversation back to an earlier user message (optionally restoring files)",
34
35
  "cleanup.description": "Manage automatic cleanup of session snapshot backups",
35
36
  "cleanup.inputHint": "on | off | max-age <days> | run [--apply] [--current]",
36
- "cleanup.status": "Auto-cleanup: {state}. Max age: {days} day(s). Config: {path} ({present}).",
37
+ "cleanup.status": "Auto-cleanup: {state}. Max age: {days} day(s).",
37
38
  "cleanup.enabled": "enabled",
38
39
  "cleanup.disabled": "disabled",
39
- "cleanup.present": "file present",
40
- "cleanup.absent": "no file \u2014 defaults",
41
40
  "cleanup.onOk": "Auto-cleanup enabled.",
42
41
  "cleanup.offOk": "Auto-cleanup disabled \u2014 all snapshots kept.",
43
42
  "cleanup.maxAgeOk": "Auto-cleanup max age set to {days} day(s).",
@@ -83,11 +82,9 @@ var zh = {
83
82
  "command.description": "\u5728\u540C\u7A97\u53E3\u5185\u5C06\u5BF9\u8BDD\u56DE\u9000\u5230\u66F4\u65E9\u7684\u7528\u6237\u6D88\u606F\uFF08\u53EF\u540C\u65F6\u8FD8\u539F\u6587\u4EF6\uFF09",
84
83
  "cleanup.description": "\u7BA1\u7406\u4F1A\u8BDD\u5FEB\u7167\u5907\u4EFD\u7684\u81EA\u52A8\u6E05\u7406",
85
84
  "cleanup.inputHint": "on | off | max-age <\u5929\u6570> | run [--apply] [--current]",
86
- "cleanup.status": "\u81EA\u52A8\u6E05\u7406\uFF1A{state}\u3002\u6700\u5927\u4FDD\u7559\u5929\u6570\uFF1A{days} \u5929\u3002\u914D\u7F6E\uFF1A{path}\uFF08{present}\uFF09\u3002",
85
+ "cleanup.status": "\u81EA\u52A8\u6E05\u7406\uFF1A{state}\u3002\u6700\u5927\u4FDD\u7559\u5929\u6570\uFF1A{days} \u5929\u3002",
87
86
  "cleanup.enabled": "\u5DF2\u5F00\u542F",
88
87
  "cleanup.disabled": "\u5DF2\u5173\u95ED",
89
- "cleanup.present": "\u5B58\u5728\u914D\u7F6E\u6587\u4EF6",
90
- "cleanup.absent": "\u65E0\u6587\u4EF6\u2014\u2014\u4F7F\u7528\u9ED8\u8BA4\u503C",
91
88
  "cleanup.onOk": "\u5DF2\u5F00\u542F\u81EA\u52A8\u6E05\u7406\u3002",
92
89
  "cleanup.offOk": "\u5DF2\u5173\u95ED\u81EA\u52A8\u6E05\u7406\u2014\u2014\u4FDD\u7559\u5168\u90E8\u5FEB\u7167\u3002",
93
90
  "cleanup.maxAgeOk": "\u5DF2\u5C06\u81EA\u52A8\u6E05\u7406\u7684\u6700\u5927\u4FDD\u7559\u5929\u6570\u8BBE\u4E3A {days} \u5929\u3002",
@@ -1331,16 +1328,49 @@ async function reconcileTracked(store, sessionId, anchorSeq, tracked, probe = de
1331
1328
  }
1332
1329
 
1333
1330
  // src/snapshot-cleanup.ts
1334
- import { mkdir as mkdir2, readFile as readFile2, rename as rename2, writeFile as writeFile2 } from "node:fs/promises";
1331
+ import { mkdir as mkdir2, readFile as readFile2, rename as rename2, unlink, writeFile as writeFile2 } from "node:fs/promises";
1335
1332
  import { dirname as dirname2, join as join2 } from "node:path";
1336
1333
  import { resolveDshHome as resolveDshHome2 } from "@deepseek-ai/dsh-home-paths";
1334
+ import z from "@deepseek-ai/schemastery";
1337
1335
  var CLEANUP_CONFIG_FILENAME = "snapshot-cleanup.json";
1338
- var CLEANUP_CONFIG_ENV = "DSH_SNAPSHOT_CLEANUP_CONFIG";
1339
1336
  var DEFAULT_MAX_AGE_DAYS = 30;
1340
1337
  var DEFAULT_CLEANUP_CONFIG = { enabled: false, maxAgeDays: DEFAULT_MAX_AGE_DAYS };
1338
+ var CLEANUP_SETTINGS_NAMESPACE = "dsh-rewind-snapshot-cleanup";
1339
+ var CleanupConfigSchema = z.object({
1340
+ enabled: z.boolean().default(DEFAULT_CLEANUP_CONFIG.enabled),
1341
+ maxAgeDays: z.number().step(1).min(1).default(DEFAULT_CLEANUP_CONFIG.maxAgeDays)
1342
+ });
1343
+ function settingsCleanupStore(scope) {
1344
+ return {
1345
+ load: () => scope.get(),
1346
+ save: async (next) => {
1347
+ const parsed = parseCleanupConfig({ enabled: next.enabled, maxAgeDays: next.maxAgeDays });
1348
+ if (!parsed.ok) throw new RangeError(parsed.error);
1349
+ await scope.update({ enabled: parsed.config.enabled, maxAgeDays: parsed.config.maxAgeDays });
1350
+ }
1351
+ };
1352
+ }
1353
+ async function migrateLegacyCleanupConfig(legacyPath, scope, log) {
1354
+ const loaded = await loadCleanupConfig(legacyPath);
1355
+ if (!loaded.ok) {
1356
+ log(`[dsh-rewind] legacy snapshot-cleanup config invalid, migrating defaults and removing: ${loaded.error}`);
1357
+ await scope.update({ enabled: false, maxAgeDays: DEFAULT_MAX_AGE_DAYS });
1358
+ await unlink(legacyPath).catch(() => void 0);
1359
+ return true;
1360
+ }
1361
+ if (!loaded.fromFile) return false;
1362
+ if (loaded.config.enabled === DEFAULT_CLEANUP_CONFIG.enabled && loaded.config.maxAgeDays === DEFAULT_CLEANUP_CONFIG.maxAgeDays) {
1363
+ await unlink(legacyPath).catch(() => void 0);
1364
+ return true;
1365
+ }
1366
+ await scope.update({ enabled: loaded.config.enabled, maxAgeDays: loaded.config.maxAgeDays });
1367
+ await unlink(legacyPath).catch(() => void 0);
1368
+ log(`[dsh-rewind] migrated legacy snapshot-cleanup config (enabled=${String(loaded.config.enabled)}, maxAgeDays=${String(loaded.config.maxAgeDays)})`);
1369
+ return true;
1370
+ }
1341
1371
  var AUTO_SWEEP_INTERVAL_MS = 24 * 60 * 60 * 1e3;
1342
1372
  function resolveCleanupConfigPath(dshHome) {
1343
- return process.env[CLEANUP_CONFIG_ENV] ?? join2(resolveDshHome2(dshHome), CLEANUP_CONFIG_FILENAME);
1373
+ return join2(resolveDshHome2(dshHome), CLEANUP_CONFIG_FILENAME);
1344
1374
  }
1345
1375
  var STATE_FILENAME = "snapshot-cleanup-last-sweep.json";
1346
1376
  function resolveCleanupStatePath(dshHome) {
@@ -1363,7 +1393,7 @@ async function saveLastSweepAt(path, ms) {
1363
1393
  }
1364
1394
  async function runAutoCleanupCheck(deps, sessionId) {
1365
1395
  try {
1366
- const loaded = await loadCleanupConfig(deps.configPath);
1396
+ const loaded = await deps.readConfig();
1367
1397
  if (!loaded.ok) {
1368
1398
  deps.log(`[dsh-rewind] snapshot cleanup config invalid; auto-cleanup skipped: ${loaded.error}`);
1369
1399
  return;
@@ -1416,15 +1446,6 @@ async function loadCleanupConfig(path) {
1416
1446
  if (!parsed.ok) return { ok: false, error: parsed.error };
1417
1447
  return { ok: true, config: parsed.config, fromFile: true };
1418
1448
  }
1419
- async function saveCleanupConfig(path, config) {
1420
- if (typeof config.enabled !== "boolean" || !Number.isInteger(config.maxAgeDays) || config.maxAgeDays <= 0) {
1421
- throw new RangeError("invalid cleanup config: enabled must be a boolean and maxAgeDays a positive integer");
1422
- }
1423
- const tmp = `${path}.tmp`;
1424
- await mkdir2(dirname2(path), { recursive: true });
1425
- await writeFile2(tmp, JSON.stringify(config, null, 2), "utf8");
1426
- await rename2(tmp, path);
1427
- }
1428
1449
  function parseCleanupCommand(rawInput) {
1429
1450
  const parts = rawInput.trim().split(/\s+/).filter(Boolean);
1430
1451
  if (parts.length === 0) return { action: "status" };
@@ -1471,6 +1492,7 @@ var inject = ["commands", "tools"];
1471
1492
  var TRACKED_TOOLS = /* @__PURE__ */ new Set(["write", "edit", "str_replace_editor"]);
1472
1493
  var MUTATING_EDITOR_COMMANDS = /* @__PURE__ */ new Set(["create", "str_replace", "insert"]);
1473
1494
  var activeLocale = "en";
1495
+ var cleanupStore;
1474
1496
  function t(key, params) {
1475
1497
  return translate(activeLocale, key, params);
1476
1498
  }
@@ -1700,7 +1722,7 @@ async function executeRewind(ctx, store, fs, invocation, rawTarget, mode, inflig
1700
1722
  }
1701
1723
  let restore = "";
1702
1724
  if (mode === "both") {
1703
- const outcome = await store.restoreAfter(agent.session.id, plan.targetSeq, (path) => unlink(path));
1725
+ const outcome = await store.restoreAfter(agent.session.id, plan.targetSeq, (path) => unlink2(path));
1704
1726
  await syncRestoreObservations(ctx, fs, agent, outcome);
1705
1727
  const parts = [];
1706
1728
  if (outcome.restored.length > 0) parts.push(t("restore.count", { count: outcome.restored.length }));
@@ -1778,11 +1800,21 @@ async function maybeRunAutoCleanup(ctx, store, sessionId, dshHome) {
1778
1800
  autoSweepChecked = true;
1779
1801
  await runAutoCleanupCheck({
1780
1802
  pruner: store,
1781
- configPath: resolveCleanupConfigPath(dshHome),
1803
+ readConfig: () => readCleanupPolicy(),
1782
1804
  statePath: resolveCleanupStatePath(dshHome),
1783
1805
  log: (msg) => ctx.logger.warn(msg)
1784
1806
  }, sessionId);
1785
1807
  }
1808
+ async function readCleanupPolicy() {
1809
+ if (cleanupStore === void 0) {
1810
+ return { ok: false, error: "settings service unavailable; snapshot cleanup policy cannot be read" };
1811
+ }
1812
+ return { ok: true, config: cleanupStore.load() };
1813
+ }
1814
+ async function writeCleanupPolicy(next) {
1815
+ if (cleanupStore === void 0) throw new Error("settings service unavailable; snapshot cleanup policy cannot be written");
1816
+ await cleanupStore.save(next);
1817
+ }
1786
1818
  function formatCleanupReport(report) {
1787
1819
  const key = report.dryRun ? "cleanup.runDry" : "cleanup.runApply";
1788
1820
  const text = t(key, {
@@ -1797,37 +1829,34 @@ ${t("cleanup.skipped", { skipped: report.skippedActive })}` : text;
1797
1829
  async function handleSnapshotCleanup(store, invocation, dshHome, trackedBySession) {
1798
1830
  const parsed = parseCleanupCommand(invocation.rawInput);
1799
1831
  if ("error" in parsed) return { kind: "error", text: t("cleanup.usage") };
1800
- const configPath = resolveCleanupConfigPath(dshHome);
1801
1832
  switch (parsed.action) {
1802
1833
  case "status": {
1803
- const loaded = await loadCleanupConfig(configPath);
1834
+ const loaded = await readCleanupPolicy();
1804
1835
  if (!loaded.ok) return { kind: "error", text: t("cleanup.cfgInvalid", { detail: loaded.error }) };
1805
1836
  return {
1806
1837
  kind: "success",
1807
1838
  text: t("cleanup.status", {
1808
1839
  state: t(loaded.config.enabled ? "cleanup.enabled" : "cleanup.disabled"),
1809
- days: loaded.config.maxAgeDays,
1810
- path: configPath,
1811
- present: t(loaded.fromFile ? "cleanup.present" : "cleanup.absent")
1840
+ days: loaded.config.maxAgeDays
1812
1841
  })
1813
1842
  };
1814
1843
  }
1815
1844
  case "on":
1816
1845
  case "off": {
1817
- const loaded = await loadCleanupConfig(configPath);
1846
+ const loaded = await readCleanupPolicy();
1818
1847
  const next = { ...loaded.ok ? loaded.config : DEFAULT_CLEANUP_CONFIG, enabled: parsed.action === "on" };
1819
1848
  try {
1820
- await saveCleanupConfig(configPath, next);
1849
+ await writeCleanupPolicy(next);
1821
1850
  } catch (error) {
1822
1851
  return { kind: "error", text: t("cleanup.saveFailed", { detail: error instanceof Error ? error.message : String(error) }) };
1823
1852
  }
1824
1853
  return { kind: "success", text: t(parsed.action === "on" ? "cleanup.onOk" : "cleanup.offOk") };
1825
1854
  }
1826
1855
  case "max-age": {
1827
- const loaded = await loadCleanupConfig(configPath);
1856
+ const loaded = await readCleanupPolicy();
1828
1857
  const next = { ...loaded.ok ? loaded.config : DEFAULT_CLEANUP_CONFIG, maxAgeDays: parsed.value };
1829
1858
  try {
1830
- await saveCleanupConfig(configPath, next);
1859
+ await writeCleanupPolicy(next);
1831
1860
  } catch (error) {
1832
1861
  return { kind: "error", text: t("cleanup.saveFailed", { detail: error instanceof Error ? error.message : String(error) }) };
1833
1862
  }
@@ -1838,7 +1867,7 @@ async function handleSnapshotCleanup(store, invocation, dshHome, trackedBySessio
1838
1867
  if (parsed.target === "current") {
1839
1868
  return handleClearCurrent(store, invocation, apply2, trackedBySession);
1840
1869
  }
1841
- const loaded = await loadCleanupConfig(configPath);
1870
+ const loaded = await readCleanupPolicy();
1842
1871
  if (!loaded.ok) return { kind: "error", text: t("cleanup.cfgInvalid", { detail: loaded.error }) };
1843
1872
  try {
1844
1873
  const report = await store.pruneStale({
@@ -1901,6 +1930,15 @@ function apply(ctx, config) {
1901
1930
  if (section?.preference === "zh" || section?.preference === "en") {
1902
1931
  activeLocale = section.preference;
1903
1932
  }
1933
+ const cleanupScope = settingsCtx.settings.register(CLEANUP_SETTINGS_NAMESPACE, CleanupConfigSchema, { base: DEFAULT_CLEANUP_CONFIG });
1934
+ cleanupStore = settingsCleanupStore(cleanupScope);
1935
+ void migrateLegacyCleanupConfig(
1936
+ resolveCleanupConfigPath(dshHome),
1937
+ cleanupScope,
1938
+ (msg) => ctx.logger.warn(msg)
1939
+ ).catch((error) => {
1940
+ ctx.logger.warn(`[dsh-rewind] snapshot cleanup migration failed: ${error instanceof Error ? error.message : String(error)}`);
1941
+ });
1904
1942
  });
1905
1943
  ctx.effect(function* () {
1906
1944
  const rewindHandler = (invocation) => handleRewind(ctx, store, fsService, invocation, inflight);
@@ -25,6 +25,23 @@ export declare const zh: {
25
25
  'popover.impact.delete': string;
26
26
  'popover.confirm': string;
27
27
  'popover.back': string;
28
+ 'cleanup.title': string;
29
+ 'cleanup.desc': string;
30
+ 'cleanup.expand': string;
31
+ 'cleanup.collapse': string;
32
+ 'cleanup.unsaved': string;
33
+ 'cleanup.auto': string;
34
+ 'cleanup.auto.on': string;
35
+ 'cleanup.auto.off': string;
36
+ 'cleanup.maxAge': string;
37
+ 'cleanup.maxAge.hint': string;
38
+ 'cleanup.invalid': string;
39
+ 'cleanup.discard': string;
40
+ 'cleanup.save': string;
41
+ 'cleanup.saving': string;
42
+ 'cleanup.saved': string;
43
+ 'cleanup.saveFailed': string;
44
+ 'cleanup.readonly': string;
28
45
  };
29
46
  /** The rewind namespace key union. */
30
47
  export type RewindKey = keyof typeof zh;
@@ -60,4 +77,21 @@ export declare const en: {
60
77
  'popover.impact.delete': string;
61
78
  'popover.confirm': string;
62
79
  'popover.back': string;
80
+ 'cleanup.title': string;
81
+ 'cleanup.desc': string;
82
+ 'cleanup.expand': string;
83
+ 'cleanup.collapse': string;
84
+ 'cleanup.unsaved': string;
85
+ 'cleanup.auto': string;
86
+ 'cleanup.auto.on': string;
87
+ 'cleanup.auto.off': string;
88
+ 'cleanup.maxAge': string;
89
+ 'cleanup.maxAge.hint': string;
90
+ 'cleanup.invalid': string;
91
+ 'cleanup.discard': string;
92
+ 'cleanup.save': string;
93
+ 'cleanup.saving': string;
94
+ 'cleanup.saved': string;
95
+ 'cleanup.saveFailed': string;
96
+ 'cleanup.readonly': string;
63
97
  };
@@ -72,13 +72,14 @@ export interface RewindBridgeDeps {
72
72
  /** Structural face of the runtime slot service (see the module doc). */
73
73
  export interface SlotsLike {
74
74
  inject(key: string, install: () => () => void): () => void;
75
- register(entry: {
75
+ register<P>(entry: {
76
76
  readonly name: string;
77
- readonly id: string;
78
- readonly order: number;
79
- }, component: (props: {
80
- readonly sessionId: string;
81
- }) => ReactNode): () => void;
77
+ readonly id?: string;
78
+ readonly order?: number;
79
+ readonly key?: string;
80
+ readonly locale?: string;
81
+ readonly inject?: () => P;
82
+ }, component: (props: P) => ReactNode): () => void;
82
83
  }
83
84
  /** Join the text blocks of a user message into one plain preview. */
84
85
  /**
@@ -0,0 +1,76 @@
1
+ /**
2
+ * dsh-rewind client settings card: the "Snapshot cleanup" module under
3
+ * Settings > Plugins > Plugin configuration, drawn as one `settings.plugin.item`
4
+ * card (keyed by the host-registered settings namespace).
5
+ *
6
+ * The card edits exactly two knobs — `enabled` (auto-cleanup switch) and
7
+ * `maxAgeDays` (idle cutoff, a positive integer) — and stages them exactly like
8
+ * the host-side /snapshot-auto-cleanup command does, so the GUI and the command
9
+ * can never disagree. The switch collapses/expands the max-age editor; a
10
+ * non-positive/non-integer draft blocks save (the same single validator the
11
+ * host schema enforces). "Discard changes" restores the last-read baseline.
12
+ *
13
+ * It neither imports the client settings typed contract nor depends on the
14
+ * alpha-only `mutate` write API: it reads `getSnapshot().value` and writes via
15
+ * the `set(field, value)` method present on both rc.2 and alpha, and the card
16
+ * receives a tiny structural `CleanupCardApi` supplied by `src/client/index.ts`
17
+ * so the component stays harness-agnostic and unit-testable in isolation.
18
+ *
19
+ * @module dsh-rewind/client/settings-card
20
+ */
21
+ /**
22
+ * The dsh-settings namespace the card binds to. Duplicated here (not imported
23
+ * from the host module) because the client build must stay free of host/node
24
+ * imports; a cross-config test pins it equal to the host's constant. The
25
+ * settings grammar forbids dots, so this is hyphenated.
26
+ */
27
+ export declare const CLEANUP_SETTINGS_NAMESPACE = "dsh-rewind-snapshot-cleanup";
28
+ /** The defaults the host uses; shown as the field placeholder until a draft. */
29
+ export declare const DEFAULT_MAX_AGE_DAYS = 30;
30
+ /** The two editable knobs, exactly as the host policy exposes them. */
31
+ export interface CleanupPolicy {
32
+ readonly enabled: boolean;
33
+ readonly maxAgeDays: number;
34
+ }
35
+ /** A staged draft: the switch state and the raw (unparsed) max-age text. */
36
+ export interface CleanupDraft {
37
+ readonly enabled: boolean;
38
+ readonly maxAgeDays: string;
39
+ }
40
+ /** The structural api the card reads/saves through (supplied by the client). */
41
+ export interface CleanupCardApi {
42
+ /** Read the resolved policy; `undefined` while the describe mirror loads. */
43
+ read(): CleanupPolicy | undefined;
44
+ /** Whether the settings source accepts writes (false = read-only card). */
45
+ writable(): boolean;
46
+ /** Persist a validated policy; rejects on failure. */
47
+ save(next: CleanupPolicy): Promise<void>;
48
+ /** Optional change subscription (returns the disposer). */
49
+ subscribe(cb: () => void): () => void;
50
+ }
51
+ /** Translate one client dictionary key (the card's `t`). */
52
+ export type CardTranslate = (key: string, params?: Record<string, string | number>) => string;
53
+ /** Load a draft from a policy (defaults when the view has not loaded). */
54
+ export declare function draftFrom(policy: CleanupPolicy | undefined): CleanupDraft;
55
+ /** Parse the max-age text: a strict positive integer, else `null`. */
56
+ export declare function maxAgeOf(text: string): number | null;
57
+ /**
58
+ * The policy a draft resolves to, or `null` when the max-age draft is invalid
59
+ * (which blocks save). `enabled` is always a boolean from the switch, and
60
+ * `maxAgeDays` comes from the validated draft.
61
+ */
62
+ export declare function configOf(draft: CleanupDraft): CleanupPolicy | null;
63
+ /** True when the draft differs from the baseline (an unsaved edit). */
64
+ export declare function dirtyOf(base: CleanupDraft, draft: CleanupDraft): boolean;
65
+ /**
66
+ * The card body. Draws the switch (+ collapse), the max-age editor, and the
67
+ * discard/save actions. Pure of host wiring: everything goes through the
68
+ * supplied {@link CleanupCardApi}.
69
+ * @param api - the read/write transport.
70
+ * @param t - the client dictionary translator.
71
+ * @returns the card element.
72
+ */
73
+ export declare function SettingsCleanupCard({ api, t }: {
74
+ api: CleanupCardApi;
75
+ t: CardTranslate;
76
+ }): import("react").JSX.Element;
@@ -23,4 +23,4 @@ export declare const CLASS: {
23
23
  /** The ↶ glyph, drawn inline so the bundle stays dependency-free. */
24
24
  export declare const REWIND_ICON_SVG: string;
25
25
  /** One injected stylesheet (scoped under `.dsh-rewind-*`). */
26
- export declare const STYLE = "\n.dsh-rewind-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n padding: 6px;\n border: none;\n border-radius: 28px;\n background: transparent;\n color: var(--dsw-alias-label-tertiary);\n cursor: pointer;\n}\n.dsh-rewind-btn:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n color: var(--dsw-alias-label-secondary);\n}\n\n.dsh-rewind-popover {\n position: fixed;\n z-index: 1000;\n width: 288px;\n padding: 12px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 12px;\n background: var(--dsw-specific-menu, var(--dsw-alias-bg-layer-3));\n box-shadow: var(--dsw-shadow-lv3);\n font-size: 14px;\n line-height: 20px;\n color: var(--dsw-alias-label-primary);\n}\n.dsh-rewind-popover-title {\n font-size: 14px;\n font-weight: 600;\n line-height: 20px;\n}\n.dsh-rewind-popover-target {\n margin: 4px 0 10px;\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-tertiary);\n word-break: break-all;\n}\n.dsh-rewind-popover-option {\n display: flex;\n flex-direction: column;\n gap: 2px;\n width: 100%;\n margin: 0 0 6px;\n padding: 8px 10px;\n border: 1px solid transparent;\n border-radius: 8px;\n background: transparent;\n color: inherit;\n font: inherit;\n text-align: left;\n cursor: pointer;\n}\n.dsh-rewind-popover-option:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n.dsh-rewind-popover-option:disabled {\n opacity: 0.5;\n cursor: default;\n}\n.dsh-rewind-popover-option-label {\n font-weight: 500;\n}\n.dsh-rewind-popover-option-hint {\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-tertiary);\n}\n.dsh-rewind-popover-impact {\n margin: 4px 0 10px;\n padding: 8px 10px;\n border-radius: 8px;\n background: var(--dsw-alias-interactive-bg-hover);\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-secondary);\n white-space: pre-wrap;\n max-height: 160px;\n overflow: auto;\n}\n.dsh-rewind-popover-actions {\n display: flex;\n justify-content: flex-end;\n gap: 8px;\n}\n.dsh-rewind-popover-primary,\n.dsh-rewind-popover-ghost {\n padding: 5px 12px;\n border: none;\n border-radius: 8px;\n font: inherit;\n font-size: 13px;\n line-height: 18px;\n cursor: pointer;\n}\n.dsh-rewind-popover-primary {\n background: var(--dsw-alias-button-primary-fill);\n color: var(--dsw-alias-label-primary-foreground);\n}\n.dsh-rewind-popover-primary:hover:not(:disabled) {\n background: var(--dsw-alias-button-primary-hover);\n}\n.dsh-rewind-popover-primary:disabled {\n opacity: 0.5;\n cursor: default;\n}\n.dsh-rewind-popover-ghost {\n background: transparent;\n color: var(--dsw-alias-label-secondary);\n}\n.dsh-rewind-popover-ghost:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\n.dsh-rewind-guard-hint {\n position: fixed;\n z-index: 1000;\n max-width: min(440px, calc(100vw - 24px));\n padding: 8px 12px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 10px;\n background: var(--dsw-specific-menu, var(--dsw-alias-bg-layer-3));\n box-shadow: var(--dsw-shadow-lv3);\n font-size: 13px;\n line-height: 18px;\n color: var(--dsw-alias-label-primary);\n pointer-events: none;\n}\n";
26
+ export declare const STYLE = "\n.dsh-rewind-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n padding: 6px;\n border: none;\n border-radius: 28px;\n background: transparent;\n color: var(--dsw-alias-label-tertiary);\n cursor: pointer;\n}\n.dsh-rewind-btn:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n color: var(--dsw-alias-label-secondary);\n}\n\n.dsh-rewind-popover {\n position: fixed;\n z-index: 1000;\n width: 288px;\n padding: 12px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 12px;\n background: var(--dsw-specific-menu, var(--dsw-alias-bg-layer-3));\n box-shadow: var(--dsw-shadow-lv3);\n font-size: 14px;\n line-height: 20px;\n color: var(--dsw-alias-label-primary);\n}\n.dsh-rewind-popover-title {\n font-size: 14px;\n font-weight: 600;\n line-height: 20px;\n}\n.dsh-rewind-popover-target {\n margin: 4px 0 10px;\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-tertiary);\n word-break: break-all;\n}\n.dsh-rewind-popover-option {\n display: flex;\n flex-direction: column;\n gap: 2px;\n width: 100%;\n margin: 0 0 6px;\n padding: 8px 10px;\n border: 1px solid transparent;\n border-radius: 8px;\n background: transparent;\n color: inherit;\n font: inherit;\n text-align: left;\n cursor: pointer;\n}\n.dsh-rewind-popover-option:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n.dsh-rewind-popover-option:disabled {\n opacity: 0.5;\n cursor: default;\n}\n.dsh-rewind-popover-option-label {\n font-weight: 500;\n}\n.dsh-rewind-popover-option-hint {\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-tertiary);\n}\n.dsh-rewind-popover-impact {\n margin: 4px 0 10px;\n padding: 8px 10px;\n border-radius: 8px;\n background: var(--dsw-alias-interactive-bg-hover);\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-secondary);\n white-space: pre-wrap;\n max-height: 160px;\n overflow: auto;\n}\n.dsh-rewind-popover-actions {\n display: flex;\n justify-content: flex-end;\n gap: 8px;\n}\n.dsh-rewind-popover-primary,\n.dsh-rewind-popover-ghost {\n padding: 5px 12px;\n border: none;\n border-radius: 8px;\n font: inherit;\n font-size: 13px;\n line-height: 18px;\n cursor: pointer;\n}\n.dsh-rewind-popover-primary {\n background: var(--dsw-alias-button-primary-fill);\n color: var(--dsw-alias-label-primary-foreground);\n}\n.dsh-rewind-popover-primary:hover:not(:disabled) {\n background: var(--dsw-alias-button-primary-hover);\n}\n.dsh-rewind-popover-primary:disabled {\n opacity: 0.5;\n cursor: default;\n}\n.dsh-rewind-popover-ghost {\n background: transparent;\n color: var(--dsw-alias-label-secondary);\n}\n.dsh-rewind-popover-ghost:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\n.dsh-rewind-guard-hint {\n position: fixed;\n z-index: 1000;\n max-width: min(440px, calc(100vw - 24px));\n padding: 8px 12px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 10px;\n background: var(--dsw-specific-menu, var(--dsw-alias-bg-layer-3));\n box-shadow: var(--dsw-shadow-lv3);\n font-size: 13px;\n line-height: 18px;\n color: var(--dsw-alias-label-primary);\n pointer-events: none;\n}\n\n/* ---- Snapshot-cleanup settings card (mirrors the harness PluginCard look) ---- */\n.dsh-rewind-cleanup-card {\n list-style: none;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 12px;\n background: var(--dsw-alias-bg-layer-3);\n transition: border-color .16s, background .16s;\n}\n.dsh-rewind-cleanup-card:hover {\n border-color: var(--dsw-alias-label-dimmed);\n}\n.dsh-rewind-cleanup-card-open {\n background: var(--dsw-alias-bg-layer-2);\n border-color: var(--dsw-alias-label-dimmed);\n}\n.dsh-rewind-cleanup-header {\n width: 100%;\n appearance: none;\n border: 0;\n background: none;\n font: inherit;\n color: inherit;\n text-align: left;\n cursor: pointer;\n display: flex;\n align-items: center;\n gap: 12px;\n padding: 14px 16px;\n border-radius: 12px;\n}\n.dsh-rewind-cleanup-header:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: -2px;\n}\n.dsh-rewind-cleanup-head-text {\n flex: 1;\n min-width: 0;\n display: flex;\n flex-direction: column;\n gap: 4px;\n}\n.dsh-rewind-cleanup-name {\n font-size: 15px;\n font-weight: 600;\n line-height: 1.4;\n color: var(--dsw-alias-label-primary);\n}\n.dsh-rewind-cleanup-desc {\n font-size: 13px;\n line-height: 1.5;\n color: var(--dsw-alias-label-tertiary);\n}\n.dsh-rewind-cleanup-chevron {\n flex: none;\n color: var(--dsw-alias-label-tertiary);\n transition: transform .16s;\n}\n.dsh-rewind-cleanup-chevron-open {\n transform: rotate(180deg);\n}\n.dsh-rewind-cleanup-pending {\n flex: none;\n border-radius: 999px;\n padding: 1px 8px;\n font-size: 11px;\n line-height: 17px;\n font-weight: 500;\n white-space: nowrap;\n background: var(--dsw-alias-bg-module-platform);\n color: var(--dsw-alias-label-secondary);\n}\n.dsh-rewind-cleanup-body {\n border-top: 1px solid var(--dsw-alias-border-l2);\n margin: 0 16px;\n padding: 4px 0 8px;\n}\n.dsh-rewind-cleanup-readonly {\n margin: 12px 0 0;\n font-size: 12px;\n line-height: 1.5;\n color: var(--dsw-alias-label-tertiary);\n}\n.dsh-rewind-cleanup-permission {\n display: grid;\n gap: 6px;\n padding: 12px 0;\n}\n.dsh-rewind-cleanup-field {\n display: flex;\n flex-direction: column;\n gap: 6px;\n padding: 12px 0;\n}\n.dsh-rewind-cleanup-field + .dsh-rewind-cleanup-field {\n border-top: 1px solid var(--dsw-alias-border-l2);\n}\n.dsh-rewind-cleanup-head {\n display: flex;\n align-items: center;\n gap: 8px;\n}\n.dsh-rewind-cleanup-label {\n flex: 1;\n min-width: 0;\n font-size: 13px;\n font-weight: 500;\n line-height: 1.5;\n color: var(--dsw-alias-label-primary);\n}\n.dsh-rewind-cleanup-hint {\n margin: 0;\n font-size: 12px;\n line-height: 1.5;\n color: var(--dsw-alias-label-tertiary);\n}\n.dsh-rewind-cleanup-error {\n margin: 0;\n font-size: 12px;\n line-height: 1.5;\n color: var(--dsw-alias-label-error);\n}\n/* Switch row: label left, role=switch button right, hint below (Subagent module). */\n.dsh-rewind-cleanup-toggle-row {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 16px;\n font-size: 13px;\n line-height: 1.5;\n color: var(--dsw-alias-label-primary);\n}\n.dsh-rewind-cleanup-toggle-label {\n flex: 1;\n min-width: 0;\n}\n.dsh-rewind-cleanup-switch {\n box-sizing: border-box;\n position: relative;\n flex: 0 0 auto;\n width: 36px;\n height: 20px;\n padding: 2px;\n border: 0;\n border-radius: 10px;\n background: var(--dsw-alias-border-l3);\n cursor: pointer;\n}\n.dsh-rewind-cleanup-switch-on {\n background: var(--dsw-alias-brand-primary);\n}\n.dsh-rewind-cleanup-switch:disabled {\n cursor: default;\n opacity: 0.5;\n}\n.dsh-rewind-cleanup-switch:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: 2px;\n}\n.dsh-rewind-cleanup-thumb {\n display: block;\n width: 16px;\n height: 16px;\n border-radius: 50%;\n background: var(--dsw-alias-label-primary-foreground);\n transition: transform 120ms ease;\n}\n.dsh-rewind-cleanup-switch-on .dsh-rewind-cleanup-thumb {\n transform: translateX(16px);\n}\n.dsh-rewind-cleanup-input {\n box-sizing: border-box;\n height: 34px;\n padding: 0 12px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 8px;\n background: var(--dsw-alias-bg-layer-3);\n font: inherit;\n font-size: 13px;\n line-height: 1.5;\n color: var(--dsw-alias-label-primary);\n}\n.dsh-rewind-cleanup-input:focus-visible {\n outline: none;\n border-color: var(--dsw-alias-brand-primary);\n}\n.dsh-rewind-cleanup-input:disabled {\n color: var(--dsw-alias-label-tertiary);\n cursor: default;\n}\n.dsh-rewind-cleanup-input-invalid {\n border-color: var(--dsw-alias-label-error);\n}\n.dsh-rewind-cleanup-footer {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n gap: 8px;\n padding: 12px 0 4px;\n border-top: 1px solid var(--dsw-alias-border-l2);\n}\n.dsh-rewind-cleanup-failed {\n flex: 1;\n min-width: 0;\n margin: 0;\n font-size: 12px;\n line-height: 1.5;\n color: var(--dsw-alias-label-error);\n}\n.dsh-rewind-cleanup-discard,\n.dsh-rewind-cleanup-save {\n appearance: none;\n border: 1px solid transparent;\n border-radius: 8px;\n padding: 5px 14px;\n font: inherit;\n font-size: 13px;\n line-height: 1.5;\n cursor: pointer;\n}\n.dsh-rewind-cleanup-discard {\n border-color: var(--dsw-alias-border-l2);\n background: none;\n color: var(--dsw-alias-label-secondary);\n}\n.dsh-rewind-cleanup-discard:hover:not(:disabled) {\n color: var(--dsw-alias-label-primary);\n border-color: var(--dsw-alias-label-dimmed);\n}\n.dsh-rewind-cleanup-save {\n background: var(--dsw-alias-label-primary);\n color: var(--dsw-alias-bg-layer-3);\n}\n.dsh-rewind-cleanup-discard:disabled,\n.dsh-rewind-cleanup-save:disabled {\n opacity: 0.4;\n cursor: default;\n}\n.dsh-rewind-cleanup-discard:focus-visible,\n.dsh-rewind-cleanup-save:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: 1px;\n}\n";
@@ -54,8 +54,6 @@ export declare const en: {
54
54
  'cleanup.status': string;
55
55
  'cleanup.enabled': string;
56
56
  'cleanup.disabled': string;
57
- 'cleanup.present': string;
58
- 'cleanup.absent': string;
59
57
  'cleanup.onOk': string;
60
58
  'cleanup.offOk': string;
61
59
  'cleanup.maxAgeOk': string;
@@ -18,25 +18,79 @@
18
18
  *
19
19
  * @module dsh-rewind/snapshot-cleanup
20
20
  */
21
+ import z from '@deepseek-ai/schemastery';
21
22
  /** The cleanup policy, as persisted under `~/.dsh/snapshot-cleanup.json`. */
22
23
  export interface CleanupConfig {
23
24
  readonly enabled: boolean;
24
25
  readonly maxAgeDays: number;
25
26
  }
26
27
  export declare const CLEANUP_CONFIG_FILENAME = "snapshot-cleanup.json";
27
- /** Environment variable overriding the config file path. */
28
- export declare const CLEANUP_CONFIG_ENV = "DSH_SNAPSHOT_CLEANUP_CONFIG";
29
28
  /** The default keep threshold: finished sessions idle > 30 days are pruned. */
30
29
  export declare const DEFAULT_MAX_AGE_DAYS = 30;
31
30
  /** The safe default policy (off) — a missing/corrupt file behaves like this. */
32
31
  export declare const DEFAULT_CLEANUP_CONFIG: CleanupConfig;
32
+ /**
33
+ * The dsh-settings namespace that backs the cleanup policy after migration.
34
+ * Namespaces must match the settings provider's `^[a-z][a-z0-9-]*$` grammar (no
35
+ * dots), so this is hyphenated, not dotted.
36
+ */
37
+ export declare const CLEANUP_SETTINGS_NAMESPACE = "dsh-rewind-snapshot-cleanup";
38
+ /**
39
+ * The schemastery schema that persists + validates the cleanup policy in the
40
+ * dsh-settings document. This is the SINGLE storage validator: the `maxAgeDays`
41
+ * rule is enforced by `.step(1).min(1)` (positive integer) and the defaults by
42
+ * `.default(...)`, so the resolved value is always a valid {@link CleanupConfig}
43
+ * and a bad stored/user value cannot steer the sweep into deleting everything.
44
+ */
45
+ export declare const CleanupConfigSchema: z<CleanupConfig>;
46
+ /**
47
+ * Structural face of the settings scope the host needs for the policy: a
48
+ * resolved read and a validated write. Kept local (never imports the settings
49
+ * contract) so the host bundle links on both rc.2 and alpha — the settings
50
+ * API drift (alpha adds `mutate`, rd2 does not) is confined to the seam the
51
+ * host passes in, never to this module.
52
+ */
53
+ export interface CleanupSettingsScope {
54
+ /** The resolved policy: schema defaults, then base, then the user layer. */
55
+ get(): CleanupConfig;
56
+ /** Merge a partial patch into the user layer (validated by the schema). */
57
+ update(patch: {
58
+ enabled?: boolean;
59
+ maxAgeDays?: number;
60
+ }): Promise<void>;
61
+ }
62
+ /** A validated policy read/write port the command + auto-sweep use. */
63
+ export interface CleanupConfigStore {
64
+ /** The resolved policy (always schema-valid, fail-closes when unavailable). */
65
+ load(): CleanupConfig;
66
+ /** Persist a validated policy, throwing when invalid or unavailable. */
67
+ save(next: CleanupConfig): Promise<void>;
68
+ }
69
+ /**
70
+ * Adapter that turns a {@link CleanupSettingsScope} into a
71
+ * {@link CleanupConfigStore}. Reads come straight from the resolved scope; a
72
+ * write validates via `parseCleanupConfig` before touching the scope, so a bad
73
+ * value can never reach the document (defense-in-depth below the schema).
74
+ */
75
+ export declare function settingsCleanupStore(scope: CleanupSettingsScope): CleanupConfigStore;
76
+ /**
77
+ * One-time migration of the pre-GUI cleanup policy file into the settings
78
+ * document. Idempotent and cheap: it is called on every startup but only does
79
+ * work once — a present-and-parsed legacy file is written into the scope and
80
+ * then deleted, after which the read is an ENOENT no-op. A missing file is a
81
+ * no-op; an invalid file writes the safe default (deleting nothing) and logs.
82
+ * This is the ONLY consumption of {@link loadCleanupConfig} after migration.
83
+ * @returns whether a legacy file was actually migrated.
84
+ */
85
+ export declare function migrateLegacyCleanupConfig(legacyPath: string, scope: CleanupSettingsScope, log: (msg: string) => void): Promise<boolean>;
33
86
  /** Auto-sweep cadence (the user's hardcoded 24h rhythm — not user-set). */
34
87
  export declare const AUTO_SWEEP_INTERVAL_MS: number;
35
88
  /**
36
- * Resolve the config file path (highest first): the `DSH_SNAPSHOT_CLEANUP_CONFIG`
37
- * env override, else `<harness home>/snapshot-cleanup.json` derived from
38
- * `dshHome` (config.dshHome > `$DSH_HOME` > `~/.dsh`) so the plugin follows the
39
- * harness home instead of hardcoding `~/.dsh`.
89
+ * Resolve the LEGACY pre-migration config file path (the only remaining use of
90
+ * the file store): `<harness home>/snapshot-cleanup.json`, derived from
91
+ * `dshHome` (config.dshHome > `$DSH_HOME` > `~/.dsh`) so the migration follows
92
+ * the harness home instead of hardcoding `~/.dsh`. The `DSH_SNAPSHOT_CLEANUP_CONFIG`
93
+ * env override was removed when the policy moved into the dsh-settings document.
40
94
  */
41
95
  export declare function resolveCleanupConfigPath(dshHome?: string): string;
42
96
  /** The state file that records the last automatic-sweep wall-clock time. */
@@ -74,7 +128,13 @@ export interface AutoCleanupPruner {
74
128
  */
75
129
  export declare function runAutoCleanupCheck(deps: {
76
130
  pruner: AutoCleanupPruner;
77
- configPath: string;
131
+ readConfig: () => Promise<{
132
+ ok: true;
133
+ config: CleanupConfig;
134
+ } | {
135
+ ok: false;
136
+ error: string;
137
+ }>;
78
138
  statePath: string;
79
139
  log: (msg: string) => void;
80
140
  }, sessionId: string | undefined): Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-rewind-plugin",
3
- "version": "0.6.3",
3
+ "version": "0.7.0",
4
4
  "description": "DSH 插件:真正便捷无感的同窗口内对话回退,从不新建分支;自带轻量工作区备份,可一并还原文件(完整 Claude Code /rewind 语义)。 · DSH plugin: genuinely effortless in-window conversation rewind — never forking a new session; ships a lightweight workspace backup that restores files together with the rewind (full Claude Code /rewind semantics).",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -54,7 +54,8 @@
54
54
  "@deepseek-ai/dsh-client-locale",
55
55
  "@deepseek-ai/dsh-client-runtime",
56
56
  "@deepseek-ai/dsh-client-ui-commands",
57
- "@deepseek-ai/dsh-client-ui-conversation"
57
+ "@deepseek-ai/dsh-client-ui-conversation",
58
+ "@deepseek-ai/dsh-client-ui-settings"
58
59
  ],
59
60
  "platform": "web"
60
61
  }
@@ -72,9 +73,11 @@
72
73
  },
73
74
  "peerDependencies": {
74
75
  "@deepseek-ai/cordis": "^4.0.1",
76
+ "@deepseek-ai/schemastery": "^3.18.1",
75
77
  "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6 || ^0.1.1-rc.2 || ^0.1.2-alpha.2",
76
78
  "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6 || ^0.1.1-rc.2",
77
79
  "@deepseek-ai/dsh-client-ui-commands": "^0.1.0-rc.6 || ^0.1.1-rc.2 || ^0.1.2-alpha.2",
80
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6 || ^0.1.1-rc.2 || ^0.1.2-alpha.2",
78
81
  "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6 || ^0.1.1-rc.2 || ^0.1.2-alpha.2",
79
82
  "@deepseek-ai/dsh-commands": "^0.1.0-rc.6 || ^0.1.1-rc.2 || ^0.1.2-alpha.2",
80
83
  "@deepseek-ai/dsh-fs": "^0.1.0-rc.6 || ^0.1.1-rc.2 || ^0.1.2-alpha.2",
@@ -89,6 +92,9 @@
89
92
  "@deepseek-ai/cordis": {
90
93
  "optional": true
91
94
  },
95
+ "@deepseek-ai/schemastery": {
96
+ "optional": true
97
+ },
92
98
  "@deepseek-ai/dsh-client-locale": {
93
99
  "optional": true
94
100
  },
@@ -98,6 +104,9 @@
98
104
  "@deepseek-ai/dsh-client-ui-commands": {
99
105
  "optional": true
100
106
  },
107
+ "@deepseek-ai/dsh-client-ui-settings": {
108
+ "optional": true
109
+ },
101
110
  "@deepseek-ai/dsh-client-ui-slots": {
102
111
  "optional": true
103
112
  },
@@ -128,6 +137,7 @@
128
137
  },
129
138
  "devDependencies": {
130
139
  "@deepseek-ai/cordis": "^4.0.1",
140
+ "@deepseek-ai/schemastery": "^3.18.1",
131
141
  "@deepseek-ai/dsh-agent": "^0.1.1-rc.2",
132
142
  "@deepseek-ai/dsh-client-locale": "^0.1.1-rc.2",
133
143
  "@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.2",