grov 0.5.2 → 0.5.3

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 (48) hide show
  1. package/README.md +19 -1
  2. package/dist/cli.js +8 -0
  3. package/dist/lib/api-client.d.ts +18 -1
  4. package/dist/lib/api-client.js +57 -0
  5. package/dist/lib/llm-extractor.d.ts +14 -38
  6. package/dist/lib/llm-extractor.js +380 -406
  7. package/dist/lib/store/convenience.d.ts +40 -0
  8. package/dist/lib/store/convenience.js +104 -0
  9. package/dist/lib/store/database.d.ts +22 -0
  10. package/dist/lib/store/database.js +375 -0
  11. package/dist/lib/store/drift.d.ts +9 -0
  12. package/dist/lib/store/drift.js +89 -0
  13. package/dist/lib/store/index.d.ts +7 -0
  14. package/dist/lib/store/index.js +13 -0
  15. package/dist/lib/store/sessions.d.ts +32 -0
  16. package/dist/lib/store/sessions.js +240 -0
  17. package/dist/lib/store/steps.d.ts +40 -0
  18. package/dist/lib/store/steps.js +161 -0
  19. package/dist/lib/store/tasks.d.ts +33 -0
  20. package/dist/lib/store/tasks.js +133 -0
  21. package/dist/lib/store/types.d.ts +167 -0
  22. package/dist/lib/store/types.js +2 -0
  23. package/dist/lib/store.d.ts +1 -436
  24. package/dist/lib/store.js +2 -1478
  25. package/dist/proxy/cache.d.ts +36 -0
  26. package/dist/proxy/cache.js +51 -0
  27. package/dist/proxy/config.d.ts +1 -0
  28. package/dist/proxy/config.js +2 -0
  29. package/dist/proxy/extended-cache.d.ts +10 -0
  30. package/dist/proxy/extended-cache.js +155 -0
  31. package/dist/proxy/handlers/preprocess.d.ts +20 -0
  32. package/dist/proxy/handlers/preprocess.js +169 -0
  33. package/dist/proxy/injection/delta-tracking.d.ts +11 -0
  34. package/dist/proxy/injection/delta-tracking.js +93 -0
  35. package/dist/proxy/injection/injectors.d.ts +7 -0
  36. package/dist/proxy/injection/injectors.js +139 -0
  37. package/dist/proxy/request-processor.d.ts +18 -4
  38. package/dist/proxy/request-processor.js +151 -30
  39. package/dist/proxy/response-processor.js +93 -45
  40. package/dist/proxy/server.d.ts +0 -1
  41. package/dist/proxy/server.js +342 -566
  42. package/dist/proxy/types.d.ts +13 -0
  43. package/dist/proxy/types.js +2 -0
  44. package/dist/proxy/utils/extractors.d.ts +18 -0
  45. package/dist/proxy/utils/extractors.js +109 -0
  46. package/dist/proxy/utils/logging.d.ts +18 -0
  47. package/dist/proxy/utils/logging.js +42 -0
  48. package/package.json +5 -2
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Global team memory cache
3
+ * - Calculated ONCE on first request, reused for ALL subsequent requests
4
+ * - Invalidated only on: sync completion (in .then() callback), proxy restart
5
+ * - Ensures system prompt prefix stays CONSTANT for Anthropic cache preservation
6
+ */
7
+ export declare let globalTeamMemoryCache: {
8
+ projectPath: string;
9
+ content: string;
10
+ } | null;
11
+ /**
12
+ * Invalidate the global team memory cache
13
+ * Called after successful sync to cloud (in .then() callback)
14
+ * This ensures cache is only invalidated AFTER data is in cloud
15
+ */
16
+ export declare function invalidateTeamMemoryCache(): void;
17
+ /**
18
+ * Set the global team memory cache
19
+ * @param projectPath - Project path for cache key
20
+ * @param content - Formatted team memory content
21
+ */
22
+ export declare function setTeamMemoryCache(projectPath: string, content: string): void;
23
+ /**
24
+ * Get the current cache content if it matches the project path
25
+ * @param projectPath - Project path to check
26
+ * @returns Cached content or null if not cached/different project
27
+ */
28
+ export declare function getTeamMemoryCache(projectPath: string): string | null;
29
+ /**
30
+ * Check if cache exists for a specific project
31
+ */
32
+ export declare function hasCacheForProject(projectPath: string): boolean;
33
+ /**
34
+ * Get current cache project path (for logging/debugging)
35
+ */
36
+ export declare function getCacheProjectPath(): string | null;
@@ -0,0 +1,51 @@
1
+ // Phase 0 verified
2
+ // Cache management for team memory injection
3
+ // Shared between server.ts and response-processor.ts to avoid circular dependencies
4
+ /**
5
+ * Global team memory cache
6
+ * - Calculated ONCE on first request, reused for ALL subsequent requests
7
+ * - Invalidated only on: sync completion (in .then() callback), proxy restart
8
+ * - Ensures system prompt prefix stays CONSTANT for Anthropic cache preservation
9
+ */
10
+ export let globalTeamMemoryCache = null;
11
+ /**
12
+ * Invalidate the global team memory cache
13
+ * Called after successful sync to cloud (in .then() callback)
14
+ * This ensures cache is only invalidated AFTER data is in cloud
15
+ */
16
+ export function invalidateTeamMemoryCache() {
17
+ globalTeamMemoryCache = null;
18
+ console.log('[CACHE] Team memory cache invalidated');
19
+ }
20
+ /**
21
+ * Set the global team memory cache
22
+ * @param projectPath - Project path for cache key
23
+ * @param content - Formatted team memory content
24
+ */
25
+ export function setTeamMemoryCache(projectPath, content) {
26
+ globalTeamMemoryCache = { projectPath, content };
27
+ console.log(`[CACHE] Team memory cache set for project: ${projectPath} (${content.length} chars)`);
28
+ }
29
+ /**
30
+ * Get the current cache content if it matches the project path
31
+ * @param projectPath - Project path to check
32
+ * @returns Cached content or null if not cached/different project
33
+ */
34
+ export function getTeamMemoryCache(projectPath) {
35
+ if (globalTeamMemoryCache && globalTeamMemoryCache.projectPath === projectPath) {
36
+ return globalTeamMemoryCache.content;
37
+ }
38
+ return null;
39
+ }
40
+ /**
41
+ * Check if cache exists for a specific project
42
+ */
43
+ export function hasCacheForProject(projectPath) {
44
+ return globalTeamMemoryCache?.projectPath === projectPath;
45
+ }
46
+ /**
47
+ * Get current cache project path (for logging/debugging)
48
+ */
49
+ export function getCacheProjectPath() {
50
+ return globalTeamMemoryCache?.projectPath || null;
51
+ }
@@ -12,6 +12,7 @@ export declare const config: {
12
12
  ENABLE_TLS: boolean;
13
13
  LOG_LEVEL: string;
14
14
  LOG_REQUESTS: boolean;
15
+ EXTENDED_CACHE_ENABLED: boolean;
15
16
  };
16
17
  export declare const FORWARD_HEADERS: string[];
17
18
  export declare const SENSITIVE_HEADERS: string[];
@@ -19,6 +19,8 @@ export const config = {
19
19
  // Logging
20
20
  LOG_LEVEL: process.env.LOG_LEVEL || 'info',
21
21
  LOG_REQUESTS: process.env.LOG_REQUESTS !== 'false',
22
+ // Extended Cache - preserve Anthropic prompt cache during idle
23
+ EXTENDED_CACHE_ENABLED: process.env.GROV_EXTENDED_CACHE === 'true',
22
24
  };
23
25
  // Headers to forward to Anthropic (whitelist approach)
24
26
  export const FORWARD_HEADERS = [
@@ -0,0 +1,10 @@
1
+ export interface ExtendedCacheEntry {
2
+ headers: Record<string, string>;
3
+ rawBody: Buffer;
4
+ timestamp: number;
5
+ keepAliveCount: number;
6
+ }
7
+ export declare const extendedCache: Map<string, ExtendedCacheEntry>;
8
+ export declare function log(msg: string): void;
9
+ export declare function evictOldestCacheEntry(): void;
10
+ export declare function checkExtendedCache(): Promise<void>;
@@ -0,0 +1,155 @@
1
+ // Extended Cache - Keep Anthropic cache alive during idle
2
+ // Sends minimal keep-alive requests to prevent cache TTL expiration
3
+ import { forwardToAnthropic } from './forwarder.js';
4
+ export const extendedCache = new Map();
5
+ // Timing constants
6
+ const EXTENDED_CACHE_IDLE_THRESHOLD = 4 * 60 * 1000; // 4 minutes (under 5-min TTL)
7
+ const EXTENDED_CACHE_MAX_IDLE = 10 * 60 * 1000; // 10 minutes total
8
+ const EXTENDED_CACHE_MAX_KEEPALIVES = 2;
9
+ const EXTENDED_CACHE_MAX_ENTRIES = 100; // Max concurrent sessions (memory cap)
10
+ export function log(msg) {
11
+ console.log(`[CACHE] ${msg}`);
12
+ }
13
+ export function evictOldestCacheEntry() {
14
+ if (extendedCache.size < EXTENDED_CACHE_MAX_ENTRIES)
15
+ return;
16
+ let oldestId = null;
17
+ let oldestTime = Infinity;
18
+ for (const [id, entry] of extendedCache) {
19
+ if (entry.timestamp < oldestTime) {
20
+ oldestTime = entry.timestamp;
21
+ oldestId = id;
22
+ }
23
+ }
24
+ if (oldestId) {
25
+ extendedCache.delete(oldestId);
26
+ log(`Extended cache: evicted ${oldestId.substring(0, 8)} (capacity limit)`);
27
+ }
28
+ }
29
+ async function sendExtendedCacheKeepAlive(projectPath, entry) {
30
+ const projectName = projectPath.split('/').pop() || projectPath;
31
+ let rawBodyStr = entry.rawBody.toString('utf-8');
32
+ // 1. Find messages array and add "." message before closing bracket
33
+ const messagesMatch = rawBodyStr.match(/"messages"\s*:\s*\[/);
34
+ if (!messagesMatch || messagesMatch.index === undefined) {
35
+ throw new Error('Cannot find messages array in rawBody');
36
+ }
37
+ // Find closing bracket of messages array (handling nested arrays/objects)
38
+ const messagesStart = messagesMatch.index + messagesMatch[0].length;
39
+ let bracketDepth = 1; // We're inside the [ already
40
+ let braceDepth = 0; // Track {} for objects
41
+ let inString = false; // Track if we're inside a string
42
+ let messagesEnd = messagesStart;
43
+ for (let i = messagesStart; i < rawBodyStr.length && bracketDepth > 0; i++) {
44
+ const char = rawBodyStr[i];
45
+ const prevChar = i > 0 ? rawBodyStr[i - 1] : '';
46
+ // Handle string boundaries (skip escaped quotes)
47
+ if (char === '"' && prevChar !== '\\') {
48
+ inString = !inString;
49
+ continue;
50
+ }
51
+ // Skip everything inside strings
52
+ if (inString)
53
+ continue;
54
+ // Track brackets and braces
55
+ if (char === '[')
56
+ bracketDepth++;
57
+ else if (char === ']')
58
+ bracketDepth--;
59
+ else if (char === '{')
60
+ braceDepth++;
61
+ else if (char === '}')
62
+ braceDepth--;
63
+ // Found the closing bracket of messages array
64
+ if (bracketDepth === 0) {
65
+ messagesEnd = i;
66
+ break;
67
+ }
68
+ }
69
+ // Safety check: did we find the end?
70
+ if (bracketDepth !== 0) {
71
+ throw new Error(`Could not find closing bracket of messages array (depth=${bracketDepth})`);
72
+ }
73
+ // Check if array has content (anything between messagesStart and messagesEnd)
74
+ const arrayContent = rawBodyStr.slice(messagesStart, messagesEnd).trim();
75
+ const messagesIsEmpty = arrayContent.length === 0;
76
+ // Insert minimal user message before closing bracket
77
+ const keepAliveMsg = messagesIsEmpty
78
+ ? '{"role":"user","content":"."}'
79
+ : ',{"role":"user","content":"."}';
80
+ log(`Extended cache: SEND keep-alive project=${projectName} msg_array_size=${messagesEnd - messagesStart}`);
81
+ rawBodyStr = rawBodyStr.slice(0, messagesEnd) + keepAliveMsg + rawBodyStr.slice(messagesEnd);
82
+ // NOTE: We do NOT modify max_tokens or stream!
83
+ // Keeping them identical preserves the cache prefix for byte-exact matching.
84
+ // Claude will respond briefly to "." anyway, and forwarder handles streaming.
85
+ // 2. Validate JSON after manipulation
86
+ try {
87
+ JSON.parse(rawBodyStr);
88
+ }
89
+ catch (e) {
90
+ throw new Error(`Invalid JSON after modifications: ${e instanceof Error ? e.message : 'unknown'}`);
91
+ }
92
+ // 3. Forward to Anthropic using same undici path as regular requests
93
+ const result = await forwardToAnthropic({}, entry.headers, undefined, Buffer.from(rawBodyStr, 'utf-8'));
94
+ if (result.statusCode !== 200) {
95
+ throw new Error(`Keep-alive failed: ${result.statusCode}`);
96
+ }
97
+ // Log cache metrics
98
+ const usage = result.body.usage;
99
+ const cacheRead = usage?.cache_read_input_tokens || 0;
100
+ const cacheCreate = usage?.cache_creation_input_tokens || 0;
101
+ const inputTokens = usage?.input_tokens || 0;
102
+ log(`Extended cache: keep-alive for ${projectName} - cache_read=${cacheRead}, cache_create=${cacheCreate}, input=${inputTokens}`);
103
+ }
104
+ export async function checkExtendedCache() {
105
+ const now = Date.now();
106
+ const projectsToKeepAlive = [];
107
+ // First pass: cleanup stale/maxed entries, collect projects needing keep-alive
108
+ for (const [projectPath, entry] of extendedCache) {
109
+ const idleTime = now - entry.timestamp;
110
+ const projectName = projectPath.split('/').pop() || projectPath;
111
+ // Stale cleanup: user left after 10 minutes
112
+ if (idleTime > EXTENDED_CACHE_MAX_IDLE) {
113
+ extendedCache.delete(projectPath);
114
+ log(`Extended cache: cleared ${projectName} (stale)`);
115
+ continue;
116
+ }
117
+ // Skip if not idle enough yet
118
+ if (idleTime < EXTENDED_CACHE_IDLE_THRESHOLD) {
119
+ continue;
120
+ }
121
+ // Skip if already sent max keep-alives
122
+ if (entry.keepAliveCount >= EXTENDED_CACHE_MAX_KEEPALIVES) {
123
+ extendedCache.delete(projectPath);
124
+ log(`Extended cache: cleared ${projectName} (max retries)`);
125
+ continue;
126
+ }
127
+ projectsToKeepAlive.push({ projectPath, entry });
128
+ }
129
+ // Second pass: send all keep-alives in PARALLEL
130
+ const keepAlivePromises = [];
131
+ for (const { projectPath, entry } of projectsToKeepAlive) {
132
+ const projectName = projectPath.split('/').pop() || projectPath;
133
+ const promise = sendExtendedCacheKeepAlive(projectPath, entry)
134
+ .then(() => {
135
+ entry.timestamp = Date.now();
136
+ entry.keepAliveCount++;
137
+ })
138
+ .catch((err) => {
139
+ extendedCache.delete(projectPath);
140
+ // Handle both Error instances and ForwardError objects
141
+ const errMsg = err instanceof Error
142
+ ? err.message
143
+ : (err && typeof err === 'object' && 'message' in err)
144
+ ? String(err.message)
145
+ : JSON.stringify(err);
146
+ const errType = err && typeof err === 'object' && 'type' in err ? ` [${err.type}]` : '';
147
+ log(`Extended cache: cleared ${projectName} (error${errType}: ${errMsg})`);
148
+ });
149
+ keepAlivePromises.push(promise);
150
+ }
151
+ // Wait for all keep-alives to complete
152
+ if (keepAlivePromises.length > 0) {
153
+ await Promise.all(keepAlivePromises);
154
+ }
155
+ }
@@ -0,0 +1,20 @@
1
+ import type { MessagesRequestBody } from '../types.js';
2
+ export declare function getPendingPlanClear(): {
3
+ projectPath: string;
4
+ summary: string;
5
+ } | null;
6
+ export declare function setPendingPlanClear(value: {
7
+ projectPath: string;
8
+ summary: string;
9
+ }): void;
10
+ export declare function clearPendingPlan(): void;
11
+ export declare function preProcessRequest(body: MessagesRequestBody, sessionInfo: {
12
+ sessionId: string;
13
+ promptCount: number;
14
+ projectPath: string;
15
+ }, logger: {
16
+ info: (data: Record<string, unknown>) => void;
17
+ }, detectRequestType: (messages: Array<{
18
+ role: string;
19
+ content: unknown;
20
+ }>, projectPath: string) => 'first' | 'continuation' | 'retry'): Promise<MessagesRequestBody>;
@@ -0,0 +1,169 @@
1
+ // Pre-process requests before forwarding to Anthropic
2
+ import { config } from '../config.js';
3
+ import { extractLastUserPrompt, extractFilesFromMessages, buildTeamMemoryContextCloud } from '../request-processor.js';
4
+ import { getSessionState, updateSessionState, markCleared, } from '../../lib/store.js';
5
+ import { isSyncEnabled, getSyncTeamId } from '../../lib/cloud-sync.js';
6
+ import { globalTeamMemoryCache, setTeamMemoryCache, invalidateTeamMemoryCache } from '../cache.js';
7
+ import { buildDynamicInjection, clearSessionTracking } from '../injection/delta-tracking.js';
8
+ import { appendToSystemPrompt } from '../injection/injectors.js';
9
+ // Pending plan summary state - triggers CLEAR-like reset after planning task completes
10
+ let pendingPlanClear = null;
11
+ export function getPendingPlanClear() {
12
+ return pendingPlanClear;
13
+ }
14
+ export function setPendingPlanClear(value) {
15
+ pendingPlanClear = value;
16
+ }
17
+ export function clearPendingPlan() {
18
+ pendingPlanClear = null;
19
+ }
20
+ export async function preProcessRequest(body, sessionInfo, logger, detectRequestType) {
21
+ const modified = { ...body };
22
+ // Skip warmup requests - Claude Code sends "Warmup" as health check
23
+ // No need to do semantic search or cache operations for these
24
+ const earlyUserPrompt = extractLastUserPrompt(modified.messages || []);
25
+ if (earlyUserPrompt === 'Warmup') {
26
+ console.log('[INJECT] Skipping warmup request (no search, no cache)');
27
+ return modified;
28
+ }
29
+ // Detect request type: first, continuation, or retry
30
+ const requestType = detectRequestType(modified.messages || [], sessionInfo.projectPath);
31
+ // === NEW ARCHITECTURE: Separate static and dynamic injection ===
32
+ //
33
+ // STATIC (system prompt, cached):
34
+ // - Team memory from PAST sessions only
35
+ // - CLEAR summary when triggered
36
+ // -> Uses __grovInjection + injectIntoRawBody()
37
+ //
38
+ // DYNAMIC (user message, delta only):
39
+ // - Files edited in current session
40
+ // - Key decisions with reasoning
41
+ // - Drift correction, forced recovery
42
+ // -> Uses __grovUserMsgInjection + appendToLastUserMessage()
43
+ // Get session state
44
+ const sessionState = getSessionState(sessionInfo.sessionId);
45
+ // === PLANNING CLEAR: Reset after planning task completes ===
46
+ // This ensures implementation phase starts fresh with planning context from team memory
47
+ if (pendingPlanClear && pendingPlanClear.projectPath === sessionInfo.projectPath) {
48
+ // 1. Empty messages array (fresh start)
49
+ modified.messages = [];
50
+ // 2. Inject planning summary into system prompt
51
+ appendToSystemPrompt(modified, pendingPlanClear.summary);
52
+ // 3. Rebuild team memory NOW (includes the just-saved planning task)
53
+ const mentionedFiles = extractFilesFromMessages(modified.messages || []);
54
+ const userPrompt = extractLastUserPrompt(modified.messages || []);
55
+ // Use cloud-first approach if sync is enabled
56
+ let teamContext = null;
57
+ const teamId = getSyncTeamId();
58
+ if (isSyncEnabled() && teamId) {
59
+ console.log(`[INJECT] PLANNING_CLEAR: Using cloud team memory (teamId=${teamId.substring(0, 8)}...)`);
60
+ teamContext = await buildTeamMemoryContextCloud(teamId, sessionInfo.projectPath, mentionedFiles, userPrompt // For hybrid semantic search
61
+ );
62
+ }
63
+ else {
64
+ // Sync not enabled - no injection (cloud-first approach)
65
+ console.log('[INJECT] Sync not enabled. Enable sync for team memory injection.');
66
+ teamContext = null;
67
+ }
68
+ if (teamContext) {
69
+ modified.__grovInjection = teamContext;
70
+ modified.__grovInjectionCached = false;
71
+ // Update cache with fresh team memory
72
+ setTeamMemoryCache(sessionInfo.projectPath, teamContext);
73
+ }
74
+ // 4. Clear the pending plan (one-time use)
75
+ pendingPlanClear = null;
76
+ // 5. Clear tracking (fresh start)
77
+ clearSessionTracking(sessionInfo.sessionId);
78
+ return modified; // Skip other injections - this is a complete reset
79
+ }
80
+ // === CLEAR MODE (100% threshold) ===
81
+ // If token count exceeds threshold AND we have a pre-computed summary, apply CLEAR
82
+ if (sessionState) {
83
+ const currentTokenCount = sessionState.token_count || 0;
84
+ if (currentTokenCount > config.TOKEN_CLEAR_THRESHOLD &&
85
+ sessionState.pending_clear_summary) {
86
+ logger.info({
87
+ msg: 'CLEAR MODE ACTIVATED - resetting conversation',
88
+ tokenCount: currentTokenCount,
89
+ threshold: config.TOKEN_CLEAR_THRESHOLD,
90
+ summaryLength: sessionState.pending_clear_summary.length,
91
+ });
92
+ // 1. Empty messages array (fundamental reset)
93
+ modified.messages = [];
94
+ // 2. Inject summary into system prompt (this will cause cache miss - intentional)
95
+ appendToSystemPrompt(modified, sessionState.pending_clear_summary);
96
+ // 3. Mark session as cleared
97
+ markCleared(sessionInfo.sessionId);
98
+ // 4. Clear pending summary and invalidate GLOBAL team memory cache (new baseline)
99
+ updateSessionState(sessionInfo.sessionId, { pending_clear_summary: undefined });
100
+ invalidateTeamMemoryCache(); // Force recalculation on next request (CLEAR mode)
101
+ // 5. Clear tracking (fresh start after CLEAR)
102
+ clearSessionTracking(sessionInfo.sessionId);
103
+ logger.info({ msg: 'CLEAR complete - conversation reset with summary' });
104
+ return modified; // Skip other injections - this is a complete reset
105
+ }
106
+ }
107
+ // === STATIC INJECTION: Team memory (PAST sessions only) ===
108
+ // Cached per session - identical across all requests for cache preservation
109
+ // GLOBAL cache: same team memory for ALL requests (regardless of sessionId changes)
110
+ // Only recalculate on: first request ever, CLEAR/Summary, project change, proxy restart
111
+ const isSameProject = globalTeamMemoryCache?.projectPath === sessionInfo.projectPath;
112
+ if (globalTeamMemoryCache && isSameProject) {
113
+ // Reuse GLOBAL cached team memory (constant for entire conversation)
114
+ modified.__grovInjection = globalTeamMemoryCache.content;
115
+ modified.__grovInjectionCached = true;
116
+ console.log(`[CACHE] Using global team memory cache, size=${globalTeamMemoryCache.content.length}`);
117
+ }
118
+ else {
119
+ // First request OR project changed OR cache was invalidated: compute team memory
120
+ const mentionedFiles = extractFilesFromMessages(modified.messages || []);
121
+ const userPrompt = extractLastUserPrompt(modified.messages || []);
122
+ // Use cloud-first approach if sync is enabled
123
+ let teamContext = null;
124
+ const teamId = getSyncTeamId();
125
+ if (isSyncEnabled() && teamId) {
126
+ console.log(`[INJECT] First/cache miss: Using cloud team memory (teamId=${teamId.substring(0, 8)}...)`);
127
+ teamContext = await buildTeamMemoryContextCloud(teamId, sessionInfo.projectPath, mentionedFiles, userPrompt // For hybrid semantic search
128
+ );
129
+ }
130
+ else {
131
+ // Sync not enabled - no injection (cloud-first approach)
132
+ console.log('[INJECT] Sync not enabled. Enable sync for team memory injection.');
133
+ teamContext = null;
134
+ }
135
+ console.log(`[CACHE] Computing team memory (first/new), files=${mentionedFiles.length}, result=${teamContext ? teamContext.length : 'null'}`);
136
+ if (teamContext) {
137
+ modified.__grovInjection = teamContext;
138
+ modified.__grovInjectionCached = false;
139
+ // Store in GLOBAL cache - stays constant until CLEAR or restart
140
+ setTeamMemoryCache(sessionInfo.projectPath, teamContext);
141
+ }
142
+ else {
143
+ // No team memory available - clear global cache for this project
144
+ if (isSameProject) {
145
+ invalidateTeamMemoryCache();
146
+ }
147
+ }
148
+ }
149
+ // SKIP dynamic injection for retries and continuations
150
+ if (requestType !== 'first') {
151
+ return modified;
152
+ }
153
+ // === DYNAMIC INJECTION: User message (delta only) ===
154
+ // Includes: edited files, key decisions, drift correction, forced recovery
155
+ // This goes into the LAST user message, not system prompt
156
+ const dynamicInjection = buildDynamicInjection(sessionInfo.sessionId, sessionState, logger);
157
+ if (dynamicInjection) {
158
+ modified.__grovUserMsgInjection = dynamicInjection;
159
+ logger.info({ msg: 'Dynamic injection ready for user message', size: dynamicInjection.length });
160
+ // Clear pending corrections after building injection
161
+ if (sessionState?.pending_correction || sessionState?.pending_forced_recovery) {
162
+ updateSessionState(sessionInfo.sessionId, {
163
+ pending_correction: undefined,
164
+ pending_forced_recovery: undefined,
165
+ });
166
+ }
167
+ }
168
+ return modified;
169
+ }
@@ -0,0 +1,11 @@
1
+ import type { SessionState } from '../../lib/store.js';
2
+ export interface SessionInjectionTracking {
3
+ files: Set<string>;
4
+ decisionIds: Set<string>;
5
+ reasonings: Set<string>;
6
+ }
7
+ export declare function getOrCreateTracking(sessionId: string): SessionInjectionTracking;
8
+ export declare function clearSessionTracking(sessionId: string): void;
9
+ export declare function buildDynamicInjection(sessionId: string, sessionState: SessionState | null, logger?: {
10
+ info: (data: Record<string, unknown>) => void;
11
+ }): string | null;
@@ -0,0 +1,93 @@
1
+ // Delta tracking - avoid duplicate injections across requests
2
+ import { getEditedFiles, getKeyDecisions } from '../../lib/store.js';
3
+ import { smartTruncate } from '../../lib/utils.js';
4
+ const sessionInjectionTracking = new Map();
5
+ export function getOrCreateTracking(sessionId) {
6
+ if (!sessionInjectionTracking.has(sessionId)) {
7
+ sessionInjectionTracking.set(sessionId, {
8
+ files: new Set(),
9
+ decisionIds: new Set(),
10
+ reasonings: new Set(),
11
+ });
12
+ }
13
+ return sessionInjectionTracking.get(sessionId);
14
+ }
15
+ export function clearSessionTracking(sessionId) {
16
+ sessionInjectionTracking.delete(sessionId);
17
+ }
18
+ export function buildDynamicInjection(sessionId, sessionState, logger) {
19
+ const tracking = getOrCreateTracking(sessionId);
20
+ const parts = [];
21
+ const debugInfo = {};
22
+ // 1. Get edited files (delta - not already injected)
23
+ const allEditedFiles = getEditedFiles(sessionId);
24
+ const newFiles = allEditedFiles.filter(f => !tracking.files.has(f));
25
+ debugInfo.totalEditedFiles = allEditedFiles.length;
26
+ debugInfo.newEditedFiles = newFiles.length;
27
+ debugInfo.alreadyTrackedFiles = tracking.files.size;
28
+ if (newFiles.length > 0) {
29
+ // Track and add to injection
30
+ newFiles.forEach(f => tracking.files.add(f));
31
+ const fileNames = newFiles.slice(0, 5).map(f => f.split('/').pop());
32
+ parts.push(`[EDITED: ${fileNames.join(', ')}]`);
33
+ debugInfo.editedFilesInjected = fileNames;
34
+ }
35
+ // 2. Get key decisions with reasoning (delta - not already injected)
36
+ const keyDecisions = getKeyDecisions(sessionId, 5);
37
+ debugInfo.totalKeyDecisions = keyDecisions.length;
38
+ debugInfo.alreadyTrackedDecisions = tracking.decisionIds.size;
39
+ const newDecisions = keyDecisions.filter(d => !tracking.decisionIds.has(d.id) &&
40
+ d.reasoning &&
41
+ !tracking.reasonings.has(d.reasoning));
42
+ debugInfo.newKeyDecisions = newDecisions.length;
43
+ for (const decision of newDecisions.slice(0, 3)) {
44
+ tracking.decisionIds.add(decision.id);
45
+ tracking.reasonings.add(decision.reasoning);
46
+ const truncated = smartTruncate(decision.reasoning, 120);
47
+ parts.push(`[DECISION: ${truncated}]`);
48
+ // Log the original and truncated reasoning for debugging
49
+ if (logger) {
50
+ logger.info({
51
+ msg: 'Key decision reasoning extracted',
52
+ originalLength: decision.reasoning.length,
53
+ truncatedLength: truncated.length,
54
+ original: decision.reasoning.substring(0, 200) + (decision.reasoning.length > 200 ? '...' : ''),
55
+ truncated,
56
+ });
57
+ }
58
+ }
59
+ debugInfo.decisionsInjected = newDecisions.slice(0, 3).length;
60
+ // 3. Add drift correction if pending
61
+ if (sessionState?.pending_correction) {
62
+ parts.push(`[DRIFT: ${sessionState.pending_correction}]`);
63
+ debugInfo.hasDriftCorrection = true;
64
+ debugInfo.driftCorrectionLength = sessionState.pending_correction.length;
65
+ }
66
+ // 4. Add forced recovery if pending
67
+ if (sessionState?.pending_forced_recovery) {
68
+ parts.push(`[RECOVERY: ${sessionState.pending_forced_recovery}]`);
69
+ debugInfo.hasForcedRecovery = true;
70
+ debugInfo.forcedRecoveryLength = sessionState.pending_forced_recovery.length;
71
+ }
72
+ // Log debug info
73
+ if (logger) {
74
+ logger.info({
75
+ msg: 'Dynamic injection build details',
76
+ ...debugInfo,
77
+ partsCount: parts.length,
78
+ });
79
+ }
80
+ if (parts.length === 0) {
81
+ return null;
82
+ }
83
+ const injection = '---\n[GROV CONTEXT]\n' + parts.join('\n');
84
+ // Log final injection content
85
+ if (logger) {
86
+ logger.info({
87
+ msg: 'Dynamic injection content',
88
+ size: injection.length,
89
+ content: injection,
90
+ });
91
+ }
92
+ return injection;
93
+ }
@@ -0,0 +1,7 @@
1
+ import type { MessagesRequestBody } from '../types.js';
2
+ export declare function appendToLastUserMessage(rawBody: string, injection: string): string;
3
+ export declare function appendToSystemPrompt(body: MessagesRequestBody, textToAppend: string): void;
4
+ export declare function injectIntoRawBody(rawBody: string, injectionText: string): {
5
+ modified: string;
6
+ success: boolean;
7
+ };