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.
- package/LICENSE +190 -0
- package/README.md +211 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +106 -0
- package/dist/commands/capture.d.ts +6 -0
- package/dist/commands/capture.js +324 -0
- package/dist/commands/drift-test.d.ts +7 -0
- package/dist/commands/drift-test.js +177 -0
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.js +27 -0
- package/dist/commands/inject.d.ts +5 -0
- package/dist/commands/inject.js +88 -0
- package/dist/commands/prompt-inject.d.ts +4 -0
- package/dist/commands/prompt-inject.js +451 -0
- package/dist/commands/status.d.ts +5 -0
- package/dist/commands/status.js +51 -0
- package/dist/commands/unregister.d.ts +1 -0
- package/dist/commands/unregister.js +22 -0
- package/dist/lib/anchor-extractor.d.ts +30 -0
- package/dist/lib/anchor-extractor.js +296 -0
- package/dist/lib/correction-builder.d.ts +10 -0
- package/dist/lib/correction-builder.js +226 -0
- package/dist/lib/debug.d.ts +24 -0
- package/dist/lib/debug.js +34 -0
- package/dist/lib/drift-checker.d.ts +66 -0
- package/dist/lib/drift-checker.js +341 -0
- package/dist/lib/hooks.d.ts +27 -0
- package/dist/lib/hooks.js +258 -0
- package/dist/lib/jsonl-parser.d.ts +87 -0
- package/dist/lib/jsonl-parser.js +281 -0
- package/dist/lib/llm-extractor.d.ts +50 -0
- package/dist/lib/llm-extractor.js +408 -0
- package/dist/lib/session-parser.d.ts +44 -0
- package/dist/lib/session-parser.js +256 -0
- package/dist/lib/store.d.ts +248 -0
- package/dist/lib/store.js +793 -0
- package/dist/lib/utils.d.ts +31 -0
- package/dist/lib/utils.js +76 -0
- package/package.json +67 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
interface JsonlEntry {
|
|
2
|
+
type: 'user' | 'assistant' | 'system';
|
|
3
|
+
message?: {
|
|
4
|
+
role: string;
|
|
5
|
+
content: string | ContentBlock[];
|
|
6
|
+
};
|
|
7
|
+
timestamp?: string;
|
|
8
|
+
session_id?: string;
|
|
9
|
+
[key: string]: unknown;
|
|
10
|
+
}
|
|
11
|
+
interface ContentBlock {
|
|
12
|
+
type: string;
|
|
13
|
+
text?: string;
|
|
14
|
+
name?: string;
|
|
15
|
+
input?: unknown;
|
|
16
|
+
[key: string]: unknown;
|
|
17
|
+
}
|
|
18
|
+
export interface ParsedSession {
|
|
19
|
+
sessionId: string;
|
|
20
|
+
projectPath: string;
|
|
21
|
+
startTime: string;
|
|
22
|
+
endTime: string;
|
|
23
|
+
userMessages: string[];
|
|
24
|
+
assistantMessages: string[];
|
|
25
|
+
toolCalls: ToolCall[];
|
|
26
|
+
filesRead: string[];
|
|
27
|
+
filesWritten: string[];
|
|
28
|
+
rawEntries: JsonlEntry[];
|
|
29
|
+
}
|
|
30
|
+
export interface ToolCall {
|
|
31
|
+
name: string;
|
|
32
|
+
input: unknown;
|
|
33
|
+
timestamp?: string;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Encode a project path the same way Claude Code does
|
|
37
|
+
* /Users/dev/myapp -> -Users-dev-myapp
|
|
38
|
+
*/
|
|
39
|
+
export declare function encodeProjectPath(projectPath: string): string;
|
|
40
|
+
/**
|
|
41
|
+
* Decode an encoded project path back to the original.
|
|
42
|
+
* SECURITY: Validates that the decoded path doesn't contain traversal sequences.
|
|
43
|
+
* @throws Error if path contains traversal sequences
|
|
44
|
+
*/
|
|
45
|
+
export declare function decodeProjectPath(encoded: string): string;
|
|
46
|
+
/**
|
|
47
|
+
* Validate that a file path is within the expected project boundary.
|
|
48
|
+
* SECURITY: Prevents path traversal outside project directory.
|
|
49
|
+
*/
|
|
50
|
+
export declare function isPathWithinProject(projectPath: string, filePath: string): boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Get the directory where Claude stores sessions for a project
|
|
53
|
+
*/
|
|
54
|
+
export declare function getProjectSessionsDir(projectPath: string): string;
|
|
55
|
+
/**
|
|
56
|
+
* Extract session ID from a JSONL file path
|
|
57
|
+
* ~/.claude/projects/-Users-dev-myapp/abc123def456.jsonl -> abc123def456
|
|
58
|
+
* SECURITY: Validates session ID format to prevent path confusion attacks.
|
|
59
|
+
*/
|
|
60
|
+
export declare function getSessionIdFromPath(jsonlPath: string): string;
|
|
61
|
+
/**
|
|
62
|
+
* Get the current session ID for a project (from the most recent session file)
|
|
63
|
+
*/
|
|
64
|
+
export declare function getCurrentSessionId(projectPath: string): string | null;
|
|
65
|
+
/**
|
|
66
|
+
* Find the most recent session file for a project.
|
|
67
|
+
* SECURITY: Handles TOCTOU race conditions gracefully.
|
|
68
|
+
*/
|
|
69
|
+
export declare function findLatestSessionFile(projectPath: string): string | null;
|
|
70
|
+
/**
|
|
71
|
+
* List all session files for a project
|
|
72
|
+
*/
|
|
73
|
+
export declare function listSessionFiles(projectPath: string): string[];
|
|
74
|
+
/**
|
|
75
|
+
* Parse a JSONL file into entries.
|
|
76
|
+
* SECURITY: Limits entries to prevent memory exhaustion attacks.
|
|
77
|
+
*/
|
|
78
|
+
export declare function parseJsonlFile(filePath: string): JsonlEntry[];
|
|
79
|
+
/**
|
|
80
|
+
* Parse a session file and extract structured data
|
|
81
|
+
*/
|
|
82
|
+
export declare function parseSession(filePath: string): ParsedSession;
|
|
83
|
+
/**
|
|
84
|
+
* Get a summary of the session for LLM extraction
|
|
85
|
+
*/
|
|
86
|
+
export declare function getSessionSummary(session: ParsedSession): string;
|
|
87
|
+
export {};
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// Parse Claude Code session JSONL files from ~/.claude/projects/
|
|
2
|
+
import { readFileSync, readdirSync, statSync, existsSync } from 'fs';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import { join, basename, resolve, normalize } from 'path';
|
|
5
|
+
import { debugParser } from './debug.js';
|
|
6
|
+
const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects');
|
|
7
|
+
/**
|
|
8
|
+
* Encode a project path the same way Claude Code does
|
|
9
|
+
* /Users/dev/myapp -> -Users-dev-myapp
|
|
10
|
+
*/
|
|
11
|
+
export function encodeProjectPath(projectPath) {
|
|
12
|
+
// Normalize the path first to prevent encoding malicious paths
|
|
13
|
+
const normalized = projectPath.replace(/\.\.+/g, '.');
|
|
14
|
+
return normalized.replace(/\//g, '-');
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Decode an encoded project path back to the original.
|
|
18
|
+
* SECURITY: Validates that the decoded path doesn't contain traversal sequences.
|
|
19
|
+
* @throws Error if path contains traversal sequences
|
|
20
|
+
*/
|
|
21
|
+
export function decodeProjectPath(encoded) {
|
|
22
|
+
// First char is always '-' representing the root '/'
|
|
23
|
+
const decoded = encoded.replace(/-/g, '/');
|
|
24
|
+
// SECURITY: Prevent path traversal attacks
|
|
25
|
+
if (decoded.includes('..')) {
|
|
26
|
+
throw new Error('Invalid path: traversal sequence detected');
|
|
27
|
+
}
|
|
28
|
+
return decoded;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Validate that a file path is within the expected project boundary.
|
|
32
|
+
* SECURITY: Prevents path traversal outside project directory.
|
|
33
|
+
*/
|
|
34
|
+
export function isPathWithinProject(projectPath, filePath) {
|
|
35
|
+
const resolvedProject = resolve(normalize(projectPath));
|
|
36
|
+
const resolvedFile = resolve(normalize(filePath));
|
|
37
|
+
return resolvedFile.startsWith(resolvedProject);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Get the directory where Claude stores sessions for a project
|
|
41
|
+
*/
|
|
42
|
+
export function getProjectSessionsDir(projectPath) {
|
|
43
|
+
const encoded = encodeProjectPath(projectPath);
|
|
44
|
+
return join(CLAUDE_PROJECTS_DIR, encoded);
|
|
45
|
+
}
|
|
46
|
+
// SECURITY: Valid session ID pattern (hex chars, dashes for UUIDs)
|
|
47
|
+
const SESSION_ID_PATTERN = /^[a-f0-9-]+$/i;
|
|
48
|
+
/**
|
|
49
|
+
* Extract session ID from a JSONL file path
|
|
50
|
+
* ~/.claude/projects/-Users-dev-myapp/abc123def456.jsonl -> abc123def456
|
|
51
|
+
* SECURITY: Validates session ID format to prevent path confusion attacks.
|
|
52
|
+
*/
|
|
53
|
+
export function getSessionIdFromPath(jsonlPath) {
|
|
54
|
+
const sessionId = basename(jsonlPath, '.jsonl');
|
|
55
|
+
// SECURITY: Validate session ID contains only safe characters
|
|
56
|
+
if (!SESSION_ID_PATTERN.test(sessionId)) {
|
|
57
|
+
debugParser('Invalid session ID format: %s', sessionId.substring(0, 20));
|
|
58
|
+
throw new Error('Invalid session ID format');
|
|
59
|
+
}
|
|
60
|
+
return sessionId;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Get the current session ID for a project (from the most recent session file)
|
|
64
|
+
*/
|
|
65
|
+
export function getCurrentSessionId(projectPath) {
|
|
66
|
+
const latestFile = findLatestSessionFile(projectPath);
|
|
67
|
+
return latestFile ? getSessionIdFromPath(latestFile) : null;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Find the most recent session file for a project.
|
|
71
|
+
* SECURITY: Handles TOCTOU race conditions gracefully.
|
|
72
|
+
*/
|
|
73
|
+
export function findLatestSessionFile(projectPath) {
|
|
74
|
+
const sessionsDir = getProjectSessionsDir(projectPath);
|
|
75
|
+
if (!existsSync(sessionsDir)) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
// SECURITY: Handle TOCTOU - files may be deleted between list and stat
|
|
79
|
+
const files = [];
|
|
80
|
+
try {
|
|
81
|
+
const entries = readdirSync(sessionsDir).filter(f => f.endsWith('.jsonl'));
|
|
82
|
+
for (const f of entries) {
|
|
83
|
+
const filePath = join(sessionsDir, f);
|
|
84
|
+
try {
|
|
85
|
+
// SECURITY: Wrap stat in try-catch to handle deleted files
|
|
86
|
+
const stat = statSync(filePath);
|
|
87
|
+
files.push({
|
|
88
|
+
name: f,
|
|
89
|
+
path: filePath,
|
|
90
|
+
mtime: stat.mtime
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// File was deleted between readdir and stat - skip it
|
|
95
|
+
debugParser('File disappeared during listing: %s', f);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// Directory may have been deleted
|
|
101
|
+
debugParser('Could not read sessions directory: %s', sessionsDir);
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
files.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
105
|
+
return files.length > 0 ? files[0].path : null;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* List all session files for a project
|
|
109
|
+
*/
|
|
110
|
+
export function listSessionFiles(projectPath) {
|
|
111
|
+
const sessionsDir = getProjectSessionsDir(projectPath);
|
|
112
|
+
if (!existsSync(sessionsDir)) {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
return readdirSync(sessionsDir)
|
|
116
|
+
.filter(f => f.endsWith('.jsonl'))
|
|
117
|
+
.map(f => join(sessionsDir, f));
|
|
118
|
+
}
|
|
119
|
+
// SECURITY: Maximum entries to prevent memory exhaustion
|
|
120
|
+
const MAX_JSONL_ENTRIES = 10000;
|
|
121
|
+
/**
|
|
122
|
+
* Parse a JSONL file into entries.
|
|
123
|
+
* SECURITY: Limits entries to prevent memory exhaustion attacks.
|
|
124
|
+
*/
|
|
125
|
+
export function parseJsonlFile(filePath) {
|
|
126
|
+
let content;
|
|
127
|
+
try {
|
|
128
|
+
content = readFileSync(filePath, 'utf-8');
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// File may have been deleted/moved between finding and reading
|
|
132
|
+
debugParser('Could not read file: %s', filePath);
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
const lines = content.trim().split('\n').filter(line => line.trim());
|
|
136
|
+
const entries = [];
|
|
137
|
+
for (const line of lines) {
|
|
138
|
+
// SECURITY: Limit entries to prevent memory exhaustion
|
|
139
|
+
if (entries.length >= MAX_JSONL_ENTRIES) {
|
|
140
|
+
debugParser('Entry limit reached (%d), stopping parse', MAX_JSONL_ENTRIES);
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
const entry = JSON.parse(line);
|
|
145
|
+
entries.push(entry);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
// Skip malformed lines
|
|
149
|
+
debugParser('Skipping malformed JSONL line');
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return entries;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Extract text content from a message content array
|
|
156
|
+
*/
|
|
157
|
+
function extractTextContent(content) {
|
|
158
|
+
if (typeof content === 'string') {
|
|
159
|
+
return content;
|
|
160
|
+
}
|
|
161
|
+
return content
|
|
162
|
+
.filter(block => block.type === 'text' && block.text)
|
|
163
|
+
.map(block => block.text)
|
|
164
|
+
.join('\n');
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Extract tool calls from assistant message content
|
|
168
|
+
*/
|
|
169
|
+
function extractToolCalls(content, timestamp) {
|
|
170
|
+
if (typeof content === 'string') {
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
return content
|
|
174
|
+
.filter(block => block.type === 'tool_use' && block.name)
|
|
175
|
+
.map(block => ({
|
|
176
|
+
name: block.name,
|
|
177
|
+
input: block.input,
|
|
178
|
+
timestamp
|
|
179
|
+
}));
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Parse a session file and extract structured data
|
|
183
|
+
*/
|
|
184
|
+
export function parseSession(filePath) {
|
|
185
|
+
const entries = parseJsonlFile(filePath);
|
|
186
|
+
const sessionId = basename(filePath, '.jsonl');
|
|
187
|
+
const userMessages = [];
|
|
188
|
+
const assistantMessages = [];
|
|
189
|
+
const toolCalls = [];
|
|
190
|
+
const filesRead = new Set();
|
|
191
|
+
const filesWritten = new Set();
|
|
192
|
+
let startTime = '';
|
|
193
|
+
let endTime = '';
|
|
194
|
+
let projectPath = '';
|
|
195
|
+
for (const entry of entries) {
|
|
196
|
+
// Track timestamps
|
|
197
|
+
if (entry.timestamp) {
|
|
198
|
+
if (!startTime)
|
|
199
|
+
startTime = entry.timestamp;
|
|
200
|
+
endTime = entry.timestamp;
|
|
201
|
+
}
|
|
202
|
+
// Extract user messages
|
|
203
|
+
if (entry.type === 'user' && entry.message?.content) {
|
|
204
|
+
const text = extractTextContent(entry.message.content);
|
|
205
|
+
if (text)
|
|
206
|
+
userMessages.push(text);
|
|
207
|
+
}
|
|
208
|
+
// Extract assistant messages and tool calls
|
|
209
|
+
if (entry.type === 'assistant' && entry.message?.content) {
|
|
210
|
+
const text = extractTextContent(entry.message.content);
|
|
211
|
+
if (text)
|
|
212
|
+
assistantMessages.push(text);
|
|
213
|
+
const tools = extractToolCalls(entry.message.content, entry.timestamp);
|
|
214
|
+
toolCalls.push(...tools);
|
|
215
|
+
// Track files from tool calls
|
|
216
|
+
for (const tool of tools) {
|
|
217
|
+
const input = tool.input;
|
|
218
|
+
if (input?.file_path && typeof input.file_path === 'string') {
|
|
219
|
+
if (tool.name === 'Read') {
|
|
220
|
+
filesRead.add(input.file_path);
|
|
221
|
+
}
|
|
222
|
+
else if (tool.name === 'Write' || tool.name === 'Edit') {
|
|
223
|
+
filesWritten.add(input.file_path);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// Try to infer project path from file paths
|
|
230
|
+
const allFiles = [...filesRead, ...filesWritten];
|
|
231
|
+
if (allFiles.length > 0) {
|
|
232
|
+
// Find common prefix
|
|
233
|
+
const firstFile = allFiles[0];
|
|
234
|
+
const parts = firstFile.split('/');
|
|
235
|
+
// Assume project is a few levels deep (e.g., /Users/dev/project)
|
|
236
|
+
if (parts.length >= 4) {
|
|
237
|
+
projectPath = parts.slice(0, 4).join('/');
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
sessionId,
|
|
242
|
+
projectPath,
|
|
243
|
+
startTime,
|
|
244
|
+
endTime,
|
|
245
|
+
userMessages,
|
|
246
|
+
assistantMessages,
|
|
247
|
+
toolCalls,
|
|
248
|
+
filesRead: [...filesRead],
|
|
249
|
+
filesWritten: [...filesWritten],
|
|
250
|
+
rawEntries: entries
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Get a summary of the session for LLM extraction
|
|
255
|
+
*/
|
|
256
|
+
export function getSessionSummary(session) {
|
|
257
|
+
const lines = [];
|
|
258
|
+
lines.push(`Session ID: ${session.sessionId}`);
|
|
259
|
+
lines.push(`Time: ${session.startTime} to ${session.endTime}`);
|
|
260
|
+
lines.push('');
|
|
261
|
+
lines.push('## User Messages');
|
|
262
|
+
session.userMessages.forEach((msg, i) => {
|
|
263
|
+
lines.push(`[${i + 1}] ${msg.substring(0, 500)}${msg.length > 500 ? '...' : ''}`);
|
|
264
|
+
});
|
|
265
|
+
lines.push('');
|
|
266
|
+
lines.push('## Files Read');
|
|
267
|
+
session.filesRead.forEach(f => lines.push(` - ${f}`));
|
|
268
|
+
lines.push('');
|
|
269
|
+
lines.push('## Files Written/Edited');
|
|
270
|
+
session.filesWritten.forEach(f => lines.push(` - ${f}`));
|
|
271
|
+
lines.push('');
|
|
272
|
+
lines.push('## Tool Calls');
|
|
273
|
+
const toolSummary = session.toolCalls.reduce((acc, t) => {
|
|
274
|
+
acc[t.name] = (acc[t.name] || 0) + 1;
|
|
275
|
+
return acc;
|
|
276
|
+
}, {});
|
|
277
|
+
Object.entries(toolSummary).forEach(([name, count]) => {
|
|
278
|
+
lines.push(` - ${name}: ${count}x`);
|
|
279
|
+
});
|
|
280
|
+
return lines.join('\n');
|
|
281
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ParsedSession } from './jsonl-parser.js';
|
|
2
|
+
import type { TaskStatus } from './store.js';
|
|
3
|
+
export interface ExtractedReasoning {
|
|
4
|
+
task: string;
|
|
5
|
+
goal: string;
|
|
6
|
+
reasoning_trace: string[];
|
|
7
|
+
files_touched: string[];
|
|
8
|
+
decisions: Array<{
|
|
9
|
+
choice: string;
|
|
10
|
+
reason: string;
|
|
11
|
+
}>;
|
|
12
|
+
constraints: string[];
|
|
13
|
+
status: TaskStatus;
|
|
14
|
+
tags: string[];
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Check if LLM extraction is available (OpenAI API key set)
|
|
18
|
+
*/
|
|
19
|
+
export declare function isLLMAvailable(): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Check if Anthropic API is available (for drift detection)
|
|
22
|
+
*/
|
|
23
|
+
export declare function isAnthropicAvailable(): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Get the drift model to use (from env or default)
|
|
26
|
+
*/
|
|
27
|
+
export declare function getDriftModel(): string;
|
|
28
|
+
/**
|
|
29
|
+
* Extract structured reasoning from a parsed session using GPT-3.5-turbo
|
|
30
|
+
*/
|
|
31
|
+
export declare function extractReasoning(session: ParsedSession): Promise<ExtractedReasoning>;
|
|
32
|
+
/**
|
|
33
|
+
* Classify just the task status (lighter weight than full extraction)
|
|
34
|
+
*/
|
|
35
|
+
export declare function classifyTaskStatus(session: ParsedSession): Promise<TaskStatus>;
|
|
36
|
+
/**
|
|
37
|
+
* Extracted intent from first prompt
|
|
38
|
+
*/
|
|
39
|
+
export interface ExtractedIntent {
|
|
40
|
+
goal: string;
|
|
41
|
+
expected_scope: string[];
|
|
42
|
+
constraints: string[];
|
|
43
|
+
success_criteria: string[];
|
|
44
|
+
keywords: string[];
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Extract intent from a prompt using Claude Haiku
|
|
48
|
+
* Falls back to basic extraction if API unavailable
|
|
49
|
+
*/
|
|
50
|
+
export declare function extractIntent(prompt: string): Promise<ExtractedIntent>;
|