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.
@@ -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$/u.test(entry.name)) {
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 stream = createReadStream(filePath, {
168
- encoding: "utf8",
169
- });
203
+ const { input, source } = createTranscriptTextStream(filePath);
170
204
  const interfaceHandle = readline.createInterface({
171
205
  crlfDelay: Infinity,
172
- input: stream,
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
- stream.destroy();
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 stream = createReadStream(filePath, {
218
- encoding: "utf8",
219
- });
256
+ const { input, source } = createTranscriptTextStream(filePath);
220
257
  const interfaceHandle = readline.createInterface({
221
258
  crlfDelay: Infinity,
222
- input: stream,
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
- stream.destroy();
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 transcriptHeaders = await indexTranscriptHeaders(paths.transcriptDirectories);
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 indexTranscriptHeaders(transcriptDirectories) {
1261
- const headersById = new Map();
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
- const current = header?.id ? headersById.get(String(header.id)) : null;
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
- if (header?.id && (!current || header.filePath > current.filePath)) {
1270
- headersById.set(String(header.id), { ...header, id: String(header.id) });
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 headersById;
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, transcriptHeaders] = await Promise.all([
1395
+ const [availability, transcriptIndex] = await Promise.all([
1326
1396
  getStoreAvailability(paths),
1327
- indexTranscriptHeaders(paths.transcriptDirectories),
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 = [...recordIds];
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 transcriptPaths = selectedRecords
1949
- .map((record) => record.rolloutPath)
1950
- .filter(Boolean);
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 memoryRowCount = (store.memoryDatabasePaths ?? [store.memoryDatabasePath].filter(Boolean))
2012
- .reduce((total, databasePath) => total + countRowsForIds(databasePath, "stage1_outputs", "thread_id", deletionIds), 0);
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", deletionIds), 0), 0);
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(hash, databasePath, tableName, "thread_id", plan.ids);
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 = 0;
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(databasePath, deleteStatements("stage1_outputs", "thread_id", plan.ids));
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
- findRowsForIds(databasePath, "stage1_outputs", "thread_id", plan.ids))
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(databasePath, tableName, "thread_id", plan.ids, { limitOne: true })))
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,
@@ -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.1",
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
  }