cogpit-memory 0.1.7 → 0.1.9

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.
Files changed (3) hide show
  1. package/dist/cli.js +184 -86
  2. package/dist/index.js +184 -86
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -951,19 +951,32 @@ function extractSessionMetadata(messages) {
951
951
  }
952
952
  return meta;
953
953
  }
954
- function parseSession(jsonlText) {
954
+ function emptyStats(turnCount) {
955
+ return {
956
+ totalInputTokens: 0,
957
+ totalOutputTokens: 0,
958
+ totalCacheCreationTokens: 0,
959
+ totalCacheReadTokens: 0,
960
+ totalCostUSD: 0,
961
+ toolCallCounts: {},
962
+ errorCount: 0,
963
+ totalDurationMs: 0,
964
+ turnCount
965
+ };
966
+ }
967
+ function parseSession(jsonlText, opts) {
955
968
  if (isCodexSessionText(jsonlText)) {
956
969
  return parseCodexSession(jsonlText);
957
970
  }
958
971
  const rawMessages = parseLines(jsonlText);
959
972
  const metadata = extractSessionMetadata(rawMessages);
960
973
  const turns = buildTurns(rawMessages);
961
- const stats = computeStats(turns);
974
+ const stats = opts?.skipStats ? emptyStats(turns.length) : computeStats(turns);
962
975
  return {
963
976
  ...metadata,
964
977
  turns,
965
978
  stats,
966
- rawMessages,
979
+ rawMessages: opts?.skipStats ? [] : rawMessages,
967
980
  agentKind: "claude"
968
981
  };
969
982
  }
@@ -974,6 +987,10 @@ function getUserMessageText(content) {
974
987
  }
975
988
 
976
989
  // src/lib/search-index.ts
990
+ var MAX_CONTENT_LEN = 4096;
991
+ function truncContent(text) {
992
+ return text.length > MAX_CONTENT_LEN ? text.slice(0, MAX_CONTENT_LEN) : text;
993
+ }
977
994
  var SearchIndex = class {
978
995
  db;
979
996
  dbPath;
@@ -1007,7 +1024,7 @@ var SearchIndex = class {
1007
1024
  source_file,
1008
1025
  location,
1009
1026
  content,
1010
- tokenize = 'trigram'
1027
+ tokenize = 'unicode61'
1011
1028
  )`);
1012
1029
  }
1013
1030
  }
@@ -1034,6 +1051,56 @@ var SearchIndex = class {
1034
1051
  lastUpdate: this._lastUpdate
1035
1052
  };
1036
1053
  }
1054
+ /** Index tool call inputs and results under the given location prefix. */
1055
+ insertToolCalls(insert, sessionId, filePath, prefix, toolCalls) {
1056
+ for (const tc of toolCalls) {
1057
+ const inputStr = JSON.stringify(tc.input);
1058
+ if (inputStr && inputStr !== "{}") {
1059
+ insert.run(sessionId, filePath, `${prefix}/toolCall/${tc.id}/input`, truncContent(inputStr));
1060
+ }
1061
+ if (tc.result) {
1062
+ insert.run(sessionId, filePath, `${prefix}/toolCall/${tc.id}/result`, truncContent(tc.result));
1063
+ }
1064
+ }
1065
+ }
1066
+ /**
1067
+ * Insert all searchable content from a parsed session into the FTS5 index.
1068
+ * Shared by both `indexFile` (single-file) and `buildFull` (batch).
1069
+ */
1070
+ insertSessionContent(insert, sessionId, filePath, session) {
1071
+ for (let i = 0; i < session.turns.length; i++) {
1072
+ const turn = session.turns[i];
1073
+ const prefix = `turn/${i}`;
1074
+ const userText = getUserMessageText(turn.userMessage);
1075
+ if (userText.trim()) {
1076
+ insert.run(sessionId, filePath, `${prefix}/userMessage`, truncContent(userText));
1077
+ }
1078
+ const assistantJoined = turn.assistantText.join("\n\n").trim();
1079
+ if (assistantJoined) {
1080
+ insert.run(sessionId, filePath, `${prefix}/assistantMessage`, truncContent(assistantJoined));
1081
+ }
1082
+ const thinkingText = turn.thinking.filter((t) => t.thinking && t.thinking.length > 0).map((t) => t.thinking).join("\n\n").trim();
1083
+ if (thinkingText) {
1084
+ insert.run(sessionId, filePath, `${prefix}/thinking`, truncContent(thinkingText));
1085
+ }
1086
+ this.insertToolCalls(insert, sessionId, filePath, prefix, turn.toolCalls);
1087
+ for (const sa of turn.subAgentActivity) {
1088
+ const saPrefix = `agent/${sa.agentId}`;
1089
+ const saText = sa.text.join("\n\n").trim();
1090
+ if (saText) {
1091
+ insert.run(sessionId, filePath, `${saPrefix}/assistantMessage`, truncContent(saText));
1092
+ }
1093
+ const saThinking = sa.thinking.filter((t) => t.length > 0).join("\n\n").trim();
1094
+ if (saThinking) {
1095
+ insert.run(sessionId, filePath, `${saPrefix}/thinking`, truncContent(saThinking));
1096
+ }
1097
+ this.insertToolCalls(insert, sessionId, filePath, saPrefix, sa.toolCalls);
1098
+ }
1099
+ if (turn.compactionSummary) {
1100
+ insert.run(sessionId, filePath, `${prefix}/compactionSummary`, truncContent(turn.compactionSummary));
1101
+ }
1102
+ }
1103
+ }
1037
1104
  /**
1038
1105
  * Parse a JSONL file and insert all searchable content into the FTS5 index.
1039
1106
  * Idempotent: deletes old data for the file before re-indexing.
@@ -1047,7 +1114,7 @@ var SearchIndex = class {
1047
1114
  mtimeMs = (0, import_node_fs.statSync)(filePath).mtimeMs;
1048
1115
  }
1049
1116
  const content = (0, import_node_fs.readFileSync)(filePath, "utf-8");
1050
- const session = parseSession(content);
1117
+ const session = parseSession(content, { skipStats: true });
1051
1118
  const isSubagent = opts?.isSubagent ? 1 : 0;
1052
1119
  const parentSessionId = opts?.parentSessionId ?? null;
1053
1120
  const insert = this.db.prepare(
@@ -1065,54 +1132,7 @@ var SearchIndex = class {
1065
1132
  const txn = this.db.transaction(() => {
1066
1133
  deleteContent.run(filePath);
1067
1134
  deleteFile.run(filePath);
1068
- for (let i = 0; i < session.turns.length; i++) {
1069
- const turn = session.turns[i];
1070
- const prefix = `turn/${i}`;
1071
- const userText = getUserMessageText(turn.userMessage);
1072
- if (userText.trim()) {
1073
- insert.run(sessionId, filePath, `${prefix}/userMessage`, userText);
1074
- }
1075
- const assistantJoined = turn.assistantText.join("\n\n").trim();
1076
- if (assistantJoined) {
1077
- insert.run(sessionId, filePath, `${prefix}/assistantMessage`, assistantJoined);
1078
- }
1079
- const thinkingText = turn.thinking.filter((t) => t.thinking && t.thinking.length > 0).map((t) => t.thinking).join("\n\n").trim();
1080
- if (thinkingText) {
1081
- insert.run(sessionId, filePath, `${prefix}/thinking`, thinkingText);
1082
- }
1083
- for (const tc of turn.toolCalls) {
1084
- const inputStr = JSON.stringify(tc.input);
1085
- if (inputStr && inputStr !== "{}") {
1086
- insert.run(sessionId, filePath, `${prefix}/toolCall/${tc.id}/input`, inputStr);
1087
- }
1088
- if (tc.result) {
1089
- insert.run(sessionId, filePath, `${prefix}/toolCall/${tc.id}/result`, tc.result);
1090
- }
1091
- }
1092
- for (const sa of turn.subAgentActivity) {
1093
- const saPrefix = `agent/${sa.agentId}`;
1094
- const saText = sa.text.join("\n\n").trim();
1095
- if (saText) {
1096
- insert.run(sessionId, filePath, `${saPrefix}/assistantMessage`, saText);
1097
- }
1098
- const saThinking = sa.thinking.filter((t) => t.length > 0).join("\n\n").trim();
1099
- if (saThinking) {
1100
- insert.run(sessionId, filePath, `${saPrefix}/thinking`, saThinking);
1101
- }
1102
- for (const tc of sa.toolCalls) {
1103
- const inputStr = JSON.stringify(tc.input);
1104
- if (inputStr && inputStr !== "{}") {
1105
- insert.run(sessionId, filePath, `${saPrefix}/toolCall/${tc.id}/input`, inputStr);
1106
- }
1107
- if (tc.result) {
1108
- insert.run(sessionId, filePath, `${saPrefix}/toolCall/${tc.id}/result`, tc.result);
1109
- }
1110
- }
1111
- }
1112
- if (turn.compactionSummary) {
1113
- insert.run(sessionId, filePath, `${prefix}/compactionSummary`, turn.compactionSummary);
1114
- }
1115
- }
1135
+ this.insertSessionContent(insert, sessionId, filePath, session);
1116
1136
  insertFile.run(filePath, mtimeMs, sessionId, isSubagent, parentSessionId);
1117
1137
  });
1118
1138
  txn();
@@ -1121,7 +1141,7 @@ var SearchIndex = class {
1121
1141
  /**
1122
1142
  * Query the FTS5 index and return structured search results.
1123
1143
  *
1124
- * - FTS5 trigram tokenizer is case-insensitive by default.
1144
+ * - FTS5 unicode61 tokenizer is case-insensitive by default.
1125
1145
  * - When `caseSensitive` is true, a post-filter checks the original query
1126
1146
  * against the snippet text (exact case match).
1127
1147
  * - When `maxAgeMs` is provided, only files whose mtime in `indexed_files`
@@ -1163,7 +1183,7 @@ var SearchIndex = class {
1163
1183
  location: row.location,
1164
1184
  snippet: row.snippet,
1165
1185
  matchCount: 1
1166
- // FTS5 trigram doesn't expose per-row match count; 1 = "at least one match"
1186
+ // FTS5 doesn't expose per-row match count; 1 = "at least one match"
1167
1187
  }));
1168
1188
  if (caseSensitive) {
1169
1189
  hits = hits.filter((h) => h.snippet.includes(query));
@@ -1202,19 +1222,67 @@ var SearchIndex = class {
1202
1222
  * Structure: projectsDir/{projectName}/{sessionId}.jsonl
1203
1223
  * Subagents: projectsDir/{projectName}/{sessionId}/subagents/agent-{id}.jsonl
1204
1224
  *
1225
+ * Optimized: discovers all files first, then processes them in a single
1226
+ * SQLite transaction with pre-prepared statements. This avoids the overhead
1227
+ * of 3000+ individual transactions (each forcing a disk sync).
1228
+ *
1205
1229
  * Stores `projectsDir` as a class field so `rebuild()` can reuse it.
1206
1230
  */
1207
1231
  buildFull(projectsDir) {
1208
1232
  this.projectsDir = projectsDir;
1209
- this.db.exec("DELETE FROM search_content");
1210
- this.db.exec("DELETE FROM indexed_files");
1211
- this.indexProjectsDir(projectsDir);
1233
+ this.db.close();
1234
+ try {
1235
+ (0, import_node_fs.unlinkSync)(this.dbPath);
1236
+ } catch {
1237
+ }
1238
+ try {
1239
+ (0, import_node_fs.unlinkSync)(this.dbPath + "-wal");
1240
+ } catch {
1241
+ }
1242
+ try {
1243
+ (0, import_node_fs.unlinkSync)(this.dbPath + "-shm");
1244
+ } catch {
1245
+ }
1246
+ this.db = new Database(this.dbPath);
1247
+ this.db.exec("PRAGMA journal_mode = WAL");
1248
+ this.db.exec("PRAGMA synchronous = OFF");
1249
+ this.db.exec("PRAGMA cache_size = -64000");
1250
+ this.db.exec("PRAGMA temp_store = MEMORY");
1251
+ this.db.exec("PRAGMA mmap_size = 268435456");
1252
+ this.initSchema();
1253
+ const files = [];
1254
+ this.discoverFiles(projectsDir, (filePath, sessionId, mtimeMs, isSubagent, parentSessionId) => {
1255
+ files.push({ path: filePath, sessionId, mtimeMs, isSubagent, parentSessionId });
1256
+ });
1257
+ const insert = this.db.prepare(
1258
+ "INSERT INTO search_content (session_id, source_file, location, content) VALUES (?, ?, ?, ?)"
1259
+ );
1260
+ const insertFile = this.db.prepare(
1261
+ "INSERT OR REPLACE INTO indexed_files (file_path, mtime_ms, session_id, is_subagent, parent_session_id) VALUES (?, ?, ?, ?, ?)"
1262
+ );
1263
+ const txn = this.db.transaction(() => {
1264
+ for (const file of files) {
1265
+ try {
1266
+ const content = (0, import_node_fs.readFileSync)(file.path, "utf-8");
1267
+ const session = parseSession(content, { skipStats: true });
1268
+ this.insertSessionContent(insert, file.sessionId, file.path, session);
1269
+ insertFile.run(file.path, file.mtimeMs, file.sessionId, file.isSubagent ? 1 : 0, file.parentSessionId);
1270
+ } catch {
1271
+ }
1272
+ }
1273
+ });
1274
+ txn();
1275
+ this.db.exec("PRAGMA synchronous = NORMAL");
1212
1276
  this._lastFullBuild = (/* @__PURE__ */ new Date()).toISOString();
1213
1277
  this._lastUpdate = (/* @__PURE__ */ new Date()).toISOString();
1214
1278
  }
1215
1279
  /**
1216
1280
  * Incrementally re-index only files whose mtime has changed since last index.
1217
1281
  * New files (not in indexed_files) are always indexed.
1282
+ *
1283
+ * WARNING: This walks ALL files under projectsDir and stats each one.
1284
+ * On large session stores (3000+ files) this can take minutes.
1285
+ * Prefer `updateRecent()` for CLI search paths.
1218
1286
  */
1219
1287
  updateStale(projectsDir) {
1220
1288
  this.projectsDir = projectsDir;
@@ -1241,6 +1309,46 @@ var SearchIndex = class {
1241
1309
  this._lastUpdate = (/* @__PURE__ */ new Date()).toISOString();
1242
1310
  }
1243
1311
  }
1312
+ /**
1313
+ * Lightweight incremental update for CLI search paths.
1314
+ *
1315
+ * Still walks and stats all files via `discoverFiles`, but skips DB lookups
1316
+ * for files with mtime <= the high-water mark that are already indexed.
1317
+ * Caps re-indexing to `maxFiles` to prevent blocking on large backlogs
1318
+ * (run `index rebuild` for a full catch-up).
1319
+ */
1320
+ updateRecent(projectsDir, maxFiles = 50) {
1321
+ this.projectsDir = projectsDir;
1322
+ const row = this.db.prepare(
1323
+ "SELECT MAX(mtime_ms) as max_mtime FROM indexed_files"
1324
+ ).get();
1325
+ const highWater = row?.max_mtime ?? 0;
1326
+ const getIndexed = this.db.prepare(
1327
+ "SELECT mtime_ms FROM indexed_files WHERE file_path = ?"
1328
+ );
1329
+ const filesToIndex = [];
1330
+ this.discoverFiles(projectsDir, (filePath, sessionId, mtimeMs, isSubagent, parentSessionId) => {
1331
+ if (mtimeMs <= highWater) {
1332
+ const existing = getIndexed.get(filePath);
1333
+ if (existing && existing.mtime_ms >= mtimeMs) return;
1334
+ }
1335
+ filesToIndex.push({ path: filePath, sessionId, mtimeMs, isSubagent, parentSessionId });
1336
+ });
1337
+ filesToIndex.sort((a, b) => b.mtimeMs - a.mtimeMs);
1338
+ const batch = filesToIndex.slice(0, maxFiles);
1339
+ for (const file of batch) {
1340
+ try {
1341
+ this.indexFile(file.path, file.sessionId, file.mtimeMs, {
1342
+ isSubagent: file.isSubagent,
1343
+ parentSessionId: file.parentSessionId
1344
+ });
1345
+ } catch {
1346
+ }
1347
+ }
1348
+ if (batch.length > 0) {
1349
+ this._lastUpdate = (/* @__PURE__ */ new Date()).toISOString();
1350
+ }
1351
+ }
1244
1352
  /**
1245
1353
  * Re-run buildFull using the previously stored projectsDir.
1246
1354
  * No-op if projectsDir was never set.
@@ -1249,18 +1357,6 @@ var SearchIndex = class {
1249
1357
  if (!this.projectsDir) return;
1250
1358
  this.buildFull(this.projectsDir);
1251
1359
  }
1252
- /**
1253
- * Walk all project directories under `projectsDir` and index every discovered
1254
- * JSONL file (both sessions and subagents).
1255
- */
1256
- indexProjectsDir(projectsDir) {
1257
- this.discoverFiles(projectsDir, (filePath, sessionId, mtimeMs, isSubagent, parentSessionId) => {
1258
- try {
1259
- this.indexFile(filePath, sessionId, mtimeMs, { isSubagent, parentSessionId });
1260
- } catch {
1261
- }
1262
- });
1263
- }
1264
1360
  /**
1265
1361
  * Walk the projects directory tree and invoke `callback` for every JSONL file.
1266
1362
  *
@@ -1276,29 +1372,23 @@ var SearchIndex = class {
1276
1372
  discoverFiles(projectsDir, callback) {
1277
1373
  let entries;
1278
1374
  try {
1279
- entries = (0, import_node_fs.readdirSync)(projectsDir);
1375
+ entries = (0, import_node_fs.readdirSync)(projectsDir, { withFileTypes: true });
1280
1376
  } catch {
1281
1377
  return;
1282
1378
  }
1283
- for (const projectName of entries) {
1284
- if (projectName === "memory") continue;
1285
- const projectDir = (0, import_node_path.join)(projectsDir, projectName);
1286
- try {
1287
- const s = (0, import_node_fs.statSync)(projectDir);
1288
- if (!s.isDirectory()) continue;
1289
- } catch {
1290
- continue;
1291
- }
1379
+ for (const entry of entries) {
1380
+ if (entry.name === "memory" || !entry.isDirectory()) continue;
1381
+ const projectDir = (0, import_node_path.join)(projectsDir, entry.name);
1292
1382
  let files;
1293
1383
  try {
1294
- files = (0, import_node_fs.readdirSync)(projectDir);
1384
+ files = (0, import_node_fs.readdirSync)(projectDir, { withFileTypes: true });
1295
1385
  } catch {
1296
1386
  continue;
1297
1387
  }
1298
1388
  for (const file of files) {
1299
- if (!file.endsWith(".jsonl")) continue;
1300
- const filePath = (0, import_node_path.join)(projectDir, file);
1301
- const sessionId = (0, import_node_path.basename)(file, ".jsonl");
1389
+ if (!file.name.endsWith(".jsonl") || !file.isFile()) continue;
1390
+ const filePath = (0, import_node_path.join)(projectDir, file.name);
1391
+ const sessionId = (0, import_node_path.basename)(file.name, ".jsonl");
1302
1392
  try {
1303
1393
  const s = (0, import_node_fs.statSync)(filePath);
1304
1394
  callback(filePath, sessionId, s.mtimeMs, false, null);
@@ -1544,11 +1634,19 @@ async function searchSessions(query, opts, searchIndex) {
1544
1634
  const depth = Math.min(Math.max(1, opts.depth ?? 4), 4);
1545
1635
  let index = searchIndex ?? null;
1546
1636
  let ownedIndex = false;
1547
- if (!index && (0, import_node_fs2.existsSync)(DEFAULT_DB_PATH)) {
1637
+ if (!index && searchIndex === void 0) {
1548
1638
  try {
1639
+ const dbExists = (0, import_node_fs2.existsSync)(DEFAULT_DB_PATH);
1640
+ if (!dbExists) {
1641
+ (0, import_node_fs2.mkdirSync)((0, import_node_path4.dirname)(DEFAULT_DB_PATH), { recursive: true });
1642
+ }
1549
1643
  index = new SearchIndex(DEFAULT_DB_PATH);
1550
1644
  ownedIndex = true;
1551
- index.updateStale(dirs.PROJECTS_DIR);
1645
+ if (!dbExists) {
1646
+ index.buildFull(dirs.PROJECTS_DIR);
1647
+ } else {
1648
+ index.updateRecent(dirs.PROJECTS_DIR);
1649
+ }
1552
1650
  } catch {
1553
1651
  }
1554
1652
  }
package/dist/index.js CHANGED
@@ -967,19 +967,32 @@ function extractSessionMetadata(messages) {
967
967
  }
968
968
  return meta;
969
969
  }
970
- function parseSession(jsonlText) {
970
+ function emptyStats(turnCount) {
971
+ return {
972
+ totalInputTokens: 0,
973
+ totalOutputTokens: 0,
974
+ totalCacheCreationTokens: 0,
975
+ totalCacheReadTokens: 0,
976
+ totalCostUSD: 0,
977
+ toolCallCounts: {},
978
+ errorCount: 0,
979
+ totalDurationMs: 0,
980
+ turnCount
981
+ };
982
+ }
983
+ function parseSession(jsonlText, opts) {
971
984
  if (isCodexSessionText(jsonlText)) {
972
985
  return parseCodexSession(jsonlText);
973
986
  }
974
987
  const rawMessages = parseLines(jsonlText);
975
988
  const metadata = extractSessionMetadata(rawMessages);
976
989
  const turns = buildTurns(rawMessages);
977
- const stats = computeStats(turns);
990
+ const stats = opts?.skipStats ? emptyStats(turns.length) : computeStats(turns);
978
991
  return {
979
992
  ...metadata,
980
993
  turns,
981
994
  stats,
982
- rawMessages,
995
+ rawMessages: opts?.skipStats ? [] : rawMessages,
983
996
  agentKind: "claude"
984
997
  };
985
998
  }
@@ -1072,6 +1085,10 @@ function getUserMessageImages(content) {
1072
1085
  }
1073
1086
 
1074
1087
  // src/lib/search-index.ts
1088
+ var MAX_CONTENT_LEN = 4096;
1089
+ function truncContent(text) {
1090
+ return text.length > MAX_CONTENT_LEN ? text.slice(0, MAX_CONTENT_LEN) : text;
1091
+ }
1075
1092
  var SearchIndex = class {
1076
1093
  db;
1077
1094
  dbPath;
@@ -1105,7 +1122,7 @@ var SearchIndex = class {
1105
1122
  source_file,
1106
1123
  location,
1107
1124
  content,
1108
- tokenize = 'trigram'
1125
+ tokenize = 'unicode61'
1109
1126
  )`);
1110
1127
  }
1111
1128
  }
@@ -1132,6 +1149,56 @@ var SearchIndex = class {
1132
1149
  lastUpdate: this._lastUpdate
1133
1150
  };
1134
1151
  }
1152
+ /** Index tool call inputs and results under the given location prefix. */
1153
+ insertToolCalls(insert, sessionId, filePath, prefix, toolCalls) {
1154
+ for (const tc of toolCalls) {
1155
+ const inputStr = JSON.stringify(tc.input);
1156
+ if (inputStr && inputStr !== "{}") {
1157
+ insert.run(sessionId, filePath, `${prefix}/toolCall/${tc.id}/input`, truncContent(inputStr));
1158
+ }
1159
+ if (tc.result) {
1160
+ insert.run(sessionId, filePath, `${prefix}/toolCall/${tc.id}/result`, truncContent(tc.result));
1161
+ }
1162
+ }
1163
+ }
1164
+ /**
1165
+ * Insert all searchable content from a parsed session into the FTS5 index.
1166
+ * Shared by both `indexFile` (single-file) and `buildFull` (batch).
1167
+ */
1168
+ insertSessionContent(insert, sessionId, filePath, session) {
1169
+ for (let i = 0; i < session.turns.length; i++) {
1170
+ const turn = session.turns[i];
1171
+ const prefix = `turn/${i}`;
1172
+ const userText = getUserMessageText(turn.userMessage);
1173
+ if (userText.trim()) {
1174
+ insert.run(sessionId, filePath, `${prefix}/userMessage`, truncContent(userText));
1175
+ }
1176
+ const assistantJoined = turn.assistantText.join("\n\n").trim();
1177
+ if (assistantJoined) {
1178
+ insert.run(sessionId, filePath, `${prefix}/assistantMessage`, truncContent(assistantJoined));
1179
+ }
1180
+ const thinkingText = turn.thinking.filter((t) => t.thinking && t.thinking.length > 0).map((t) => t.thinking).join("\n\n").trim();
1181
+ if (thinkingText) {
1182
+ insert.run(sessionId, filePath, `${prefix}/thinking`, truncContent(thinkingText));
1183
+ }
1184
+ this.insertToolCalls(insert, sessionId, filePath, prefix, turn.toolCalls);
1185
+ for (const sa of turn.subAgentActivity) {
1186
+ const saPrefix = `agent/${sa.agentId}`;
1187
+ const saText = sa.text.join("\n\n").trim();
1188
+ if (saText) {
1189
+ insert.run(sessionId, filePath, `${saPrefix}/assistantMessage`, truncContent(saText));
1190
+ }
1191
+ const saThinking = sa.thinking.filter((t) => t.length > 0).join("\n\n").trim();
1192
+ if (saThinking) {
1193
+ insert.run(sessionId, filePath, `${saPrefix}/thinking`, truncContent(saThinking));
1194
+ }
1195
+ this.insertToolCalls(insert, sessionId, filePath, saPrefix, sa.toolCalls);
1196
+ }
1197
+ if (turn.compactionSummary) {
1198
+ insert.run(sessionId, filePath, `${prefix}/compactionSummary`, truncContent(turn.compactionSummary));
1199
+ }
1200
+ }
1201
+ }
1135
1202
  /**
1136
1203
  * Parse a JSONL file and insert all searchable content into the FTS5 index.
1137
1204
  * Idempotent: deletes old data for the file before re-indexing.
@@ -1145,7 +1212,7 @@ var SearchIndex = class {
1145
1212
  mtimeMs = (0, import_node_fs.statSync)(filePath).mtimeMs;
1146
1213
  }
1147
1214
  const content = (0, import_node_fs.readFileSync)(filePath, "utf-8");
1148
- const session = parseSession(content);
1215
+ const session = parseSession(content, { skipStats: true });
1149
1216
  const isSubagent = opts?.isSubagent ? 1 : 0;
1150
1217
  const parentSessionId = opts?.parentSessionId ?? null;
1151
1218
  const insert = this.db.prepare(
@@ -1163,54 +1230,7 @@ var SearchIndex = class {
1163
1230
  const txn = this.db.transaction(() => {
1164
1231
  deleteContent.run(filePath);
1165
1232
  deleteFile.run(filePath);
1166
- for (let i = 0; i < session.turns.length; i++) {
1167
- const turn = session.turns[i];
1168
- const prefix = `turn/${i}`;
1169
- const userText = getUserMessageText(turn.userMessage);
1170
- if (userText.trim()) {
1171
- insert.run(sessionId, filePath, `${prefix}/userMessage`, userText);
1172
- }
1173
- const assistantJoined = turn.assistantText.join("\n\n").trim();
1174
- if (assistantJoined) {
1175
- insert.run(sessionId, filePath, `${prefix}/assistantMessage`, assistantJoined);
1176
- }
1177
- const thinkingText = turn.thinking.filter((t) => t.thinking && t.thinking.length > 0).map((t) => t.thinking).join("\n\n").trim();
1178
- if (thinkingText) {
1179
- insert.run(sessionId, filePath, `${prefix}/thinking`, thinkingText);
1180
- }
1181
- for (const tc of turn.toolCalls) {
1182
- const inputStr = JSON.stringify(tc.input);
1183
- if (inputStr && inputStr !== "{}") {
1184
- insert.run(sessionId, filePath, `${prefix}/toolCall/${tc.id}/input`, inputStr);
1185
- }
1186
- if (tc.result) {
1187
- insert.run(sessionId, filePath, `${prefix}/toolCall/${tc.id}/result`, tc.result);
1188
- }
1189
- }
1190
- for (const sa of turn.subAgentActivity) {
1191
- const saPrefix = `agent/${sa.agentId}`;
1192
- const saText = sa.text.join("\n\n").trim();
1193
- if (saText) {
1194
- insert.run(sessionId, filePath, `${saPrefix}/assistantMessage`, saText);
1195
- }
1196
- const saThinking = sa.thinking.filter((t) => t.length > 0).join("\n\n").trim();
1197
- if (saThinking) {
1198
- insert.run(sessionId, filePath, `${saPrefix}/thinking`, saThinking);
1199
- }
1200
- for (const tc of sa.toolCalls) {
1201
- const inputStr = JSON.stringify(tc.input);
1202
- if (inputStr && inputStr !== "{}") {
1203
- insert.run(sessionId, filePath, `${saPrefix}/toolCall/${tc.id}/input`, inputStr);
1204
- }
1205
- if (tc.result) {
1206
- insert.run(sessionId, filePath, `${saPrefix}/toolCall/${tc.id}/result`, tc.result);
1207
- }
1208
- }
1209
- }
1210
- if (turn.compactionSummary) {
1211
- insert.run(sessionId, filePath, `${prefix}/compactionSummary`, turn.compactionSummary);
1212
- }
1213
- }
1233
+ this.insertSessionContent(insert, sessionId, filePath, session);
1214
1234
  insertFile.run(filePath, mtimeMs, sessionId, isSubagent, parentSessionId);
1215
1235
  });
1216
1236
  txn();
@@ -1219,7 +1239,7 @@ var SearchIndex = class {
1219
1239
  /**
1220
1240
  * Query the FTS5 index and return structured search results.
1221
1241
  *
1222
- * - FTS5 trigram tokenizer is case-insensitive by default.
1242
+ * - FTS5 unicode61 tokenizer is case-insensitive by default.
1223
1243
  * - When `caseSensitive` is true, a post-filter checks the original query
1224
1244
  * against the snippet text (exact case match).
1225
1245
  * - When `maxAgeMs` is provided, only files whose mtime in `indexed_files`
@@ -1261,7 +1281,7 @@ var SearchIndex = class {
1261
1281
  location: row.location,
1262
1282
  snippet: row.snippet,
1263
1283
  matchCount: 1
1264
- // FTS5 trigram doesn't expose per-row match count; 1 = "at least one match"
1284
+ // FTS5 doesn't expose per-row match count; 1 = "at least one match"
1265
1285
  }));
1266
1286
  if (caseSensitive) {
1267
1287
  hits = hits.filter((h) => h.snippet.includes(query));
@@ -1300,19 +1320,67 @@ var SearchIndex = class {
1300
1320
  * Structure: projectsDir/{projectName}/{sessionId}.jsonl
1301
1321
  * Subagents: projectsDir/{projectName}/{sessionId}/subagents/agent-{id}.jsonl
1302
1322
  *
1323
+ * Optimized: discovers all files first, then processes them in a single
1324
+ * SQLite transaction with pre-prepared statements. This avoids the overhead
1325
+ * of 3000+ individual transactions (each forcing a disk sync).
1326
+ *
1303
1327
  * Stores `projectsDir` as a class field so `rebuild()` can reuse it.
1304
1328
  */
1305
1329
  buildFull(projectsDir) {
1306
1330
  this.projectsDir = projectsDir;
1307
- this.db.exec("DELETE FROM search_content");
1308
- this.db.exec("DELETE FROM indexed_files");
1309
- this.indexProjectsDir(projectsDir);
1331
+ this.db.close();
1332
+ try {
1333
+ (0, import_node_fs.unlinkSync)(this.dbPath);
1334
+ } catch {
1335
+ }
1336
+ try {
1337
+ (0, import_node_fs.unlinkSync)(this.dbPath + "-wal");
1338
+ } catch {
1339
+ }
1340
+ try {
1341
+ (0, import_node_fs.unlinkSync)(this.dbPath + "-shm");
1342
+ } catch {
1343
+ }
1344
+ this.db = new Database(this.dbPath);
1345
+ this.db.exec("PRAGMA journal_mode = WAL");
1346
+ this.db.exec("PRAGMA synchronous = OFF");
1347
+ this.db.exec("PRAGMA cache_size = -64000");
1348
+ this.db.exec("PRAGMA temp_store = MEMORY");
1349
+ this.db.exec("PRAGMA mmap_size = 268435456");
1350
+ this.initSchema();
1351
+ const files = [];
1352
+ this.discoverFiles(projectsDir, (filePath, sessionId, mtimeMs, isSubagent, parentSessionId) => {
1353
+ files.push({ path: filePath, sessionId, mtimeMs, isSubagent, parentSessionId });
1354
+ });
1355
+ const insert = this.db.prepare(
1356
+ "INSERT INTO search_content (session_id, source_file, location, content) VALUES (?, ?, ?, ?)"
1357
+ );
1358
+ const insertFile = this.db.prepare(
1359
+ "INSERT OR REPLACE INTO indexed_files (file_path, mtime_ms, session_id, is_subagent, parent_session_id) VALUES (?, ?, ?, ?, ?)"
1360
+ );
1361
+ const txn = this.db.transaction(() => {
1362
+ for (const file of files) {
1363
+ try {
1364
+ const content = (0, import_node_fs.readFileSync)(file.path, "utf-8");
1365
+ const session = parseSession(content, { skipStats: true });
1366
+ this.insertSessionContent(insert, file.sessionId, file.path, session);
1367
+ insertFile.run(file.path, file.mtimeMs, file.sessionId, file.isSubagent ? 1 : 0, file.parentSessionId);
1368
+ } catch {
1369
+ }
1370
+ }
1371
+ });
1372
+ txn();
1373
+ this.db.exec("PRAGMA synchronous = NORMAL");
1310
1374
  this._lastFullBuild = (/* @__PURE__ */ new Date()).toISOString();
1311
1375
  this._lastUpdate = (/* @__PURE__ */ new Date()).toISOString();
1312
1376
  }
1313
1377
  /**
1314
1378
  * Incrementally re-index only files whose mtime has changed since last index.
1315
1379
  * New files (not in indexed_files) are always indexed.
1380
+ *
1381
+ * WARNING: This walks ALL files under projectsDir and stats each one.
1382
+ * On large session stores (3000+ files) this can take minutes.
1383
+ * Prefer `updateRecent()` for CLI search paths.
1316
1384
  */
1317
1385
  updateStale(projectsDir) {
1318
1386
  this.projectsDir = projectsDir;
@@ -1339,6 +1407,46 @@ var SearchIndex = class {
1339
1407
  this._lastUpdate = (/* @__PURE__ */ new Date()).toISOString();
1340
1408
  }
1341
1409
  }
1410
+ /**
1411
+ * Lightweight incremental update for CLI search paths.
1412
+ *
1413
+ * Still walks and stats all files via `discoverFiles`, but skips DB lookups
1414
+ * for files with mtime <= the high-water mark that are already indexed.
1415
+ * Caps re-indexing to `maxFiles` to prevent blocking on large backlogs
1416
+ * (run `index rebuild` for a full catch-up).
1417
+ */
1418
+ updateRecent(projectsDir, maxFiles = 50) {
1419
+ this.projectsDir = projectsDir;
1420
+ const row = this.db.prepare(
1421
+ "SELECT MAX(mtime_ms) as max_mtime FROM indexed_files"
1422
+ ).get();
1423
+ const highWater = row?.max_mtime ?? 0;
1424
+ const getIndexed = this.db.prepare(
1425
+ "SELECT mtime_ms FROM indexed_files WHERE file_path = ?"
1426
+ );
1427
+ const filesToIndex = [];
1428
+ this.discoverFiles(projectsDir, (filePath, sessionId, mtimeMs, isSubagent, parentSessionId) => {
1429
+ if (mtimeMs <= highWater) {
1430
+ const existing = getIndexed.get(filePath);
1431
+ if (existing && existing.mtime_ms >= mtimeMs) return;
1432
+ }
1433
+ filesToIndex.push({ path: filePath, sessionId, mtimeMs, isSubagent, parentSessionId });
1434
+ });
1435
+ filesToIndex.sort((a, b) => b.mtimeMs - a.mtimeMs);
1436
+ const batch = filesToIndex.slice(0, maxFiles);
1437
+ for (const file of batch) {
1438
+ try {
1439
+ this.indexFile(file.path, file.sessionId, file.mtimeMs, {
1440
+ isSubagent: file.isSubagent,
1441
+ parentSessionId: file.parentSessionId
1442
+ });
1443
+ } catch {
1444
+ }
1445
+ }
1446
+ if (batch.length > 0) {
1447
+ this._lastUpdate = (/* @__PURE__ */ new Date()).toISOString();
1448
+ }
1449
+ }
1342
1450
  /**
1343
1451
  * Re-run buildFull using the previously stored projectsDir.
1344
1452
  * No-op if projectsDir was never set.
@@ -1347,18 +1455,6 @@ var SearchIndex = class {
1347
1455
  if (!this.projectsDir) return;
1348
1456
  this.buildFull(this.projectsDir);
1349
1457
  }
1350
- /**
1351
- * Walk all project directories under `projectsDir` and index every discovered
1352
- * JSONL file (both sessions and subagents).
1353
- */
1354
- indexProjectsDir(projectsDir) {
1355
- this.discoverFiles(projectsDir, (filePath, sessionId, mtimeMs, isSubagent, parentSessionId) => {
1356
- try {
1357
- this.indexFile(filePath, sessionId, mtimeMs, { isSubagent, parentSessionId });
1358
- } catch {
1359
- }
1360
- });
1361
- }
1362
1458
  /**
1363
1459
  * Walk the projects directory tree and invoke `callback` for every JSONL file.
1364
1460
  *
@@ -1374,29 +1470,23 @@ var SearchIndex = class {
1374
1470
  discoverFiles(projectsDir, callback) {
1375
1471
  let entries;
1376
1472
  try {
1377
- entries = (0, import_node_fs.readdirSync)(projectsDir);
1473
+ entries = (0, import_node_fs.readdirSync)(projectsDir, { withFileTypes: true });
1378
1474
  } catch {
1379
1475
  return;
1380
1476
  }
1381
- for (const projectName of entries) {
1382
- if (projectName === "memory") continue;
1383
- const projectDir = (0, import_node_path.join)(projectsDir, projectName);
1384
- try {
1385
- const s = (0, import_node_fs.statSync)(projectDir);
1386
- if (!s.isDirectory()) continue;
1387
- } catch {
1388
- continue;
1389
- }
1477
+ for (const entry of entries) {
1478
+ if (entry.name === "memory" || !entry.isDirectory()) continue;
1479
+ const projectDir = (0, import_node_path.join)(projectsDir, entry.name);
1390
1480
  let files;
1391
1481
  try {
1392
- files = (0, import_node_fs.readdirSync)(projectDir);
1482
+ files = (0, import_node_fs.readdirSync)(projectDir, { withFileTypes: true });
1393
1483
  } catch {
1394
1484
  continue;
1395
1485
  }
1396
1486
  for (const file of files) {
1397
- if (!file.endsWith(".jsonl")) continue;
1398
- const filePath = (0, import_node_path.join)(projectDir, file);
1399
- const sessionId = (0, import_node_path.basename)(file, ".jsonl");
1487
+ if (!file.name.endsWith(".jsonl") || !file.isFile()) continue;
1488
+ const filePath = (0, import_node_path.join)(projectDir, file.name);
1489
+ const sessionId = (0, import_node_path.basename)(file.name, ".jsonl");
1400
1490
  try {
1401
1491
  const s = (0, import_node_fs.statSync)(filePath);
1402
1492
  callback(filePath, sessionId, s.mtimeMs, false, null);
@@ -1647,11 +1737,19 @@ async function searchSessions(query, opts, searchIndex) {
1647
1737
  const depth = Math.min(Math.max(1, opts.depth ?? 4), 4);
1648
1738
  let index = searchIndex ?? null;
1649
1739
  let ownedIndex = false;
1650
- if (!index && (0, import_node_fs2.existsSync)(DEFAULT_DB_PATH)) {
1740
+ if (!index && searchIndex === void 0) {
1651
1741
  try {
1742
+ const dbExists = (0, import_node_fs2.existsSync)(DEFAULT_DB_PATH);
1743
+ if (!dbExists) {
1744
+ (0, import_node_fs2.mkdirSync)((0, import_node_path4.dirname)(DEFAULT_DB_PATH), { recursive: true });
1745
+ }
1652
1746
  index = new SearchIndex(DEFAULT_DB_PATH);
1653
1747
  ownedIndex = true;
1654
- index.updateStale(dirs.PROJECTS_DIR);
1748
+ if (!dbExists) {
1749
+ index.buildFull(dirs.PROJECTS_DIR);
1750
+ } else {
1751
+ index.updateRecent(dirs.PROJECTS_DIR);
1752
+ }
1655
1753
  } catch {
1656
1754
  }
1657
1755
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cogpit-memory",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "CLI tool for Claude Code session introspection — search, browse, and drill into past sessions",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./src/index.ts",