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,408 @@
1
+ // LLM-based extraction using OpenAI GPT-3.5-turbo for reasoning summaries
2
+ // and Anthropic Claude Haiku for drift detection
3
+ import OpenAI from 'openai';
4
+ import Anthropic from '@anthropic-ai/sdk';
5
+ import { debugLLM } from './debug.js';
6
+ import { truncate } from './utils.js';
7
+ let client = null;
8
+ let anthropicClient = null;
9
+ /**
10
+ * Initialize the OpenAI client
11
+ */
12
+ function getClient() {
13
+ if (!client) {
14
+ const apiKey = process.env.OPENAI_API_KEY;
15
+ if (!apiKey) {
16
+ // SECURITY: Generic error to avoid confirming API key mechanism exists
17
+ throw new Error('LLM extraction unavailable');
18
+ }
19
+ client = new OpenAI({ apiKey });
20
+ }
21
+ return client;
22
+ }
23
+ /**
24
+ * Initialize the Anthropic client
25
+ */
26
+ function getAnthropicClient() {
27
+ if (!anthropicClient) {
28
+ const apiKey = process.env.ANTHROPIC_API_KEY;
29
+ if (!apiKey) {
30
+ throw new Error('ANTHROPIC_API_KEY environment variable is required for drift detection');
31
+ }
32
+ anthropicClient = new Anthropic({ apiKey });
33
+ }
34
+ return anthropicClient;
35
+ }
36
+ /**
37
+ * Check if LLM extraction is available (OpenAI API key set)
38
+ */
39
+ export function isLLMAvailable() {
40
+ return !!process.env.OPENAI_API_KEY;
41
+ }
42
+ /**
43
+ * Check if Anthropic API is available (for drift detection)
44
+ */
45
+ export function isAnthropicAvailable() {
46
+ return !!process.env.ANTHROPIC_API_KEY;
47
+ }
48
+ /**
49
+ * Get the drift model to use (from env or default)
50
+ */
51
+ export function getDriftModel() {
52
+ return process.env.GROV_DRIFT_MODEL || 'claude-haiku-4-5';
53
+ }
54
+ /**
55
+ * Extract structured reasoning from a parsed session using GPT-3.5-turbo
56
+ */
57
+ export async function extractReasoning(session) {
58
+ const openai = getClient();
59
+ // Build session summary for the prompt
60
+ const sessionSummary = buildSessionSummary(session);
61
+ const response = await openai.chat.completions.create({
62
+ model: 'gpt-3.5-turbo',
63
+ max_tokens: 1024,
64
+ messages: [
65
+ {
66
+ role: 'system',
67
+ content: 'You are a helpful assistant that extracts structured information from coding sessions. Always respond with valid JSON only, no explanation.'
68
+ },
69
+ {
70
+ role: 'user',
71
+ content: `Analyze this Claude Code session and extract a structured reasoning summary.
72
+
73
+ SESSION DATA:
74
+ ${sessionSummary}
75
+
76
+ Extract the following as JSON:
77
+ {
78
+ "task": "Brief description of what the user was trying to do (1 sentence)",
79
+ "goal": "The underlying goal or problem being solved",
80
+ "reasoning_trace": ["Key reasoning steps taken", "Decisions made and why", "What was investigated"],
81
+ "decisions": [{"choice": "What was decided", "reason": "Why this choice was made"}],
82
+ "constraints": ["Any constraints or requirements discovered"],
83
+ "status": "complete|partial|question|abandoned",
84
+ "tags": ["relevant", "domain", "tags"]
85
+ }
86
+
87
+ Status definitions:
88
+ - "complete": Task was finished, implementation done
89
+ - "partial": Work started but not finished
90
+ - "question": Claude asked a question and is waiting for user response
91
+ - "abandoned": User interrupted or moved to different topic
92
+
93
+ Return ONLY valid JSON, no explanation.`
94
+ }
95
+ ]
96
+ });
97
+ // Parse the response
98
+ const content = response.choices[0]?.message?.content;
99
+ if (!content) {
100
+ throw new Error('No response from OpenAI');
101
+ }
102
+ try {
103
+ // SECURITY: Parse to plain object first, then sanitize prototype pollution
104
+ const rawParsed = JSON.parse(content);
105
+ // SECURITY: Prevent prototype pollution from LLM-generated JSON
106
+ // An attacker could manipulate LLM to return {"__proto__": {"isAdmin": true}}
107
+ const pollutionKeys = ['__proto__', 'constructor', 'prototype'];
108
+ for (const key of pollutionKeys) {
109
+ if (key in rawParsed) {
110
+ delete rawParsed[key];
111
+ }
112
+ }
113
+ const extracted = rawParsed;
114
+ // SECURITY: Validate types to prevent LLM injection attacks
115
+ const safeTask = typeof extracted.task === 'string' ? extracted.task : '';
116
+ const safeGoal = typeof extracted.goal === 'string' ? extracted.goal : '';
117
+ const safeTrace = Array.isArray(extracted.reasoning_trace)
118
+ ? extracted.reasoning_trace.filter((t) => typeof t === 'string')
119
+ : [];
120
+ const safeDecisions = Array.isArray(extracted.decisions)
121
+ ? extracted.decisions.filter((d) => d && typeof d === 'object' && typeof d.choice === 'string' && typeof d.reason === 'string')
122
+ : [];
123
+ const safeConstraints = Array.isArray(extracted.constraints)
124
+ ? extracted.constraints.filter((c) => typeof c === 'string')
125
+ : [];
126
+ const safeTags = Array.isArray(extracted.tags)
127
+ ? extracted.tags.filter((t) => typeof t === 'string')
128
+ : [];
129
+ // Fill defaults with validated values
130
+ return {
131
+ task: safeTask || session.userMessages[0]?.substring(0, 100) || 'Unknown task',
132
+ goal: safeGoal || safeTask || 'Unknown goal',
133
+ reasoning_trace: safeTrace,
134
+ files_touched: session.filesRead.concat(session.filesWritten),
135
+ decisions: safeDecisions,
136
+ constraints: safeConstraints,
137
+ status: validateStatus(extracted.status),
138
+ tags: safeTags
139
+ };
140
+ }
141
+ catch (parseError) {
142
+ // If JSON parsing fails, return basic extraction
143
+ debugLLM('Failed to parse LLM response, using fallback');
144
+ return createFallbackExtraction(session);
145
+ }
146
+ }
147
+ /**
148
+ * Classify just the task status (lighter weight than full extraction)
149
+ */
150
+ export async function classifyTaskStatus(session) {
151
+ const openai = getClient();
152
+ // Get last few exchanges for classification
153
+ const lastMessages = session.userMessages.slice(-2).join('\n---\n');
154
+ const lastAssistant = session.assistantMessages.slice(-1)[0] || '';
155
+ const response = await openai.chat.completions.create({
156
+ model: 'gpt-3.5-turbo',
157
+ max_tokens: 50,
158
+ messages: [
159
+ {
160
+ role: 'system',
161
+ content: 'Classify conversation state. Return ONLY one word: complete, partial, question, or abandoned.'
162
+ },
163
+ {
164
+ role: 'user',
165
+ content: `Last user message(s):
166
+ ${lastMessages}
167
+
168
+ Last assistant response (truncated):
169
+ ${lastAssistant.substring(0, 500)}
170
+
171
+ Files written: ${session.filesWritten.length}
172
+ Files read: ${session.filesRead.length}
173
+
174
+ Classification:`
175
+ }
176
+ ]
177
+ });
178
+ const content = response.choices[0]?.message?.content;
179
+ if (!content) {
180
+ return 'partial';
181
+ }
182
+ return validateStatus(content.trim().toLowerCase());
183
+ }
184
+ /**
185
+ * Build a summary of the session for the LLM prompt
186
+ */
187
+ function buildSessionSummary(session) {
188
+ const lines = [];
189
+ // User messages
190
+ lines.push('USER MESSAGES:');
191
+ session.userMessages.forEach((msg, i) => {
192
+ lines.push(`[${i + 1}] ${truncate(msg, 300)}`);
193
+ });
194
+ lines.push('');
195
+ // Files touched
196
+ lines.push('FILES READ:');
197
+ session.filesRead.slice(0, 10).forEach(f => lines.push(` - ${f}`));
198
+ if (session.filesRead.length > 10) {
199
+ lines.push(` ... and ${session.filesRead.length - 10} more`);
200
+ }
201
+ lines.push('');
202
+ lines.push('FILES WRITTEN/EDITED:');
203
+ session.filesWritten.forEach(f => lines.push(` - ${f}`));
204
+ lines.push('');
205
+ // Tool usage summary
206
+ lines.push('TOOL USAGE:');
207
+ const toolCounts = session.toolCalls.reduce((acc, t) => {
208
+ acc[t.name] = (acc[t.name] || 0) + 1;
209
+ return acc;
210
+ }, {});
211
+ Object.entries(toolCounts).forEach(([name, count]) => {
212
+ lines.push(` - ${name}: ${count}x`);
213
+ });
214
+ lines.push('');
215
+ // Last assistant message (often contains summary/conclusion)
216
+ const lastAssistant = session.assistantMessages[session.assistantMessages.length - 1];
217
+ if (lastAssistant) {
218
+ lines.push('LAST ASSISTANT MESSAGE:');
219
+ lines.push(truncate(lastAssistant, 500));
220
+ }
221
+ return lines.join('\n');
222
+ }
223
+ /**
224
+ * Create fallback extraction when LLM fails
225
+ */
226
+ function createFallbackExtraction(session) {
227
+ const filesTouched = [...new Set([...session.filesRead, ...session.filesWritten])];
228
+ return {
229
+ task: session.userMessages[0]?.substring(0, 100) || 'Unknown task',
230
+ goal: session.userMessages[0]?.substring(0, 100) || 'Unknown goal',
231
+ reasoning_trace: generateBasicTrace(session),
232
+ files_touched: filesTouched,
233
+ decisions: [],
234
+ constraints: [],
235
+ status: session.filesWritten.length > 0 ? 'complete' : 'partial',
236
+ tags: generateTagsFromFiles(filesTouched)
237
+ };
238
+ }
239
+ /**
240
+ * Generate basic reasoning trace from tool usage
241
+ */
242
+ function generateBasicTrace(session) {
243
+ const trace = [];
244
+ const toolCounts = session.toolCalls.reduce((acc, t) => {
245
+ acc[t.name] = (acc[t.name] || 0) + 1;
246
+ return acc;
247
+ }, {});
248
+ if (toolCounts['Read'])
249
+ trace.push(`Read ${toolCounts['Read']} files`);
250
+ if (toolCounts['Write'])
251
+ trace.push(`Wrote ${toolCounts['Write']} files`);
252
+ if (toolCounts['Edit'])
253
+ trace.push(`Edited ${toolCounts['Edit']} files`);
254
+ if (toolCounts['Grep'] || toolCounts['Glob'])
255
+ trace.push('Searched codebase');
256
+ if (toolCounts['Bash'])
257
+ trace.push(`Ran ${toolCounts['Bash']} commands`);
258
+ return trace;
259
+ }
260
+ /**
261
+ * Generate tags from file paths
262
+ */
263
+ function generateTagsFromFiles(files) {
264
+ const tags = new Set();
265
+ for (const file of files) {
266
+ const parts = file.split('/');
267
+ for (const part of parts) {
268
+ if (part && !part.includes('.') && part !== 'src' && part !== 'lib') {
269
+ tags.add(part.toLowerCase());
270
+ }
271
+ }
272
+ // Common patterns
273
+ if (file.includes('auth'))
274
+ tags.add('auth');
275
+ if (file.includes('api'))
276
+ tags.add('api');
277
+ if (file.includes('test'))
278
+ tags.add('test');
279
+ }
280
+ return [...tags].slice(0, 10);
281
+ }
282
+ /**
283
+ * Validate and normalize status
284
+ */
285
+ function validateStatus(status) {
286
+ const normalized = status?.toLowerCase().trim();
287
+ if (normalized === 'complete' || normalized === 'partial' ||
288
+ normalized === 'question' || normalized === 'abandoned') {
289
+ return normalized;
290
+ }
291
+ return 'partial'; // Default
292
+ }
293
+ /**
294
+ * Extract intent from a prompt using Claude Haiku
295
+ * Falls back to basic extraction if API unavailable
296
+ */
297
+ export async function extractIntent(prompt) {
298
+ // Try LLM extraction if available
299
+ if (isAnthropicAvailable()) {
300
+ try {
301
+ return await extractIntentWithLLM(prompt);
302
+ }
303
+ catch (error) {
304
+ debugLLM('extractIntent LLM failed, using fallback: %O', error);
305
+ return extractIntentBasic(prompt);
306
+ }
307
+ }
308
+ // Fallback to basic extraction
309
+ return extractIntentBasic(prompt);
310
+ }
311
+ /**
312
+ * Extract intent using Claude Haiku
313
+ */
314
+ async function extractIntentWithLLM(prompt) {
315
+ const anthropic = getAnthropicClient();
316
+ const model = getDriftModel();
317
+ const response = await anthropic.messages.create({
318
+ model,
319
+ max_tokens: 1024,
320
+ messages: [
321
+ {
322
+ role: 'user',
323
+ content: `Analyze this user prompt and extract the task intent. Return ONLY valid JSON, no explanation.
324
+
325
+ USER PROMPT:
326
+ ${prompt}
327
+
328
+ Extract as JSON:
329
+ {
330
+ "goal": "The main objective the user wants to achieve (1 sentence)",
331
+ "expected_scope": ["List of files, directories, or components that should be touched"],
332
+ "constraints": ["Any constraints or requirements mentioned"],
333
+ "success_criteria": ["How to know when the task is complete"],
334
+ "keywords": ["Important technical terms from the prompt"]
335
+ }
336
+
337
+ Return ONLY valid JSON.`
338
+ }
339
+ ]
340
+ });
341
+ // Extract text content from response
342
+ const content = response.content[0];
343
+ if (content.type !== 'text') {
344
+ throw new Error('Unexpected response type from Anthropic');
345
+ }
346
+ const parsed = JSON.parse(content.text);
347
+ return {
348
+ goal: parsed.goal || prompt.substring(0, 100),
349
+ expected_scope: parsed.expected_scope || [],
350
+ constraints: parsed.constraints || [],
351
+ success_criteria: parsed.success_criteria || [],
352
+ keywords: parsed.keywords || extractKeywordsBasic(prompt)
353
+ };
354
+ }
355
+ /**
356
+ * Basic intent extraction without LLM
357
+ */
358
+ function extractIntentBasic(prompt) {
359
+ return {
360
+ goal: prompt.substring(0, 200),
361
+ expected_scope: extractFilesFromPrompt(prompt),
362
+ constraints: [],
363
+ success_criteria: [],
364
+ keywords: extractKeywordsBasic(prompt)
365
+ };
366
+ }
367
+ /**
368
+ * Extract file paths from prompt text
369
+ */
370
+ function extractFilesFromPrompt(prompt) {
371
+ const patterns = [
372
+ /(?:^|\s)(\/[\w\-\.\/]+\.\w+)/g,
373
+ /(?:^|\s)(\.\/[\w\-\.\/]+\.\w+)/g,
374
+ /(?:^|\s)([\w\-]+\/[\w\-\.\/]+\.\w+)/g,
375
+ /(?:^|\s|['"`])([\w\-]+\.\w{1,5})(?:\s|$|,|:|['"`])/g,
376
+ ];
377
+ const files = new Set();
378
+ for (const pattern of patterns) {
379
+ const matches = prompt.matchAll(pattern);
380
+ for (const match of matches) {
381
+ const file = match[1].trim();
382
+ if (file && !file.match(/^(http|https|ftp|mailto)/) && !file.match(/^\d+\.\d+/)) {
383
+ files.add(file);
384
+ }
385
+ }
386
+ }
387
+ return [...files];
388
+ }
389
+ /**
390
+ * Extract keywords from prompt (basic)
391
+ */
392
+ function extractKeywordsBasic(prompt) {
393
+ const stopWords = new Set([
394
+ 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been',
395
+ 'to', 'for', 'and', 'or', 'in', 'on', 'at', 'of', 'with',
396
+ 'this', 'that', 'it', 'i', 'you', 'we', 'they', 'my', 'your',
397
+ 'can', 'could', 'would', 'should', 'will', 'do', 'does', 'did',
398
+ 'have', 'has', 'had', 'not', 'but', 'if', 'then', 'when', 'where',
399
+ 'how', 'what', 'why', 'which', 'who', 'all', 'some', 'any', 'no',
400
+ 'from', 'by', 'as', 'so', 'too', 'also', 'just', 'only', 'now',
401
+ 'please', 'help', 'me', 'make', 'get', 'add', 'fix', 'update', 'change'
402
+ ]);
403
+ const words = prompt.toLowerCase()
404
+ .replace(/[^\w\s]/g, ' ')
405
+ .split(/\s+/)
406
+ .filter(w => w.length > 2 && !stopWords.has(w));
407
+ return [...new Set(words)].slice(0, 15);
408
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Claude's action extracted from session JSONL
3
+ */
4
+ export interface ClaudeAction {
5
+ type: 'edit' | 'write' | 'bash' | 'read' | 'delete' | 'grep' | 'glob' | 'multiedit';
6
+ files: string[];
7
+ command?: string;
8
+ timestamp: number;
9
+ }
10
+ /**
11
+ * Find session JSONL path from session_id and project path.
12
+ *
13
+ * Claude Code stores sessions in:
14
+ * ~/.claude/projects/<encoded-path>/<session_id>.jsonl
15
+ *
16
+ * The encoded path uses a specific encoding (not standard URL encoding).
17
+ */
18
+ export declare function findSessionFile(sessionId: string, projectPath: string): string | null;
19
+ /**
20
+ * Parse JSONL and extract ALL Claude's tool calls
21
+ */
22
+ export declare function parseSessionActions(sessionPath: string): ClaudeAction[];
23
+ /**
24
+ * Get only NEW actions since last check timestamp.
25
+ * This is the main function used by prompt-inject.
26
+ */
27
+ export declare function getNewActions(sessionPath: string, lastCheckedTimestamp: number): ClaudeAction[];
28
+ /**
29
+ * Get actions that MODIFY files (not reads).
30
+ * Use this for drift detection - reads are exploration, not drift.
31
+ */
32
+ export declare function getModifyingActions(actions: ClaudeAction[]): ClaudeAction[];
33
+ /**
34
+ * Extract all unique files touched by actions
35
+ */
36
+ export declare function extractFilesFromActions(actions: ClaudeAction[]): string[];
37
+ /**
38
+ * Extract unique folders from actions
39
+ */
40
+ export declare function extractFoldersFromActions(actions: ClaudeAction[]): string[];
41
+ /**
42
+ * Extract keywords from an action (for step storage)
43
+ */
44
+ export declare function extractKeywordsFromAction(action: ClaudeAction): string[];