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.
@@ -18,6 +18,13 @@ import {
18
18
  placeholders,
19
19
  queryRows,
20
20
  } from "../../storage/sqlite.mjs";
21
+ import {
22
+ allValidDatabases,
23
+ CODEX_DATABASE_PROFILE,
24
+ databaseFamilySummary,
25
+ invalidateCodexDatabaseResolution,
26
+ resolveCodexDatabases,
27
+ } from "./database-families.mjs";
21
28
 
22
29
  function expandHome(value) {
23
30
  if (!value || value === "~") {
@@ -440,23 +447,36 @@ function deriveDisplayName({
440
447
  };
441
448
  }
442
449
 
443
- function getCodexPaths(codexHomeInput) {
450
+ export function getCodexPaths(codexHomeInput, { refresh = false } = {}) {
444
451
  const codexHome = path.resolve(expandHome(codexHomeInput || "~/.codex"));
445
452
  const archivedSessionsDirectory = path.join(codexHome, "archived_sessions");
446
453
  const sessionsDirectory = path.join(codexHome, "sessions");
454
+ const resolution = resolveCodexDatabases(codexHome, { refresh });
455
+ const { goals, logs, memories, state } = resolution.families;
456
+ const stateDatabasePath = state.primary?.path ?? path.join(codexHome, "state_5.sqlite");
457
+ const logsDatabasePath = logs.primary?.path ?? path.join(codexHome, "logs_2.sqlite");
458
+ const memoryDatabasePath = memories.primary?.path ?? path.join(codexHome, "memories_1.sqlite");
459
+ const goalsDatabasePath = goals.primary?.path ?? path.join(codexHome, "goals_1.sqlite");
447
460
 
448
461
  return {
449
462
  archivedSessionsDirectory,
450
463
  codexHome,
451
464
  desktopStateBackupPath: path.join(codexHome, ".codex-global-state.json.bak"),
452
465
  desktopStatePath: path.join(codexHome, ".codex-global-state.json"),
453
- goalsDatabasePath: path.join(codexHome, "goals_1.sqlite"),
466
+ databaseFamilies: resolution.families,
467
+ goalsDatabasePath,
468
+ goalsDatabasePaths: allValidDatabases(goals).map((database) => database.path),
454
469
  historyPath: path.join(codexHome, "history.jsonl"),
455
- logsDatabasePath: path.join(codexHome, "logs_2.sqlite"),
456
- memoryDatabasePath: path.join(codexHome, "memories_1.sqlite"),
470
+ logsDatabasePath,
471
+ logsDatabasePaths: allValidDatabases(logs).map((database) => database.path),
472
+ memoryDatabasePath,
473
+ memoryDatabasePaths: allValidDatabases(memories).map((database) => database.path),
474
+ resolvedDatabases: databaseFamilySummary(resolution),
457
475
  sessionIndexPath: path.join(codexHome, "session_index.jsonl"),
458
476
  sessionsDirectory,
459
- stateDatabasePath: path.join(codexHome, "state_5.sqlite"),
477
+ stateDatabasePath,
478
+ stateDatabases: allValidDatabases(state),
479
+ stateDatabasePaths: allValidDatabases(state).map((database) => database.path),
460
480
  transcriptDirectories: [sessionsDirectory, archivedSessionsDirectory],
461
481
  };
462
482
  }
@@ -470,27 +490,6 @@ const DESKTOP_THREAD_MAP_KEYS = [
470
490
 
471
491
  const DESKTOP_THREAD_ARRAY_KEYS = ["projectless-thread-ids"];
472
492
 
473
- const COMPATIBILITY_PROFILE = {
474
- id: "local-store-2026-07",
475
- builtFor: {
476
- chatgptDesktop: ["26.727.40816"],
477
- codexCli: ["0.144.1", "0.146.0"],
478
- },
479
- };
480
-
481
- const SCHEMA_REQUIREMENTS = [
482
- {
483
- database: "state_5.sqlite",
484
- required: true,
485
- tables: [
486
- { name: "threads", columns: ["id", "rollout_path", "cwd", "title", "first_user_message", "agent_nickname", "agent_role", "archived", "is_pinned"] },
487
- { name: "thread_spawn_edges", columns: ["parent_thread_id", "child_thread_id", "status"] },
488
- ],
489
- },
490
- { database: "logs_2.sqlite", required: false, tables: [{ name: "logs", columns: ["thread_id"] }] },
491
- { database: "memories_1.sqlite", required: false, tables: [{ name: "stage1_outputs", columns: ["thread_id"] }] },
492
- { database: "goals_1.sqlite", required: false, tables: [{ name: "thread_goals", columns: ["thread_id"] }, { name: "thread_goal_continuation_deferrals", columns: ["thread_id"] }] },
493
- ];
494
493
 
495
494
  function getMatchingDesktopStateEntryCount(state, deletedIdSet) {
496
495
  if (!state || typeof state !== "object" || Array.isArray(state)) {
@@ -587,35 +586,22 @@ export async function loadSessionStore({ codexHome }) {
587
586
  pathExists(paths.logsDatabasePath),
588
587
  pathExists(paths.memoryDatabasePath),
589
588
  ]);
590
- const threadRows = queryRows(
591
- paths.stateDatabasePath,
592
- `
593
- select
594
- id,
595
- rollout_path,
596
- cwd,
597
- title,
598
- first_user_message,
599
- agent_nickname,
600
- agent_role,
601
- archived,
602
- is_pinned,
603
- coalesce(created_at_ms, created_at * 1000) as created_at_ms,
604
- coalesce(updated_at_ms, updated_at * 1000) as updated_at_ms
605
- from threads
606
- order by updated_at_ms desc, updated_at desc
607
- `,
608
- );
609
- const spawnEdges = queryRows(
610
- paths.stateDatabasePath,
611
- `
612
- select
613
- parent_thread_id,
614
- child_thread_id,
615
- status
616
- from thread_spawn_edges
617
- `,
618
- );
589
+ const threadRowsById = new Map();
590
+ const spawnEdgesByKey = new Map();
591
+ for (const database of paths.stateDatabases) {
592
+ for (const row of queryRows(database.path, `select ${sessionColumns(database)} from threads t`)) {
593
+ const id = String(row.id);
594
+ if (!threadRowsById.has(id)) threadRowsById.set(id, { ...row, _stateDatabasePath: database.path });
595
+ }
596
+ if (stateSchema(database).hasSpawnEdges) {
597
+ for (const edge of queryRows(database.path, "select parent_thread_id, child_thread_id, status from thread_spawn_edges")) {
598
+ const key = `${edge.parent_thread_id}\0${edge.child_thread_id}`;
599
+ if (!spawnEdgesByKey.has(key)) spawnEdgesByKey.set(key, edge);
600
+ }
601
+ }
602
+ }
603
+ const threadRows = [...threadRowsById.values()];
604
+ const spawnEdges = [...spawnEdgesByKey.values()];
619
605
  const transcriptHeaders = await indexTranscriptHeaders(paths.transcriptDirectories);
620
606
 
621
607
  const discoveryIds = new Set(threadRows.map((threadRow) => String(threadRow.id)));
@@ -673,7 +659,7 @@ export async function loadSessionStore({ codexHome }) {
673
659
  agentRole: threadRow.agent_role ?? transcriptHeader?.agentRole ?? null,
674
660
  archived: Boolean(threadRow.archived),
675
661
  childThreadIds: childIdsByParentId.get(threadRow.id) ?? [],
676
- createdAtMs: toTimestampMs(threadRow.created_at_ms),
662
+ createdAtMs: toTimestampMs(threadRow.created_at_ms_resolved ?? threadRow.created_at_ms),
677
663
  cwd: threadRow.cwd ?? transcriptHeader?.cwd ?? "",
678
664
  displayName: "",
679
665
  firstUserMessage: threadRow.first_user_message ?? "",
@@ -691,7 +677,8 @@ export async function loadSessionStore({ codexHome }) {
691
677
  rolloutPath,
692
678
  title: threadRow.title ?? "",
693
679
  titleSource: "",
694
- updatedAtMs: toTimestampMs(threadRow.updated_at_ms),
680
+ updatedAtMs: toTimestampMs(threadRow.updated_at_ms_resolved ?? threadRow.updated_at_ms),
681
+ _stateDatabasePath: threadRow._stateDatabasePath,
695
682
  };
696
683
 
697
684
  if (
@@ -798,6 +785,12 @@ export async function loadSessionStore({ codexHome }) {
798
785
  sessionIndexPath: paths.sessionIndexPath,
799
786
  spawnEdges,
800
787
  stateDatabasePath: paths.stateDatabasePath,
788
+ stateDatabasePaths: paths.stateDatabasePaths,
789
+ stateDatabases: paths.stateDatabases,
790
+ resolvedDatabases: paths.resolvedDatabases,
791
+ logsDatabasePaths: paths.logsDatabasePaths,
792
+ memoryDatabasePaths: paths.memoryDatabasePaths,
793
+ goalsDatabasePaths: paths.goalsDatabasePaths,
801
794
  transcriptHeaders,
802
795
  historyPath: paths.historyPath,
803
796
  };
@@ -824,51 +817,21 @@ function inspectSqliteTable(databasePath, tableName) {
824
817
  }
825
818
 
826
819
  export async function diagnoseStorageCompatibility({ codexHome }) {
827
- const paths = getCodexPaths(codexHome);
828
- const recognizedDatabases = new Set(SCHEMA_REQUIREMENTS.map((requirement) => requirement.database));
820
+ const paths = getCodexPaths(codexHome, { refresh: true });
829
821
  const missing = [];
830
822
  const changed = [];
831
823
  const available = [];
832
-
833
- for (const requirement of SCHEMA_REQUIREMENTS) {
834
- const databasePath = path.join(paths.codexHome, requirement.database);
835
-
836
- if (!(await pathExists(databasePath))) {
837
- if (requirement.required) {
838
- missing.push(`Required session database is missing: ${requirement.database}`);
839
- } else {
840
- available.push(`Not present: ${requirement.database}`);
841
- }
842
- continue;
824
+ for (const [familyName, family] of Object.entries(paths.databaseFamilies)) {
825
+ if (!family.primary && family.required) {
826
+ missing.push(`A supported ${familyName} session database was not found.`);
827
+ } else if (!family.primary) {
828
+ available.push(`Not present: ${familyName} database`);
843
829
  }
844
-
845
- let databaseChanged = false;
846
-
847
- for (const table of requirement.tables) {
848
- const inspection = inspectSqliteTable(databasePath, table.name);
849
-
850
- if (inspection.error) {
851
- changed.push(`Could not read ${requirement.database}.`);
852
- databaseChanged = true;
853
- continue;
854
- }
855
-
856
- if (!inspection.exists) {
857
- changed.push(`Expected table is missing in ${requirement.database}: ${table.name}.`);
858
- databaseChanged = true;
859
- continue;
860
- }
861
-
862
- const missingColumns = table.columns.filter((column) => !inspection.columns.includes(column));
863
-
864
- if (missingColumns.length > 0) {
865
- changed.push(`Expected fields changed in ${requirement.database}: ${table.name}.`);
866
- databaseChanged = true;
867
- }
830
+ for (const database of allValidDatabases(family)) {
831
+ available.push(`Supported: ${database.filename}`);
868
832
  }
869
-
870
- if (!databaseChanged) {
871
- available.push(`Supported: ${requirement.database}`);
833
+ for (const database of family.invalid) {
834
+ changed.push(`Could not use ${database.filename}: ${database.reason}`);
872
835
  }
873
836
  }
874
837
 
@@ -879,23 +842,28 @@ export async function diagnoseStorageCompatibility({ codexHome }) {
879
842
  missing.push("The local Codex folder could not be read.");
880
843
  }
881
844
 
845
+ const recognizedDatabases = new Set(Object.values(paths.databaseFamilies).flatMap((family) => [
846
+ ...allValidDatabases(family),
847
+ ...family.invalid,
848
+ ]).map((database) => database.filename));
882
849
  const newlyDiscovered = entries
883
850
  .filter((entry) => entry.isFile() && entry.name.endsWith(".sqlite") && !recognizedDatabases.has(entry.name))
884
851
  .map((entry) => `Other local database found: ${entry.name}`)
885
852
  .sort();
886
853
  const status = missing.length > 0 || changed.length > 0
887
- ? "update-needed"
854
+ ? "unsupported"
888
855
  : newlyDiscovered.length > 0
889
- ? "newer-version"
856
+ ? "partial"
890
857
  : "ready";
891
858
 
892
859
  return {
893
860
  available,
894
- builtFor: COMPATIBILITY_PROFILE.builtFor,
861
+ builtFor: CODEX_DATABASE_PROFILE.builtFor,
895
862
  changed,
896
863
  missing,
897
864
  newlyDiscovered,
898
- profileId: COMPATIBILITY_PROFILE.id,
865
+ profileId: CODEX_DATABASE_PROFILE.id,
866
+ resolvedDatabases: paths.resolvedDatabases,
899
867
  status,
900
868
  };
901
869
  }
@@ -903,11 +871,7 @@ export async function diagnoseStorageCompatibility({ codexHome }) {
903
871
  export async function assertDeepCleanupSupported({ codexHome }) {
904
872
  const diagnostic = await diagnoseStorageCompatibility({ codexHome });
905
873
 
906
- if (diagnostic.status === "newer-version") {
907
- throw new Error("Deep cleanup is paused because unrecognized Codex storage was found.");
908
- }
909
-
910
- if (diagnostic.status !== "ready") {
874
+ if (diagnostic.status === "unsupported") {
911
875
  throw new Error("Deep cleanup is paused because this Codex storage layout is not supported.");
912
876
  }
913
877
 
@@ -925,12 +889,60 @@ const SESSION_UPDATED_SQL = "coalesce(nullif(t.updated_at_ms, 0), nullif(t.updat
925
889
  const SESSION_ACTIVITY_SQL = `coalesce(nullif(${SESSION_UPDATED_SQL}, 0), nullif(${SESSION_CREATED_SQL}, 0), 0)`;
926
890
  const SESSION_SORTS = new Set(["created", "cwd", "name", "size", "updated"]);
927
891
  const sessionSizeCache = new Map();
892
+ const stateSchemaCache = new WeakMap();
893
+
894
+ const OPTIONAL_THREAD_COLUMNS = [
895
+ "agent_nickname", "agent_role", "archived", "cwd", "first_user_message",
896
+ "is_pinned", "title",
897
+ ];
898
+
899
+ function stateSchema(database) {
900
+ const known = stateSchemaCache.get(database);
901
+ if (known) return known;
902
+ const cached = database.tables?.threads;
903
+ const columns = cached?.columns?.size
904
+ ? cached.columns
905
+ : new Set(inspectSqliteTable(database.path, "threads").columns ?? []);
906
+ const spawn = database.tables?.thread_spawn_edges?.exists !== undefined
907
+ ? database.tables.thread_spawn_edges
908
+ : inspectSqliteTable(database.path, "thread_spawn_edges");
909
+ const schema = { columns, hasSpawnEdges: Boolean(spawn?.exists) };
910
+ stateSchemaCache.set(database, schema);
911
+ return schema;
912
+ }
913
+
914
+ function columnExpression(columns, column, fallback = "null") {
915
+ return columns.has(column) ? `t.${column}` : fallback;
916
+ }
917
+
918
+ function timestampExpression(columns, prefix) {
919
+ const expressions = [];
920
+ if (columns.has(`${prefix}_at_ms`)) expressions.push(`nullif(t.${prefix}_at_ms, 0)`);
921
+ if (columns.has(`${prefix}_at`)) expressions.push(`nullif(t.${prefix}_at, 0) * 1000`);
922
+ return expressions.length ? `coalesce(${expressions.join(", ")}, 0)` : "0";
923
+ }
924
+
925
+ function stateExpressions(database) {
926
+ const schema = stateSchema(database);
927
+ const created = timestampExpression(schema.columns, "created");
928
+ const updated = timestampExpression(schema.columns, "updated");
929
+ return { ...schema, activity: `coalesce(nullif(${updated}, 0), nullif(${created}, 0), 0)`, created, updated };
930
+ }
928
931
 
929
932
  async function buildSessionSizeIndex(paths) {
930
- const rows = queryRows(
931
- paths.stateDatabasePath,
932
- "select id, rollout_path from threads",
933
- );
933
+ let rows;
934
+ if (paths.stateDatabases.length === 1) {
935
+ rows = queryRows(paths.stateDatabases[0].path, "select id, rollout_path from threads");
936
+ } else {
937
+ const rowsById = new Map();
938
+ for (const database of paths.stateDatabases) {
939
+ for (const row of queryRows(database.path, "select id, rollout_path from threads")) {
940
+ const id = String(row.id);
941
+ if (!rowsById.has(id)) rowsById.set(id, row);
942
+ }
943
+ }
944
+ rows = [...rowsById.values()];
945
+ }
934
946
  const sizes = new Map();
935
947
  let nextIndex = 0;
936
948
 
@@ -984,7 +996,9 @@ async function getSessionSizeIndex(paths, { refresh = false } = {}) {
984
996
  }
985
997
 
986
998
  export function invalidateSessionCache({ codexHome }) {
987
- sessionSizeCache.delete(getCodexPaths(codexHome).codexHome);
999
+ const resolvedHome = path.resolve(expandHome(codexHome || "~/.codex"));
1000
+ sessionSizeCache.delete(resolvedHome);
1001
+ invalidateCodexDatabaseResolution(resolvedHome);
988
1002
  }
989
1003
 
990
1004
  function compareSessionIdsBySize(leftId, rightId, sizes) {
@@ -1000,18 +1014,48 @@ function compareSessionIdsBySize(leftId, rightId, sizes) {
1000
1014
  return (rightSize ?? 0) - (leftSize ?? 0) || leftId.localeCompare(rightId);
1001
1015
  }
1002
1016
 
1003
- function getSessionOrder(sort) {
1004
- const name = "lower(coalesce(nullif(trim(t.title), ''), nullif(trim(t.first_user_message), ''), t.id))";
1017
+ function getSessionSort(sort, database) {
1018
+ const { activity, columns, created } = stateExpressions(database);
1019
+ const nameParts = ["t.title", "t.first_user_message"].filter((column) => columns.has(column.slice(2)))
1020
+ .map((column) => `nullif(trim(${column}), '')`);
1021
+ const name = `lower(coalesce(${[...nameParts, "t.id"].join(", ")}))`;
1022
+ const cwd = columns.has("cwd") ? "lower(coalesce(t.cwd, ''))" : "''";
1005
1023
 
1006
1024
  return {
1007
- created: `${SESSION_CREATED_SQL} desc, t.id asc`,
1008
- cwd: `lower(coalesce(t.cwd, '')) asc, ${SESSION_ACTIVITY_SQL} desc, t.id asc`,
1009
- name: `${name} asc, ${SESSION_ACTIVITY_SQL} desc, t.id asc`,
1010
- updated: `${SESSION_ACTIVITY_SQL} desc, t.id asc`,
1011
- }[sort] ?? `${SESSION_ACTIVITY_SQL} desc, t.id asc`;
1025
+ created: {
1026
+ compare: (left, right) => Number(right.sort_number) - Number(left.sort_number),
1027
+ order: `${created} desc, t.id asc`,
1028
+ selection: `${created} as sort_number`,
1029
+ },
1030
+ cwd: {
1031
+ compare: (left, right) => String(left.sort_text).localeCompare(String(right.sort_text))
1032
+ || Number(right.sort_activity) - Number(left.sort_activity),
1033
+ order: `${cwd} asc, ${activity} desc, t.id asc`,
1034
+ selection: `${cwd} as sort_text, ${activity} as sort_activity`,
1035
+ },
1036
+ name: {
1037
+ compare: (left, right) => String(left.sort_text).localeCompare(String(right.sort_text))
1038
+ || Number(right.sort_activity) - Number(left.sort_activity),
1039
+ order: `${name} asc, ${activity} desc, t.id asc`,
1040
+ selection: `${name} as sort_text, ${activity} as sort_activity`,
1041
+ },
1042
+ updated: {
1043
+ compare: (left, right) => Number(right.sort_number) - Number(left.sort_number),
1044
+ order: `${activity} desc, t.id asc`,
1045
+ selection: `${activity} as sort_number`,
1046
+ },
1047
+ }[sort] ?? {
1048
+ compare: (left, right) => Number(right.sort_number) - Number(left.sort_number),
1049
+ order: `${activity} desc, t.id asc`,
1050
+ selection: `${activity} as sort_number`,
1051
+ };
1052
+ }
1053
+
1054
+ function getSessionOrder(sort, database) {
1055
+ return getSessionSort(sort, database).order;
1012
1056
  }
1013
1057
 
1014
- function getSessionConditions({
1058
+ function getSessionConditions(database, {
1015
1059
  archiveStatus,
1016
1060
  inactiveBeforeMs,
1017
1061
  includeInternals,
@@ -1019,32 +1063,34 @@ function getSessionConditions({
1019
1063
  search,
1020
1064
  workspace,
1021
1065
  }) {
1066
+ const { activity, columns, hasSpawnEdges } = stateExpressions(database);
1022
1067
  const conditions = [];
1023
1068
  const parameters = [];
1024
1069
 
1025
- if (archiveStatus === "active") {
1070
+ if (archiveStatus === "active" && columns.has("archived")) {
1026
1071
  conditions.push("coalesce(t.archived, 0) = 0");
1027
1072
  } else if (archiveStatus === "archived") {
1028
- conditions.push("coalesce(t.archived, 0) <> 0");
1073
+ conditions.push(columns.has("archived") ? "coalesce(t.archived, 0) <> 0" : "0 = 1");
1029
1074
  }
1030
1075
 
1031
- if (!includeInternals) {
1076
+ if (!includeInternals && hasSpawnEdges) {
1032
1077
  conditions.push(`not exists (
1033
1078
  select 1 from thread_spawn_edges edge where edge.child_thread_id = t.id
1034
1079
  )`);
1035
1080
  }
1036
1081
 
1037
- if (!includeSupporting) {
1038
- conditions.push("coalesce(nullif(trim(t.title), ''), nullif(trim(t.first_user_message), ''), '') not like ?");
1082
+ const supportingColumns = ["title", "first_user_message"].filter((column) => columns.has(column));
1083
+ if (!includeSupporting && supportingColumns.length > 0) {
1084
+ conditions.push(`coalesce(${supportingColumns.map((column) => `nullif(trim(t.${column}), '')`).join(", ")}, '') not like ?`);
1039
1085
  parameters.push(`${SUPPORTING_THREAD_PREFIX}%`);
1040
1086
  }
1041
1087
 
1042
1088
  if (Number.isFinite(inactiveBeforeMs) && inactiveBeforeMs > 0) {
1043
- conditions.push(`${SESSION_ACTIVITY_SQL} > 0 and ${SESSION_ACTIVITY_SQL} <= ?`);
1089
+ conditions.push(`${activity} > 0 and ${activity} <= ?`);
1044
1090
  parameters.push(Math.trunc(inactiveBeforeMs));
1045
1091
  }
1046
1092
 
1047
- if (typeof workspace === "string") {
1093
+ if (typeof workspace === "string" && columns.has("cwd")) {
1048
1094
  conditions.push("coalesce(t.cwd, '') = ?");
1049
1095
  parameters.push(workspace);
1050
1096
  }
@@ -1052,14 +1098,10 @@ function getSessionConditions({
1052
1098
  const normalizedSearch = normalizeText(search).toLowerCase();
1053
1099
 
1054
1100
  if (normalizedSearch) {
1055
- conditions.push(`(
1056
- instr(lower(t.id), ?) > 0
1057
- or instr(lower(coalesce(t.title, '')), ?) > 0
1058
- or instr(lower(coalesce(t.first_user_message, '')), ?) > 0
1059
- or instr(lower(coalesce(t.cwd, '')), ?) > 0
1060
- or instr(lower(coalesce(t.rollout_path, '')), ?) > 0
1061
- )`);
1062
- parameters.push(...Array(5).fill(normalizedSearch));
1101
+ const searchColumns = ["id", "title", "first_user_message", "cwd", "rollout_path"]
1102
+ .filter((column) => column === "id" || columns.has(column));
1103
+ conditions.push(`(${searchColumns.map((column) => `instr(lower(coalesce(t.${column}, '')), ?) > 0`).join(" or ")})`);
1104
+ parameters.push(...Array(searchColumns.length).fill(normalizedSearch));
1063
1105
  }
1064
1106
 
1065
1107
  return {
@@ -1068,30 +1110,25 @@ function getSessionConditions({
1068
1110
  };
1069
1111
  }
1070
1112
 
1071
- const SESSION_COLUMNS = `
1072
- t.id,
1073
- t.rollout_path,
1074
- t.cwd,
1075
- t.title,
1076
- t.first_user_message,
1077
- t.agent_nickname,
1078
- t.agent_role,
1079
- t.archived,
1080
- t.is_pinned,
1081
- ${SESSION_CREATED_SQL} as created_at_ms,
1082
- ${SESSION_ACTIVITY_SQL} as updated_at_ms,
1083
- (
1084
- select edge.parent_thread_id
1085
- from thread_spawn_edges edge
1086
- where edge.child_thread_id = t.id
1087
- limit 1
1088
- ) as parent_thread_id
1089
- `;
1090
-
1091
- async function formatPagedThreadRows(stateDatabasePath, threadRows) {
1113
+ function sessionColumns(database) {
1114
+ const { activity, columns, created, hasSpawnEdges } = stateExpressions(database);
1115
+ const optional = OPTIONAL_THREAD_COLUMNS.map((column) =>
1116
+ `${columnExpression(columns, column)} as ${column}`,
1117
+ );
1118
+ const parent = hasSpawnEdges
1119
+ ? `(select edge.parent_thread_id from thread_spawn_edges edge where edge.child_thread_id = t.id limit 1)`
1120
+ : "null";
1121
+ return `t.id, t.rollout_path, ${optional.join(", ")}, ${created} as created_at_ms_resolved, ${activity} as updated_at_ms_resolved, ${parent} as parent_thread_id`;
1122
+ }
1123
+
1124
+ async function formatPagedThreadRows(stateDatabase, threadRows) {
1125
+ const stateDatabasePath = typeof stateDatabase === "string" ? stateDatabase : stateDatabase.path;
1126
+ const hasSpawnEdges = typeof stateDatabase === "string"
1127
+ ? inspectSqliteTable(stateDatabasePath, "thread_spawn_edges").exists
1128
+ : stateSchema(stateDatabase).hasSpawnEdges;
1092
1129
  const childIdsByParentId = new Map();
1093
1130
 
1094
- if (threadRows.length > 0) {
1131
+ if (threadRows.length > 0 && hasSpawnEdges) {
1095
1132
  const ids = threadRows.map((row) => String(row.id));
1096
1133
 
1097
1134
  for (const idBatch of batches(ids)) {
@@ -1139,7 +1176,7 @@ async function formatPagedThreadRows(stateDatabasePath, threadRows) {
1139
1176
  agentRole: threadRow.agent_role ?? transcriptHeader?.agentRole ?? null,
1140
1177
  archived: Boolean(threadRow.archived),
1141
1178
  childThreadIds: [...(childIdsByParentId.get(threadRow.id) ?? [])].sort(),
1142
- createdAtMs: toTimestampMs(threadRow.created_at_ms),
1179
+ createdAtMs: toTimestampMs(threadRow.created_at_ms_resolved ?? threadRow.created_at_ms),
1143
1180
  cwd: threadRow.cwd ?? "",
1144
1181
  displayName,
1145
1182
  firstUserMessage: threadRow.first_user_message ?? "",
@@ -1156,7 +1193,8 @@ async function formatPagedThreadRows(stateDatabasePath, threadRows) {
1156
1193
  title: threadRow.title ?? "",
1157
1194
  titleSource: title ? "sqlite_title" : firstUserMessage ? "sqlite_first_user_message" : "fallback",
1158
1195
  transcriptBytes,
1159
- updatedAtMs: toTimestampMs(threadRow.updated_at_ms),
1196
+ updatedAtMs: toTimestampMs(threadRow.updated_at_ms_resolved ?? threadRow.updated_at_ms),
1197
+ _stateDatabasePath: stateDatabasePath,
1160
1198
  });
1161
1199
  }
1162
1200
 
@@ -1173,9 +1211,9 @@ async function getStoreAvailability(paths) {
1173
1211
  ] = await Promise.all([
1174
1212
  pathExists(paths.desktopStatePath),
1175
1213
  pathExists(paths.desktopStateBackupPath),
1176
- pathExists(paths.goalsDatabasePath),
1177
- pathExists(paths.logsDatabasePath),
1178
- pathExists(paths.memoryDatabasePath),
1214
+ Promise.resolve(paths.goalsDatabasePaths.length > 0),
1215
+ Promise.resolve(paths.logsDatabasePaths.length > 0),
1216
+ Promise.resolve(paths.memoryDatabasePaths.length > 0),
1179
1217
  ]);
1180
1218
 
1181
1219
  return {
@@ -1261,13 +1299,13 @@ export async function loadDeletionStore({ codexHome, recordIds }) {
1261
1299
 
1262
1300
  for (let offset = 0; offset < pendingIds.length; offset += 400) {
1263
1301
  const idBatch = pendingIds.slice(offset, offset + 400);
1264
- const stateChildren = queryRows(
1265
- paths.stateDatabasePath,
1266
- `select parent_thread_id, child_thread_id
1267
- from thread_spawn_edges
1268
- where parent_thread_id in (${placeholders(idBatch)})`,
1269
- idBatch,
1270
- );
1302
+ const stateChildren = paths.stateDatabases.flatMap((database) => stateSchema(database).hasSpawnEdges
1303
+ ? queryRows(
1304
+ database.path,
1305
+ `select parent_thread_id, child_thread_id from thread_spawn_edges where parent_thread_id in (${placeholders(idBatch)})`,
1306
+ idBatch,
1307
+ )
1308
+ : []);
1271
1309
 
1272
1310
  for (const edge of stateChildren) {
1273
1311
  const childId = String(edge.child_thread_id);
@@ -1288,19 +1326,29 @@ export async function loadDeletionStore({ codexHome, recordIds }) {
1288
1326
  }
1289
1327
 
1290
1328
  const ids = [...selectedIds];
1291
- const threadRows = [];
1292
-
1293
- for (const idBatch of batches(ids)) {
1294
- threadRows.push(...queryRows(
1295
- paths.stateDatabasePath,
1296
- `select ${SESSION_COLUMNS}
1297
- from threads t
1298
- where t.id in (${placeholders(idBatch)})`,
1299
- idBatch,
1300
- ));
1329
+ const threadRowsById = new Map();
1330
+ for (const database of paths.stateDatabases) {
1331
+ for (const idBatch of batches(ids)) {
1332
+ for (const row of queryRows(
1333
+ database.path,
1334
+ `select ${sessionColumns(database)} from threads t where t.id in (${placeholders(idBatch)})`,
1335
+ idBatch,
1336
+ )) {
1337
+ const id = String(row.id);
1338
+ if (!threadRowsById.has(id)) threadRowsById.set(id, { ...row, _database: database });
1339
+ }
1340
+ }
1301
1341
  }
1302
-
1303
- const spawnEdges = queryRelatedSpawnEdges(paths.stateDatabasePath, ids);
1342
+ const threadRows = [...threadRowsById.values()];
1343
+ const spawnEdgesByKey = new Map();
1344
+ for (const database of paths.stateDatabases) {
1345
+ if (!stateSchema(database).hasSpawnEdges) continue;
1346
+ for (const edge of queryRelatedSpawnEdges(database.path, ids)) {
1347
+ const key = `${edge.parent_thread_id}\0${edge.child_thread_id}`;
1348
+ if (!spawnEdgesByKey.has(key)) spawnEdgesByKey.set(key, edge);
1349
+ }
1350
+ }
1351
+ const spawnEdges = [...spawnEdgesByKey.values()];
1304
1352
  const childIdsByParentId = new Map();
1305
1353
  const parentIdsByChildId = new Map();
1306
1354
 
@@ -1325,7 +1373,13 @@ export async function loadDeletionStore({ codexHome, recordIds }) {
1325
1373
  }
1326
1374
  }
1327
1375
 
1328
- const formattedRows = await formatPagedThreadRows(paths.stateDatabasePath, threadRows);
1376
+ const formattedRows = [];
1377
+ for (const database of paths.stateDatabases) {
1378
+ formattedRows.push(...await formatPagedThreadRows(
1379
+ database,
1380
+ threadRows.filter((row) => row._database === database),
1381
+ ));
1382
+ }
1329
1383
  const recordsById = new Map();
1330
1384
 
1331
1385
  for (const record of formattedRows) {
@@ -1427,6 +1481,12 @@ export async function loadDeletionStore({ codexHome, recordIds }) {
1427
1481
  sessionIndexPath: paths.sessionIndexPath,
1428
1482
  spawnEdges,
1429
1483
  stateDatabasePath: paths.stateDatabasePath,
1484
+ stateDatabasePaths: paths.stateDatabasePaths,
1485
+ stateDatabases: paths.stateDatabases,
1486
+ resolvedDatabases: paths.resolvedDatabases,
1487
+ logsDatabasePaths: paths.logsDatabasePaths,
1488
+ memoryDatabasePaths: paths.memoryDatabasePaths,
1489
+ goalsDatabasePaths: paths.goalsDatabasePaths,
1430
1490
  transcriptHeaders: new Map(
1431
1491
  [...transcriptHeaders].filter(([id]) => relevantIds.has(id)),
1432
1492
  ),
@@ -1445,80 +1505,109 @@ export async function listSessions({
1445
1505
  search = "",
1446
1506
  sort = "updated",
1447
1507
  workspace,
1508
+ forceUnion = false,
1448
1509
  }) {
1449
- const paths = getCodexPaths(codexHome);
1510
+ const paths = getCodexPaths(codexHome, { refresh });
1450
1511
  const boundedPageSize = Number.isFinite(pageSize)
1451
1512
  ? Math.min(MAX_PAGE_SIZE, Math.max(1, Math.trunc(pageSize)))
1452
1513
  : DEFAULT_PAGE_SIZE;
1453
1514
  const requestedPage = Number.isFinite(page) ? Math.max(1, Math.trunc(page)) : 1;
1454
1515
  const resolvedSort = SESSION_SORTS.has(sort) ? sort : "updated";
1455
- const conditions = getSessionConditions({
1456
- archiveStatus,
1457
- inactiveBeforeMs,
1458
- includeInternals,
1459
- includeSupporting,
1460
- search,
1461
- workspace,
1462
- });
1463
- if (resolvedSort === "size") {
1464
- const sizes = await getSessionSizeIndex(paths, { refresh });
1465
- const matchingIds = queryRows(
1466
- paths.stateDatabasePath,
1467
- `select t.id from threads t ${conditions.sql}`,
1516
+ if (paths.stateDatabases.length === 1 && resolvedSort !== "size" && !forceUnion) {
1517
+ const database = paths.stateDatabases[0];
1518
+ const conditions = getSessionConditions(database, {
1519
+ archiveStatus, inactiveBeforeMs, includeInternals, includeSupporting, search, workspace,
1520
+ });
1521
+ const countRow = queryRows(
1522
+ database.path,
1523
+ `select count(*) as count from threads t ${conditions.sql}`,
1468
1524
  conditions.parameters,
1469
- ).map((row) => String(row.id));
1470
- matchingIds.sort((left, right) => compareSessionIdsBySize(left, right, sizes));
1471
- const total = matchingIds.length;
1525
+ )[0];
1526
+ const total = Number(countRow?.count ?? 0);
1472
1527
  const pageCount = Math.max(1, Math.ceil(total / boundedPageSize));
1473
1528
  const currentPage = Math.min(requestedPage, pageCount);
1474
- const pageIds = matchingIds.slice(
1475
- (currentPage - 1) * boundedPageSize,
1476
- currentPage * boundedPageSize,
1529
+ const rows = queryRows(
1530
+ database.path,
1531
+ `select ${sessionColumns(database)}
1532
+ from threads t
1533
+ ${conditions.sql}
1534
+ order by ${getSessionOrder(resolvedSort, database)}
1535
+ limit ? offset ?`,
1536
+ [...conditions.parameters, boundedPageSize, (currentPage - 1) * boundedPageSize],
1477
1537
  );
1478
- const threadRows = pageIds.length > 0
1479
- ? queryRows(
1480
- paths.stateDatabasePath,
1481
- `select ${SESSION_COLUMNS}
1482
- from threads t
1483
- where t.id in (${placeholders(pageIds)})`,
1484
- pageIds,
1485
- )
1486
- : [];
1487
- const rowsById = new Map(threadRows.map((row) => [String(row.id), row]));
1488
- const orderedRows = pageIds.map((id) => rowsById.get(id)).filter(Boolean);
1489
1538
 
1490
1539
  return {
1491
1540
  page: currentPage,
1492
1541
  pageCount,
1493
1542
  pageSize: boundedPageSize,
1494
- records: await formatPagedThreadRows(paths.stateDatabasePath, orderedRows),
1543
+ records: await formatPagedThreadRows(database, rows),
1495
1544
  total,
1496
1545
  };
1497
1546
  }
1498
1547
 
1499
- const countRow = queryRows(
1500
- paths.stateDatabasePath,
1501
- `select count(*) as count from threads t ${conditions.sql}`,
1502
- conditions.parameters,
1503
- )[0];
1504
- const total = Number(countRow?.count ?? 0);
1548
+ const ordered = [];
1549
+ const seenIds = paths.stateDatabases.length > 1 ? new Set() : null;
1550
+ const compactSizeIds = resolvedSort === "size" && paths.stateDatabases.length === 1;
1551
+ let compareSortRows = null;
1552
+ for (const database of paths.stateDatabases) {
1553
+ const conditions = getSessionConditions(database, {
1554
+ archiveStatus, inactiveBeforeMs, includeInternals, includeSupporting, search, workspace,
1555
+ });
1556
+ const sessionSort = resolvedSort === "size" ? null : getSessionSort(resolvedSort, database);
1557
+ compareSortRows ??= sessionSort?.compare ?? null;
1558
+ const rows = queryRows(
1559
+ database.path,
1560
+ `select t.id${sessionSort ? `, ${sessionSort.selection}` : ""} from threads t ${conditions.sql}`,
1561
+ conditions.parameters,
1562
+ );
1563
+ for (const row of rows) {
1564
+ const id = String(row.id);
1565
+ if (seenIds?.has(id)) continue;
1566
+ seenIds?.add(id);
1567
+ if (compactSizeIds) {
1568
+ ordered.push(id);
1569
+ continue;
1570
+ }
1571
+ row.database = database;
1572
+ row.id = id;
1573
+ ordered.push(row);
1574
+ }
1575
+ }
1576
+ if (resolvedSort === "size") {
1577
+ const sizes = await getSessionSizeIndex(paths, { refresh });
1578
+ ordered.sort((left, right) => compareSessionIdsBySize(
1579
+ compactSizeIds ? left : left.id,
1580
+ compactSizeIds ? right : right.id,
1581
+ sizes,
1582
+ ));
1583
+ } else {
1584
+ ordered.sort((left, right) => compareSortRows(left, right) || left.id.localeCompare(right.id));
1585
+ }
1586
+ const total = ordered.length;
1505
1587
  const pageCount = Math.max(1, Math.ceil(total / boundedPageSize));
1506
1588
  const currentPage = Math.min(requestedPage, pageCount);
1507
- const threadRows = queryRows(
1508
- paths.stateDatabasePath,
1509
- `select ${SESSION_COLUMNS}
1510
- from threads t
1511
- ${conditions.sql}
1512
- order by ${getSessionOrder(resolvedSort)}
1513
- limit ? offset ?`,
1514
- [...conditions.parameters, boundedPageSize, (currentPage - 1) * boundedPageSize],
1515
- );
1589
+ const pageItems = ordered.slice((currentPage - 1) * boundedPageSize, currentPage * boundedPageSize);
1590
+ const recordsById = new Map();
1591
+ const itemsByDatabase = new Map();
1592
+ if (compactSizeIds) {
1593
+ itemsByDatabase.set(paths.stateDatabases[0], pageItems);
1594
+ } else {
1595
+ for (const item of pageItems) {
1596
+ const values = itemsByDatabase.get(item.database) ?? [];
1597
+ values.push(item.id);
1598
+ itemsByDatabase.set(item.database, values);
1599
+ }
1600
+ }
1601
+ for (const [database, ids] of itemsByDatabase) {
1602
+ const rows = queryRows(database.path, `select ${sessionColumns(database)} from threads t where t.id in (${placeholders(ids)})`, ids);
1603
+ for (const record of await formatPagedThreadRows(database, rows)) recordsById.set(record.id, record);
1604
+ }
1516
1605
 
1517
1606
  return {
1518
1607
  page: currentPage,
1519
1608
  pageCount,
1520
1609
  pageSize: boundedPageSize,
1521
- records: await formatPagedThreadRows(paths.stateDatabasePath, threadRows),
1610
+ records: pageItems.map((item) => recordsById.get(compactSizeIds ? item : item.id)).filter(Boolean),
1522
1611
  total,
1523
1612
  };
1524
1613
  }
@@ -1573,47 +1662,41 @@ async function measureTranscriptStorage(transcriptDirectories) {
1573
1662
  }
1574
1663
 
1575
1664
  export async function getSessionOverview({ codexHome, refresh = false }) {
1576
- const paths = getCodexPaths(codexHome);
1577
- const supportingPattern = `${SUPPORTING_THREAD_PREFIX}%`;
1578
- const overviewRow = queryRows(
1579
- paths.stateDatabasePath,
1580
- `select
1581
- count(*) as total,
1582
- coalesce(sum(case when coalesce(t.archived, 0) <> 0 then 1 else 0 end), 0) as archived_count,
1583
- coalesce(sum(case when coalesce(t.archived, 0) = 0 then 1 else 0 end), 0) as active_count,
1584
- count(children.child_thread_id) as subagent_count,
1585
- coalesce(sum(case
1586
- when children.child_thread_id is null
1587
- and not (coalesce(nullif(trim(t.title), ''), nullif(trim(t.first_user_message), ''), '') like ?)
1588
- then 1
1589
- else 0
1590
- end), 0) as primary_session_count,
1591
- coalesce(sum(case
1592
- when coalesce(nullif(trim(t.title), ''), nullif(trim(t.first_user_message), ''), '') like ? then 1
1593
- else 0
1594
- end), 0) as supporting_count,
1595
- coalesce(sum(case when ${SESSION_ACTIVITY_SQL} = 0 then 1 else 0 end), 0) as unknown_activity_count
1596
- from threads t
1597
- left join (
1598
- select distinct child_thread_id from thread_spawn_edges
1599
- ) children on children.child_thread_id = t.id`,
1600
- [supportingPattern, supportingPattern],
1601
- )[0] ?? {};
1602
- const workspaceSessionRows = queryRows(
1603
- paths.stateDatabasePath,
1604
- `select
1605
- t.id,
1606
- coalesce(t.cwd, '') as path,
1607
- ${SESSION_ACTIVITY_SQL} as last_activity_at_ms
1608
- from threads t`,
1609
- );
1665
+ const paths = getCodexPaths(codexHome, { refresh });
1666
+ const sessions = new Map();
1667
+ for (const database of paths.stateDatabases) {
1668
+ const { activity, columns, hasSpawnEdges } = stateExpressions(database);
1669
+ const projection = [
1670
+ "t.id",
1671
+ `${columns.has("cwd") ? "coalesce(t.cwd, '')" : "''"} as path`,
1672
+ `${columns.has("archived") ? "coalesce(t.archived, 0)" : "0"} as archived`,
1673
+ `${columns.has("title") ? "coalesce(t.title, '')" : "''"} as title`,
1674
+ `${columns.has("first_user_message") ? "coalesce(t.first_user_message, '')" : "''"} as first_user_message`,
1675
+ `${activity} as last_activity_at_ms`,
1676
+ `${hasSpawnEdges ? "exists(select 1 from thread_spawn_edges edge where edge.child_thread_id = t.id)" : "0"} as is_subagent`,
1677
+ ].join(", ");
1678
+ for (const row of queryRows(database.path, `select ${projection} from threads t`)) {
1679
+ const id = String(row.id);
1680
+ if (!sessions.has(id)) sessions.set(id, row);
1681
+ }
1682
+ }
1683
+ const workspaceSessionRows = [...sessions.values()];
1610
1684
  const [sizes, storage] = await Promise.all([
1611
1685
  getSessionSizeIndex(paths, { refresh }),
1612
1686
  measureTranscriptStorage(paths.transcriptDirectories),
1613
1687
  ]);
1614
1688
  const workspaces = new Map();
1689
+ let archivedSessionCount = 0;
1690
+ let subagentCount = 0;
1691
+ let supportingCount = 0;
1692
+ let unknownActivityCount = 0;
1615
1693
 
1616
1694
  for (const row of workspaceSessionRows) {
1695
+ const supporting = `${row.title || row.first_user_message || ""}`.startsWith(SUPPORTING_THREAD_PREFIX);
1696
+ archivedSessionCount += row.archived ? 1 : 0;
1697
+ subagentCount += row.is_subagent ? 1 : 0;
1698
+ supportingCount += supporting ? 1 : 0;
1699
+ unknownActivityCount += Number(row.last_activity_at_ms) ? 0 : 1;
1617
1700
  const workspacePath = row.path ?? "";
1618
1701
  const current = workspaces.get(workspacePath) ?? {
1619
1702
  lastActivityAtMs: 0,
@@ -1632,14 +1715,14 @@ export async function getSessionOverview({ codexHome, refresh = false }) {
1632
1715
  }
1633
1716
 
1634
1717
  return {
1635
- activeSessionCount: Number(overviewRow.active_count ?? 0),
1636
- archivedSessionCount: Number(overviewRow.archived_count ?? 0),
1718
+ activeSessionCount: sessions.size - archivedSessionCount,
1719
+ archivedSessionCount,
1637
1720
  calculatedAtMs: Date.now(),
1638
- primarySessionCount: Number(overviewRow.primary_session_count ?? 0),
1639
- sessionCount: Number(overviewRow.total ?? 0),
1640
- subagentCount: Number(overviewRow.subagent_count ?? 0),
1641
- supportingCount: Number(overviewRow.supporting_count ?? 0),
1642
- unknownActivityCount: Number(overviewRow.unknown_activity_count ?? 0),
1721
+ primarySessionCount: sessions.size - subagentCount - supportingCount,
1722
+ sessionCount: sessions.size,
1723
+ subagentCount,
1724
+ supportingCount,
1725
+ unknownActivityCount,
1643
1726
  workspaces: [...workspaces.values()].sort((left, right) =>
1644
1727
  Number(!left.path) - Number(!right.path)
1645
1728
  || right.lastActivityAtMs - left.lastActivityAtMs
@@ -1650,13 +1733,11 @@ export async function getSessionOverview({ codexHome, refresh = false }) {
1650
1733
 
1651
1734
  export async function getSessionRecord({ codexHome, id }) {
1652
1735
  const paths = getCodexPaths(codexHome);
1653
- const rows = queryRows(
1654
- paths.stateDatabasePath,
1655
- `select ${SESSION_COLUMNS} from threads t where t.id = ? limit 1`,
1656
- [id],
1657
- );
1658
- const records = await formatPagedThreadRows(paths.stateDatabasePath, rows);
1659
- return records[0] ?? null;
1736
+ for (const database of paths.stateDatabases) {
1737
+ const rows = queryRows(database.path, `select ${sessionColumns(database)} from threads t where t.id = ? limit 1`, [id]);
1738
+ if (rows.length) return (await formatPagedThreadRows(database, rows))[0] ?? null;
1739
+ }
1740
+ return null;
1660
1741
  }
1661
1742
 
1662
1743
  export function filterAndSortSessions({
@@ -1753,6 +1834,18 @@ function findRowsForIds(databasePath, tableName, columnName, ids, { limitOne = f
1753
1834
  return rows;
1754
1835
  }
1755
1836
 
1837
+ function fingerprintRowsForIds(hash, databasePath, tableName, columnName, ids) {
1838
+ for (const idBatch of batches(ids)) {
1839
+ const rows = queryRows(
1840
+ databasePath,
1841
+ `select * from ${tableName} where ${columnName} in (${placeholders(idBatch)})`,
1842
+ idBatch,
1843
+ );
1844
+ rows.sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
1845
+ hash.update(`${databasePath}\0${tableName}\0${JSON.stringify(rows)}\0`);
1846
+ }
1847
+ }
1848
+
1756
1849
  function* deleteStatements(tableName, columnName, ids) {
1757
1850
  for (const idBatch of batches(ids)) {
1758
1851
  yield {
@@ -1842,15 +1935,16 @@ export async function planSessionDeletion({ recordIds, store }) {
1842
1935
 
1843
1936
  return count;
1844
1937
  }, 0);
1845
- const logRowCount = store.hasLogsDatabase
1846
- ? countRowsForIds(store.logsDatabasePath, "logs", "thread_id", deletionIds)
1847
- : 0;
1848
- const memoryRowCount = store.hasMemoryDatabase
1849
- ? countRowsForIds(store.memoryDatabasePath, "stage1_outputs", "thread_id", deletionIds)
1850
- : 0;
1851
- const goalRowCount = store.hasGoalsDatabase
1852
- ? countRowsForIds(store.goalsDatabasePath, "thread_goals", "thread_id", deletionIds)
1853
- : 0;
1938
+ const logRowCount = (store.logsDatabasePaths ?? [store.logsDatabasePath].filter(Boolean))
1939
+ .reduce((total, databasePath) => total + countRowsForIds(databasePath, "logs", "thread_id", deletionIds), 0);
1940
+ const memoryRowCount = (store.memoryDatabasePaths ?? [store.memoryDatabasePath].filter(Boolean))
1941
+ .reduce((total, databasePath) => total + countRowsForIds(databasePath, "stage1_outputs", "thread_id", deletionIds), 0);
1942
+ const goalRowCount = (store.goalsDatabasePaths ?? [store.goalsDatabasePath].filter(Boolean))
1943
+ .reduce((total, databasePath) => total + countRowsForIds(databasePath, "thread_goals", "thread_id", deletionIds), 0);
1944
+ const stateTargets = (store.stateDatabases ?? [{ path: store.stateDatabasePath }]).map((database) => ({
1945
+ database,
1946
+ ids: findRowsForIds(database.path, "threads", "id", deletionIds).map((row) => String(row.id)),
1947
+ }));
1854
1948
 
1855
1949
  return {
1856
1950
  childCount: Math.max(0, deletionIds.length - recordIds.length),
@@ -1869,6 +1963,7 @@ export async function planSessionDeletion({ recordIds, store }) {
1869
1963
  records: selectedRecords,
1870
1964
  sessionIndexMatchCount: sessionIndexMatches.count,
1871
1965
  spawnEdgeCount,
1966
+ stateTargets,
1872
1967
  transcriptBytes,
1873
1968
  transcriptFileCount,
1874
1969
  transcriptPaths,
@@ -1880,10 +1975,10 @@ const BACKUP_RESERVE_RATIO = 0.05;
1880
1975
 
1881
1976
  function getBackupSourcePaths({ plan, scope, store }) {
1882
1977
  const databasePaths = [
1883
- store.stateDatabasePath,
1884
- store.hasLogsDatabase ? store.logsDatabasePath : null,
1885
- scope === "deep" && store.hasMemoryDatabase ? store.memoryDatabasePath : null,
1886
- scope === "deep" && store.hasGoalsDatabase ? store.goalsDatabasePath : null,
1978
+ ...(store.stateDatabasePaths ?? [store.stateDatabasePath]),
1979
+ ...(store.logsDatabasePaths ?? [store.logsDatabasePath].filter(Boolean)),
1980
+ ...(scope === "deep" ? (store.memoryDatabasePaths ?? [store.memoryDatabasePath].filter(Boolean)) : []),
1981
+ ...(scope === "deep" ? (store.goalsDatabasePaths ?? [store.goalsDatabasePath].filter(Boolean)) : []),
1887
1982
  ].filter(Boolean);
1888
1983
 
1889
1984
  return [
@@ -1937,6 +2032,7 @@ export async function fingerprintSessionDeletion({ plan, scope, store }) {
1937
2032
  store.hasMemoryDatabase,
1938
2033
  ] : []),
1939
2034
  ].map(Number).join("\0")}\0`);
2035
+ hash.update(`resolved-databases\0${JSON.stringify(store.resolvedDatabases ?? {})}\0`);
1940
2036
 
1941
2037
  for (const record of [...plan.records].sort((left, right) => left.id.localeCompare(right.id))) {
1942
2038
  hash.update([
@@ -1955,6 +2051,26 @@ export async function fingerprintSessionDeletion({ plan, scope, store }) {
1955
2051
  hash.update(`id\0${id}\0`);
1956
2052
  }
1957
2053
 
2054
+ for (const database of store.stateDatabases ?? [{ path: store.stateDatabasePath }]) {
2055
+ fingerprintRowsForIds(hash, database.path, "threads", "id", plan.ids);
2056
+ if (stateSchema(database).hasSpawnEdges) {
2057
+ fingerprintRowsForIds(hash, database.path, "thread_spawn_edges", "parent_thread_id", plan.ids);
2058
+ fingerprintRowsForIds(hash, database.path, "thread_spawn_edges", "child_thread_id", plan.ids);
2059
+ }
2060
+ }
2061
+ for (const databasePath of store.logsDatabasePaths ?? [store.logsDatabasePath].filter(Boolean)) {
2062
+ fingerprintRowsForIds(hash, databasePath, "logs", "thread_id", plan.ids);
2063
+ }
2064
+ if (scope === "deep") {
2065
+ for (const databasePath of store.memoryDatabasePaths ?? [store.memoryDatabasePath].filter(Boolean)) {
2066
+ fingerprintRowsForIds(hash, databasePath, "stage1_outputs", "thread_id", plan.ids);
2067
+ }
2068
+ for (const databasePath of store.goalsDatabasePaths ?? [store.goalsDatabasePath].filter(Boolean)) {
2069
+ fingerprintRowsForIds(hash, databasePath, "thread_goals", "thread_id", plan.ids);
2070
+ fingerprintRowsForIds(hash, databasePath, "thread_goal_continuation_deferrals", "thread_id", plan.ids);
2071
+ }
2072
+ }
2073
+
1958
2074
  for (const filePath of [...plan.missingTranscriptPaths].sort()) {
1959
2075
  hash.update(`missing-transcript\0${filePath}\0`);
1960
2076
  }
@@ -2123,10 +2239,10 @@ async function createOperationBackup({ onProgress, plan, scope, store }) {
2123
2239
  transcriptNameCounts.set(name, (transcriptNameCounts.get(name) ?? 0) + 1);
2124
2240
  }
2125
2241
  const snapshotCandidates = [
2126
- [store.stateDatabasePath, "state_5.sqlite", true],
2127
- [store.hasLogsDatabase ? store.logsDatabasePath : null, "logs_2.sqlite", true],
2128
- [store.hasMemoryDatabase ? store.memoryDatabasePath : null, "memories_1.sqlite", scope === "deep"],
2129
- [store.hasGoalsDatabase ? store.goalsDatabasePath : null, "goals_1.sqlite", scope === "deep"],
2242
+ ...(store.stateDatabasePaths ?? [store.stateDatabasePath]).map((databasePath) => [databasePath, path.basename(databasePath), true]),
2243
+ ...(store.logsDatabasePaths ?? [store.logsDatabasePath].filter(Boolean)).map((databasePath) => [databasePath, path.basename(databasePath), true]),
2244
+ ...(scope === "deep" ? (store.memoryDatabasePaths ?? [store.memoryDatabasePath].filter(Boolean)) : []).map((databasePath) => [databasePath, path.basename(databasePath), true]),
2245
+ ...(scope === "deep" ? (store.goalsDatabasePaths ?? [store.goalsDatabasePath].filter(Boolean)) : []).map((databasePath) => [databasePath, path.basename(databasePath), true]),
2130
2246
  ];
2131
2247
  const totalItems = backupFiles.length + plan.transcriptPaths.length + snapshotCandidates.length;
2132
2248
  let completedItems = 0;
@@ -2199,7 +2315,18 @@ async function createOperationBackup({ onProgress, plan, scope, store }) {
2199
2315
 
2200
2316
  await atomicWriteFile(
2201
2317
  path.join(backupDirectory, "operation.json"),
2202
- `${JSON.stringify({ version: 2, ids: plan.ids, scope, createdAtMs: Date.now(), copiedFiles, databaseSnapshots, files }, null, 2)}\n`,
2318
+ `${JSON.stringify({
2319
+ version: 3,
2320
+ ids: plan.ids,
2321
+ scope,
2322
+ createdAtMs: Date.now(),
2323
+ copiedFiles,
2324
+ databaseSnapshots,
2325
+ files,
2326
+ profileId: CODEX_DATABASE_PROFILE.id,
2327
+ resolvedDatabases: store.resolvedDatabases ?? {},
2328
+ compatibilityStatus: (await diagnoseStorageCompatibility({ codexHome: store.codexHome })).status,
2329
+ }, null, 2)}\n`,
2203
2330
  );
2204
2331
 
2205
2332
  return backupDirectory;
@@ -2266,39 +2393,40 @@ export async function executeSessionDeletion({
2266
2393
 
2267
2394
  try {
2268
2395
  if (scope === "deep" && store.hasMemoryDatabase) {
2269
- executeTransaction(
2270
- store.memoryDatabasePath,
2271
- deleteStatements("stage1_outputs", "thread_id", plan.ids),
2272
- );
2396
+ for (const databasePath of store.memoryDatabasePaths ?? [store.memoryDatabasePath]) {
2397
+ executeTransaction(databasePath, deleteStatements("stage1_outputs", "thread_id", plan.ids));
2398
+ }
2273
2399
  }
2274
2400
  if (scope === "deep" && store.hasGoalsDatabase) {
2275
- executeTransaction(
2276
- store.goalsDatabasePath,
2277
- deleteStatements("thread_goals", "thread_id", plan.ids),
2278
- );
2401
+ for (const databasePath of store.goalsDatabasePaths ?? [store.goalsDatabasePath]) {
2402
+ executeTransaction(databasePath, [
2403
+ ...deleteStatements("thread_goal_continuation_deferrals", "thread_id", plan.ids),
2404
+ ...deleteStatements("thread_goals", "thread_id", plan.ids),
2405
+ ]);
2406
+ }
2279
2407
  }
2280
2408
 
2281
2409
  if (store.hasLogsDatabase) {
2282
- executeTransaction(
2283
- store.logsDatabasePath,
2284
- deleteStatements("logs", "thread_id", plan.ids),
2285
- );
2410
+ for (const databasePath of store.logsDatabasePaths ?? [store.logsDatabasePath]) {
2411
+ executeTransaction(databasePath, deleteStatements("logs", "thread_id", plan.ids));
2412
+ }
2286
2413
  }
2287
- const stateStatements = [];
2288
-
2289
- for (const idBatch of batches(plan.ids)) {
2290
- const idPlaceholders = placeholders(idBatch);
2291
- stateStatements.push({
2292
- parameters: [...idBatch, ...idBatch],
2293
- sql: `delete from thread_spawn_edges where parent_thread_id in (${idPlaceholders}) or child_thread_id in (${idPlaceholders})`,
2294
- });
2295
- stateStatements.push({
2296
- parameters: idBatch,
2297
- sql: `delete from threads where id in (${idPlaceholders})`,
2298
- });
2414
+ for (const { database, ids } of plan.stateTargets ?? (store.stateDatabases ?? [{ path: store.stateDatabasePath }]).map((database) => ({ database, ids: plan.ids }))) {
2415
+ const stateStatements = [];
2416
+ const hasSpawnEdges = stateSchema(database).hasSpawnEdges;
2417
+ for (const idBatch of batches(ids)) {
2418
+ const idPlaceholders = placeholders(idBatch);
2419
+ if (hasSpawnEdges) stateStatements.push({
2420
+ parameters: [...idBatch, ...idBatch],
2421
+ sql: `delete from thread_spawn_edges where parent_thread_id in (${idPlaceholders}) or child_thread_id in (${idPlaceholders})`,
2422
+ });
2423
+ stateStatements.push({
2424
+ parameters: idBatch,
2425
+ sql: `delete from threads where id in (${idPlaceholders})`,
2426
+ });
2427
+ }
2428
+ executeTransaction(database.path, stateStatements);
2299
2429
  }
2300
-
2301
- executeTransaction(store.stateDatabasePath, stateStatements);
2302
2430
  await reportProgress(onProgress, {
2303
2431
  canCancel: false,
2304
2432
  message: "Updating session records",
@@ -2434,7 +2562,7 @@ export async function listSessionDeletionBackups({ codexHome }) {
2434
2562
  fs.stat(backupDirectory),
2435
2563
  measurePath(backupDirectory),
2436
2564
  ]);
2437
- const restorable = operation?.version === 2 &&
2565
+ const restorable = [2, 3].includes(operation?.version) &&
2438
2566
  Array.isArray(operation.files) &&
2439
2567
  operation.files.length > 0;
2440
2568
 
@@ -2484,9 +2612,10 @@ export async function restoreSessionDeletionBackup({
2484
2612
  const operationPath = path.join(resolvedBackupDirectory, "operation.json");
2485
2613
  const operation = await readJsonFile(operationPath);
2486
2614
 
2487
- if (operation?.version !== 2 || !Array.isArray(operation.files) || operation.files.length === 0) {
2615
+ if (![2, 3].includes(operation?.version) || !Array.isArray(operation.files) || operation.files.length === 0) {
2488
2616
  throw new Error("This backup cannot be restored automatically. Its files are still available for manual recovery.");
2489
2617
  }
2618
+ const currentDatabasesBeforeRestore = getCodexPaths(codexHome, { refresh: true }).resolvedDatabases;
2490
2619
 
2491
2620
  const files = operation.files.map((entry) => {
2492
2621
  if (typeof entry?.backupPath !== "string" || typeof entry?.originalPath !== "string") {
@@ -2599,7 +2728,10 @@ export async function restoreSessionDeletionBackup({
2599
2728
  progress: 100,
2600
2729
  });
2601
2730
 
2731
+ const layoutChanged = operation.version === 3
2732
+ && JSON.stringify(operation.resolvedDatabases ?? {}) !== JSON.stringify(currentDatabasesBeforeRestore);
2602
2733
  return {
2734
+ note: layoutChanged ? "The Codex storage layout changed after this backup was created. The original files were restored to their recorded locations." : null,
2603
2735
  restoredFileCount: files.length,
2604
2736
  safetyBackupDirectory,
2605
2737
  };
@@ -2615,17 +2747,22 @@ export async function restoreSessionDeletionBackup({
2615
2747
 
2616
2748
  export async function verifySessionDeletion({ plan, scope = "deep", store }) {
2617
2749
  const deletedIdSet = new Set(plan.ids);
2618
- const remainingThreads = findRowsForIds(
2619
- store.stateDatabasePath, "threads", "id", plan.ids,
2620
- );
2750
+ const remainingThreads = (store.stateDatabasePaths ?? [store.stateDatabasePath])
2751
+ .flatMap((databasePath) => findRowsForIds(databasePath, "threads", "id", plan.ids));
2621
2752
  const remainingMemoryRecords = scope === "deep" && store.hasMemoryDatabase
2622
- ? findRowsForIds(store.memoryDatabasePath, "stage1_outputs", "thread_id", plan.ids)
2753
+ ? (store.memoryDatabasePaths ?? [store.memoryDatabasePath]).flatMap((databasePath) =>
2754
+ findRowsForIds(databasePath, "stage1_outputs", "thread_id", plan.ids))
2623
2755
  : [];
2624
2756
  const remainingGoalRecords = scope === "deep" && store.hasGoalsDatabase
2625
- ? findRowsForIds(store.goalsDatabasePath, "thread_goals", "thread_id", plan.ids)
2757
+ ? (store.goalsDatabasePaths ?? [store.goalsDatabasePath]).flatMap((databasePath) =>
2758
+ [
2759
+ ...findRowsForIds(databasePath, "thread_goals", "thread_id", plan.ids),
2760
+ ...findRowsForIds(databasePath, "thread_goal_continuation_deferrals", "thread_id", plan.ids),
2761
+ ])
2626
2762
  : [];
2627
2763
  const remainingLogRecords = store.hasLogsDatabase
2628
- ? findRowsForIds(store.logsDatabasePath, "logs", "thread_id", plan.ids, { limitOne: true })
2764
+ ? (store.logsDatabasePaths ?? [store.logsDatabasePath]).flatMap((databasePath) =>
2765
+ findRowsForIds(databasePath, "logs", "thread_id", plan.ids, { limitOne: true }))
2629
2766
  : [];
2630
2767
  const [sessionIndexMatches, historyMatches] = await Promise.all([
2631
2768
  inspectJsonlMatches(