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,256 @@
1
+ // Session JSONL parser for anti-drift system
2
+ // Parses Claude Code session files to extract Claude's ACTIONS (tool calls)
3
+ //
4
+ // CRITICAL: We monitor Claude's ACTIONS, NOT user prompts.
5
+ // User can explore freely. We check what CLAUDE DOES.
6
+ import { existsSync, readFileSync, readdirSync } from 'fs';
7
+ import { homedir } from 'os';
8
+ import { join, dirname } from 'path';
9
+ import { debugInject } from './debug.js';
10
+ // ============================================
11
+ // MAIN FUNCTIONS
12
+ // ============================================
13
+ /**
14
+ * Find session JSONL path from session_id and project path.
15
+ *
16
+ * Claude Code stores sessions in:
17
+ * ~/.claude/projects/<encoded-path>/<session_id>.jsonl
18
+ *
19
+ * The encoded path uses a specific encoding (not standard URL encoding).
20
+ */
21
+ export function findSessionFile(sessionId, projectPath) {
22
+ const claudeProjectsDir = join(homedir(), '.claude', 'projects');
23
+ if (!existsSync(claudeProjectsDir)) {
24
+ debugInject('Claude projects dir not found: %s', claudeProjectsDir);
25
+ return null;
26
+ }
27
+ // Try to find the project folder
28
+ // Claude Code uses a specific encoding for the path
29
+ // We'll search for folders that might contain our session
30
+ const projectFolders = readdirSync(claudeProjectsDir, { withFileTypes: true })
31
+ .filter(d => d.isDirectory())
32
+ .map(d => d.name);
33
+ // First, try URL-encoded path
34
+ const urlEncoded = encodeURIComponent(projectPath);
35
+ if (projectFolders.includes(urlEncoded)) {
36
+ const sessionPath = join(claudeProjectsDir, urlEncoded, `${sessionId}.jsonl`);
37
+ if (existsSync(sessionPath)) {
38
+ debugInject('Found session via URL encoding: %s', sessionPath);
39
+ return sessionPath;
40
+ }
41
+ }
42
+ // Try custom Claude encoding (%2F for /, etc.)
43
+ const customEncoded = projectPath
44
+ .replace(/\//g, '%2F')
45
+ .replace(/:/g, '%3A');
46
+ if (projectFolders.includes(customEncoded)) {
47
+ const sessionPath = join(claudeProjectsDir, customEncoded, `${sessionId}.jsonl`);
48
+ if (existsSync(sessionPath)) {
49
+ debugInject('Found session via custom encoding: %s', sessionPath);
50
+ return sessionPath;
51
+ }
52
+ }
53
+ // Search in all project folders for the session
54
+ for (const folder of projectFolders) {
55
+ const sessionPath = join(claudeProjectsDir, folder, `${sessionId}.jsonl`);
56
+ if (existsSync(sessionPath)) {
57
+ debugInject('Found session by scanning: %s', sessionPath);
58
+ return sessionPath;
59
+ }
60
+ }
61
+ debugInject('Session file not found for: %s in %s', sessionId, projectPath);
62
+ return null;
63
+ }
64
+ /**
65
+ * Parse JSONL and extract ALL Claude's tool calls
66
+ */
67
+ export function parseSessionActions(sessionPath) {
68
+ if (!existsSync(sessionPath)) {
69
+ debugInject('Session file does not exist: %s', sessionPath);
70
+ return [];
71
+ }
72
+ const content = readFileSync(sessionPath, 'utf-8');
73
+ const lines = content.trim().split('\n').filter(Boolean);
74
+ const actions = [];
75
+ for (const line of lines) {
76
+ try {
77
+ const entry = JSON.parse(line);
78
+ // Only process assistant messages (Claude's responses)
79
+ if (entry.type !== 'assistant')
80
+ continue;
81
+ const timestamp = new Date(entry.timestamp).getTime();
82
+ // Extract tool calls from content array
83
+ for (const block of entry.message?.content || []) {
84
+ if (block.type !== 'tool_use')
85
+ continue;
86
+ const action = parseToolCall(block, timestamp);
87
+ if (action) {
88
+ actions.push(action);
89
+ }
90
+ }
91
+ }
92
+ catch {
93
+ // Skip malformed lines silently
94
+ continue;
95
+ }
96
+ }
97
+ debugInject('Parsed %d actions from session', actions.length);
98
+ return actions;
99
+ }
100
+ /**
101
+ * Get only NEW actions since last check timestamp.
102
+ * This is the main function used by prompt-inject.
103
+ */
104
+ export function getNewActions(sessionPath, lastCheckedTimestamp) {
105
+ const allActions = parseSessionActions(sessionPath);
106
+ const newActions = allActions.filter(a => a.timestamp > lastCheckedTimestamp);
107
+ debugInject('Found %d new actions since %d', newActions.length, lastCheckedTimestamp);
108
+ return newActions;
109
+ }
110
+ /**
111
+ * Get actions that MODIFY files (not reads).
112
+ * Use this for drift detection - reads are exploration, not drift.
113
+ */
114
+ export function getModifyingActions(actions) {
115
+ return actions.filter(a => a.type !== 'read' && a.type !== 'grep' && a.type !== 'glob');
116
+ }
117
+ /**
118
+ * Extract all unique files touched by actions
119
+ */
120
+ export function extractFilesFromActions(actions) {
121
+ const files = new Set();
122
+ for (const action of actions) {
123
+ for (const file of action.files) {
124
+ files.add(file);
125
+ }
126
+ }
127
+ return [...files];
128
+ }
129
+ /**
130
+ * Extract unique folders from actions
131
+ */
132
+ export function extractFoldersFromActions(actions) {
133
+ const folders = new Set();
134
+ for (const action of actions) {
135
+ for (const file of action.files) {
136
+ const folder = dirname(file);
137
+ if (folder && folder !== '.') {
138
+ folders.add(folder);
139
+ }
140
+ }
141
+ }
142
+ return [...folders];
143
+ }
144
+ // ============================================
145
+ // HELPERS
146
+ // ============================================
147
+ /**
148
+ * Parse a single tool call block into ClaudeAction
149
+ */
150
+ function parseToolCall(block, timestamp) {
151
+ const name = block.name?.toLowerCase();
152
+ const input = block.input || {};
153
+ switch (name) {
154
+ case 'edit':
155
+ return {
156
+ type: 'edit',
157
+ files: [input.file_path].filter(Boolean),
158
+ timestamp
159
+ };
160
+ case 'multiedit':
161
+ return {
162
+ type: 'multiedit',
163
+ files: [input.file_path].filter(Boolean),
164
+ timestamp
165
+ };
166
+ case 'write':
167
+ return {
168
+ type: 'write',
169
+ files: [input.file_path].filter(Boolean),
170
+ timestamp
171
+ };
172
+ case 'bash':
173
+ return {
174
+ type: 'bash',
175
+ files: extractFilesFromCommand(input.command || ''),
176
+ command: input.command,
177
+ timestamp
178
+ };
179
+ case 'read':
180
+ return {
181
+ type: 'read',
182
+ files: [input.file_path].filter(Boolean),
183
+ timestamp
184
+ };
185
+ case 'grep':
186
+ return {
187
+ type: 'grep',
188
+ files: [input.path].filter(Boolean),
189
+ timestamp
190
+ };
191
+ case 'glob':
192
+ return {
193
+ type: 'glob',
194
+ files: [input.path].filter(Boolean),
195
+ timestamp
196
+ };
197
+ default:
198
+ // Ignore other tools (Task, WebFetch, etc.)
199
+ return null;
200
+ }
201
+ }
202
+ /**
203
+ * Extract file paths from a bash command.
204
+ * Basic extraction - not perfect but catches common patterns.
205
+ */
206
+ function extractFilesFromCommand(command) {
207
+ if (!command)
208
+ return [];
209
+ const files = [];
210
+ const patterns = [
211
+ // Absolute paths: /path/to/file.ts
212
+ /(?:^|\s)(\/[\w\-\.\/]+\.\w+)/g,
213
+ // Relative paths with ./: ./src/file.ts
214
+ /(?:^|\s)(\.\/[\w\-\.\/]+\.\w+)/g,
215
+ // Relative paths: src/file.ts
216
+ /(?:^|\s)([\w\-]+\/[\w\-\.\/]+\.\w+)/g,
217
+ ];
218
+ for (const pattern of patterns) {
219
+ for (const match of command.matchAll(pattern)) {
220
+ const file = match[1];
221
+ // Filter out common non-files
222
+ if (file && !file.startsWith('http') && !file.match(/^\d+\.\d+/)) {
223
+ files.push(file);
224
+ }
225
+ }
226
+ }
227
+ return [...new Set(files)];
228
+ }
229
+ /**
230
+ * Extract keywords from an action (for step storage)
231
+ */
232
+ export function extractKeywordsFromAction(action) {
233
+ const keywords = [];
234
+ // Extract from file names
235
+ for (const file of action.files) {
236
+ const fileName = file.split('/').pop() || '';
237
+ const baseName = fileName.replace(/\.\w+$/, '');
238
+ // Split camelCase and kebab-case
239
+ const parts = baseName
240
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
241
+ .replace(/[-_]/g, ' ')
242
+ .toLowerCase()
243
+ .split(/\s+/)
244
+ .filter(p => p.length > 2);
245
+ keywords.push(...parts);
246
+ }
247
+ // Extract from bash command
248
+ if (action.command) {
249
+ const commandParts = action.command
250
+ .toLowerCase()
251
+ .split(/\s+/)
252
+ .filter(p => p.length > 3 && !p.startsWith('-'));
253
+ keywords.push(...commandParts.slice(0, 5));
254
+ }
255
+ return [...new Set(keywords)];
256
+ }
@@ -0,0 +1,248 @@
1
+ import Database from 'better-sqlite3';
2
+ import type { ClaudeAction } from './session-parser.js';
3
+ export type TaskStatus = 'complete' | 'question' | 'partial' | 'abandoned';
4
+ export interface Task {
5
+ id: string;
6
+ project_path: string;
7
+ user?: string;
8
+ original_query: string;
9
+ goal?: string;
10
+ reasoning_trace: string[];
11
+ files_touched: string[];
12
+ status: TaskStatus;
13
+ linked_commit?: string;
14
+ parent_task_id?: string;
15
+ turn_number?: number;
16
+ tags: string[];
17
+ created_at: string;
18
+ }
19
+ export interface CreateTaskInput {
20
+ project_path: string;
21
+ user?: string;
22
+ original_query: string;
23
+ goal?: string;
24
+ reasoning_trace?: string[];
25
+ files_touched?: string[];
26
+ status: TaskStatus;
27
+ linked_commit?: string;
28
+ parent_task_id?: string;
29
+ turn_number?: number;
30
+ tags?: string[];
31
+ }
32
+ export type SessionStatus = 'active' | 'completed' | 'abandoned';
33
+ export interface SessionState {
34
+ session_id: string;
35
+ user_id?: string;
36
+ project_path: string;
37
+ original_goal?: string;
38
+ actions_taken: string[];
39
+ files_explored: string[];
40
+ current_intent?: string;
41
+ drift_warnings: string[];
42
+ start_time: string;
43
+ last_update: string;
44
+ status: SessionStatus;
45
+ expected_scope: string[];
46
+ constraints: string[];
47
+ success_criteria: string[];
48
+ keywords: string[];
49
+ last_drift_score?: number;
50
+ escalation_count: number;
51
+ pending_recovery_plan?: RecoveryPlan;
52
+ drift_history: DriftEvent[];
53
+ last_checked_at: number;
54
+ }
55
+ export interface RecoveryPlan {
56
+ steps: Array<{
57
+ file?: string;
58
+ action: string;
59
+ }>;
60
+ }
61
+ export interface DriftEvent {
62
+ timestamp: string;
63
+ score: number;
64
+ level: string;
65
+ prompt_summary: string;
66
+ }
67
+ export interface CreateSessionStateInput {
68
+ session_id: string;
69
+ user_id?: string;
70
+ project_path: string;
71
+ original_goal?: string;
72
+ expected_scope?: string[];
73
+ constraints?: string[];
74
+ success_criteria?: string[];
75
+ keywords?: string[];
76
+ }
77
+ export type ChangeType = 'read' | 'write' | 'edit' | 'create' | 'delete';
78
+ export interface FileReasoning {
79
+ id: string;
80
+ task_id?: string;
81
+ file_path: string;
82
+ anchor?: string;
83
+ line_start?: number;
84
+ line_end?: number;
85
+ code_hash?: string;
86
+ change_type?: ChangeType;
87
+ reasoning: string;
88
+ created_at: string;
89
+ }
90
+ export interface CreateFileReasoningInput {
91
+ task_id?: string;
92
+ file_path: string;
93
+ anchor?: string;
94
+ line_start?: number;
95
+ line_end?: number;
96
+ code_hash?: string;
97
+ change_type?: ChangeType;
98
+ reasoning: string;
99
+ }
100
+ /**
101
+ * Initialize the database connection and create tables
102
+ */
103
+ export declare function initDatabase(): Database.Database;
104
+ /**
105
+ * Close the database connection
106
+ */
107
+ export declare function closeDatabase(): void;
108
+ /**
109
+ * Create a new task
110
+ */
111
+ export declare function createTask(input: CreateTaskInput): Task;
112
+ /**
113
+ * Get tasks for a project
114
+ */
115
+ export declare function getTasksForProject(projectPath: string, options?: {
116
+ status?: TaskStatus;
117
+ limit?: number;
118
+ }): Task[];
119
+ export declare function getTasksByFiles(projectPath: string, files: string[], options?: {
120
+ status?: TaskStatus;
121
+ limit?: number;
122
+ }): Task[];
123
+ /**
124
+ * Get a task by ID
125
+ */
126
+ export declare function getTaskById(id: string): Task | null;
127
+ /**
128
+ * Update a task's status
129
+ */
130
+ export declare function updateTaskStatus(id: string, status: TaskStatus): void;
131
+ /**
132
+ * Get task count for a project
133
+ */
134
+ export declare function getTaskCount(projectPath: string): number;
135
+ /**
136
+ * Create a new session state.
137
+ * FIXED: Uses INSERT OR IGNORE to handle race conditions safely.
138
+ */
139
+ export declare function createSessionState(input: CreateSessionStateInput): SessionState;
140
+ /**
141
+ * Get a session state by ID
142
+ */
143
+ export declare function getSessionState(sessionId: string): SessionState | null;
144
+ /**
145
+ * Update a session state.
146
+ * SECURITY: Uses transaction for atomic updates to prevent race conditions.
147
+ */
148
+ export declare function updateSessionState(sessionId: string, updates: Partial<Omit<SessionState, 'session_id' | 'start_time'>>): void;
149
+ /**
150
+ * Delete a session state
151
+ */
152
+ export declare function deleteSessionState(sessionId: string): void;
153
+ /**
154
+ * Get active sessions for a project
155
+ */
156
+ export declare function getActiveSessionsForProject(projectPath: string): SessionState[];
157
+ /**
158
+ * Correction level types for drift detection
159
+ */
160
+ export type CorrectionLevel = 'nudge' | 'correct' | 'intervene' | 'halt';
161
+ /**
162
+ * Update session drift metrics after a prompt check
163
+ */
164
+ export declare function updateSessionDrift(sessionId: string, driftScore: number, correctionLevel: CorrectionLevel | null, promptSummary: string, recoveryPlan?: RecoveryPlan): void;
165
+ /**
166
+ * Check if a session should be flagged for review
167
+ * Returns true if: status=drifted OR warnings>=3 OR avg_score<6
168
+ */
169
+ export declare function shouldFlagForReview(sessionId: string): boolean;
170
+ /**
171
+ * Get drift summary for a session (used by capture)
172
+ */
173
+ export declare function getDriftSummary(sessionId: string): {
174
+ totalEvents: number;
175
+ resolved: boolean;
176
+ finalScore: number | null;
177
+ hadHalt: boolean;
178
+ };
179
+ /**
180
+ * Create a new file reasoning entry
181
+ */
182
+ export declare function createFileReasoning(input: CreateFileReasoningInput): FileReasoning;
183
+ /**
184
+ * Get file reasoning entries for a task
185
+ */
186
+ export declare function getFileReasoningForTask(taskId: string): FileReasoning[];
187
+ /**
188
+ * Get file reasoning entries by file path
189
+ */
190
+ export declare function getFileReasoningByPath(filePath: string, limit?: number): FileReasoning[];
191
+ /**
192
+ * Get file reasoning entries matching a pattern (for files in a project).
193
+ * SECURITY: Uses escaped LIKE patterns to prevent injection.
194
+ */
195
+ export declare function getFileReasoningByPathPattern(pathPattern: string, limit?: number): FileReasoning[];
196
+ /**
197
+ * Get the database path
198
+ */
199
+ export declare function getDatabasePath(): string;
200
+ /**
201
+ * Step record - a single Claude action stored in DB
202
+ */
203
+ export interface StepRecord {
204
+ id: string;
205
+ session_id: string;
206
+ action_type: string;
207
+ files: string[];
208
+ folders: string[];
209
+ command?: string;
210
+ reasoning?: string;
211
+ drift_score: number;
212
+ is_key_decision: boolean;
213
+ keywords: string[];
214
+ timestamp: number;
215
+ }
216
+ /**
217
+ * Save a Claude action as a step
218
+ */
219
+ export declare function saveStep(sessionId: string, action: ClaudeAction, driftScore: number, isKeyDecision?: boolean, keywords?: string[]): void;
220
+ /**
221
+ * Get recent steps for a session (most recent first)
222
+ */
223
+ export declare function getRecentSteps(sessionId: string, limit?: number): StepRecord[];
224
+ /**
225
+ * Update last_checked_at timestamp for a session
226
+ */
227
+ export declare function updateLastChecked(sessionId: string, timestamp: number): void;
228
+ /**
229
+ * Get steps that touched specific files
230
+ */
231
+ export declare function getStepsByFiles(sessionId: string, files: string[], limit?: number): StepRecord[];
232
+ /**
233
+ * Get steps that touched specific folders
234
+ */
235
+ export declare function getStepsByFolders(sessionId: string, folders: string[], limit?: number): StepRecord[];
236
+ /**
237
+ * Get steps matching keywords
238
+ */
239
+ export declare function getStepsByKeywords(sessionId: string, keywords: string[], limit?: number): StepRecord[];
240
+ /**
241
+ * Get key decision steps
242
+ */
243
+ export declare function getKeyDecisionSteps(sessionId: string, limit?: number): StepRecord[];
244
+ /**
245
+ * Combined retrieval: runs all 4 queries and deduplicates
246
+ * Priority: key decisions > files > folders > keywords
247
+ */
248
+ export declare function getRelevantSteps(sessionId: string, currentFiles: string[], currentFolders: string[], keywords: string[], limit?: number): StepRecord[];