cogpit-memory 0.1.7 → 0.1.8

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