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,451 @@
|
|
|
1
|
+
// grov prompt-inject - Called by UserPromptSubmit hook, outputs context JSON
|
|
2
|
+
// This provides continuous context injection on every user prompt
|
|
3
|
+
// Includes anti-drift detection and correction injection
|
|
4
|
+
//
|
|
5
|
+
// CRITICAL: We check Claude's ACTIONS, NOT user prompts.
|
|
6
|
+
// User can explore freely. We monitor what CLAUDE DOES.
|
|
7
|
+
import 'dotenv/config';
|
|
8
|
+
import { getTasksForProject, getTasksByFiles, getFileReasoningByPathPattern, getSessionState, createSessionState, updateSessionDrift, saveStep, updateLastChecked, } from '../lib/store.js';
|
|
9
|
+
import { extractIntent } from '../lib/llm-extractor.js';
|
|
10
|
+
import { buildDriftCheckInput, checkDrift } from '../lib/drift-checker.js';
|
|
11
|
+
import { determineCorrectionLevel, buildCorrection } from '../lib/correction-builder.js';
|
|
12
|
+
import { debugInject } from '../lib/debug.js';
|
|
13
|
+
import { truncate } from '../lib/utils.js';
|
|
14
|
+
import { findSessionFile, getNewActions, getModifyingActions, extractKeywordsFromAction, } from '../lib/session-parser.js';
|
|
15
|
+
// Maximum stdin size to prevent memory exhaustion (1MB)
|
|
16
|
+
const MAX_STDIN_SIZE = 1024 * 1024;
|
|
17
|
+
// Simple prompts that don't need context injection
|
|
18
|
+
const SIMPLE_PROMPTS = [
|
|
19
|
+
'yes', 'no', 'ok', 'okay', 'continue', 'go ahead',
|
|
20
|
+
'sure', 'yep', 'nope', 'y', 'n', 'proceed', 'do it',
|
|
21
|
+
'looks good', 'that works', 'perfect', 'thanks', 'thank you',
|
|
22
|
+
'next', 'done', 'good', 'great', 'fine', 'correct',
|
|
23
|
+
'right', 'exactly', 'agreed', 'approve', 'confirm'
|
|
24
|
+
];
|
|
25
|
+
// Stop words to filter out when extracting keywords
|
|
26
|
+
const STOP_WORDS = new Set([
|
|
27
|
+
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been',
|
|
28
|
+
'to', 'for', 'and', 'or', 'in', 'on', 'at', 'of', 'with',
|
|
29
|
+
'this', 'that', 'these', 'those', 'it', 'its', 'i', 'you',
|
|
30
|
+
'we', 'they', 'my', 'your', 'our', 'their', 'can', 'could',
|
|
31
|
+
'would', 'should', 'will', 'do', 'does', 'did', 'have', 'has',
|
|
32
|
+
'had', 'not', 'but', 'if', 'then', 'else', 'when', 'where',
|
|
33
|
+
'how', 'what', 'why', 'which', 'who', 'all', 'each', 'every',
|
|
34
|
+
'some', 'any', 'no', 'from', 'by', 'as', 'so', 'too', 'also',
|
|
35
|
+
'just', 'only', 'now', 'here', 'there', 'please', 'help', 'me',
|
|
36
|
+
'make', 'get', 'add', 'fix', 'update', 'change', 'modify', 'create'
|
|
37
|
+
]);
|
|
38
|
+
export async function promptInject(_options) {
|
|
39
|
+
try {
|
|
40
|
+
// Read input from stdin
|
|
41
|
+
const input = await readStdinInput();
|
|
42
|
+
if (!input || !input.prompt) {
|
|
43
|
+
return; // No prompt, no injection
|
|
44
|
+
}
|
|
45
|
+
// Skip simple prompts to save tokens
|
|
46
|
+
if (isSimplePrompt(input.prompt)) {
|
|
47
|
+
return; // No output = no injection
|
|
48
|
+
}
|
|
49
|
+
const projectPath = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
50
|
+
const sessionId = input.session_id;
|
|
51
|
+
// Check if we have a session state (determines if this is first prompt)
|
|
52
|
+
const sessionState = sessionId ? getSessionState(sessionId) : null;
|
|
53
|
+
let correctionText = null;
|
|
54
|
+
// === FIRST PROMPT: Create session state with extracted intent ===
|
|
55
|
+
if (!sessionState && sessionId) {
|
|
56
|
+
correctionText = await handleFirstPrompt(input.prompt, projectPath, sessionId);
|
|
57
|
+
}
|
|
58
|
+
// === SUBSEQUENT PROMPTS: Check Claude's ACTIONS for drift ===
|
|
59
|
+
else if (sessionState) {
|
|
60
|
+
// CRITICAL: We pass projectPath, not prompt. We check Claude's ACTIONS.
|
|
61
|
+
correctionText = await handleDriftCheck(projectPath, sessionState);
|
|
62
|
+
}
|
|
63
|
+
// Get recent completed tasks for this project
|
|
64
|
+
const tasks = getTasksForProject(projectPath, {
|
|
65
|
+
status: 'complete',
|
|
66
|
+
limit: 20
|
|
67
|
+
});
|
|
68
|
+
// Find relevant tasks via file paths and keywords
|
|
69
|
+
const explicitFiles = extractFilePaths(input.prompt);
|
|
70
|
+
const fileTasks = explicitFiles.length > 0 && tasks.length > 0
|
|
71
|
+
? getTasksByFiles(projectPath, explicitFiles, { status: 'complete', limit: 10 })
|
|
72
|
+
: [];
|
|
73
|
+
const keywordTasks = tasks.length > 0
|
|
74
|
+
? findKeywordMatches(input.prompt, tasks)
|
|
75
|
+
: [];
|
|
76
|
+
// Also get file-level reasoning for mentioned files
|
|
77
|
+
const fileReasonings = explicitFiles.length > 0
|
|
78
|
+
? explicitFiles.flatMap(f => getFileReasoningByPathPattern(f, 5))
|
|
79
|
+
: [];
|
|
80
|
+
// Combine and deduplicate tasks
|
|
81
|
+
const relevantTasks = dedupeAndLimit([...fileTasks, ...keywordTasks], 5);
|
|
82
|
+
// Build context (past reasoning from team memory)
|
|
83
|
+
const memoryContext = (relevantTasks.length > 0 || fileReasonings.length > 0)
|
|
84
|
+
? buildPromptContext(relevantTasks, explicitFiles, fileReasonings)
|
|
85
|
+
: null;
|
|
86
|
+
// Combine correction and memory context
|
|
87
|
+
const combinedContext = buildCombinedContext(correctionText, memoryContext);
|
|
88
|
+
// Output if we have anything to inject
|
|
89
|
+
if (combinedContext) {
|
|
90
|
+
const output = {
|
|
91
|
+
hookSpecificOutput: {
|
|
92
|
+
hookEventName: "UserPromptSubmit",
|
|
93
|
+
additionalContext: combinedContext
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
console.log(JSON.stringify(output));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
// Silent fail - don't break user workflow
|
|
101
|
+
debugInject('prompt-inject error: %O', error);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Handle first prompt: extract intent and create session state
|
|
106
|
+
*/
|
|
107
|
+
async function handleFirstPrompt(prompt, projectPath, sessionId) {
|
|
108
|
+
try {
|
|
109
|
+
debugInject('First prompt detected, extracting intent...');
|
|
110
|
+
// Extract intent from prompt
|
|
111
|
+
const intent = await extractIntent(prompt);
|
|
112
|
+
// Create session state with intent
|
|
113
|
+
createSessionState({
|
|
114
|
+
session_id: sessionId,
|
|
115
|
+
project_path: projectPath,
|
|
116
|
+
original_goal: intent.goal,
|
|
117
|
+
expected_scope: intent.expected_scope,
|
|
118
|
+
constraints: intent.constraints,
|
|
119
|
+
success_criteria: intent.success_criteria,
|
|
120
|
+
keywords: intent.keywords
|
|
121
|
+
});
|
|
122
|
+
debugInject('Session state created: goal=%s', intent.goal.substring(0, 50));
|
|
123
|
+
// No correction needed for first prompt
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
debugInject('handleFirstPrompt error: %O', error);
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Handle drift check for subsequent prompts.
|
|
133
|
+
*
|
|
134
|
+
* CRITICAL: We check Claude's ACTIONS, not user prompts.
|
|
135
|
+
* 1. Parse session JSONL to get Claude's recent actions
|
|
136
|
+
* 2. If no modifying actions, skip drift check (user just asked a question - OK!)
|
|
137
|
+
* 3. Build drift input from ACTIONS
|
|
138
|
+
* 4. Run drift check
|
|
139
|
+
* 5. Save steps and update last_checked_at
|
|
140
|
+
*/
|
|
141
|
+
async function handleDriftCheck(projectPath, sessionState) {
|
|
142
|
+
try {
|
|
143
|
+
const sessionId = sessionState.session_id;
|
|
144
|
+
debugInject('Running drift check for session: %s', sessionId);
|
|
145
|
+
// 1. Find session JSONL file
|
|
146
|
+
const sessionPath = findSessionFile(sessionId, projectPath);
|
|
147
|
+
if (!sessionPath) {
|
|
148
|
+
debugInject('Session JSONL not found, skipping drift check');
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
// 2. Get Claude's actions since last check
|
|
152
|
+
const lastChecked = sessionState.last_checked_at || 0;
|
|
153
|
+
const claudeActions = getNewActions(sessionPath, lastChecked);
|
|
154
|
+
debugInject('Found %d new actions since last check', claudeActions.length);
|
|
155
|
+
// 3. If no new actions, user just asked a question - OK!
|
|
156
|
+
if (claudeActions.length === 0) {
|
|
157
|
+
debugInject('No new actions - user is exploring, not drift');
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
// 4. Filter to modifying actions only (read is always OK)
|
|
161
|
+
const modifyingActions = getModifyingActions(claudeActions);
|
|
162
|
+
if (modifyingActions.length === 0) {
|
|
163
|
+
debugInject('Only read actions - exploration, not drift');
|
|
164
|
+
// Still update last_checked to track progress
|
|
165
|
+
updateLastChecked(sessionId, Date.now());
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
// 5. Build drift input from ACTIONS (not prompt!)
|
|
169
|
+
const driftInput = buildDriftCheckInput(claudeActions, sessionId, sessionState);
|
|
170
|
+
// 6. Check drift
|
|
171
|
+
const driftResult = await checkDrift(driftInput);
|
|
172
|
+
debugInject('Drift check result: score=%d, type=%s', driftResult.score, driftResult.type);
|
|
173
|
+
// 7. Save steps to DB
|
|
174
|
+
for (const action of claudeActions) {
|
|
175
|
+
const isKeyDecision = driftResult.score >= 9 && action.type !== 'read';
|
|
176
|
+
const keywords = extractKeywordsFromAction(action);
|
|
177
|
+
saveStep(sessionId, action, driftResult.score, isKeyDecision, keywords);
|
|
178
|
+
}
|
|
179
|
+
// 8. Update last_checked timestamp
|
|
180
|
+
updateLastChecked(sessionId, Date.now());
|
|
181
|
+
// 9. Determine correction level
|
|
182
|
+
const level = determineCorrectionLevel(driftResult.score, sessionState.escalation_count);
|
|
183
|
+
debugInject('Correction level: %s', level || 'none');
|
|
184
|
+
// 10. Update session drift metrics
|
|
185
|
+
const actionsSummary = `${modifyingActions.length} modifying actions`;
|
|
186
|
+
updateSessionDrift(sessionId, driftResult.score, level, actionsSummary, driftResult.recoveryPlan);
|
|
187
|
+
// 11. Build correction if needed
|
|
188
|
+
if (level) {
|
|
189
|
+
return buildCorrection(driftResult, sessionState, level);
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
debugInject('handleDriftCheck error: %O', error);
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Combine correction and memory context
|
|
200
|
+
*/
|
|
201
|
+
function buildCombinedContext(correction, memoryContext) {
|
|
202
|
+
if (!correction && !memoryContext) {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
const parts = [];
|
|
206
|
+
// Correction comes first (most important)
|
|
207
|
+
if (correction) {
|
|
208
|
+
parts.push(correction);
|
|
209
|
+
}
|
|
210
|
+
// Memory context second
|
|
211
|
+
if (memoryContext) {
|
|
212
|
+
parts.push(memoryContext);
|
|
213
|
+
}
|
|
214
|
+
return parts.join('\n\n');
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Read JSON input from stdin with timeout and size limit.
|
|
218
|
+
* SECURITY: Limits input size to prevent memory exhaustion attacks.
|
|
219
|
+
* OPTIMIZED: Uses array + join instead of O(n²) string concatenation.
|
|
220
|
+
* FIXED: Properly cleans up event listeners to prevent memory leaks.
|
|
221
|
+
*/
|
|
222
|
+
async function readStdinInput() {
|
|
223
|
+
return new Promise((resolve) => {
|
|
224
|
+
const chunks = [];
|
|
225
|
+
let totalLength = 0;
|
|
226
|
+
let resolved = false;
|
|
227
|
+
// Cleanup function to remove all listeners
|
|
228
|
+
const cleanup = () => {
|
|
229
|
+
process.stdin.removeListener('readable', onReadable);
|
|
230
|
+
process.stdin.removeListener('end', onEnd);
|
|
231
|
+
process.stdin.removeListener('error', onError);
|
|
232
|
+
};
|
|
233
|
+
// Safe resolve that only resolves once and cleans up
|
|
234
|
+
const safeResolve = (value) => {
|
|
235
|
+
if (resolved)
|
|
236
|
+
return;
|
|
237
|
+
resolved = true;
|
|
238
|
+
clearTimeout(timeout);
|
|
239
|
+
cleanup();
|
|
240
|
+
resolve(value);
|
|
241
|
+
};
|
|
242
|
+
// Set a timeout to prevent hanging
|
|
243
|
+
const timeout = setTimeout(() => {
|
|
244
|
+
debugInject('stdin timeout reached');
|
|
245
|
+
safeResolve(null);
|
|
246
|
+
}, 3000); // 3 second timeout
|
|
247
|
+
process.stdin.setEncoding('utf-8');
|
|
248
|
+
const onReadable = () => {
|
|
249
|
+
let chunk;
|
|
250
|
+
while ((chunk = process.stdin.read()) !== null) {
|
|
251
|
+
totalLength += chunk.length;
|
|
252
|
+
// SECURITY: Check size limit
|
|
253
|
+
if (totalLength > MAX_STDIN_SIZE) {
|
|
254
|
+
debugInject('stdin size limit exceeded');
|
|
255
|
+
safeResolve(null);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
chunks.push(chunk);
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
const onEnd = () => {
|
|
262
|
+
try {
|
|
263
|
+
const data = chunks.join('');
|
|
264
|
+
const parsed = JSON.parse(data.trim());
|
|
265
|
+
// Validate required fields
|
|
266
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
267
|
+
debugInject('Invalid stdin input: not an object');
|
|
268
|
+
safeResolve(null);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const input = parsed;
|
|
272
|
+
if (typeof input.prompt !== 'string' || typeof input.cwd !== 'string') {
|
|
273
|
+
debugInject('Invalid stdin input: missing required fields');
|
|
274
|
+
safeResolve(null);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
safeResolve(input);
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
debugInject('Failed to parse stdin JSON');
|
|
281
|
+
safeResolve(null);
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
const onError = () => {
|
|
285
|
+
debugInject('stdin error');
|
|
286
|
+
safeResolve(null);
|
|
287
|
+
};
|
|
288
|
+
process.stdin.on('readable', onReadable);
|
|
289
|
+
process.stdin.on('end', onEnd);
|
|
290
|
+
process.stdin.on('error', onError);
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Check if a prompt is simple and doesn't need context injection
|
|
295
|
+
*/
|
|
296
|
+
function isSimplePrompt(prompt) {
|
|
297
|
+
const normalized = prompt.trim().toLowerCase();
|
|
298
|
+
// Very short prompts are likely simple
|
|
299
|
+
if (normalized.length < 3)
|
|
300
|
+
return true;
|
|
301
|
+
// Check against simple prompt list
|
|
302
|
+
for (const simple of SIMPLE_PROMPTS) {
|
|
303
|
+
if (normalized === simple ||
|
|
304
|
+
normalized.startsWith(simple + ' ') ||
|
|
305
|
+
normalized.startsWith(simple + ',') ||
|
|
306
|
+
normalized.startsWith(simple + '.')) {
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// If the prompt is very short and doesn't contain code-related words
|
|
311
|
+
if (normalized.length < 20 && !normalized.match(/\.(ts|js|py|go|rs|java|tsx|jsx)/)) {
|
|
312
|
+
// Check if it's just a simple acknowledgment
|
|
313
|
+
const words = normalized.split(/\s+/);
|
|
314
|
+
if (words.length <= 3) {
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
// PERFORMANCE: Pre-compiled regexes for file path extraction
|
|
321
|
+
const TOKEN_SPLIT_REGEX = /[\s,;:'"``]+/;
|
|
322
|
+
const URL_PATTERN = /^https?:\/\//i;
|
|
323
|
+
const FILE_PATTERN = /^[.\/]?[\w\-\/]+\.\w{1,5}$/;
|
|
324
|
+
const VERSION_PATTERN = /^\d+\.\d+/;
|
|
325
|
+
/**
|
|
326
|
+
* Extract file paths from a prompt.
|
|
327
|
+
* SECURITY: Uses simplified patterns to avoid ReDoS with pathological input.
|
|
328
|
+
* PERFORMANCE: Uses pre-compiled regexes for efficiency.
|
|
329
|
+
*/
|
|
330
|
+
function extractFilePaths(prompt) {
|
|
331
|
+
// SECURITY: Limit input length to prevent ReDoS
|
|
332
|
+
const safePrompt = prompt.length > 10000 ? prompt.substring(0, 10000) : prompt;
|
|
333
|
+
const files = new Set();
|
|
334
|
+
// Split by whitespace and common delimiters for simpler, safer matching
|
|
335
|
+
const tokens = safePrompt.split(TOKEN_SPLIT_REGEX);
|
|
336
|
+
for (const token of tokens) {
|
|
337
|
+
// Skip empty tokens and URLs
|
|
338
|
+
if (!token || URL_PATTERN.test(token))
|
|
339
|
+
continue;
|
|
340
|
+
// Match file-like patterns: must have extension
|
|
341
|
+
if (FILE_PATTERN.test(token)) {
|
|
342
|
+
// Filter out version numbers like 1.0.0
|
|
343
|
+
if (!VERSION_PATTERN.test(token)) {
|
|
344
|
+
files.add(token);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return [...files];
|
|
349
|
+
}
|
|
350
|
+
// SECURITY: Maximum keywords to prevent memory exhaustion
|
|
351
|
+
const MAX_KEYWORDS = 100;
|
|
352
|
+
/**
|
|
353
|
+
* Extract keywords from a prompt for matching against tasks.
|
|
354
|
+
* SECURITY: Limits keywords to MAX_KEYWORDS to prevent memory exhaustion.
|
|
355
|
+
*/
|
|
356
|
+
function extractKeywords(prompt) {
|
|
357
|
+
const words = prompt.toLowerCase()
|
|
358
|
+
.replace(/[^\w\s]/g, ' ')
|
|
359
|
+
.split(/\s+/)
|
|
360
|
+
.filter(w => w.length > 2 && !STOP_WORDS.has(w));
|
|
361
|
+
const uniqueWords = [...new Set(words)];
|
|
362
|
+
// SECURITY: Limit keywords to prevent memory exhaustion
|
|
363
|
+
return uniqueWords.length > MAX_KEYWORDS
|
|
364
|
+
? uniqueWords.slice(0, MAX_KEYWORDS)
|
|
365
|
+
: uniqueWords;
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Find tasks that match keywords from the prompt.
|
|
369
|
+
* OPTIMIZED: O(n) instead of O(n²) - builds keyword set once.
|
|
370
|
+
*/
|
|
371
|
+
function findKeywordMatches(prompt, tasks) {
|
|
372
|
+
const keywords = extractKeywords(prompt);
|
|
373
|
+
if (keywords.length === 0) {
|
|
374
|
+
return [];
|
|
375
|
+
}
|
|
376
|
+
// Build keyword set for O(1) lookups
|
|
377
|
+
const keywordSet = new Set(keywords);
|
|
378
|
+
return tasks.filter(task => {
|
|
379
|
+
// Match against task tags - O(tags) lookup
|
|
380
|
+
const tagMatch = task.tags.some(tag => {
|
|
381
|
+
const lowerTag = tag.toLowerCase();
|
|
382
|
+
return keywordSet.has(lowerTag) ||
|
|
383
|
+
keywords.some(kw => lowerTag.includes(kw) || kw.includes(lowerTag));
|
|
384
|
+
});
|
|
385
|
+
if (tagMatch)
|
|
386
|
+
return true;
|
|
387
|
+
// Match against goal - extract once per task
|
|
388
|
+
const goalWords = (task.goal || '').toLowerCase().split(/\s+/).filter(w => w.length > 2);
|
|
389
|
+
const goalMatch = goalWords.some(gw => keywordSet.has(gw));
|
|
390
|
+
if (goalMatch)
|
|
391
|
+
return true;
|
|
392
|
+
// Match against original query - extract once per task
|
|
393
|
+
const queryWords = task.original_query.toLowerCase().split(/\s+/).filter(w => w.length > 2);
|
|
394
|
+
const queryMatch = queryWords.some(qw => keywordSet.has(qw));
|
|
395
|
+
return queryMatch;
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Deduplicate tasks by ID and limit count
|
|
400
|
+
*/
|
|
401
|
+
function dedupeAndLimit(tasks, limit) {
|
|
402
|
+
const seen = new Set();
|
|
403
|
+
const unique = [];
|
|
404
|
+
for (const task of tasks) {
|
|
405
|
+
if (!seen.has(task.id)) {
|
|
406
|
+
seen.add(task.id);
|
|
407
|
+
unique.push(task);
|
|
408
|
+
if (unique.length >= limit)
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return unique;
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Build context string for prompt injection
|
|
416
|
+
*/
|
|
417
|
+
function buildPromptContext(tasks, files, fileReasonings) {
|
|
418
|
+
const lines = [];
|
|
419
|
+
lines.push('[GROV CONTEXT - Relevant past reasoning]');
|
|
420
|
+
lines.push('');
|
|
421
|
+
// Add file-specific reasoning if available
|
|
422
|
+
if (fileReasonings.length > 0) {
|
|
423
|
+
lines.push('File-level context:');
|
|
424
|
+
for (const fr of fileReasonings.slice(0, 5)) {
|
|
425
|
+
const anchor = fr.anchor ? ` (${fr.anchor})` : '';
|
|
426
|
+
lines.push(`- ${fr.file_path}${anchor}: ${truncate(fr.reasoning, 100)}`);
|
|
427
|
+
}
|
|
428
|
+
lines.push('');
|
|
429
|
+
}
|
|
430
|
+
// Add task context
|
|
431
|
+
if (tasks.length > 0) {
|
|
432
|
+
lines.push('Related past tasks:');
|
|
433
|
+
for (const task of tasks) {
|
|
434
|
+
lines.push(`- ${truncate(task.original_query, 60)}`);
|
|
435
|
+
if (task.files_touched.length > 0) {
|
|
436
|
+
const fileList = task.files_touched.slice(0, 3).map(f => f.split('/').pop()).join(', ');
|
|
437
|
+
lines.push(` Files: ${fileList}`);
|
|
438
|
+
}
|
|
439
|
+
if (task.reasoning_trace.length > 0) {
|
|
440
|
+
lines.push(` Key: ${truncate(task.reasoning_trace[0], 80)}`);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
lines.push('');
|
|
444
|
+
}
|
|
445
|
+
// Add instruction
|
|
446
|
+
if (files.length > 0) {
|
|
447
|
+
lines.push(`You may already have context for: ${files.join(', ')}`);
|
|
448
|
+
}
|
|
449
|
+
lines.push('[END GROV CONTEXT]');
|
|
450
|
+
return lines.join('\n');
|
|
451
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// grov status - Show stored reasoning for current project
|
|
2
|
+
import { getTasksForProject, getTaskCount, getDatabasePath } from '../lib/store.js';
|
|
3
|
+
export async function status(options) {
|
|
4
|
+
const projectPath = process.cwd();
|
|
5
|
+
console.log('Grov Status');
|
|
6
|
+
console.log('===========\n');
|
|
7
|
+
console.log(`Project: ${projectPath}`);
|
|
8
|
+
console.log(`Database: ${getDatabasePath()}\n`);
|
|
9
|
+
// Get task count
|
|
10
|
+
const totalCount = getTaskCount(projectPath);
|
|
11
|
+
console.log(`Total tasks captured: ${totalCount}\n`);
|
|
12
|
+
if (totalCount === 0) {
|
|
13
|
+
console.log('No tasks captured yet for this project.');
|
|
14
|
+
console.log('Tasks will be captured automatically as you use Claude Code.');
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
// Get tasks
|
|
18
|
+
const tasks = getTasksForProject(projectPath, {
|
|
19
|
+
status: options.all ? undefined : 'complete',
|
|
20
|
+
limit: 10
|
|
21
|
+
});
|
|
22
|
+
console.log(`Showing ${options.all ? 'all' : 'completed'} tasks (most recent ${tasks.length}):\n`);
|
|
23
|
+
for (const task of tasks) {
|
|
24
|
+
console.log(`[${task.status.toUpperCase()}] ${truncate(task.original_query, 60)}`);
|
|
25
|
+
console.log(` ID: ${task.id.substring(0, 8)}...`);
|
|
26
|
+
console.log(` Created: ${formatDate(task.created_at)}`);
|
|
27
|
+
if (task.files_touched.length > 0) {
|
|
28
|
+
const fileList = task.files_touched
|
|
29
|
+
.slice(0, 3)
|
|
30
|
+
.map(f => f.split('/').pop())
|
|
31
|
+
.join(', ');
|
|
32
|
+
console.log(` Files: ${fileList}${task.files_touched.length > 3 ? ` (+${task.files_touched.length - 3} more)` : ''}`);
|
|
33
|
+
}
|
|
34
|
+
if (task.tags.length > 0) {
|
|
35
|
+
console.log(` Tags: ${task.tags.join(', ')}`);
|
|
36
|
+
}
|
|
37
|
+
console.log('');
|
|
38
|
+
}
|
|
39
|
+
if (!options.all) {
|
|
40
|
+
console.log('Use --all to see all tasks (including partial/abandoned).');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function truncate(str, maxLength) {
|
|
44
|
+
if (str.length <= maxLength)
|
|
45
|
+
return str;
|
|
46
|
+
return str.substring(0, maxLength - 3) + '...';
|
|
47
|
+
}
|
|
48
|
+
function formatDate(isoString) {
|
|
49
|
+
const date = new Date(isoString);
|
|
50
|
+
return date.toLocaleString();
|
|
51
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function unregister(): Promise<void>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// grov unregister - Remove hooks from Claude Code settings
|
|
2
|
+
import { unregisterGrovHooks, getSettingsPath } from '../lib/hooks.js';
|
|
3
|
+
export async function unregister() {
|
|
4
|
+
console.log('Removing grov hooks from Claude Code...\n');
|
|
5
|
+
try {
|
|
6
|
+
const { removed } = unregisterGrovHooks();
|
|
7
|
+
if (removed.length > 0) {
|
|
8
|
+
console.log('Removed hooks:');
|
|
9
|
+
removed.forEach(hook => console.log(` - ${hook}`));
|
|
10
|
+
}
|
|
11
|
+
else {
|
|
12
|
+
console.log('No grov hooks found to remove.');
|
|
13
|
+
}
|
|
14
|
+
console.log(`\nSettings file: ${getSettingsPath()}`);
|
|
15
|
+
console.log('\nGrov hooks have been disabled.');
|
|
16
|
+
console.log('Your stored reasoning data remains in ~/.grov/memory.db');
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
console.error('Failed to unregister hooks:', error);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type AnchorType = 'function' | 'class' | 'method' | 'variable' | 'unknown';
|
|
2
|
+
export interface AnchorInfo {
|
|
3
|
+
type: AnchorType;
|
|
4
|
+
name: string;
|
|
5
|
+
lineStart: number;
|
|
6
|
+
lineEnd?: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Extract all anchors from a source file.
|
|
10
|
+
* PERFORMANCE: Uses single-pass O(n) algorithm instead of O(n²).
|
|
11
|
+
* SECURITY: Limits anchors to prevent DoS with pathological files.
|
|
12
|
+
*/
|
|
13
|
+
export declare function extractAnchors(filePath: string, content: string): AnchorInfo[];
|
|
14
|
+
/**
|
|
15
|
+
* Find which anchor contains a given line number
|
|
16
|
+
*/
|
|
17
|
+
export declare function findAnchorAtLine(anchors: AnchorInfo[], lineNumber: number): AnchorInfo | null;
|
|
18
|
+
/**
|
|
19
|
+
* Compute a hash of a code region for change detection.
|
|
20
|
+
* Uses SHA-256 (truncated) for security scanner compliance.
|
|
21
|
+
*/
|
|
22
|
+
export declare function computeCodeHash(content: string, lineStart: number, lineEnd: number): string;
|
|
23
|
+
/**
|
|
24
|
+
* Estimate the line number where a string appears in content
|
|
25
|
+
*/
|
|
26
|
+
export declare function estimateLineNumber(searchString: string, content: string): number | null;
|
|
27
|
+
/**
|
|
28
|
+
* Get a human-readable description of an anchor
|
|
29
|
+
*/
|
|
30
|
+
export declare function describeAnchor(anchor: AnchorInfo): string;
|