grov 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +211 -0
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +106 -0
  5. package/dist/commands/capture.d.ts +6 -0
  6. package/dist/commands/capture.js +324 -0
  7. package/dist/commands/drift-test.d.ts +7 -0
  8. package/dist/commands/drift-test.js +177 -0
  9. package/dist/commands/init.d.ts +1 -0
  10. package/dist/commands/init.js +27 -0
  11. package/dist/commands/inject.d.ts +5 -0
  12. package/dist/commands/inject.js +88 -0
  13. package/dist/commands/prompt-inject.d.ts +4 -0
  14. package/dist/commands/prompt-inject.js +451 -0
  15. package/dist/commands/status.d.ts +5 -0
  16. package/dist/commands/status.js +51 -0
  17. package/dist/commands/unregister.d.ts +1 -0
  18. package/dist/commands/unregister.js +22 -0
  19. package/dist/lib/anchor-extractor.d.ts +30 -0
  20. package/dist/lib/anchor-extractor.js +296 -0
  21. package/dist/lib/correction-builder.d.ts +10 -0
  22. package/dist/lib/correction-builder.js +226 -0
  23. package/dist/lib/debug.d.ts +24 -0
  24. package/dist/lib/debug.js +34 -0
  25. package/dist/lib/drift-checker.d.ts +66 -0
  26. package/dist/lib/drift-checker.js +341 -0
  27. package/dist/lib/hooks.d.ts +27 -0
  28. package/dist/lib/hooks.js +258 -0
  29. package/dist/lib/jsonl-parser.d.ts +87 -0
  30. package/dist/lib/jsonl-parser.js +281 -0
  31. package/dist/lib/llm-extractor.d.ts +50 -0
  32. package/dist/lib/llm-extractor.js +408 -0
  33. package/dist/lib/session-parser.d.ts +44 -0
  34. package/dist/lib/session-parser.js +256 -0
  35. package/dist/lib/store.d.ts +248 -0
  36. package/dist/lib/store.js +793 -0
  37. package/dist/lib/utils.d.ts +31 -0
  38. package/dist/lib/utils.js +76 -0
  39. package/package.json +67 -0
@@ -0,0 +1,793 @@
1
+ // SQLite store for task reasoning at ~/.grov/memory.db
2
+ import Database from 'better-sqlite3';
3
+ import { existsSync, mkdirSync, chmodSync } from 'fs';
4
+ import { homedir } from 'os';
5
+ import { join } from 'path';
6
+ import { randomUUID } from 'crypto';
7
+ /**
8
+ * Escape LIKE pattern special characters to prevent SQL injection.
9
+ * SECURITY: Prevents wildcard injection in LIKE queries.
10
+ */
11
+ function escapeLikePattern(str) {
12
+ return str.replace(/[%_\\]/g, '\\$&');
13
+ }
14
+ const GROV_DIR = join(homedir(), '.grov');
15
+ const DB_PATH = join(GROV_DIR, 'memory.db');
16
+ let db = null;
17
+ // PERFORMANCE: Statement cache to avoid re-preparing frequently used queries
18
+ const statementCache = new Map();
19
+ /**
20
+ * Get a cached prepared statement or create a new one.
21
+ * PERFORMANCE: Avoids overhead of re-preparing the same SQL.
22
+ */
23
+ function getCachedStatement(database, sql) {
24
+ let stmt = statementCache.get(sql);
25
+ if (!stmt) {
26
+ stmt = database.prepare(sql);
27
+ statementCache.set(sql, stmt);
28
+ }
29
+ return stmt;
30
+ }
31
+ /**
32
+ * Initialize the database connection and create tables
33
+ */
34
+ export function initDatabase() {
35
+ if (db)
36
+ return db;
37
+ // Ensure .grov directory exists with secure permissions
38
+ if (!existsSync(GROV_DIR)) {
39
+ mkdirSync(GROV_DIR, { recursive: true, mode: 0o700 });
40
+ }
41
+ db = new Database(DB_PATH);
42
+ // Set secure file permissions on the database
43
+ try {
44
+ chmodSync(DB_PATH, 0o600);
45
+ }
46
+ catch {
47
+ // SECURITY: Warn user if permissions can't be set (e.g., on Windows)
48
+ // The database may be world-readable on some systems
49
+ console.warn('Warning: Could not set restrictive permissions on ~/.grov/memory.db');
50
+ console.warn('Please ensure the file has appropriate permissions for your system.');
51
+ }
52
+ // OPTIMIZATION: Enable WAL mode for better concurrent performance
53
+ db.pragma('journal_mode = WAL');
54
+ // Create all tables in a single transaction for efficiency
55
+ db.exec(`
56
+ CREATE TABLE IF NOT EXISTS tasks (
57
+ id TEXT PRIMARY KEY,
58
+ project_path TEXT NOT NULL,
59
+ user TEXT,
60
+ original_query TEXT NOT NULL,
61
+ goal TEXT,
62
+ reasoning_trace JSON DEFAULT '[]',
63
+ files_touched JSON DEFAULT '[]',
64
+ status TEXT NOT NULL CHECK(status IN ('complete', 'question', 'partial', 'abandoned')),
65
+ linked_commit TEXT,
66
+ parent_task_id TEXT,
67
+ turn_number INTEGER,
68
+ tags JSON DEFAULT '[]',
69
+ created_at TEXT NOT NULL,
70
+ FOREIGN KEY (parent_task_id) REFERENCES tasks(id)
71
+ );
72
+
73
+ CREATE INDEX IF NOT EXISTS idx_project ON tasks(project_path);
74
+ CREATE INDEX IF NOT EXISTS idx_status ON tasks(status);
75
+ CREATE INDEX IF NOT EXISTS idx_created ON tasks(created_at);
76
+ `);
77
+ // Create session_states table (temporary per-session tracking)
78
+ db.exec(`
79
+ CREATE TABLE IF NOT EXISTS session_states (
80
+ session_id TEXT PRIMARY KEY,
81
+ user_id TEXT,
82
+ project_path TEXT NOT NULL,
83
+ original_goal TEXT,
84
+ actions_taken JSON DEFAULT '[]',
85
+ files_explored JSON DEFAULT '[]',
86
+ current_intent TEXT,
87
+ drift_warnings JSON DEFAULT '[]',
88
+ start_time TEXT NOT NULL,
89
+ last_update TEXT NOT NULL,
90
+ status TEXT DEFAULT 'active' CHECK(status IN ('active', 'completed', 'abandoned'))
91
+ );
92
+
93
+ CREATE INDEX IF NOT EXISTS idx_session_project ON session_states(project_path);
94
+ CREATE INDEX IF NOT EXISTS idx_session_status ON session_states(status);
95
+ `);
96
+ // Create file_reasoning table (file-level reasoning with anchoring)
97
+ db.exec(`
98
+ CREATE TABLE IF NOT EXISTS file_reasoning (
99
+ id TEXT PRIMARY KEY,
100
+ task_id TEXT,
101
+ file_path TEXT NOT NULL,
102
+ anchor TEXT,
103
+ line_start INTEGER,
104
+ line_end INTEGER,
105
+ code_hash TEXT,
106
+ change_type TEXT CHECK(change_type IN ('read', 'write', 'edit', 'create', 'delete')),
107
+ reasoning TEXT NOT NULL,
108
+ created_at TEXT NOT NULL,
109
+ FOREIGN KEY (task_id) REFERENCES tasks(id)
110
+ );
111
+
112
+ CREATE INDEX IF NOT EXISTS idx_file_task ON file_reasoning(task_id);
113
+ CREATE INDEX IF NOT EXISTS idx_file_path ON file_reasoning(file_path);
114
+ -- PERFORMANCE: Composite index for common query pattern (file_path + ORDER BY created_at)
115
+ CREATE INDEX IF NOT EXISTS idx_file_path_created ON file_reasoning(file_path, created_at DESC);
116
+ `);
117
+ // Migration: Add drift detection columns to session_states (safe to run multiple times)
118
+ const columns = db.pragma('table_info(session_states)');
119
+ const existingColumns = new Set(columns.map(c => c.name));
120
+ if (!existingColumns.has('expected_scope')) {
121
+ db.exec(`ALTER TABLE session_states ADD COLUMN expected_scope JSON DEFAULT '[]'`);
122
+ }
123
+ if (!existingColumns.has('constraints')) {
124
+ db.exec(`ALTER TABLE session_states ADD COLUMN constraints JSON DEFAULT '[]'`);
125
+ }
126
+ if (!existingColumns.has('success_criteria')) {
127
+ db.exec(`ALTER TABLE session_states ADD COLUMN success_criteria JSON DEFAULT '[]'`);
128
+ }
129
+ if (!existingColumns.has('keywords')) {
130
+ db.exec(`ALTER TABLE session_states ADD COLUMN keywords JSON DEFAULT '[]'`);
131
+ }
132
+ if (!existingColumns.has('last_drift_score')) {
133
+ db.exec(`ALTER TABLE session_states ADD COLUMN last_drift_score INTEGER`);
134
+ }
135
+ if (!existingColumns.has('escalation_count')) {
136
+ db.exec(`ALTER TABLE session_states ADD COLUMN escalation_count INTEGER DEFAULT 0`);
137
+ }
138
+ if (!existingColumns.has('pending_recovery_plan')) {
139
+ db.exec(`ALTER TABLE session_states ADD COLUMN pending_recovery_plan JSON`);
140
+ }
141
+ if (!existingColumns.has('drift_history')) {
142
+ db.exec(`ALTER TABLE session_states ADD COLUMN drift_history JSON DEFAULT '[]'`);
143
+ }
144
+ if (!existingColumns.has('last_checked_at')) {
145
+ db.exec(`ALTER TABLE session_states ADD COLUMN last_checked_at INTEGER DEFAULT 0`);
146
+ }
147
+ // Create steps table (Claude's actions for drift detection)
148
+ db.exec(`
149
+ CREATE TABLE IF NOT EXISTS steps (
150
+ id TEXT PRIMARY KEY,
151
+ session_id TEXT NOT NULL,
152
+ action_type TEXT NOT NULL,
153
+ files JSON DEFAULT '[]',
154
+ folders JSON DEFAULT '[]',
155
+ command TEXT,
156
+ reasoning TEXT,
157
+ drift_score INTEGER,
158
+ is_key_decision BOOLEAN DEFAULT 0,
159
+ keywords JSON DEFAULT '[]',
160
+ timestamp INTEGER NOT NULL,
161
+ FOREIGN KEY (session_id) REFERENCES session_states(session_id)
162
+ );
163
+ CREATE INDEX IF NOT EXISTS idx_steps_session ON steps(session_id);
164
+ CREATE INDEX IF NOT EXISTS idx_steps_timestamp ON steps(timestamp);
165
+ `);
166
+ return db;
167
+ }
168
+ /**
169
+ * Close the database connection
170
+ */
171
+ export function closeDatabase() {
172
+ if (db) {
173
+ db.close();
174
+ db = null;
175
+ // PERFORMANCE: Clear statement cache when database is closed
176
+ statementCache.clear();
177
+ }
178
+ }
179
+ /**
180
+ * Create a new task
181
+ */
182
+ export function createTask(input) {
183
+ const database = initDatabase();
184
+ const task = {
185
+ id: randomUUID(),
186
+ project_path: input.project_path,
187
+ user: input.user,
188
+ original_query: input.original_query,
189
+ goal: input.goal,
190
+ reasoning_trace: input.reasoning_trace || [],
191
+ files_touched: input.files_touched || [],
192
+ status: input.status,
193
+ linked_commit: input.linked_commit,
194
+ parent_task_id: input.parent_task_id,
195
+ turn_number: input.turn_number,
196
+ tags: input.tags || [],
197
+ created_at: new Date().toISOString()
198
+ };
199
+ const stmt = database.prepare(`
200
+ INSERT INTO tasks (
201
+ id, project_path, user, original_query, goal,
202
+ reasoning_trace, files_touched, status, linked_commit,
203
+ parent_task_id, turn_number, tags, created_at
204
+ ) VALUES (
205
+ ?, ?, ?, ?, ?,
206
+ ?, ?, ?, ?,
207
+ ?, ?, ?, ?
208
+ )
209
+ `);
210
+ stmt.run(task.id, task.project_path, task.user || null, task.original_query, task.goal || null, JSON.stringify(task.reasoning_trace), JSON.stringify(task.files_touched), task.status, task.linked_commit || null, task.parent_task_id || null, task.turn_number || null, JSON.stringify(task.tags), task.created_at);
211
+ return task;
212
+ }
213
+ /**
214
+ * Get tasks for a project
215
+ */
216
+ export function getTasksForProject(projectPath, options = {}) {
217
+ const database = initDatabase();
218
+ let sql = 'SELECT * FROM tasks WHERE project_path = ?';
219
+ const params = [projectPath];
220
+ if (options.status) {
221
+ sql += ' AND status = ?';
222
+ params.push(options.status);
223
+ }
224
+ sql += ' ORDER BY created_at DESC';
225
+ if (options.limit) {
226
+ sql += ' LIMIT ?';
227
+ params.push(options.limit);
228
+ }
229
+ const stmt = database.prepare(sql);
230
+ const rows = stmt.all(...params);
231
+ return rows.map(rowToTask);
232
+ }
233
+ /**
234
+ * Get tasks that touched specific files.
235
+ * SECURITY: Uses json_each for proper array handling and escaped LIKE patterns.
236
+ */
237
+ // SECURITY: Maximum files per query to prevent SQL DoS
238
+ const MAX_FILES_PER_QUERY = 100;
239
+ export function getTasksByFiles(projectPath, files, options = {}) {
240
+ const database = initDatabase();
241
+ if (files.length === 0) {
242
+ return [];
243
+ }
244
+ // SECURITY: Limit file count to prevent SQL DoS via massive query generation
245
+ const limitedFiles = files.length > MAX_FILES_PER_QUERY
246
+ ? files.slice(0, MAX_FILES_PER_QUERY)
247
+ : files;
248
+ // Use json_each for proper array iteration with escaped LIKE patterns
249
+ const fileConditions = limitedFiles.map(() => "EXISTS (SELECT 1 FROM json_each(files_touched) WHERE value LIKE ? ESCAPE '\\')").join(' OR ');
250
+ let sql = `SELECT * FROM tasks WHERE project_path = ? AND (${fileConditions})`;
251
+ // Escape LIKE special characters to prevent injection
252
+ const params = [
253
+ projectPath,
254
+ ...limitedFiles.map(f => `%${escapeLikePattern(f)}%`)
255
+ ];
256
+ if (options.status) {
257
+ sql += ' AND status = ?';
258
+ params.push(options.status);
259
+ }
260
+ sql += ' ORDER BY created_at DESC';
261
+ if (options.limit) {
262
+ sql += ' LIMIT ?';
263
+ params.push(options.limit);
264
+ }
265
+ const stmt = database.prepare(sql);
266
+ const rows = stmt.all(...params);
267
+ return rows.map(rowToTask);
268
+ }
269
+ /**
270
+ * Get a task by ID
271
+ */
272
+ export function getTaskById(id) {
273
+ const database = initDatabase();
274
+ const stmt = database.prepare('SELECT * FROM tasks WHERE id = ?');
275
+ const row = stmt.get(id);
276
+ return row ? rowToTask(row) : null;
277
+ }
278
+ /**
279
+ * Update a task's status
280
+ */
281
+ export function updateTaskStatus(id, status) {
282
+ const database = initDatabase();
283
+ const stmt = database.prepare('UPDATE tasks SET status = ? WHERE id = ?');
284
+ stmt.run(status, id);
285
+ }
286
+ /**
287
+ * Get task count for a project
288
+ */
289
+ export function getTaskCount(projectPath) {
290
+ const database = initDatabase();
291
+ const stmt = database.prepare('SELECT COUNT(*) as count FROM tasks WHERE project_path = ?');
292
+ const row = stmt.get(projectPath);
293
+ return row?.count ?? 0;
294
+ }
295
+ /**
296
+ * Safely parse JSON with fallback to empty array.
297
+ */
298
+ function safeJsonParse(value, fallback) {
299
+ if (typeof value !== 'string' || !value) {
300
+ return fallback;
301
+ }
302
+ try {
303
+ return JSON.parse(value);
304
+ }
305
+ catch {
306
+ return fallback;
307
+ }
308
+ }
309
+ /**
310
+ * Convert database row to Task object
311
+ */
312
+ function rowToTask(row) {
313
+ return {
314
+ id: row.id,
315
+ project_path: row.project_path,
316
+ user: row.user,
317
+ original_query: row.original_query,
318
+ goal: row.goal,
319
+ reasoning_trace: safeJsonParse(row.reasoning_trace, []),
320
+ files_touched: safeJsonParse(row.files_touched, []),
321
+ status: row.status,
322
+ linked_commit: row.linked_commit,
323
+ parent_task_id: row.parent_task_id,
324
+ turn_number: row.turn_number,
325
+ tags: safeJsonParse(row.tags, []),
326
+ created_at: row.created_at
327
+ };
328
+ }
329
+ // ============================================
330
+ // SESSION STATE CRUD OPERATIONS
331
+ // ============================================
332
+ /**
333
+ * Create a new session state.
334
+ * FIXED: Uses INSERT OR IGNORE to handle race conditions safely.
335
+ */
336
+ export function createSessionState(input) {
337
+ const database = initDatabase();
338
+ const now = new Date().toISOString();
339
+ const sessionState = {
340
+ session_id: input.session_id,
341
+ user_id: input.user_id,
342
+ project_path: input.project_path,
343
+ original_goal: input.original_goal,
344
+ actions_taken: [],
345
+ files_explored: [],
346
+ current_intent: undefined,
347
+ drift_warnings: [],
348
+ start_time: now,
349
+ last_update: now,
350
+ status: 'active',
351
+ // Drift detection fields
352
+ expected_scope: input.expected_scope || [],
353
+ constraints: input.constraints || [],
354
+ success_criteria: input.success_criteria || [],
355
+ keywords: input.keywords || [],
356
+ // Drift tracking fields
357
+ last_drift_score: undefined,
358
+ escalation_count: 0,
359
+ pending_recovery_plan: undefined,
360
+ drift_history: [],
361
+ // Action tracking
362
+ last_checked_at: 0
363
+ };
364
+ // Use INSERT OR IGNORE to safely handle race conditions where
365
+ // multiple processes might try to create the same session
366
+ const stmt = database.prepare(`
367
+ INSERT OR IGNORE INTO session_states (
368
+ session_id, user_id, project_path, original_goal,
369
+ actions_taken, files_explored, current_intent, drift_warnings,
370
+ start_time, last_update, status,
371
+ expected_scope, constraints, success_criteria, keywords,
372
+ last_drift_score, escalation_count, pending_recovery_plan, drift_history,
373
+ last_checked_at
374
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
375
+ `);
376
+ stmt.run(sessionState.session_id, sessionState.user_id || null, sessionState.project_path, sessionState.original_goal || null, JSON.stringify(sessionState.actions_taken), JSON.stringify(sessionState.files_explored), sessionState.current_intent || null, JSON.stringify(sessionState.drift_warnings), sessionState.start_time, sessionState.last_update, sessionState.status, JSON.stringify(sessionState.expected_scope), JSON.stringify(sessionState.constraints), JSON.stringify(sessionState.success_criteria), JSON.stringify(sessionState.keywords), sessionState.last_drift_score || null, sessionState.escalation_count, sessionState.pending_recovery_plan ? JSON.stringify(sessionState.pending_recovery_plan) : null, JSON.stringify(sessionState.drift_history), sessionState.last_checked_at);
377
+ return sessionState;
378
+ }
379
+ /**
380
+ * Get a session state by ID
381
+ */
382
+ export function getSessionState(sessionId) {
383
+ const database = initDatabase();
384
+ const stmt = database.prepare('SELECT * FROM session_states WHERE session_id = ?');
385
+ const row = stmt.get(sessionId);
386
+ return row ? rowToSessionState(row) : null;
387
+ }
388
+ /**
389
+ * Update a session state.
390
+ * SECURITY: Uses transaction for atomic updates to prevent race conditions.
391
+ */
392
+ export function updateSessionState(sessionId, updates) {
393
+ const database = initDatabase();
394
+ const setClauses = [];
395
+ const params = [];
396
+ if (updates.user_id !== undefined) {
397
+ setClauses.push('user_id = ?');
398
+ params.push(updates.user_id || null);
399
+ }
400
+ if (updates.project_path !== undefined) {
401
+ setClauses.push('project_path = ?');
402
+ params.push(updates.project_path);
403
+ }
404
+ if (updates.original_goal !== undefined) {
405
+ setClauses.push('original_goal = ?');
406
+ params.push(updates.original_goal || null);
407
+ }
408
+ if (updates.actions_taken !== undefined) {
409
+ setClauses.push('actions_taken = ?');
410
+ params.push(JSON.stringify(updates.actions_taken));
411
+ }
412
+ if (updates.files_explored !== undefined) {
413
+ setClauses.push('files_explored = ?');
414
+ params.push(JSON.stringify(updates.files_explored));
415
+ }
416
+ if (updates.current_intent !== undefined) {
417
+ setClauses.push('current_intent = ?');
418
+ params.push(updates.current_intent || null);
419
+ }
420
+ if (updates.drift_warnings !== undefined) {
421
+ setClauses.push('drift_warnings = ?');
422
+ params.push(JSON.stringify(updates.drift_warnings));
423
+ }
424
+ if (updates.status !== undefined) {
425
+ setClauses.push('status = ?');
426
+ params.push(updates.status);
427
+ }
428
+ // Always update last_update
429
+ setClauses.push('last_update = ?');
430
+ params.push(new Date().toISOString());
431
+ if (setClauses.length === 0)
432
+ return;
433
+ params.push(sessionId);
434
+ const sql = `UPDATE session_states SET ${setClauses.join(', ')} WHERE session_id = ?`;
435
+ // SECURITY: Use transaction for atomic updates to prevent race conditions
436
+ const transaction = database.transaction(() => {
437
+ database.prepare(sql).run(...params);
438
+ });
439
+ transaction();
440
+ }
441
+ /**
442
+ * Delete a session state
443
+ */
444
+ export function deleteSessionState(sessionId) {
445
+ const database = initDatabase();
446
+ database.prepare('DELETE FROM session_states WHERE session_id = ?').run(sessionId);
447
+ }
448
+ /**
449
+ * Get active sessions for a project
450
+ */
451
+ export function getActiveSessionsForProject(projectPath) {
452
+ const database = initDatabase();
453
+ const stmt = database.prepare("SELECT * FROM session_states WHERE project_path = ? AND status = 'active' ORDER BY start_time DESC");
454
+ const rows = stmt.all(projectPath);
455
+ return rows.map(rowToSessionState);
456
+ }
457
+ /**
458
+ * Convert database row to SessionState object
459
+ */
460
+ function rowToSessionState(row) {
461
+ return {
462
+ session_id: row.session_id,
463
+ user_id: row.user_id,
464
+ project_path: row.project_path,
465
+ original_goal: row.original_goal,
466
+ actions_taken: safeJsonParse(row.actions_taken, []),
467
+ files_explored: safeJsonParse(row.files_explored, []),
468
+ current_intent: row.current_intent,
469
+ drift_warnings: safeJsonParse(row.drift_warnings, []),
470
+ start_time: row.start_time,
471
+ last_update: row.last_update,
472
+ status: row.status,
473
+ // Drift detection fields
474
+ expected_scope: safeJsonParse(row.expected_scope, []),
475
+ constraints: safeJsonParse(row.constraints, []),
476
+ success_criteria: safeJsonParse(row.success_criteria, []),
477
+ keywords: safeJsonParse(row.keywords, []),
478
+ // Drift tracking fields
479
+ last_drift_score: row.last_drift_score,
480
+ escalation_count: row.escalation_count || 0,
481
+ pending_recovery_plan: safeJsonParse(row.pending_recovery_plan, undefined),
482
+ drift_history: safeJsonParse(row.drift_history, []),
483
+ // Action tracking
484
+ last_checked_at: row.last_checked_at || 0
485
+ };
486
+ }
487
+ /**
488
+ * Update session drift metrics after a prompt check
489
+ */
490
+ export function updateSessionDrift(sessionId, driftScore, correctionLevel, promptSummary, recoveryPlan) {
491
+ const database = initDatabase();
492
+ const session = getSessionState(sessionId);
493
+ if (!session)
494
+ return;
495
+ const now = new Date().toISOString();
496
+ // Calculate new escalation count
497
+ let newEscalation = session.escalation_count;
498
+ if (driftScore >= 8) {
499
+ // Recovery - decrease escalation
500
+ newEscalation = Math.max(0, newEscalation - 1);
501
+ }
502
+ else if (correctionLevel && correctionLevel !== 'nudge') {
503
+ // Significant drift - increase escalation
504
+ newEscalation = Math.min(3, newEscalation + 1);
505
+ }
506
+ // Add to drift history
507
+ const driftEvent = {
508
+ timestamp: now,
509
+ score: driftScore,
510
+ level: correctionLevel || 'none',
511
+ prompt_summary: promptSummary.substring(0, 100)
512
+ };
513
+ const newHistory = [...session.drift_history, driftEvent];
514
+ // Add to drift_warnings if correction was given
515
+ const newWarnings = correctionLevel
516
+ ? [...session.drift_warnings, `[${now}] ${correctionLevel}: score ${driftScore}`]
517
+ : session.drift_warnings;
518
+ const stmt = database.prepare(`
519
+ UPDATE session_states SET
520
+ last_drift_score = ?,
521
+ escalation_count = ?,
522
+ pending_recovery_plan = ?,
523
+ drift_history = ?,
524
+ drift_warnings = ?,
525
+ last_update = ?
526
+ WHERE session_id = ?
527
+ `);
528
+ stmt.run(driftScore, newEscalation, recoveryPlan ? JSON.stringify(recoveryPlan) : null, JSON.stringify(newHistory), JSON.stringify(newWarnings), now, sessionId);
529
+ }
530
+ /**
531
+ * Check if a session should be flagged for review
532
+ * Returns true if: status=drifted OR warnings>=3 OR avg_score<6
533
+ */
534
+ export function shouldFlagForReview(sessionId) {
535
+ const session = getSessionState(sessionId);
536
+ if (!session)
537
+ return false;
538
+ // Check number of warnings
539
+ if (session.drift_warnings.length >= 3) {
540
+ return true;
541
+ }
542
+ // Check drift history for average score
543
+ if (session.drift_history.length >= 2) {
544
+ const totalScore = session.drift_history.reduce((sum, e) => sum + e.score, 0);
545
+ const avgScore = totalScore / session.drift_history.length;
546
+ if (avgScore < 6) {
547
+ return true;
548
+ }
549
+ }
550
+ // Check if any HALT level drift occurred
551
+ if (session.drift_history.some(e => e.level === 'halt')) {
552
+ return true;
553
+ }
554
+ // Check current escalation level
555
+ if (session.escalation_count >= 2) {
556
+ return true;
557
+ }
558
+ return false;
559
+ }
560
+ /**
561
+ * Get drift summary for a session (used by capture)
562
+ */
563
+ export function getDriftSummary(sessionId) {
564
+ const session = getSessionState(sessionId);
565
+ if (!session || session.drift_history.length === 0) {
566
+ return { totalEvents: 0, resolved: true, finalScore: null, hadHalt: false };
567
+ }
568
+ const lastEvent = session.drift_history[session.drift_history.length - 1];
569
+ return {
570
+ totalEvents: session.drift_history.length,
571
+ resolved: lastEvent.score >= 8,
572
+ finalScore: lastEvent.score,
573
+ hadHalt: session.drift_history.some(e => e.level === 'halt')
574
+ };
575
+ }
576
+ // ============================================
577
+ // FILE REASONING CRUD OPERATIONS
578
+ // ============================================
579
+ /**
580
+ * Create a new file reasoning entry
581
+ */
582
+ export function createFileReasoning(input) {
583
+ const database = initDatabase();
584
+ const fileReasoning = {
585
+ id: randomUUID(),
586
+ task_id: input.task_id,
587
+ file_path: input.file_path,
588
+ anchor: input.anchor,
589
+ line_start: input.line_start,
590
+ line_end: input.line_end,
591
+ code_hash: input.code_hash,
592
+ change_type: input.change_type,
593
+ reasoning: input.reasoning,
594
+ created_at: new Date().toISOString()
595
+ };
596
+ const stmt = database.prepare(`
597
+ INSERT INTO file_reasoning (
598
+ id, task_id, file_path, anchor, line_start, line_end,
599
+ code_hash, change_type, reasoning, created_at
600
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
601
+ `);
602
+ stmt.run(fileReasoning.id, fileReasoning.task_id || null, fileReasoning.file_path, fileReasoning.anchor || null, fileReasoning.line_start || null, fileReasoning.line_end || null, fileReasoning.code_hash || null, fileReasoning.change_type || null, fileReasoning.reasoning, fileReasoning.created_at);
603
+ return fileReasoning;
604
+ }
605
+ /**
606
+ * Get file reasoning entries for a task
607
+ */
608
+ export function getFileReasoningForTask(taskId) {
609
+ const database = initDatabase();
610
+ const stmt = database.prepare('SELECT * FROM file_reasoning WHERE task_id = ? ORDER BY created_at DESC');
611
+ const rows = stmt.all(taskId);
612
+ return rows.map(rowToFileReasoning);
613
+ }
614
+ /**
615
+ * Get file reasoning entries by file path
616
+ */
617
+ export function getFileReasoningByPath(filePath, limit = 10) {
618
+ const database = initDatabase();
619
+ const stmt = database.prepare('SELECT * FROM file_reasoning WHERE file_path = ? ORDER BY created_at DESC LIMIT ?');
620
+ const rows = stmt.all(filePath, limit);
621
+ return rows.map(rowToFileReasoning);
622
+ }
623
+ /**
624
+ * Get file reasoning entries matching a pattern (for files in a project).
625
+ * SECURITY: Uses escaped LIKE patterns to prevent injection.
626
+ */
627
+ export function getFileReasoningByPathPattern(pathPattern, limit = 20) {
628
+ const database = initDatabase();
629
+ // Escape LIKE special characters to prevent injection
630
+ const escapedPattern = escapeLikePattern(pathPattern);
631
+ const stmt = database.prepare("SELECT * FROM file_reasoning WHERE file_path LIKE ? ESCAPE '\\' ORDER BY created_at DESC LIMIT ?");
632
+ const rows = stmt.all(`%${escapedPattern}%`, limit);
633
+ return rows.map(rowToFileReasoning);
634
+ }
635
+ /**
636
+ * Convert database row to FileReasoning object
637
+ */
638
+ function rowToFileReasoning(row) {
639
+ return {
640
+ id: row.id,
641
+ task_id: row.task_id,
642
+ file_path: row.file_path,
643
+ anchor: row.anchor,
644
+ line_start: row.line_start,
645
+ line_end: row.line_end,
646
+ code_hash: row.code_hash,
647
+ change_type: row.change_type,
648
+ reasoning: row.reasoning,
649
+ created_at: row.created_at
650
+ };
651
+ }
652
+ /**
653
+ * Get the database path
654
+ */
655
+ export function getDatabasePath() {
656
+ return DB_PATH;
657
+ }
658
+ /**
659
+ * Save a Claude action as a step
660
+ */
661
+ export function saveStep(sessionId, action, driftScore, isKeyDecision = false, keywords = []) {
662
+ const database = initDatabase();
663
+ // Extract folders from files
664
+ const folders = [...new Set(action.files
665
+ .map(f => f.split('/').slice(0, -1).join('/'))
666
+ .filter(f => f.length > 0))];
667
+ database.prepare(`
668
+ INSERT INTO steps (id, session_id, action_type, files, folders, command, drift_score, is_key_decision, keywords, timestamp)
669
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
670
+ `).run(randomUUID(), sessionId, action.type, JSON.stringify(action.files), JSON.stringify(folders), action.command || null, driftScore, isKeyDecision ? 1 : 0, JSON.stringify(keywords), action.timestamp);
671
+ }
672
+ /**
673
+ * Get recent steps for a session (most recent first)
674
+ */
675
+ export function getRecentSteps(sessionId, limit = 10) {
676
+ const database = initDatabase();
677
+ const rows = database.prepare(`
678
+ SELECT * FROM steps WHERE session_id = ? ORDER BY timestamp DESC LIMIT ?
679
+ `).all(sessionId, limit);
680
+ return rows.map(rowToStepRecord);
681
+ }
682
+ /**
683
+ * Update last_checked_at timestamp for a session
684
+ */
685
+ export function updateLastChecked(sessionId, timestamp) {
686
+ const database = initDatabase();
687
+ database.prepare(`
688
+ UPDATE session_states SET last_checked_at = ? WHERE session_id = ?
689
+ `).run(timestamp, sessionId);
690
+ }
691
+ // ============================================
692
+ // 4-QUERY RETRIEVAL (from deep_dive.md)
693
+ // ============================================
694
+ /**
695
+ * Get steps that touched specific files
696
+ */
697
+ export function getStepsByFiles(sessionId, files, limit = 5) {
698
+ if (files.length === 0)
699
+ return [];
700
+ const database = initDatabase();
701
+ const placeholders = files.map(() => `files LIKE ?`).join(' OR ');
702
+ const patterns = files.map(f => `%"${escapeLikePattern(f)}"%`);
703
+ const rows = database.prepare(`
704
+ SELECT * FROM steps
705
+ WHERE session_id = ? AND drift_score >= 5 AND (${placeholders})
706
+ ORDER BY timestamp DESC LIMIT ?
707
+ `).all(sessionId, ...patterns, limit);
708
+ return rows.map(rowToStepRecord);
709
+ }
710
+ /**
711
+ * Get steps that touched specific folders
712
+ */
713
+ export function getStepsByFolders(sessionId, folders, limit = 5) {
714
+ if (folders.length === 0)
715
+ return [];
716
+ const database = initDatabase();
717
+ const placeholders = folders.map(() => `folders LIKE ?`).join(' OR ');
718
+ const patterns = folders.map(f => `%"${escapeLikePattern(f)}"%`);
719
+ const rows = database.prepare(`
720
+ SELECT * FROM steps
721
+ WHERE session_id = ? AND drift_score >= 5 AND (${placeholders})
722
+ ORDER BY timestamp DESC LIMIT ?
723
+ `).all(sessionId, ...patterns, limit);
724
+ return rows.map(rowToStepRecord);
725
+ }
726
+ /**
727
+ * Get steps matching keywords
728
+ */
729
+ export function getStepsByKeywords(sessionId, keywords, limit = 5) {
730
+ if (keywords.length === 0)
731
+ return [];
732
+ const database = initDatabase();
733
+ const conditions = keywords.map(() => `keywords LIKE ?`).join(' OR ');
734
+ const patterns = keywords.map(k => `%"${escapeLikePattern(k)}"%`);
735
+ const rows = database.prepare(`
736
+ SELECT * FROM steps
737
+ WHERE session_id = ? AND drift_score >= 5 AND (${conditions})
738
+ ORDER BY timestamp DESC LIMIT ?
739
+ `).all(sessionId, ...patterns, limit);
740
+ return rows.map(rowToStepRecord);
741
+ }
742
+ /**
743
+ * Get key decision steps
744
+ */
745
+ export function getKeyDecisionSteps(sessionId, limit = 5) {
746
+ const database = initDatabase();
747
+ const rows = database.prepare(`
748
+ SELECT * FROM steps
749
+ WHERE session_id = ? AND is_key_decision = 1
750
+ ORDER BY timestamp DESC LIMIT ?
751
+ `).all(sessionId, limit);
752
+ return rows.map(rowToStepRecord);
753
+ }
754
+ /**
755
+ * Combined retrieval: runs all 4 queries and deduplicates
756
+ * Priority: key decisions > files > folders > keywords
757
+ */
758
+ export function getRelevantSteps(sessionId, currentFiles, currentFolders, keywords, limit = 10) {
759
+ const byFiles = getStepsByFiles(sessionId, currentFiles, 5);
760
+ const byFolders = getStepsByFolders(sessionId, currentFolders, 5);
761
+ const byKeywords = getStepsByKeywords(sessionId, keywords, 5);
762
+ const keyDecisions = getKeyDecisionSteps(sessionId, 5);
763
+ const seen = new Set();
764
+ const results = [];
765
+ // Priority order: key decisions > files > folders > keywords
766
+ for (const step of [...keyDecisions, ...byFiles, ...byFolders, ...byKeywords]) {
767
+ if (!seen.has(step.id)) {
768
+ seen.add(step.id);
769
+ results.push(step);
770
+ if (results.length >= limit)
771
+ break;
772
+ }
773
+ }
774
+ return results;
775
+ }
776
+ /**
777
+ * Convert database row to StepRecord
778
+ */
779
+ function rowToStepRecord(row) {
780
+ return {
781
+ id: row.id,
782
+ session_id: row.session_id,
783
+ action_type: row.action_type,
784
+ files: safeJsonParse(row.files, []),
785
+ folders: safeJsonParse(row.folders, []),
786
+ command: row.command,
787
+ reasoning: row.reasoning,
788
+ drift_score: row.drift_score,
789
+ is_key_decision: row.is_key_decision === 1,
790
+ keywords: safeJsonParse(row.keywords, []),
791
+ timestamp: row.timestamp
792
+ };
793
+ }