session-steward 0.3.0 → 0.4.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/dist/index.html CHANGED
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width,initial-scale=1.0"/>
6
6
  <link href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%23171717'/%3E%3Cpath d='M16 4 26 8v7c0 6-4 11-10 13C10 26 6 21 6 15V8Z' fill='%23f5f5f5'/%3E%3Cpath d='m9.5 16 2.2-2.2 3 3 6.5-6.5 2.2 2.2-8.7 8.7Z' fill='%23171717'/%3E%3C/svg%3E" rel="icon"/>
7
7
  <title>Session Steward</title>
8
- <script type="module" crossorigin src="/assets/index-fUX3qen0.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-CN9iax_v.css">
8
+ <script type="module" crossorigin src="/assets/index-QhQbSn0H.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-BQ6SUqXr.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
package/lib/cli.mjs CHANGED
@@ -908,6 +908,11 @@ async function runInteractive(state) {
908
908
  `Skipped ${result.skippedTranscriptPaths.length} missing transcript paths.\n`,
909
909
  );
910
910
  }
911
+ if (result.unrecognizedLocationCount > 0) {
912
+ output.write(
913
+ `${result.unrecognizedLocationCount} ${result.unrecognizedLocationCount === 1 ? "location" : "locations"} in your Claude folder ${result.unrecognizedLocationCount === 1 ? "was" : "were"} not recognized and ${result.unrecognizedLocationCount === 1 ? "was" : "were"} not examined.\n`,
914
+ );
915
+ }
911
916
 
912
917
  state.provider.invalidateSessionCache?.(
913
918
  providerOptions(state.provider.id, state.providerHome),
@@ -10,6 +10,10 @@ import { measurePath } from "../../storage/files.mjs";
10
10
  import { readJsonlEntries, rewriteJsonlFile } from "../../storage/jsonl.mjs";
11
11
 
12
12
  const PROVIDER_ID = "claude-code";
13
+ const COMPATIBILITY_PROFILE = Object.freeze({
14
+ id: "claude-local-store-2026-08",
15
+ builtFor: { claudeCli: ["2.1.199", "2.1.220"], claudeDesktop: ["1.24012.9"] },
16
+ });
13
17
  const SUPPORTED_ENTRYPOINTS = new Set(["cli", "claude-desktop"]);
14
18
  const KNOWN_TOP_LEVEL = new Set([
15
19
  ".DS_Store", ".last-cleanup", ".last-update-result.json", "agents", "backups", "cache", "commands", "debug", "downloads", "file-history", "history.jsonl",
@@ -331,8 +335,9 @@ async function discover(claudeHome, desktopDataHome) {
331
335
  const summariesById = new Map();
332
336
  const unknown = [];
333
337
  let projectDirectories = [];
338
+ let projectsAvailability = "available";
334
339
  try { projectDirectories = await fs.readdir(paths.projectsDirectory, { withFileTypes: true }); } catch (error) {
335
- if (error?.code !== "ENOENT") throw error;
340
+ projectsAvailability = error?.code === "ENOENT" ? "missing" : "unreadable";
336
341
  }
337
342
  for (const projectEntry of projectDirectories) {
338
343
  if (!projectEntry.isDirectory()) continue;
@@ -387,7 +392,7 @@ async function discover(claudeHome, desktopDataHome) {
387
392
  updatedAtMs: Math.max(...copies.map((item) => item.activityAtMs)),
388
393
  });
389
394
  }
390
- return { desktop, paths, records, recordsById: new Map(records.map((record) => [record.id, record])), unknown };
395
+ return { desktop, paths, projectsAvailability, records, recordsById: new Map(records.map((record) => [record.id, record])), unknown };
391
396
  }
392
397
 
393
398
  async function discoverCached(claudeHome, desktopDataHome, { refresh = false } = {}) {
@@ -513,24 +518,31 @@ async function topLevelEntries(directory) {
513
518
 
514
519
  export async function diagnoseStorageCompatibility({ claudeHome, desktopDataHome }) {
515
520
  const store = await discoverCached(claudeHome, desktopDataHome);
516
- const newlyDiscovered = [];
521
+ const unrecognized = [];
517
522
  for (const entry of await topLevelEntries(store.paths.claudeHome)) {
518
- if (!KNOWN_TOP_LEVEL.has(entry.name)) newlyDiscovered.push(`Unrecognized Claude data: ${entry.name}`);
523
+ if (!KNOWN_TOP_LEVEL.has(entry.name)) unrecognized.push(`Unrecognized Claude data: ${entry.name}`);
519
524
  }
520
- if (store.unknown.length) newlyDiscovered.push(`${store.unknown.length} session file${store.unknown.length === 1 ? "" : "s"} could not be classified safely.`);
521
- if (store.desktop.unlinked.length) newlyDiscovered.push(`${store.desktop.unlinked.length} Desktop session record${store.desktop.unlinked.length === 1 ? "" : "s"} could not be linked safely.`);
525
+ if (store.unknown.length) unrecognized.push(`${store.unknown.length} session file${store.unknown.length === 1 ? "" : "s"} could not be classified safely.`);
526
+ if (store.desktop.unlinked.length) unrecognized.push(`${store.desktop.unlinked.length} Desktop session record${store.desktop.unlinked.length === 1 ? "" : "s"} could not be linked safely.`);
527
+ const missing = store.projectsAvailability === "available"
528
+ ? []
529
+ : [store.projectsAvailability === "missing"
530
+ ? "Claude project sessions folder was not found."
531
+ : "Claude project sessions folder could not be read."];
522
532
  return {
523
- builtFor: { claudeCli: ["2.1.199", "2.1.220"], claudeDesktop: ["1.24012.9"] },
533
+ builtFor: COMPATIBILITY_PROFILE.builtFor,
524
534
  changed: [],
525
- missing: (await exists(store.paths.projectsDirectory)) ? [] : ["Claude project sessions folder was not found."],
526
- newlyDiscovered,
527
- status: newlyDiscovered.length ? "newer-version" : "ready",
535
+ missing,
536
+ profileId: COMPATIBILITY_PROFILE.id,
537
+ status: missing.length ? "unsupported" : unrecognized.length ? "partial" : "ready",
538
+ unrecognized,
528
539
  };
529
540
  }
530
541
 
531
542
  export async function assertDeepCleanupSupported(options) {
532
543
  const diagnostic = await diagnoseStorageCompatibility(options);
533
- if (diagnostic.status !== "ready") throw new Error("Thorough cleanup is paused because some Claude session data is not recognized yet. Standard cleanup is still available.");
544
+ if (diagnostic.status === "unsupported") throw new Error("Thorough cleanup is paused because the Claude project sessions folder could not be read.");
545
+ return diagnostic;
534
546
  }
535
547
 
536
548
  async function matchingHistoryStats(historyPath, ids) {
@@ -630,6 +642,8 @@ export async function planSessionDeletion({ recordIds, store }) {
630
642
  transcriptBytes: files.reduce((sum, file) => sum + file.size, 0),
631
643
  transcriptFileCount: files.length,
632
644
  transcriptPaths: [...selectedPaths],
645
+ unrecognizedLocationCount: (await topLevelEntries(store.paths.claudeHome))
646
+ .filter((entry) => !KNOWN_TOP_LEVEL.has(entry.name)).length + store.unknown.length + store.desktop.unlinked.length,
633
647
  };
634
648
  }
635
649
 
@@ -784,7 +798,20 @@ async function createBackup(plan, store, scope) {
784
798
  await backupHistoryRows(store.paths.historyPath, destination, new Set(plan.ids));
785
799
  sharedJsonl.push({ backupRelative, relative: "history.jsonl", root: "claude" });
786
800
  }
787
- const manifest = { createdAt: new Date().toISOString(), entries, providerId: PROVIDER_ID, scope, sharedJsonl, version: 1 };
801
+ const compatibility = await diagnoseStorageCompatibility({
802
+ claudeHome: store.paths.claudeHome,
803
+ desktopDataHome: store.paths.desktopDataHome,
804
+ });
805
+ const manifest = {
806
+ compatibilityStatus: compatibility.status,
807
+ createdAt: new Date().toISOString(),
808
+ entries,
809
+ profileId: COMPATIBILITY_PROFILE.id,
810
+ providerId: PROVIDER_ID,
811
+ scope,
812
+ sharedJsonl,
813
+ version: 2,
814
+ };
788
815
  await fs.writeFile(path.join(backupDirectory, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
789
816
  return backupDirectory;
790
817
  } catch (error) {
@@ -807,7 +834,13 @@ export async function executeSessionDeletion({ onProgress = () => {}, plan, scop
807
834
  if (scope === "deep") targets.push(...plan.deepPaths);
808
835
  for (const target of [...new Set(targets)].sort((a, b) => b.length - a.length)) await fs.rm(target, { force: true, recursive: true });
809
836
  onProgress({ canCancel: false, message: "Checking cleanup", phase: "verification", progress: 90 });
810
- return { backupDirectory, deletedIds: plan.ids, deletedTranscriptPaths: plan.transcriptPaths, skippedTranscriptPaths: [] };
837
+ return {
838
+ backupDirectory,
839
+ deletedIds: plan.ids,
840
+ deletedTranscriptPaths: plan.transcriptPaths,
841
+ skippedTranscriptPaths: [],
842
+ unrecognizedLocationCount: plan.unrecognizedLocationCount,
843
+ };
811
844
  } catch (error) {
812
845
  error.backupDirectory = backupDirectory;
813
846
  throw error;
@@ -863,7 +896,7 @@ export async function listSessionDeletionBackups({ claudeHome }) {
863
896
  measurePath(backupDirectory),
864
897
  ]);
865
898
  const restorable = manifest?.providerId === PROVIDER_ID &&
866
- manifest?.version === 1 &&
899
+ [1, 2].includes(manifest?.version) &&
867
900
  Array.isArray(manifest.entries);
868
901
 
869
902
  backups.push({
@@ -892,6 +925,8 @@ export async function restoreSessionDeletionBackup({ backupDirectory, claudeHome
892
925
  }
893
926
  const manifest = JSON.parse(await fs.readFile(path.join(backupDirectory, "manifest.json"), "utf8"));
894
927
  if (manifest?.providerId !== PROVIDER_ID || !Array.isArray(manifest.entries)) throw new Error("This recovery backup is not valid for Claude Code.");
928
+ invalidateSessionCache({ claudeHome, desktopDataHome });
929
+ const currentCompatibilityBeforeRestore = await diagnoseStorageCompatibility({ claudeHome, desktopDataHome });
895
930
  const safetyBackupDirectory = path.join(store.paths.backupRoot, `restore-safety-${Date.now()}-${randomBytes(4).toString("hex")}`);
896
931
  await fs.mkdir(safetyBackupDirectory, { mode: 0o700, recursive: true });
897
932
  try {
@@ -931,7 +966,17 @@ export async function restoreSessionDeletionBackup({ backupDirectory, claudeHome
931
966
  await pipeline(createReadStream(source), createWriteStream(destination, { flags: "a", mode: 0o600 }));
932
967
  }
933
968
  onProgress({ message: "Checking restored sessions", progress: 92 });
934
- return { recoveryBackupsDeleted: false, restoredEntryCount: manifest.entries.length + (manifest.sharedJsonl?.length ?? 0), safetyBackupDirectory };
969
+ const layoutChanged = manifest.version === 2 && (
970
+ manifest.profileId !== COMPATIBILITY_PROFILE.id
971
+ || manifest.compatibilityStatus !== currentCompatibilityBeforeRestore.status
972
+ );
973
+ invalidateSessionCache({ claudeHome, desktopDataHome });
974
+ return {
975
+ note: layoutChanged ? "The Claude storage layout changed after this backup was created. The recorded files were restored to their original locations." : null,
976
+ recoveryBackupsDeleted: false,
977
+ restoredEntryCount: manifest.entries.length + (manifest.sharedJsonl?.length ?? 0),
978
+ safetyBackupDirectory,
979
+ };
935
980
  } catch (error) {
936
981
  error.safetyBackupDirectory = safetyBackupDirectory;
937
982
  throw error;
@@ -0,0 +1,177 @@
1
+ import { readdirSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { queryRows } from "../../storage/sqlite.mjs";
5
+
6
+ const CACHE_TTL_MS = 2_000;
7
+
8
+ export const CODEX_DATABASE_PROFILE = Object.freeze({
9
+ id: "codex-local-store-2026-08",
10
+ builtFor: {
11
+ chatgptDesktop: ["26.727.40816"],
12
+ codexCli: ["0.144.1", "0.146.0"],
13
+ },
14
+ });
15
+
16
+ const SCHEMA_REQUIREMENTS = Object.freeze({
17
+ state: {
18
+ fallback: "state_5.sqlite",
19
+ pattern: /^state_(\d+)\.sqlite$/u,
20
+ required: true,
21
+ tables: [{ name: "threads", requiredColumns: ["id", "rollout_path"] }],
22
+ },
23
+ logs: {
24
+ fallback: "logs_2.sqlite",
25
+ pattern: /^logs_(\d+)\.sqlite$/u,
26
+ required: false,
27
+ tables: [{ name: "logs", requiredColumns: ["thread_id"] }],
28
+ },
29
+ memories: {
30
+ fallback: "memories_1.sqlite",
31
+ pattern: /^memories_(\d+)\.sqlite$/u,
32
+ required: false,
33
+ tables: [{ name: "stage1_outputs", requiredColumns: ["thread_id"] }],
34
+ },
35
+ goals: {
36
+ fallback: "goals_1.sqlite",
37
+ pattern: /^goals_(\d+)\.sqlite$/u,
38
+ required: false,
39
+ tables: [
40
+ { name: "thread_goals", requiredColumns: ["thread_id"] },
41
+ { name: "thread_goal_continuation_deferrals", requiredColumns: ["thread_id"] },
42
+ ],
43
+ },
44
+ });
45
+
46
+ const resolutionCache = new Map();
47
+
48
+ function inspectTable(databasePath, tableName) {
49
+ const exists = queryRows(
50
+ databasePath,
51
+ "select name from sqlite_master where type = 'table' and name = ?",
52
+ [tableName],
53
+ ).length > 0;
54
+ if (!exists) return { columns: new Set(), exists: false };
55
+ return {
56
+ columns: new Set(
57
+ queryRows(databasePath, "select name from pragma_table_info(?)", [tableName])
58
+ .map((column) => String(column.name)),
59
+ ),
60
+ exists: true,
61
+ };
62
+ }
63
+
64
+ function inspectCandidate(codexHome, filename, version, family) {
65
+ const databasePath = path.join(codexHome, filename);
66
+ try {
67
+ const tables = Object.fromEntries(
68
+ family.tables.map((requirement) => {
69
+ const inspection = inspectTable(databasePath, requirement.name);
70
+ const missingColumns = requirement.requiredColumns.filter(
71
+ (column) => !inspection.columns.has(column),
72
+ );
73
+ return [requirement.name, { ...inspection, missingColumns }];
74
+ }),
75
+ );
76
+ const invalidTable = family.tables.find((requirement) => {
77
+ const table = tables[requirement.name];
78
+ return !table.exists || table.missingColumns.length > 0;
79
+ });
80
+ return {
81
+ filename,
82
+ path: databasePath,
83
+ reason: invalidTable
84
+ ? !tables[invalidTable.name].exists
85
+ ? `Missing table: ${invalidTable.name}`
86
+ : `Missing fields in ${invalidTable.name}: ${tables[invalidTable.name].missingColumns.join(", ")}`
87
+ : null,
88
+ tables,
89
+ valid: !invalidTable,
90
+ version,
91
+ };
92
+ } catch (error) {
93
+ return {
94
+ filename,
95
+ path: databasePath,
96
+ reason: error instanceof Error ? error.message : "Database could not be inspected.",
97
+ tables: {},
98
+ valid: false,
99
+ version,
100
+ };
101
+ }
102
+ }
103
+
104
+ function fallbackResolution(codexHome) {
105
+ return Object.fromEntries(Object.entries(SCHEMA_REQUIREMENTS).map(([name, family]) => [name, {
106
+ invalid: [],
107
+ primary: {
108
+ filename: family.fallback,
109
+ path: path.join(codexHome, family.fallback),
110
+ tables: {},
111
+ valid: true,
112
+ version: Number(family.pattern.exec(family.fallback)?.[1] ?? 0),
113
+ },
114
+ required: family.required,
115
+ secondaries: [],
116
+ }]));
117
+ }
118
+
119
+ function discover(codexHome) {
120
+ let names;
121
+ try {
122
+ names = readdirSync(codexHome, { withFileTypes: true })
123
+ .filter((entry) => entry.isFile())
124
+ .map((entry) => entry.name);
125
+ } catch {
126
+ return { families: fallbackResolution(codexHome), readable: false };
127
+ }
128
+
129
+ const families = {};
130
+ for (const [name, family] of Object.entries(SCHEMA_REQUIREMENTS)) {
131
+ const candidates = names.flatMap((filename) => {
132
+ const match = family.pattern.exec(filename);
133
+ return match ? [{ filename, version: Number(match[1]) }] : [];
134
+ }).sort((left, right) => right.version - left.version || left.filename.localeCompare(right.filename));
135
+ const inspected = candidates.map((candidate) => inspectCandidate(
136
+ codexHome,
137
+ candidate.filename,
138
+ candidate.version,
139
+ family,
140
+ ));
141
+ const valid = inspected.filter((candidate) => candidate.valid);
142
+ families[name] = {
143
+ invalid: inspected.filter((candidate) => !candidate.valid),
144
+ primary: valid[0] ?? null,
145
+ required: family.required,
146
+ secondaries: valid.slice(1),
147
+ };
148
+ }
149
+ return { families, readable: true };
150
+ }
151
+
152
+ export function resolveCodexDatabases(codexHomeInput, { refresh = false } = {}) {
153
+ const codexHome = path.resolve(codexHomeInput);
154
+ const cached = resolutionCache.get(codexHome);
155
+ if (!refresh && cached?.expiresAtMs > Date.now()) return cached.value;
156
+ const value = discover(codexHome);
157
+ resolutionCache.set(codexHome, { expiresAtMs: Date.now() + CACHE_TTL_MS, value });
158
+ return value;
159
+ }
160
+
161
+ export function invalidateCodexDatabaseResolution(codexHomeInput) {
162
+ resolutionCache.delete(path.resolve(codexHomeInput));
163
+ }
164
+
165
+ export function databaseFamilySummary(resolution) {
166
+ return Object.fromEntries(Object.entries(resolution.families).map(([name, family]) => [name, {
167
+ invalid: family.invalid.map(({ filename, reason, version }) => ({ filename, reason, version })),
168
+ primary: family.primary
169
+ ? { filename: family.primary.filename, version: family.primary.version }
170
+ : null,
171
+ secondaries: family.secondaries.map(({ filename, version }) => ({ filename, version })),
172
+ }]));
173
+ }
174
+
175
+ export function allValidDatabases(family) {
176
+ return family.primary ? [family.primary, ...family.secondaries] : [];
177
+ }