codetree-claude 1.4.7 → 1.5.1

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 (138) hide show
  1. package/README.md +144 -86
  2. package/dist/cli.js +69 -83
  3. package/dist/cli.js.map +1 -1
  4. package/dist/config.d.ts +126 -0
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +22 -0
  7. package/dist/config.js.map +1 -1
  8. package/dist/diff.d.ts +37 -0
  9. package/dist/diff.d.ts.map +1 -0
  10. package/dist/diff.js +181 -0
  11. package/dist/diff.js.map +1 -0
  12. package/dist/doctor.d.ts +5 -0
  13. package/dist/doctor.d.ts.map +1 -0
  14. package/dist/doctor.js +139 -0
  15. package/dist/doctor.js.map +1 -0
  16. package/dist/hook/post-compact.js +1 -23
  17. package/dist/hook/post-compact.js.map +1 -1
  18. package/dist/hook/post-tool-use.js +15 -27
  19. package/dist/hook/post-tool-use.js.map +1 -1
  20. package/dist/hook/pre-compact.js +1 -22
  21. package/dist/hook/pre-compact.js.map +1 -1
  22. package/dist/hook/pre-tool-use.bundled.js +49 -69
  23. package/dist/hook/pre-tool-use.js +23 -80
  24. package/dist/hook/pre-tool-use.js.map +1 -1
  25. package/dist/hook/session-start.js +19 -23
  26. package/dist/hook/session-start.js.map +1 -1
  27. package/dist/index.d.ts +7 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +8 -1
  30. package/dist/index.js.map +1 -1
  31. package/dist/indexer/extractor-registry.d.ts.map +1 -1
  32. package/dist/indexer/extractor-registry.js +6 -0
  33. package/dist/indexer/extractor-registry.js.map +1 -1
  34. package/dist/indexer/extractors/csharp.d.ts +12 -0
  35. package/dist/indexer/extractors/csharp.d.ts.map +1 -0
  36. package/dist/indexer/extractors/csharp.js +165 -0
  37. package/dist/indexer/extractors/csharp.js.map +1 -0
  38. package/dist/indexer/extractors/go.js +44 -1
  39. package/dist/indexer/extractors/go.js.map +1 -1
  40. package/dist/indexer/extractors/java.d.ts.map +1 -1
  41. package/dist/indexer/extractors/java.js +41 -3
  42. package/dist/indexer/extractors/java.js.map +1 -1
  43. package/dist/indexer/extractors/javascript.d.ts.map +1 -1
  44. package/dist/indexer/extractors/javascript.js +67 -3
  45. package/dist/indexer/extractors/javascript.js.map +1 -1
  46. package/dist/indexer/extractors/python.d.ts.map +1 -1
  47. package/dist/indexer/extractors/python.js +31 -2
  48. package/dist/indexer/extractors/python.js.map +1 -1
  49. package/dist/indexer/extractors/ruby.d.ts +10 -0
  50. package/dist/indexer/extractors/ruby.d.ts.map +1 -0
  51. package/dist/indexer/extractors/ruby.js +145 -0
  52. package/dist/indexer/extractors/ruby.js.map +1 -0
  53. package/dist/indexer/extractors/rust.d.ts +10 -0
  54. package/dist/indexer/extractors/rust.d.ts.map +1 -0
  55. package/dist/indexer/extractors/rust.js +194 -0
  56. package/dist/indexer/extractors/rust.js.map +1 -0
  57. package/dist/indexer/extractors/types.d.ts.map +1 -1
  58. package/dist/indexer/extractors/types.js +6 -0
  59. package/dist/indexer/extractors/types.js.map +1 -1
  60. package/dist/indexer/indexer.d.ts +23 -0
  61. package/dist/indexer/indexer.d.ts.map +1 -1
  62. package/dist/indexer/indexer.js +233 -14
  63. package/dist/indexer/indexer.js.map +1 -1
  64. package/dist/indexer/watcher.d.ts +3 -0
  65. package/dist/indexer/watcher.d.ts.map +1 -1
  66. package/dist/indexer/watcher.js +33 -1
  67. package/dist/indexer/watcher.js.map +1 -1
  68. package/dist/server/ipc.d.ts +10 -4
  69. package/dist/server/ipc.d.ts.map +1 -1
  70. package/dist/server/ipc.js +190 -109
  71. package/dist/server/ipc.js.map +1 -1
  72. package/dist/server/mcp-server.js +38 -29
  73. package/dist/server/mcp-server.js.map +1 -1
  74. package/dist/server/tools/codetree-edit.d.ts +2 -1
  75. package/dist/server/tools/codetree-edit.d.ts.map +1 -1
  76. package/dist/server/tools/codetree-edit.js +24 -6
  77. package/dist/server/tools/codetree-edit.js.map +1 -1
  78. package/dist/server/tools/codetree-find-refs.d.ts.map +1 -1
  79. package/dist/server/tools/codetree-find-refs.js +29 -15
  80. package/dist/server/tools/codetree-find-refs.js.map +1 -1
  81. package/dist/server/tools/codetree-memory.d.ts +3 -6
  82. package/dist/server/tools/codetree-memory.d.ts.map +1 -1
  83. package/dist/server/tools/codetree-memory.js +9 -10
  84. package/dist/server/tools/codetree-memory.js.map +1 -1
  85. package/dist/server/tools/codetree-outline.d.ts +57 -0
  86. package/dist/server/tools/codetree-outline.d.ts.map +1 -0
  87. package/dist/server/tools/codetree-outline.js +76 -0
  88. package/dist/server/tools/codetree-outline.js.map +1 -0
  89. package/dist/server/tools/codetree-probe.d.ts +53 -0
  90. package/dist/server/tools/codetree-probe.d.ts.map +1 -0
  91. package/dist/server/tools/codetree-probe.js +81 -0
  92. package/dist/server/tools/codetree-probe.js.map +1 -0
  93. package/dist/server/tools/codetree-read.d.ts +52 -3
  94. package/dist/server/tools/codetree-read.d.ts.map +1 -1
  95. package/dist/server/tools/codetree-read.js +249 -38
  96. package/dist/server/tools/codetree-read.js.map +1 -1
  97. package/dist/server/tools/codetree-search.d.ts +13 -1
  98. package/dist/server/tools/codetree-search.d.ts.map +1 -1
  99. package/dist/server/tools/codetree-search.js +89 -49
  100. package/dist/server/tools/codetree-search.js.map +1 -1
  101. package/dist/server/tools/codetree-structure.d.ts +25 -1
  102. package/dist/server/tools/codetree-structure.d.ts.map +1 -1
  103. package/dist/server/tools/codetree-structure.js +99 -31
  104. package/dist/server/tools/codetree-structure.js.map +1 -1
  105. package/dist/server/tools/codetree-summary.d.ts +2 -0
  106. package/dist/server/tools/codetree-summary.d.ts.map +1 -1
  107. package/dist/server/tools/codetree-summary.js +5 -0
  108. package/dist/server/tools/codetree-summary.js.map +1 -1
  109. package/dist/server/tools/codetree-write.d.ts +2 -1
  110. package/dist/server/tools/codetree-write.d.ts.map +1 -1
  111. package/dist/server/tools/codetree-write.js +9 -1
  112. package/dist/server/tools/codetree-write.js.map +1 -1
  113. package/dist/setup/install.d.ts +45 -1
  114. package/dist/setup/install.d.ts.map +1 -1
  115. package/dist/setup/install.js +163 -58
  116. package/dist/setup/install.js.map +1 -1
  117. package/dist/setup/template-load.d.ts +9 -0
  118. package/dist/setup/template-load.d.ts.map +1 -0
  119. package/dist/setup/template-load.js +39 -0
  120. package/dist/setup/template-load.js.map +1 -0
  121. package/dist/storage/database.d.ts +49 -0
  122. package/dist/storage/database.d.ts.map +1 -1
  123. package/dist/storage/database.js +398 -53
  124. package/dist/storage/database.js.map +1 -1
  125. package/dist/storage/disk-manager.d.ts.map +1 -1
  126. package/dist/storage/disk-manager.js +1 -5
  127. package/dist/storage/disk-manager.js.map +1 -1
  128. package/dist/utils.d.ts +23 -0
  129. package/dist/utils.d.ts.map +1 -0
  130. package/dist/utils.js +102 -0
  131. package/dist/utils.js.map +1 -0
  132. package/package.json +63 -5
  133. package/templates/README.md +15 -0
  134. package/templates/agents-codetree.snippet.md +24 -0
  135. package/templates/claude-md-codetree.snippet.md +22 -0
  136. package/templates/claude-rules-codetree.md +23 -0
  137. package/templates/cursor-codetree.mdc +31 -0
  138. package/templates/gemini-codetree.snippet.md +6 -0
@@ -1,5 +1,26 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
+ import { inflateSync } from "node:zlib";
4
+ /**
5
+ * Extract lowercase trigrams from content for the trigram index.
6
+ * Caps at 5000 unique trigrams per file to keep the index manageable.
7
+ */
8
+ function extractTrigrams(content, maxTrigrams = 5000) {
9
+ const trigrams = new Set();
10
+ const lower = content.toLowerCase();
11
+ for (let i = 0; i < lower.length - 2 && trigrams.size < maxTrigrams; i++) {
12
+ const c0 = lower.charCodeAt(i);
13
+ // Only index trigrams from identifier-like characters (letters, digits, underscore)
14
+ if ((c0 >= 97 && c0 <= 122) || (c0 >= 48 && c0 <= 57) || c0 === 95) {
15
+ const tri = lower.slice(i, i + 3);
16
+ // Skip trigrams with whitespace/newlines
17
+ if (!/\s/.test(tri)) {
18
+ trigrams.add(tri);
19
+ }
20
+ }
21
+ }
22
+ return trigrams;
23
+ }
3
24
  const SCHEMA_SQL = `
4
25
  CREATE TABLE IF NOT EXISTS files (
5
26
  path TEXT PRIMARY KEY,
@@ -123,6 +144,28 @@ CREATE TABLE IF NOT EXISTS compaction_snapshots (
123
144
 
124
145
  CREATE INDEX IF NOT EXISTS idx_compaction_session ON compaction_snapshots(session_id);
125
146
  CREATE INDEX IF NOT EXISTS idx_compaction_time ON compaction_snapshots(created_at);
147
+
148
+ -- Previous content: stores old file content for diff-based reads
149
+ CREATE TABLE IF NOT EXISTS previous_content (
150
+ hash TEXT PRIMARY KEY,
151
+ content BLOB NOT NULL,
152
+ saved_at INTEGER NOT NULL
153
+ );
154
+
155
+ -- Token postings: persisted inverted index for fast content search
156
+ CREATE TABLE IF NOT EXISTS token_postings (
157
+ token TEXT NOT NULL,
158
+ file_path TEXT NOT NULL,
159
+ PRIMARY KEY (token, file_path)
160
+ );
161
+ CREATE INDEX IF NOT EXISTS idx_token_postings_file ON token_postings(file_path);
162
+
163
+ -- Trigram index for fast content search (replaces sequential Grep scans)
164
+ CREATE TABLE IF NOT EXISTS trigrams (
165
+ trigram TEXT NOT NULL,
166
+ file_path TEXT NOT NULL,
167
+ PRIMARY KEY (trigram, file_path)
168
+ );
126
169
  `;
127
170
  export class Database {
128
171
  config;
@@ -154,7 +197,7 @@ export class Database {
154
197
  // Enable WAL-like behavior and foreign keys
155
198
  this.db.run("PRAGMA journal_mode=MEMORY");
156
199
  this.db.run("PRAGMA foreign_keys=ON");
157
- this.db.run("PRAGMA synchronous=OFF");
200
+ this.db.run("PRAGMA synchronous=NORMAL");
158
201
  // Create schema
159
202
  this.db.run(SCHEMA_SQL);
160
203
  // Schema version check — force re-extraction on upgrade
@@ -162,10 +205,21 @@ export class Database {
162
205
  const result = this.db.exec("SELECT value FROM meta WHERE key = 'schema_version'");
163
206
  const existingVersion = result.length > 0 ? String(result[0].values[0][0]) : "0";
164
207
  if (existingVersion !== CURRENT_SCHEMA) {
165
- // Clear all file hashes to force re-extraction on next scan
208
+ // Clear all file hashes AND mtime to force re-extraction on next scan.
209
+ // (mtime must be cleared too — otherwise indexFile's quickHash short-circuit
210
+ // matches and skips re-extraction even though we want to re-extract.)
166
211
  this.db.run("DELETE FROM symbols");
167
212
  this.db.run("DELETE FROM dependencies");
168
- this.db.run("UPDATE files SET hash = 'force-reindex'");
213
+ this.db.run("UPDATE files SET hash = 'force-reindex', mtime_ms = 0");
214
+ // Drop derived in-memory/persisted indexes so they get rebuilt with the new logic
215
+ try {
216
+ this.db.run("DELETE FROM token_postings");
217
+ }
218
+ catch { /* table may not exist on first run */ }
219
+ try {
220
+ this.db.run("DELETE FROM trigrams");
221
+ }
222
+ catch { /* table may not exist on first run */ }
169
223
  this.db.run(`INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', '${CURRENT_SCHEMA}')`);
170
224
  this.dirty = true;
171
225
  this.persistToDisk();
@@ -271,8 +325,33 @@ export class Database {
271
325
  searchSymbols(query, kind, limit = 50) {
272
326
  if (!this.db)
273
327
  throw new Error("Database not initialized");
274
- let sql = "SELECT * FROM symbols WHERE name LIKE ?";
275
- const params = [`%${query}%`];
328
+ // Try exact match first (uses idx_symbols_name index efficiently)
329
+ let sql;
330
+ let params;
331
+ const exactResults = this.searchSymbolsExact(query, kind, limit);
332
+ if (exactResults.length > 0)
333
+ return exactResults;
334
+ // Prefix match (still uses index for prefix portion)
335
+ sql = "SELECT * FROM symbols WHERE name LIKE ?";
336
+ params = [`${query}%`];
337
+ if (kind) {
338
+ sql += " AND kind = ?";
339
+ params.push(kind);
340
+ }
341
+ sql += ` LIMIT ${limit}`;
342
+ const prefixStmt = this.db.prepare(sql);
343
+ prefixStmt.bind(params);
344
+ const prefixResults = [];
345
+ while (prefixStmt.step()) {
346
+ const row = prefixStmt.getAsObject();
347
+ prefixResults.push(this.rowToSymbol(row));
348
+ }
349
+ prefixStmt.free();
350
+ if (prefixResults.length >= Math.min(5, limit))
351
+ return prefixResults;
352
+ // Fall back to substring match (no index benefit, but rare)
353
+ sql = "SELECT * FROM symbols WHERE name LIKE ?";
354
+ params = [`%${query}%`];
276
355
  if (kind) {
277
356
  sql += " AND kind = ?";
278
357
  params.push(kind);
@@ -282,17 +361,38 @@ export class Database {
282
361
  stmt.bind(params);
283
362
  const results = [];
284
363
  while (stmt.step()) {
285
- const row = stmt.getAsObject();
286
- results.push({
287
- id: row.id,
288
- file_path: row.file_path,
289
- name: row.name,
290
- kind: row.kind,
291
- line_start: row.line_start,
292
- line_end: row.line_end,
293
- signature: row.signature,
294
- exported: row.exported === 1,
295
- });
364
+ results.push(this.rowToSymbol(stmt.getAsObject()));
365
+ }
366
+ stmt.free();
367
+ return results;
368
+ }
369
+ rowToSymbol(row) {
370
+ return {
371
+ id: row.id,
372
+ file_path: row.file_path,
373
+ name: row.name,
374
+ kind: row.kind,
375
+ line_start: row.line_start,
376
+ line_end: row.line_end,
377
+ signature: row.signature,
378
+ exported: row.exported === 1,
379
+ };
380
+ }
381
+ searchSymbolsExact(query, kind, limit = 50) {
382
+ if (!this.db)
383
+ throw new Error("Database not initialized");
384
+ let sql = "SELECT * FROM symbols WHERE name = ?";
385
+ const params = [query];
386
+ if (kind) {
387
+ sql += " AND kind = ?";
388
+ params.push(kind);
389
+ }
390
+ sql += ` LIMIT ${limit}`;
391
+ const stmt = this.db.prepare(sql);
392
+ stmt.bind(params);
393
+ const results = [];
394
+ while (stmt.step()) {
395
+ results.push(this.rowToSymbol(stmt.getAsObject()));
296
396
  }
297
397
  stmt.free();
298
398
  return results;
@@ -385,23 +485,90 @@ export class Database {
385
485
  const maxBytes = maxSizeMB * 1024 * 1024;
386
486
  if (totalBytes <= maxBytes)
387
487
  return;
388
- // Delete oldest entries until under limit
389
- const toDelete = totalBytes - maxBytes;
390
- this.db.run(`
391
- DELETE FROM content_cache WHERE path IN (
392
- SELECT path FROM content_cache
393
- ORDER BY accessed_at ASC
394
- LIMIT (
395
- SELECT COUNT(*) FROM content_cache
396
- WHERE accessed_at <= (
397
- SELECT accessed_at FROM content_cache ORDER BY accessed_at ASC
398
- LIMIT 1 OFFSET (SELECT COUNT(*)/4 FROM content_cache)
399
- )
488
+ // Iteratively delete oldest entries until under limit
489
+ let currentBytes = totalBytes;
490
+ while (currentBytes > maxBytes) {
491
+ // Delete the oldest 10% of entries each pass
492
+ const batchSize = Math.max(1, Math.ceil((this.db.exec("SELECT COUNT(*) FROM content_cache")[0]?.values[0][0] || 0) * 0.1));
493
+ const before = this.db.exec("SELECT SUM(LENGTH(content)) FROM content_cache");
494
+ this.db.run(`
495
+ DELETE FROM content_cache WHERE path IN (
496
+ SELECT path FROM content_cache
497
+ ORDER BY accessed_at ASC
498
+ LIMIT ${batchSize}
400
499
  )
401
- )
402
- `);
500
+ `);
501
+ const after = this.db.exec("SELECT SUM(LENGTH(content)) FROM content_cache");
502
+ currentBytes = after[0]?.values[0][0] || 0;
503
+ // Safety: if nothing was deleted, break to avoid infinite loop
504
+ const beforeBytes = before[0]?.values[0][0] || 0;
505
+ if (currentBytes >= beforeBytes)
506
+ break;
507
+ }
403
508
  this.markDirty();
404
509
  }
510
+ /**
511
+ * Look up cached content for a file that previously had a given hash.
512
+ * Used for diff-based reads: find old version to compute delta.
513
+ * Checks the previous_content table first, then falls back to content_cache.
514
+ * Returns decompressed content or null if not found.
515
+ */
516
+ getCachedContentByHash(hash) {
517
+ if (!this.db)
518
+ throw new Error("Database not initialized");
519
+ // Check previous_content table (stores last version before update)
520
+ const stmt = this.db.prepare("SELECT content FROM previous_content WHERE hash = ? LIMIT 1");
521
+ stmt.bind([hash]);
522
+ if (stmt.step()) {
523
+ const row = stmt.get();
524
+ stmt.free();
525
+ try {
526
+ return inflateSync(Buffer.from(row[0])).toString("utf-8");
527
+ }
528
+ catch {
529
+ return null;
530
+ }
531
+ }
532
+ stmt.free();
533
+ return null;
534
+ }
535
+ /**
536
+ * Save previous content before overwriting, keyed by hash.
537
+ * Used for diff-based reads.
538
+ *
539
+ * Cleanup strategy:
540
+ * - TTL: 24h (long enough to survive long sessions / compaction).
541
+ * - Hard cap: 200 rows; oldest evicted first.
542
+ * Both run AFTER the insert so we never delete the row we just saved.
543
+ */
544
+ savePreviousContent(hash, compressedContent) {
545
+ if (!this.db)
546
+ throw new Error("Database not initialized");
547
+ try {
548
+ const now = Date.now();
549
+ this.db.run("INSERT OR REPLACE INTO previous_content (hash, content, saved_at) VALUES (?, ?, ?)", [hash, compressedContent, now]);
550
+ // TTL eviction (24h). Use strict < so the just-saved row is never targeted.
551
+ this.db.run("DELETE FROM previous_content WHERE saved_at < ?", [now - 24 * 3600_000]);
552
+ // Row-count cap (oldest first). Keeps table size bounded for fast-moving repos.
553
+ const MAX_ROWS = 200;
554
+ this.db.run(`DELETE FROM previous_content WHERE hash IN (
555
+ SELECT hash FROM previous_content
556
+ ORDER BY saved_at ASC
557
+ LIMIT MAX(0, (SELECT COUNT(*) FROM previous_content) - ${MAX_ROWS})
558
+ )`);
559
+ this.markDirty();
560
+ }
561
+ catch { /* ignore if table doesn't exist yet */ }
562
+ }
563
+ /**
564
+ * Get the total size of the content_cache table in bytes (compressed content).
565
+ */
566
+ getContentCacheSize() {
567
+ if (!this.db)
568
+ throw new Error("Database not initialized");
569
+ const result = this.db.exec("SELECT SUM(LENGTH(content)) FROM content_cache");
570
+ return result.length > 0 ? (result[0].values[0][0] || 0) : 0;
571
+ }
405
572
  // ─── Search Operations ─────────────────────────────────────────
406
573
  searchFiles(pattern) {
407
574
  if (!this.db)
@@ -439,6 +606,23 @@ export class Database {
439
606
  this.db.run("UPDATE sessions SET ended_at = ?, last_accessed = ?, summary = ? WHERE id = ?", [Date.now(), Date.now(), summary || null, sessionId]);
440
607
  this.markDirty();
441
608
  }
609
+ /**
610
+ * Most recent read timestamp by a session for a file (any tool).
611
+ * Returns 0 if there is no prior read.
612
+ */
613
+ getLastSessionReadAt(sessionId, filePath) {
614
+ if (!this.db)
615
+ throw new Error("Database not initialized");
616
+ const stmt = this.db.prepare("SELECT MAX(read_at) as last FROM session_reads WHERE session_id = ? AND file_path = ?");
617
+ stmt.bind([sessionId, filePath]);
618
+ let last = 0;
619
+ if (stmt.step()) {
620
+ const row = stmt.getAsObject();
621
+ last = row.last || 0;
622
+ }
623
+ stmt.free();
624
+ return last;
625
+ }
442
626
  logSessionRead(sessionId, filePath, toolName, lineStart, lineEnd) {
443
627
  if (!this.db)
444
628
  throw new Error("Database not initialized");
@@ -446,20 +630,23 @@ export class Database {
446
630
  VALUES (?, ?, ?, ?, ?, ?)`);
447
631
  stmt.run([sessionId, filePath, Date.now(), lineStart || null, lineEnd || null, toolName]);
448
632
  stmt.free();
449
- // Also update the session's files_read JSON array
450
- const session = this.getSession(sessionId);
451
- if (session) {
452
- try {
453
- const filesRead = JSON.parse(session.files_read || "[]");
454
- if (!filesRead.includes(filePath)) {
455
- filesRead.push(filePath);
456
- this.db.run("UPDATE sessions SET files_read = ? WHERE id = ?", [JSON.stringify(filesRead), sessionId]);
457
- }
458
- }
459
- catch { }
460
- }
461
633
  this.markDirty();
462
634
  }
635
+ /**
636
+ * Get list of unique files read in a session (from session_reads table, not JSON column).
637
+ */
638
+ getSessionFilesRead(sessionId) {
639
+ if (!this.db)
640
+ throw new Error("Database not initialized");
641
+ const stmt = this.db.prepare("SELECT DISTINCT file_path FROM session_reads WHERE session_id = ?");
642
+ stmt.bind([sessionId]);
643
+ const paths = [];
644
+ while (stmt.step()) {
645
+ paths.push(stmt.get()[0]);
646
+ }
647
+ stmt.free();
648
+ return paths;
649
+ }
463
650
  logSessionModified(sessionId, filePath) {
464
651
  if (!this.db)
465
652
  throw new Error("Database not initialized");
@@ -492,6 +679,25 @@ export class Database {
492
679
  }
493
680
  this.markDirty();
494
681
  }
682
+ logSessionGrep(sessionId, pattern) {
683
+ if (!this.db)
684
+ throw new Error("Database not initialized");
685
+ const session = this.getSession(sessionId);
686
+ if (session) {
687
+ try {
688
+ // Store grep patterns in the decisions JSON column (reusing existing column to avoid schema migration)
689
+ const decisions = JSON.parse(session.decisions || "{}");
690
+ const greps = Array.isArray(decisions._grep_patterns) ? decisions._grep_patterns : [];
691
+ if (!greps.includes(pattern)) {
692
+ greps.push(pattern);
693
+ decisions._grep_patterns = greps;
694
+ this.db.run("UPDATE sessions SET decisions = ? WHERE id = ?", [JSON.stringify(decisions), sessionId]);
695
+ }
696
+ }
697
+ catch { }
698
+ }
699
+ this.markDirty();
700
+ }
495
701
  addSessionInsight(sessionId, category, content, filePaths) {
496
702
  if (!this.db)
497
703
  throw new Error("Database not initialized");
@@ -579,24 +785,29 @@ export class Database {
579
785
  if (!this.db)
580
786
  throw new Error("Database not initialized");
581
787
  const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
582
- const results = this.db.exec(`
788
+ const stmt = this.db.prepare(`
583
789
  SELECT sr.file_path,
584
790
  COUNT(*) as total_reads,
585
791
  COUNT(DISTINCT sr.session_id) as sessions
586
792
  FROM session_reads sr
587
793
  JOIN sessions s ON sr.session_id = s.id
588
- WHERE s.last_accessed > ${cutoff}
794
+ WHERE s.last_accessed > ?
589
795
  GROUP BY sr.file_path
590
796
  ORDER BY sessions DESC, total_reads DESC
591
- LIMIT ${limit}
797
+ LIMIT ?
592
798
  `);
593
- if (results.length === 0)
594
- return [];
595
- return results[0].values.map((row) => ({
596
- file_path: row[0],
597
- total_reads: row[1],
598
- sessions: row[2],
599
- }));
799
+ stmt.bind([cutoff, limit]);
800
+ const results = [];
801
+ while (stmt.step()) {
802
+ const row = stmt.getAsObject();
803
+ results.push({
804
+ file_path: row.file_path,
805
+ total_reads: row.total_reads,
806
+ sessions: row.sessions,
807
+ });
808
+ }
809
+ stmt.free();
810
+ return results;
600
811
  }
601
812
  /**
602
813
  * Get files that changed since a given timestamp.
@@ -711,6 +922,15 @@ export class Database {
711
922
  fileSummaries[filePath] = `${fileRecord.language || "unknown"}, ${fileRecord.line_count} lines`;
712
923
  }
713
924
  }
925
+ // Extract grep patterns from session decisions
926
+ let grepPatterns = [];
927
+ try {
928
+ const decisions = session ? JSON.parse(session.decisions || "{}") : {};
929
+ if (Array.isArray(decisions._grep_patterns)) {
930
+ grepPatterns = decisions._grep_patterns;
931
+ }
932
+ }
933
+ catch { }
714
934
  return {
715
935
  filesRead: reads.map((r) => ({
716
936
  path: r.file_path,
@@ -718,10 +938,135 @@ export class Database {
718
938
  })),
719
939
  filesModified,
720
940
  symbolsDiscovered: symbolsSearched,
721
- grepPatterns: symbolsSearched, // reuse — greps are logged as symbol searches
941
+ grepPatterns,
722
942
  fileSummaries,
723
943
  };
724
944
  }
945
+ // ─── Token Postings (persisted inverted index) ──────────────
946
+ saveTokenPostings(filePath, tokens) {
947
+ if (!this.db)
948
+ throw new Error("Database not initialized");
949
+ this.db.run("DELETE FROM token_postings WHERE file_path = ?", [filePath]);
950
+ if (tokens.size === 0)
951
+ return;
952
+ const stmt = this.db.prepare("INSERT OR IGNORE INTO token_postings (token, file_path) VALUES (?, ?)");
953
+ for (const token of tokens) {
954
+ stmt.run([token, filePath]);
955
+ }
956
+ stmt.free();
957
+ this.markDirty();
958
+ }
959
+ deleteTokenPostings(filePath) {
960
+ if (!this.db)
961
+ throw new Error("Database not initialized");
962
+ this.db.run("DELETE FROM token_postings WHERE file_path = ?", [filePath]);
963
+ this.markDirty();
964
+ }
965
+ loadAllTokenPostings() {
966
+ if (!this.db)
967
+ throw new Error("Database not initialized");
968
+ const result = this.db.exec("SELECT token, file_path FROM token_postings");
969
+ const postings = new Map();
970
+ if (result.length === 0)
971
+ return postings;
972
+ for (const row of result[0].values) {
973
+ const token = row[0];
974
+ const filePath = row[1];
975
+ let set = postings.get(token);
976
+ if (!set) {
977
+ set = new Set();
978
+ postings.set(token, set);
979
+ }
980
+ set.add(filePath);
981
+ }
982
+ return postings;
983
+ }
984
+ getTokenPostingsFileMap() {
985
+ if (!this.db)
986
+ throw new Error("Database not initialized");
987
+ const result = this.db.exec("SELECT file_path, token FROM token_postings");
988
+ const fileTokens = new Map();
989
+ if (result.length === 0)
990
+ return fileTokens;
991
+ for (const row of result[0].values) {
992
+ const filePath = row[0];
993
+ const token = row[1];
994
+ let set = fileTokens.get(filePath);
995
+ if (!set) {
996
+ set = new Set();
997
+ fileTokens.set(filePath, set);
998
+ }
999
+ set.add(token);
1000
+ }
1001
+ return fileTokens;
1002
+ }
1003
+ // ─── Trigram Index ──────────────────────────────────────────
1004
+ saveTrigrams(filePath, content) {
1005
+ if (!this.db)
1006
+ throw new Error("Database not initialized");
1007
+ this.db.run("DELETE FROM trigrams WHERE file_path = ?", [filePath]);
1008
+ const trigrams = extractTrigrams(content);
1009
+ if (trigrams.size === 0)
1010
+ return;
1011
+ const stmt = this.db.prepare("INSERT OR IGNORE INTO trigrams (trigram, file_path) VALUES (?, ?)");
1012
+ for (const tri of trigrams) {
1013
+ stmt.run([tri, filePath]);
1014
+ }
1015
+ stmt.free();
1016
+ // Don't markDirty here — called during indexing which already persists
1017
+ }
1018
+ deleteTrigrams(filePath) {
1019
+ if (!this.db)
1020
+ throw new Error("Database not initialized");
1021
+ this.db.run("DELETE FROM trigrams WHERE file_path = ?", [filePath]);
1022
+ }
1023
+ /**
1024
+ * Find files that contain ALL trigrams from the query string.
1025
+ * This narrows candidates before doing actual content search.
1026
+ */
1027
+ searchByTrigrams(query, limit = 200) {
1028
+ if (!this.db)
1029
+ throw new Error("Database not initialized");
1030
+ const trigrams = Array.from(extractTrigrams(query));
1031
+ if (trigrams.length === 0)
1032
+ return [];
1033
+ // Intersect: files that have ALL trigrams
1034
+ // Start with the rarest trigram to minimize set size
1035
+ let sql = "SELECT file_path FROM trigrams WHERE trigram = ?";
1036
+ for (let i = 1; i < Math.min(trigrams.length, 6); i++) {
1037
+ sql += " INTERSECT SELECT file_path FROM trigrams WHERE trigram = ?";
1038
+ }
1039
+ sql += ` LIMIT ${limit}`;
1040
+ const stmt = this.db.prepare(sql);
1041
+ stmt.bind(trigrams.slice(0, 6));
1042
+ const results = [];
1043
+ while (stmt.step()) {
1044
+ results.push(stmt.get()[0]);
1045
+ }
1046
+ stmt.free();
1047
+ return results;
1048
+ }
1049
+ getSymbolCount() {
1050
+ if (!this.db)
1051
+ throw new Error("Database not initialized");
1052
+ const result = this.db.exec("SELECT COUNT(*) FROM symbols");
1053
+ return result.length > 0 ? result[0].values[0][0] : 0;
1054
+ }
1055
+ /**
1056
+ * Get symbol counts per file in a single query (avoids N+1).
1057
+ */
1058
+ getSymbolCountsByFile() {
1059
+ if (!this.db)
1060
+ throw new Error("Database not initialized");
1061
+ const result = this.db.exec("SELECT file_path, COUNT(*) as cnt FROM symbols GROUP BY file_path");
1062
+ const counts = new Map();
1063
+ if (result.length === 0)
1064
+ return counts;
1065
+ for (const row of result[0].values) {
1066
+ counts.set(row[0], row[1]);
1067
+ }
1068
+ return counts;
1069
+ }
725
1070
  // ─── Lifecycle ─────────────────────────────────────────────────
726
1071
  close() {
727
1072
  if (this.saveTimer) {