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/CHANGELOG.md +16 -0
- package/README.md +14 -14
- package/dist/assets/index-BQ6SUqXr.css +2 -0
- package/dist/assets/{index-fUX3qen0.js → index-QhQbSn0H.js} +1 -1
- package/dist/index.html +2 -2
- package/lib/cli.mjs +5 -0
- package/lib/providers/claude-code/store.mjs +60 -15
- package/lib/providers/codex/database-families.mjs +177 -0
- package/lib/providers/codex/store.mjs +466 -329
- package/lib/server.mjs +8 -3
- package/package.json +2 -1
- package/dist/assets/index-CN9iax_v.css +0 -2
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-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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
|
-
|
|
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
|
|
521
|
+
const unrecognized = [];
|
|
517
522
|
for (const entry of await topLevelEntries(store.paths.claudeHome)) {
|
|
518
|
-
if (!KNOWN_TOP_LEVEL.has(entry.name))
|
|
523
|
+
if (!KNOWN_TOP_LEVEL.has(entry.name)) unrecognized.push(`Unrecognized Claude data: ${entry.name}`);
|
|
519
524
|
}
|
|
520
|
-
if (store.unknown.length)
|
|
521
|
-
if (store.desktop.unlinked.length)
|
|
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:
|
|
533
|
+
builtFor: COMPATIBILITY_PROFILE.builtFor,
|
|
524
534
|
changed: [],
|
|
525
|
-
missing
|
|
526
|
-
|
|
527
|
-
status:
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|