session-steward 0.10.1 → 0.10.3
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 +15 -0
- package/lib/providers/claude-code/process-state.mjs +73 -0
- package/lib/providers/claude-code/store.mjs +195 -33
- package/lib/providers/codex/database-families.mjs +24 -3
- package/lib/providers/codex/desktop-catalog.mjs +179 -0
- package/lib/providers/codex/store.mjs +341 -33
- package/lib/storage/jsonl.mjs +2 -1
- package/package.json +2 -1
|
@@ -4,6 +4,7 @@ import { constants as fsConstants, createReadStream } from "node:fs";
|
|
|
4
4
|
import { promises as fs } from "node:fs";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import readline from "node:readline";
|
|
7
|
+
import { createZstdDecompress } from "node:zlib";
|
|
7
8
|
|
|
8
9
|
import { measurePath } from "../../storage/files.mjs";
|
|
9
10
|
import {
|
|
@@ -26,6 +27,12 @@ import {
|
|
|
26
27
|
resolveCodexDatabases,
|
|
27
28
|
} from "./database-families.mjs";
|
|
28
29
|
import { expandHomePath } from "../../platform.mjs";
|
|
30
|
+
import {
|
|
31
|
+
deleteDesktopCatalogs,
|
|
32
|
+
readDesktopCatalogs,
|
|
33
|
+
restoreDesktopCatalogs,
|
|
34
|
+
validateDesktopCatalogBackup,
|
|
35
|
+
} from "./desktop-catalog.mjs";
|
|
29
36
|
|
|
30
37
|
function normalizeText(value) {
|
|
31
38
|
if (typeof value !== "string") {
|
|
@@ -142,7 +149,7 @@ async function* findTranscriptFiles(rootDirectory) {
|
|
|
142
149
|
continue;
|
|
143
150
|
}
|
|
144
151
|
|
|
145
|
-
if (entry.isFile() && /^rollout-.*\.jsonl
|
|
152
|
+
if (entry.isFile() && /^rollout-.*\.jsonl(?:\.zst)?$/u.test(entry.name)) {
|
|
146
153
|
yield resolvedPath;
|
|
147
154
|
}
|
|
148
155
|
}
|
|
@@ -163,13 +170,40 @@ function isContainedPath(rootDirectory, candidatePath) {
|
|
|
163
170
|
return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
|
|
164
171
|
}
|
|
165
172
|
|
|
173
|
+
function createTranscriptTextStream(filePath) {
|
|
174
|
+
const source = createReadStream(filePath);
|
|
175
|
+
|
|
176
|
+
if (!filePath.endsWith(".zst")) {
|
|
177
|
+
source.setEncoding("utf8");
|
|
178
|
+
return { input: source, source };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const input = createZstdDecompress();
|
|
182
|
+
input.setEncoding("utf8");
|
|
183
|
+
source.on("error", (error) => input.destroy(error));
|
|
184
|
+
source.pipe(input);
|
|
185
|
+
return { input, source };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function rolloutIdFromPath(filePath) {
|
|
189
|
+
return path.basename(filePath).match(
|
|
190
|
+
/([0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12})\.jsonl(?:\.zst)?$/iu,
|
|
191
|
+
)?.[1]?.toLowerCase() ?? null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function rolloutSiblingPath(filePath) {
|
|
195
|
+
return filePath.endsWith(".jsonl.zst")
|
|
196
|
+
? filePath.slice(0, -4)
|
|
197
|
+
: filePath.endsWith(".jsonl")
|
|
198
|
+
? `${filePath}.zst`
|
|
199
|
+
: null;
|
|
200
|
+
}
|
|
201
|
+
|
|
166
202
|
async function readFirstLine(filePath) {
|
|
167
|
-
const
|
|
168
|
-
encoding: "utf8",
|
|
169
|
-
});
|
|
203
|
+
const { input, source } = createTranscriptTextStream(filePath);
|
|
170
204
|
const interfaceHandle = readline.createInterface({
|
|
171
205
|
crlfDelay: Infinity,
|
|
172
|
-
input
|
|
206
|
+
input,
|
|
173
207
|
});
|
|
174
208
|
|
|
175
209
|
try {
|
|
@@ -178,7 +212,8 @@ async function readFirstLine(filePath) {
|
|
|
178
212
|
}
|
|
179
213
|
} finally {
|
|
180
214
|
interfaceHandle.close();
|
|
181
|
-
|
|
215
|
+
input.destroy();
|
|
216
|
+
source.destroy();
|
|
182
217
|
}
|
|
183
218
|
|
|
184
219
|
return "";
|
|
@@ -207,19 +242,21 @@ async function parseTranscriptHeader(filePath) {
|
|
|
207
242
|
cwd: payload.cwd ?? "",
|
|
208
243
|
filePath,
|
|
209
244
|
forkedFromId: payload.forked_from_id ?? null,
|
|
245
|
+
historyBaseId: payload.history_base?.thread_id
|
|
246
|
+
? String(payload.history_base.thread_id)
|
|
247
|
+
: null,
|
|
210
248
|
id: payload.id,
|
|
211
249
|
parentThreadId: subagentParentId,
|
|
250
|
+
rolloutId: rolloutIdFromPath(filePath),
|
|
212
251
|
timestampMs: toTimestampMs(payload.timestamp),
|
|
213
252
|
};
|
|
214
253
|
}
|
|
215
254
|
|
|
216
255
|
async function parseTranscriptFallback(filePath) {
|
|
217
|
-
const
|
|
218
|
-
encoding: "utf8",
|
|
219
|
-
});
|
|
256
|
+
const { input, source } = createTranscriptTextStream(filePath);
|
|
220
257
|
const interfaceHandle = readline.createInterface({
|
|
221
258
|
crlfDelay: Infinity,
|
|
222
|
-
input
|
|
259
|
+
input,
|
|
223
260
|
});
|
|
224
261
|
let firstUserMessage = "";
|
|
225
262
|
let latestThreadName = "";
|
|
@@ -261,7 +298,8 @@ async function parseTranscriptFallback(filePath) {
|
|
|
261
298
|
}
|
|
262
299
|
} finally {
|
|
263
300
|
interfaceHandle.close();
|
|
264
|
-
|
|
301
|
+
input.destroy();
|
|
302
|
+
source.destroy();
|
|
265
303
|
}
|
|
266
304
|
|
|
267
305
|
return {
|
|
@@ -603,7 +641,8 @@ export async function loadSessionStore({ codexHome }) {
|
|
|
603
641
|
}
|
|
604
642
|
const threadRows = [...threadRowsById.values()];
|
|
605
643
|
const spawnEdges = [...spawnEdgesByKey.values()];
|
|
606
|
-
const
|
|
644
|
+
const transcriptIndex = await indexTranscripts(paths.transcriptDirectories);
|
|
645
|
+
const transcriptHeaders = transcriptIndex.headersByThreadId;
|
|
607
646
|
|
|
608
647
|
const discoveryIds = new Set(threadRows.map((threadRow) => String(threadRow.id)));
|
|
609
648
|
|
|
@@ -801,6 +840,9 @@ export async function loadSessionStore({ codexHome }) {
|
|
|
801
840
|
threadHistoryDatabasePaths: paths.threadHistoryDatabasePaths,
|
|
802
841
|
goalsDatabasePaths: paths.goalsDatabasePaths,
|
|
803
842
|
transcriptHeaders,
|
|
843
|
+
transcriptPathsByThreadId: transcriptIndex.pathsByThreadId,
|
|
844
|
+
rolloutIdsByThreadId: transcriptIndex.rolloutIdsByThreadId,
|
|
845
|
+
rolloutReferencesById: transcriptIndex.rolloutReferencesById,
|
|
804
846
|
historyPath: paths.historyPath,
|
|
805
847
|
};
|
|
806
848
|
}
|
|
@@ -1257,17 +1299,40 @@ async function getStoreAvailability(paths) {
|
|
|
1257
1299
|
};
|
|
1258
1300
|
}
|
|
1259
1301
|
|
|
1260
|
-
async function
|
|
1261
|
-
const
|
|
1302
|
+
async function indexTranscripts(transcriptDirectories) {
|
|
1303
|
+
const headersByThreadId = new Map();
|
|
1304
|
+
const pathsByThreadId = new Map();
|
|
1305
|
+
const rolloutIdsByThreadId = new Map();
|
|
1306
|
+
const rolloutReferencesById = new Map();
|
|
1262
1307
|
|
|
1263
1308
|
for (const transcriptDirectory of transcriptDirectories) {
|
|
1264
1309
|
for await (const transcriptFile of findTranscriptFiles(transcriptDirectory)) {
|
|
1265
1310
|
try {
|
|
1266
1311
|
const header = await parseTranscriptHeader(transcriptFile);
|
|
1267
|
-
|
|
1312
|
+
if (!header?.id) continue;
|
|
1313
|
+
const threadId = String(header.id);
|
|
1314
|
+
const current = headersByThreadId.get(threadId);
|
|
1315
|
+
|
|
1316
|
+
if (!current || header.filePath > current.filePath) {
|
|
1317
|
+
headersByThreadId.set(threadId, { ...header, id: threadId });
|
|
1318
|
+
}
|
|
1268
1319
|
|
|
1269
|
-
|
|
1270
|
-
|
|
1320
|
+
const transcriptPaths = pathsByThreadId.get(threadId) ?? new Set();
|
|
1321
|
+
transcriptPaths.add(header.filePath);
|
|
1322
|
+
pathsByThreadId.set(threadId, transcriptPaths);
|
|
1323
|
+
|
|
1324
|
+
if (header.rolloutId) {
|
|
1325
|
+
const rolloutIds = rolloutIdsByThreadId.get(threadId) ?? new Set();
|
|
1326
|
+
rolloutIds.add(header.rolloutId);
|
|
1327
|
+
rolloutIdsByThreadId.set(threadId, rolloutIds);
|
|
1328
|
+
|
|
1329
|
+
const existingReference = rolloutReferencesById.get(header.rolloutId);
|
|
1330
|
+
if (!existingReference || (!existingReference.historyBaseId && header.historyBaseId)) {
|
|
1331
|
+
rolloutReferencesById.set(header.rolloutId, {
|
|
1332
|
+
historyBaseId: header.historyBaseId,
|
|
1333
|
+
threadId,
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1271
1336
|
}
|
|
1272
1337
|
} catch {
|
|
1273
1338
|
continue;
|
|
@@ -1275,7 +1340,12 @@ async function indexTranscriptHeaders(transcriptDirectories) {
|
|
|
1275
1340
|
}
|
|
1276
1341
|
}
|
|
1277
1342
|
|
|
1278
|
-
return
|
|
1343
|
+
return {
|
|
1344
|
+
headersByThreadId,
|
|
1345
|
+
pathsByThreadId,
|
|
1346
|
+
rolloutIdsByThreadId,
|
|
1347
|
+
rolloutReferencesById,
|
|
1348
|
+
};
|
|
1279
1349
|
}
|
|
1280
1350
|
|
|
1281
1351
|
function getTranscriptChildrenByParentId(transcriptHeaders) {
|
|
@@ -1322,10 +1392,11 @@ export async function loadDeletionStore({ codexHome, recordIds }) {
|
|
|
1322
1392
|
throw new Error("Select at least one session.");
|
|
1323
1393
|
}
|
|
1324
1394
|
|
|
1325
|
-
const [availability,
|
|
1395
|
+
const [availability, transcriptIndex] = await Promise.all([
|
|
1326
1396
|
getStoreAvailability(paths),
|
|
1327
|
-
|
|
1397
|
+
indexTranscripts(paths.transcriptDirectories),
|
|
1328
1398
|
]);
|
|
1399
|
+
const transcriptHeaders = transcriptIndex.headersByThreadId;
|
|
1329
1400
|
const transcriptChildrenByParentId = getTranscriptChildrenByParentId(transcriptHeaders);
|
|
1330
1401
|
const pendingIds = [...selectedIds];
|
|
1331
1402
|
|
|
@@ -1527,6 +1598,13 @@ export async function loadDeletionStore({ codexHome, recordIds }) {
|
|
|
1527
1598
|
transcriptHeaders: new Map(
|
|
1528
1599
|
[...transcriptHeaders].filter(([id]) => relevantIds.has(id)),
|
|
1529
1600
|
),
|
|
1601
|
+
transcriptPathsByThreadId: new Map(
|
|
1602
|
+
[...transcriptIndex.pathsByThreadId].filter(([id]) => relevantIds.has(id)),
|
|
1603
|
+
),
|
|
1604
|
+
rolloutIdsByThreadId: new Map(
|
|
1605
|
+
[...transcriptIndex.rolloutIdsByThreadId].filter(([id]) => relevantIds.has(id)),
|
|
1606
|
+
),
|
|
1607
|
+
rolloutReferencesById: transcriptIndex.rolloutReferencesById,
|
|
1530
1608
|
};
|
|
1531
1609
|
}
|
|
1532
1610
|
|
|
@@ -1915,6 +1993,11 @@ const THREAD_HISTORY_TABLES_WITH_REALTIME = Object.freeze([
|
|
|
1915
1993
|
"thread_turns",
|
|
1916
1994
|
"thread_history_projection_state",
|
|
1917
1995
|
]);
|
|
1996
|
+
const MEMORY_STAGE1_JOB_KIND = "memory_stage1";
|
|
1997
|
+
const MEMORY_GLOBAL_JOB_KIND = "memory_consolidate_global";
|
|
1998
|
+
const MEMORY_GLOBAL_JOB_KEY = "global";
|
|
1999
|
+
const MEMORY_JOB_RETRY_COUNT = 3;
|
|
2000
|
+
const MEMORY_DELETE_IDS_TABLE = "session_steward_memory_delete_ids";
|
|
1918
2001
|
|
|
1919
2002
|
function threadHistoryTables(databasePath) {
|
|
1920
2003
|
return inspectSqliteTable(databasePath, "thread_realtime_items").exists
|
|
@@ -1922,9 +2005,112 @@ function threadHistoryTables(databasePath) {
|
|
|
1922
2005
|
: REQUIRED_THREAD_HISTORY_TABLES;
|
|
1923
2006
|
}
|
|
1924
2007
|
|
|
2008
|
+
function memorySchema(databasePath) {
|
|
2009
|
+
const jobs = inspectSqliteTable(databasePath, "jobs");
|
|
2010
|
+
const stage1Outputs = inspectSqliteTable(databasePath, "stage1_outputs");
|
|
2011
|
+
|
|
2012
|
+
return {
|
|
2013
|
+
hasJobs: jobs.exists,
|
|
2014
|
+
hasSelectedForPhase2: stage1Outputs.columns?.includes("selected_for_phase2") ?? false,
|
|
2015
|
+
};
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
function countMemoryJobsForIds(databasePath, ids) {
|
|
2019
|
+
if (!memorySchema(databasePath).hasJobs) return 0;
|
|
2020
|
+
return [...batches(ids)].reduce((total, idBatch) => total + Number(queryRows(
|
|
2021
|
+
databasePath,
|
|
2022
|
+
`select count(*) as count from jobs where kind = ? and job_key in (${placeholders(idBatch)})`,
|
|
2023
|
+
[MEMORY_STAGE1_JOB_KIND, ...idBatch],
|
|
2024
|
+
)[0]?.count ?? 0), 0);
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
function findMemoryJobsForIds(databasePath, ids) {
|
|
2028
|
+
if (!memorySchema(databasePath).hasJobs) return [];
|
|
2029
|
+
return [...batches(ids)].flatMap((idBatch) => queryRows(
|
|
2030
|
+
databasePath,
|
|
2031
|
+
`select * from jobs where kind = ? and job_key in (${placeholders(idBatch)})`,
|
|
2032
|
+
[MEMORY_STAGE1_JOB_KIND, ...idBatch],
|
|
2033
|
+
));
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
function memoryConsolidationWatermark(databasePath) {
|
|
2037
|
+
if (!memorySchema(databasePath).hasJobs) return null;
|
|
2038
|
+
const row = queryRows(
|
|
2039
|
+
databasePath,
|
|
2040
|
+
"select input_watermark from jobs where kind = ? and job_key = ? limit 1",
|
|
2041
|
+
[MEMORY_GLOBAL_JOB_KIND, MEMORY_GLOBAL_JOB_KEY],
|
|
2042
|
+
)[0];
|
|
2043
|
+
return row ? Number(row.input_watermark ?? 0) : null;
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
function needsMemoryConsolidation(databasePath, ids) {
|
|
2047
|
+
const schema = memorySchema(databasePath);
|
|
2048
|
+
if (!schema.hasJobs || !schema.hasSelectedForPhase2) return false;
|
|
2049
|
+
|
|
2050
|
+
return [...batches(ids)].some((idBatch) => Number(queryRows(
|
|
2051
|
+
databasePath,
|
|
2052
|
+
`select count(*) as count from stage1_outputs where selected_for_phase2 != 0 and thread_id in (${placeholders(idBatch)})`,
|
|
2053
|
+
idBatch,
|
|
2054
|
+
)[0]?.count ?? 0) > 0);
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
function memoryDeletionStatements(databasePath, ids, inputWatermark) {
|
|
2058
|
+
const schema = memorySchema(databasePath);
|
|
2059
|
+
const statements = [
|
|
2060
|
+
{ sql: `create temp table ${MEMORY_DELETE_IDS_TABLE} (thread_id text primary key) without rowid` },
|
|
2061
|
+
...[...batches(ids)].map((idBatch) => ({
|
|
2062
|
+
parameters: idBatch,
|
|
2063
|
+
sql: `insert into ${MEMORY_DELETE_IDS_TABLE} (thread_id) values ${idBatch.map(() => "(?)").join(", ")}`,
|
|
2064
|
+
})),
|
|
2065
|
+
];
|
|
2066
|
+
|
|
2067
|
+
if (schema.hasJobs && schema.hasSelectedForPhase2) {
|
|
2068
|
+
statements.push({
|
|
2069
|
+
parameters: [
|
|
2070
|
+
MEMORY_GLOBAL_JOB_KIND,
|
|
2071
|
+
MEMORY_GLOBAL_JOB_KEY,
|
|
2072
|
+
MEMORY_JOB_RETRY_COUNT,
|
|
2073
|
+
inputWatermark,
|
|
2074
|
+
],
|
|
2075
|
+
sql: `insert into jobs (
|
|
2076
|
+
kind, job_key, status, worker_id, ownership_token, started_at, finished_at,
|
|
2077
|
+
lease_until, retry_at, retry_remaining, last_error, input_watermark,
|
|
2078
|
+
last_success_watermark
|
|
2079
|
+
)
|
|
2080
|
+
select ?, ?, 'pending', null, null, null, null, null, null, ?, null, ?, 0
|
|
2081
|
+
where exists (
|
|
2082
|
+
select 1 from stage1_outputs
|
|
2083
|
+
where selected_for_phase2 != 0
|
|
2084
|
+
and thread_id in (select thread_id from ${MEMORY_DELETE_IDS_TABLE})
|
|
2085
|
+
)
|
|
2086
|
+
on conflict(kind, job_key) do update set
|
|
2087
|
+
status = case when jobs.status = 'running' then 'running' else 'pending' end,
|
|
2088
|
+
retry_at = case when jobs.status = 'running' then jobs.retry_at else null end,
|
|
2089
|
+
retry_remaining = max(jobs.retry_remaining, excluded.retry_remaining),
|
|
2090
|
+
input_watermark = case
|
|
2091
|
+
when excluded.input_watermark > coalesce(jobs.input_watermark, 0)
|
|
2092
|
+
then excluded.input_watermark
|
|
2093
|
+
else coalesce(jobs.input_watermark, 0) + 1
|
|
2094
|
+
end`,
|
|
2095
|
+
});
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
statements.push({
|
|
2099
|
+
sql: `delete from stage1_outputs where thread_id in (select thread_id from ${MEMORY_DELETE_IDS_TABLE})`,
|
|
2100
|
+
});
|
|
2101
|
+
if (schema.hasJobs) {
|
|
2102
|
+
statements.push({
|
|
2103
|
+
parameters: [MEMORY_STAGE1_JOB_KIND],
|
|
2104
|
+
sql: `delete from jobs where kind = ? and job_key in (select thread_id from ${MEMORY_DELETE_IDS_TABLE})`,
|
|
2105
|
+
});
|
|
2106
|
+
}
|
|
2107
|
+
statements.push({ sql: `drop table ${MEMORY_DELETE_IDS_TABLE}` });
|
|
2108
|
+
return statements;
|
|
2109
|
+
}
|
|
2110
|
+
|
|
1925
2111
|
export async function planSessionDeletion({ recordIds, store }) {
|
|
1926
2112
|
const idsToDelete = new Set();
|
|
1927
|
-
const pendingIds =
|
|
2113
|
+
const pendingIds = recordIds.map(String);
|
|
1928
2114
|
|
|
1929
2115
|
while (pendingIds.length > 0) {
|
|
1930
2116
|
const currentId = pendingIds.shift();
|
|
@@ -1945,9 +2131,55 @@ export async function planSessionDeletion({ recordIds, store }) {
|
|
|
1945
2131
|
.map((id) => store.recordsById.get(id))
|
|
1946
2132
|
.filter(Boolean)
|
|
1947
2133
|
.sort((left, right) => left.displayName.localeCompare(right.displayName));
|
|
1948
|
-
const
|
|
1949
|
-
|
|
1950
|
-
|
|
2134
|
+
const deletionRolloutIds = new Set();
|
|
2135
|
+
|
|
2136
|
+
for (const id of deletionIds) {
|
|
2137
|
+
for (const rolloutId of store.rolloutIdsByThreadId?.get(id) ?? []) {
|
|
2138
|
+
deletionRolloutIds.add(rolloutId);
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
const threadHistoryIds = [...new Set([...deletionIds, ...deletionRolloutIds])];
|
|
2142
|
+
|
|
2143
|
+
const externalHistoryReferences = [];
|
|
2144
|
+
|
|
2145
|
+
for (const [rolloutId, reference] of store.rolloutReferencesById ?? []) {
|
|
2146
|
+
if (
|
|
2147
|
+
reference.historyBaseId &&
|
|
2148
|
+
deletionRolloutIds.has(reference.historyBaseId) &&
|
|
2149
|
+
!deletionRolloutIds.has(rolloutId)
|
|
2150
|
+
) {
|
|
2151
|
+
externalHistoryReferences.push({ rolloutId, ...reference });
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
if (externalHistoryReferences.length > 0) {
|
|
2156
|
+
const error = new Error(
|
|
2157
|
+
"Another Codex session still depends on the selected session history. Select the related session too, or keep both.",
|
|
2158
|
+
);
|
|
2159
|
+
error.code = "CODEX_EXTERNAL_ROLLOUT_REFERENCE";
|
|
2160
|
+
error.references = externalHistoryReferences;
|
|
2161
|
+
throw error;
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
const transcriptPathSet = new Set(deletionIds.flatMap((id) => [
|
|
2165
|
+
...(store.transcriptPathsByThreadId?.get(id) ?? []),
|
|
2166
|
+
store.recordsById.get(id)?.rolloutPath,
|
|
2167
|
+
]).filter(Boolean));
|
|
2168
|
+
const transcriptDirectories = getCodexPaths(store.codexHome).transcriptDirectories;
|
|
2169
|
+
|
|
2170
|
+
for (const transcriptPath of [...transcriptPathSet]) {
|
|
2171
|
+
const siblingPath = rolloutSiblingPath(transcriptPath);
|
|
2172
|
+
if (
|
|
2173
|
+
siblingPath &&
|
|
2174
|
+
rolloutIdFromPath(transcriptPath) &&
|
|
2175
|
+
transcriptDirectories.some((directory) => isContainedPath(directory, transcriptPath)) &&
|
|
2176
|
+
await pathExists(siblingPath)
|
|
2177
|
+
) {
|
|
2178
|
+
transcriptPathSet.add(siblingPath);
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
const transcriptPaths = [...transcriptPathSet].sort();
|
|
1951
2183
|
const missingTranscriptPaths = selectedRecords
|
|
1952
2184
|
.filter((record) => record.rolloutMissing)
|
|
1953
2185
|
.map((record) => record.rolloutPath)
|
|
@@ -2008,8 +2240,27 @@ export async function planSessionDeletion({ recordIds, store }) {
|
|
|
2008
2240
|
: 0), 0);
|
|
2009
2241
|
const logRowCount = (store.logsDatabasePaths ?? [store.logsDatabasePath].filter(Boolean))
|
|
2010
2242
|
.reduce((total, databasePath) => total + countRowsForIds(databasePath, "logs", "thread_id", deletionIds), 0);
|
|
2011
|
-
const
|
|
2012
|
-
|
|
2243
|
+
const memoryDatabasePaths = store.memoryDatabasePaths ?? [store.memoryDatabasePath].filter(Boolean);
|
|
2244
|
+
const memoryOutputRowCount = memoryDatabasePaths.reduce(
|
|
2245
|
+
(total, databasePath) => total + countRowsForIds(
|
|
2246
|
+
databasePath,
|
|
2247
|
+
"stage1_outputs",
|
|
2248
|
+
"thread_id",
|
|
2249
|
+
deletionIds,
|
|
2250
|
+
),
|
|
2251
|
+
0,
|
|
2252
|
+
);
|
|
2253
|
+
const memoryJobRowCount = memoryDatabasePaths.reduce(
|
|
2254
|
+
(total, databasePath) => total + countMemoryJobsForIds(databasePath, deletionIds),
|
|
2255
|
+
0,
|
|
2256
|
+
);
|
|
2257
|
+
const memoryConsolidations = memoryDatabasePaths
|
|
2258
|
+
.filter((databasePath) => needsMemoryConsolidation(databasePath, deletionIds))
|
|
2259
|
+
.map((databasePath) => ({
|
|
2260
|
+
databasePath,
|
|
2261
|
+
inputWatermark: memoryConsolidationWatermark(databasePath),
|
|
2262
|
+
}));
|
|
2263
|
+
const memoryRowCount = memoryOutputRowCount + memoryJobRowCount;
|
|
2013
2264
|
const goalRowCount = (store.goalsDatabasePaths ?? [store.goalsDatabasePath].filter(Boolean))
|
|
2014
2265
|
.reduce((total, databasePath) => total + countRowsForIds(databasePath, "thread_goals", "thread_id", deletionIds), 0);
|
|
2015
2266
|
const queueRowCount = (store.queueDatabasePaths ?? [store.queueDatabasePath].filter(Boolean))
|
|
@@ -2020,7 +2271,7 @@ export async function planSessionDeletion({ recordIds, store }) {
|
|
|
2020
2271
|
: 0), 0);
|
|
2021
2272
|
const threadHistoryRowCount = (store.threadHistoryDatabasePaths ?? [store.threadHistoryDatabasePath].filter(Boolean))
|
|
2022
2273
|
.reduce((total, databasePath) => total + threadHistoryTables(databasePath).reduce((databaseTotal, tableName) =>
|
|
2023
|
-
databaseTotal + countRowsForIds(databasePath, tableName, "thread_id",
|
|
2274
|
+
databaseTotal + countRowsForIds(databasePath, tableName, "thread_id", threadHistoryIds), 0), 0);
|
|
2024
2275
|
const stateTargets = (store.stateDatabases ?? [{ path: store.stateDatabasePath }]).map((database) => ({
|
|
2025
2276
|
database,
|
|
2026
2277
|
ids: findRowsForIds(database.path, "threads", "id", deletionIds).map((row) => String(row.id)),
|
|
@@ -2038,6 +2289,9 @@ export async function planSessionDeletion({ recordIds, store }) {
|
|
|
2038
2289
|
historyMatchCount: historyMatches.count,
|
|
2039
2290
|
ids: deletionIds,
|
|
2040
2291
|
logRowCount,
|
|
2292
|
+
memoryConsolidations,
|
|
2293
|
+
memoryJobRowCount,
|
|
2294
|
+
memoryOutputRowCount,
|
|
2041
2295
|
memoryRowCount,
|
|
2042
2296
|
queueRowCount,
|
|
2043
2297
|
queueRevisionRowCount,
|
|
@@ -2049,6 +2303,7 @@ export async function planSessionDeletion({ recordIds, store }) {
|
|
|
2049
2303
|
stateTargets,
|
|
2050
2304
|
transcriptBytes,
|
|
2051
2305
|
transcriptFileCount,
|
|
2306
|
+
threadHistoryIds,
|
|
2052
2307
|
threadHistoryRowCount,
|
|
2053
2308
|
transcriptPaths,
|
|
2054
2309
|
};
|
|
@@ -2100,6 +2355,7 @@ async function getPathFingerprint(filePath) {
|
|
|
2100
2355
|
export async function fingerprintSessionDeletion({ plan, scope, store }) {
|
|
2101
2356
|
const hash = createHash("sha256");
|
|
2102
2357
|
hash.update(`${store.codexHome}\0${scope}\0`);
|
|
2358
|
+
hash.update(`desktop-catalogs\0${JSON.stringify(readDesktopCatalogs(store.codexHome, plan.ids))}\0`);
|
|
2103
2359
|
|
|
2104
2360
|
const counts = [
|
|
2105
2361
|
plan.dynamicToolRowCount,
|
|
@@ -2164,12 +2420,31 @@ export async function fingerprintSessionDeletion({ plan, scope, store }) {
|
|
|
2164
2420
|
}
|
|
2165
2421
|
for (const databasePath of store.threadHistoryDatabasePaths ?? [store.threadHistoryDatabasePath].filter(Boolean)) {
|
|
2166
2422
|
for (const tableName of threadHistoryTables(databasePath)) {
|
|
2167
|
-
fingerprintRowsForIds(
|
|
2423
|
+
fingerprintRowsForIds(
|
|
2424
|
+
hash,
|
|
2425
|
+
databasePath,
|
|
2426
|
+
tableName,
|
|
2427
|
+
"thread_id",
|
|
2428
|
+
plan.threadHistoryIds ?? plan.ids,
|
|
2429
|
+
);
|
|
2168
2430
|
}
|
|
2169
2431
|
}
|
|
2170
2432
|
if (scope === "deep") {
|
|
2171
2433
|
for (const databasePath of store.memoryDatabasePaths ?? [store.memoryDatabasePath].filter(Boolean)) {
|
|
2172
2434
|
fingerprintRowsForIds(hash, databasePath, "stage1_outputs", "thread_id", plan.ids);
|
|
2435
|
+
if (memorySchema(databasePath).hasJobs) {
|
|
2436
|
+
const jobKeys = (plan.memoryConsolidations ?? [])
|
|
2437
|
+
.some((consolidation) => consolidation.databasePath === databasePath)
|
|
2438
|
+
? [...plan.ids, MEMORY_GLOBAL_JOB_KEY]
|
|
2439
|
+
: plan.ids;
|
|
2440
|
+
fingerprintRowsForIds(
|
|
2441
|
+
hash,
|
|
2442
|
+
databasePath,
|
|
2443
|
+
"jobs",
|
|
2444
|
+
"job_key",
|
|
2445
|
+
jobKeys,
|
|
2446
|
+
);
|
|
2447
|
+
}
|
|
2173
2448
|
}
|
|
2174
2449
|
for (const databasePath of store.goalsDatabasePaths ?? [store.goalsDatabasePath].filter(Boolean)) {
|
|
2175
2450
|
fingerprintRowsForIds(hash, databasePath, "thread_goals", "thread_id", plan.ids);
|
|
@@ -2199,7 +2474,7 @@ export async function fingerprintSessionDeletion({ plan, scope, store }) {
|
|
|
2199
2474
|
}
|
|
2200
2475
|
|
|
2201
2476
|
async function estimateBackupBytes({ plan, scope, store }) {
|
|
2202
|
-
let sourceBytes =
|
|
2477
|
+
let sourceBytes = Buffer.byteLength(JSON.stringify(readDesktopCatalogs(store.codexHome, plan.ids), null, 2));
|
|
2203
2478
|
const uniquePaths = new Set(getBackupSourcePaths({ plan, scope, store }));
|
|
2204
2479
|
|
|
2205
2480
|
for (const sourcePath of uniquePaths) {
|
|
@@ -2417,6 +2692,8 @@ function formatBytes(bytes) {
|
|
|
2417
2692
|
}
|
|
2418
2693
|
|
|
2419
2694
|
export async function preflightSessionDeletion({ availableDiskBytes, plan, scope = "deep", store }) {
|
|
2695
|
+
// Fail on an unknown app catalogue before backing up or mutating sessions.
|
|
2696
|
+
readDesktopCatalogs(store.codexHome, plan.ids);
|
|
2420
2697
|
const requiredPaths = [store.stateDatabasePath, store.sessionIndexPath, store.historyPath];
|
|
2421
2698
|
const missingRequiredPaths = [];
|
|
2422
2699
|
|
|
@@ -2597,6 +2874,7 @@ async function createOperationBackup({ onProgress, plan, scope, store }) {
|
|
|
2597
2874
|
files,
|
|
2598
2875
|
profileId: CODEX_DATABASE_PROFILE.id,
|
|
2599
2876
|
resolvedDatabases: store.resolvedDatabases ?? {},
|
|
2877
|
+
desktopCatalogs: readDesktopCatalogs(store.codexHome, plan.ids),
|
|
2600
2878
|
compatibilityStatus: (await diagnoseStorageCompatibility({ codexHome: store.codexHome })).status,
|
|
2601
2879
|
}, null, 2)}\n`,
|
|
2602
2880
|
);
|
|
@@ -2673,9 +2951,14 @@ export async function executeSessionDeletion({
|
|
|
2673
2951
|
});
|
|
2674
2952
|
|
|
2675
2953
|
try {
|
|
2954
|
+
const operation = await readJsonFile(path.join(backupDirectory, "operation.json"));
|
|
2955
|
+
deleteDesktopCatalogs(store.codexHome, plan.ids, operation.desktopCatalogs);
|
|
2676
2956
|
if (scope === "deep" && store.hasMemoryDatabase) {
|
|
2677
2957
|
for (const databasePath of store.memoryDatabasePaths ?? [store.memoryDatabasePath]) {
|
|
2678
|
-
executeTransaction(
|
|
2958
|
+
executeTransaction(
|
|
2959
|
+
databasePath,
|
|
2960
|
+
memoryDeletionStatements(databasePath, plan.ids, Math.floor(Date.now() / 1_000)),
|
|
2961
|
+
);
|
|
2679
2962
|
}
|
|
2680
2963
|
}
|
|
2681
2964
|
if (scope === "deep" && store.hasGoalsDatabase) {
|
|
@@ -2705,7 +2988,7 @@ export async function executeSessionDeletion({
|
|
|
2705
2988
|
if (store.hasThreadHistoryDatabase) {
|
|
2706
2989
|
for (const databasePath of store.threadHistoryDatabasePaths ?? [store.threadHistoryDatabasePath]) {
|
|
2707
2990
|
executeTransaction(databasePath, threadHistoryTables(databasePath).flatMap((tableName) =>
|
|
2708
|
-
[...deleteStatements(tableName, "thread_id", plan.ids)]));
|
|
2991
|
+
[...deleteStatements(tableName, "thread_id", plan.threadHistoryIds ?? plan.ids)]));
|
|
2709
2992
|
}
|
|
2710
2993
|
}
|
|
2711
2994
|
for (const { database, ids } of plan.stateTargets ?? (store.stateDatabases ?? [{ path: store.stateDatabasePath }]).map((database) => ({ database, ids: plan.ids }))) {
|
|
@@ -2917,6 +3200,8 @@ export async function restoreSessionDeletionBackup({
|
|
|
2917
3200
|
throw new Error("This backup cannot be restored automatically. Its files are still available for manual recovery.");
|
|
2918
3201
|
}
|
|
2919
3202
|
const currentDatabasesBeforeRestore = getCodexPaths(codexHome, { refresh: true }).resolvedDatabases;
|
|
3203
|
+
const desktopCatalogs = operation.desktopCatalogs ?? [];
|
|
3204
|
+
validateDesktopCatalogBackup(codexHome, operation.ids ?? [], desktopCatalogs);
|
|
2920
3205
|
|
|
2921
3206
|
const files = operation.files.map((entry) => {
|
|
2922
3207
|
if (typeof entry?.backupPath !== "string" || typeof entry?.originalPath !== "string") {
|
|
@@ -3022,6 +3307,8 @@ export async function restoreSessionDeletionBackup({
|
|
|
3022
3307
|
}
|
|
3023
3308
|
}
|
|
3024
3309
|
|
|
3310
|
+
restoreDesktopCatalogs(codexHome, operation.ids ?? [], desktopCatalogs);
|
|
3311
|
+
|
|
3025
3312
|
await reportProgress(onProgress, {
|
|
3026
3313
|
canCancel: false,
|
|
3027
3314
|
message: "Restore completed",
|
|
@@ -3047,6 +3334,8 @@ export async function restoreSessionDeletionBackup({
|
|
|
3047
3334
|
}
|
|
3048
3335
|
|
|
3049
3336
|
export async function verifySessionDeletion({ plan, scope = "deep", store }) {
|
|
3337
|
+
const remainingDesktopCatalogEntries = readDesktopCatalogs(store.codexHome, plan.ids)
|
|
3338
|
+
.flatMap((entry) => entry.rows.map((row) => ({ filename: entry.filename, id: row.thread_id })));
|
|
3050
3339
|
const deletedIdSet = new Set(plan.ids);
|
|
3051
3340
|
const remainingThreads = (store.stateDatabasePaths ?? [store.stateDatabasePath])
|
|
3052
3341
|
.flatMap((databasePath) => findRowsForIds(databasePath, "threads", "id", plan.ids));
|
|
@@ -3056,7 +3345,16 @@ export async function verifySessionDeletion({ plan, scope = "deep", store }) {
|
|
|
3056
3345
|
: []);
|
|
3057
3346
|
const remainingMemoryRecords = scope === "deep" && store.hasMemoryDatabase
|
|
3058
3347
|
? (store.memoryDatabasePaths ?? [store.memoryDatabasePath]).flatMap((databasePath) =>
|
|
3059
|
-
|
|
3348
|
+
[
|
|
3349
|
+
...findRowsForIds(databasePath, "stage1_outputs", "thread_id", plan.ids),
|
|
3350
|
+
...findMemoryJobsForIds(databasePath, plan.ids),
|
|
3351
|
+
])
|
|
3352
|
+
: [];
|
|
3353
|
+
const missingMemoryConsolidations = scope === "deep"
|
|
3354
|
+
? (plan.memoryConsolidations ?? []).filter(({ databasePath, inputWatermark }) => {
|
|
3355
|
+
const currentWatermark = memoryConsolidationWatermark(databasePath);
|
|
3356
|
+
return currentWatermark === null || currentWatermark <= (inputWatermark ?? 0);
|
|
3357
|
+
})
|
|
3060
3358
|
: [];
|
|
3061
3359
|
const remainingGoalRecords = scope === "deep" && store.hasGoalsDatabase
|
|
3062
3360
|
? (store.goalsDatabasePaths ?? [store.goalsDatabasePath]).flatMap((databasePath) =>
|
|
@@ -3080,7 +3378,13 @@ export async function verifySessionDeletion({ plan, scope = "deep", store }) {
|
|
|
3080
3378
|
const remainingThreadHistoryRecords = store.hasThreadHistoryDatabase
|
|
3081
3379
|
? (store.threadHistoryDatabasePaths ?? [store.threadHistoryDatabasePath]).flatMap((databasePath) =>
|
|
3082
3380
|
threadHistoryTables(databasePath).flatMap((tableName) =>
|
|
3083
|
-
findRowsForIds(
|
|
3381
|
+
findRowsForIds(
|
|
3382
|
+
databasePath,
|
|
3383
|
+
tableName,
|
|
3384
|
+
"thread_id",
|
|
3385
|
+
plan.threadHistoryIds ?? plan.ids,
|
|
3386
|
+
{ limitOne: true },
|
|
3387
|
+
)))
|
|
3084
3388
|
: [];
|
|
3085
3389
|
const [sessionIndexMatches, historyMatches] = await Promise.all([
|
|
3086
3390
|
inspectJsonlMatches(
|
|
@@ -3124,6 +3428,7 @@ export async function verifySessionDeletion({ plan, scope = "deep", store }) {
|
|
|
3124
3428
|
remainingThreads.length === 0 &&
|
|
3125
3429
|
remainingDynamicTools.length === 0 &&
|
|
3126
3430
|
remainingMemoryRecords.length === 0 &&
|
|
3431
|
+
missingMemoryConsolidations.length === 0 &&
|
|
3127
3432
|
remainingGoalRecords.length === 0 &&
|
|
3128
3433
|
remainingLogRecords.length === 0 &&
|
|
3129
3434
|
remainingQueueRecords.length === 0 &&
|
|
@@ -3131,13 +3436,16 @@ export async function verifySessionDeletion({ plan, scope = "deep", store }) {
|
|
|
3131
3436
|
sessionIndexMatches.count === 0 &&
|
|
3132
3437
|
historyMatches.count === 0 &&
|
|
3133
3438
|
remainingTranscriptPaths.length === 0 &&
|
|
3439
|
+
remainingDesktopCatalogEntries.length === 0 &&
|
|
3134
3440
|
remainingDesktopStateReferences.length === 0,
|
|
3441
|
+
remainingDesktopCatalogEntries,
|
|
3135
3442
|
remainingDesktopStateReferences,
|
|
3136
3443
|
remainingDynamicTools,
|
|
3137
3444
|
remainingGoalRecords,
|
|
3138
3445
|
remainingHistoryEntries,
|
|
3139
3446
|
remainingHistoryEntryCount: historyMatches.count,
|
|
3140
3447
|
remainingLogRecords,
|
|
3448
|
+
missingMemoryConsolidations,
|
|
3141
3449
|
remainingMemoryRecords,
|
|
3142
3450
|
remainingQueueRecords,
|
|
3143
3451
|
remainingThreadHistoryRecords,
|
package/lib/storage/jsonl.mjs
CHANGED
|
@@ -208,7 +208,7 @@ export async function inspectJsonlMatches(filePath, matches, { sampleLimit = 100
|
|
|
208
208
|
return { count, samples };
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
-
export async function rewriteJsonlFile(filePath, keep) {
|
|
211
|
+
export async function rewriteJsonlFile(filePath, keep, { beforeCommit = () => {} } = {}) {
|
|
212
212
|
const fileStats = await fs.stat(filePath);
|
|
213
213
|
const temporaryPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
214
214
|
const output = createWriteStream(temporaryPath, {
|
|
@@ -233,6 +233,7 @@ export async function rewriteJsonlFile(filePath, keep) {
|
|
|
233
233
|
|
|
234
234
|
output.end();
|
|
235
235
|
await finished(output);
|
|
236
|
+
await beforeCommit();
|
|
236
237
|
await fs.rename(temporaryPath, filePath);
|
|
237
238
|
return { removedCount, retainedCount };
|
|
238
239
|
} catch (error) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "session-steward",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.3",
|
|
4
4
|
"description": "Codex and Claude Code session manager - browse, back up, and delete old sessions. Local browser UI + CLI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Mallik Cheripally",
|
|
@@ -83,6 +83,7 @@
|
|
|
83
83
|
},
|
|
84
84
|
"dependencies": {
|
|
85
85
|
"@modelcontextprotocol/server": "^2.0.0",
|
|
86
|
+
"proper-lockfile": "4.1.2",
|
|
86
87
|
"zod": "^4.4.3"
|
|
87
88
|
}
|
|
88
89
|
}
|