grov 0.2.3 → 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.
- package/README.md +44 -5
- package/dist/cli.js +40 -2
- package/dist/commands/login.d.ts +1 -0
- package/dist/commands/login.js +115 -0
- package/dist/commands/logout.d.ts +1 -0
- package/dist/commands/logout.js +13 -0
- package/dist/commands/sync.d.ts +8 -0
- package/dist/commands/sync.js +127 -0
- package/dist/lib/api-client.d.ts +57 -0
- package/dist/lib/api-client.js +174 -0
- package/dist/lib/cloud-sync.d.ts +33 -0
- package/dist/lib/cloud-sync.js +176 -0
- package/dist/lib/credentials.d.ts +53 -0
- package/dist/lib/credentials.js +201 -0
- package/dist/lib/llm-extractor.d.ts +15 -39
- package/dist/lib/llm-extractor.js +400 -418
- package/dist/lib/store/convenience.d.ts +40 -0
- package/dist/lib/store/convenience.js +104 -0
- package/dist/lib/store/database.d.ts +22 -0
- package/dist/lib/store/database.js +375 -0
- package/dist/lib/store/drift.d.ts +9 -0
- package/dist/lib/store/drift.js +89 -0
- package/dist/lib/store/index.d.ts +7 -0
- package/dist/lib/store/index.js +13 -0
- package/dist/lib/store/sessions.d.ts +32 -0
- package/dist/lib/store/sessions.js +240 -0
- package/dist/lib/store/steps.d.ts +40 -0
- package/dist/lib/store/steps.js +161 -0
- package/dist/lib/store/tasks.d.ts +33 -0
- package/dist/lib/store/tasks.js +133 -0
- package/dist/lib/store/types.d.ts +167 -0
- package/dist/lib/store/types.js +2 -0
- package/dist/lib/store.d.ts +1 -406
- package/dist/lib/store.js +2 -1356
- package/dist/lib/utils.d.ts +5 -0
- package/dist/lib/utils.js +45 -0
- package/dist/proxy/action-parser.d.ts +10 -2
- package/dist/proxy/action-parser.js +4 -2
- package/dist/proxy/cache.d.ts +36 -0
- package/dist/proxy/cache.js +51 -0
- package/dist/proxy/config.d.ts +1 -0
- package/dist/proxy/config.js +2 -0
- package/dist/proxy/extended-cache.d.ts +10 -0
- package/dist/proxy/extended-cache.js +155 -0
- package/dist/proxy/forwarder.d.ts +7 -1
- package/dist/proxy/forwarder.js +157 -7
- package/dist/proxy/handlers/preprocess.d.ts +20 -0
- package/dist/proxy/handlers/preprocess.js +169 -0
- package/dist/proxy/injection/delta-tracking.d.ts +11 -0
- package/dist/proxy/injection/delta-tracking.js +93 -0
- package/dist/proxy/injection/injectors.d.ts +7 -0
- package/dist/proxy/injection/injectors.js +139 -0
- package/dist/proxy/request-processor.d.ts +18 -3
- package/dist/proxy/request-processor.js +151 -28
- package/dist/proxy/response-processor.js +116 -47
- package/dist/proxy/server.d.ts +4 -1
- package/dist/proxy/server.js +592 -253
- package/dist/proxy/types.d.ts +13 -0
- package/dist/proxy/types.js +2 -0
- package/dist/proxy/utils/extractors.d.ts +18 -0
- package/dist/proxy/utils/extractors.js +109 -0
- package/dist/proxy/utils/logging.d.ts +18 -0
- package/dist/proxy/utils/logging.js +42 -0
- package/package.json +22 -4
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Injection helpers for modifying request bodies
|
|
2
|
+
export function appendToLastUserMessage(rawBody, injection) {
|
|
3
|
+
// Find the last occurrence of "role":"user" followed by content
|
|
4
|
+
// We need to find the content field of the last user message and append to it
|
|
5
|
+
// Strategy: Find all user messages, get the last one, append to its content
|
|
6
|
+
// This is tricky because content can be string or array
|
|
7
|
+
// Simpler approach: Find the last user message's closing content
|
|
8
|
+
// Look for pattern: "role":"user","content":"..." or "role":"user","content":[...]
|
|
9
|
+
// Find last "role":"user"
|
|
10
|
+
const userRolePattern = /"role"\s*:\s*"user"/g;
|
|
11
|
+
let lastUserMatch = null;
|
|
12
|
+
let match;
|
|
13
|
+
while ((match = userRolePattern.exec(rawBody)) !== null) {
|
|
14
|
+
lastUserMatch = match;
|
|
15
|
+
}
|
|
16
|
+
if (!lastUserMatch) {
|
|
17
|
+
// No user message found, can't inject
|
|
18
|
+
return rawBody;
|
|
19
|
+
}
|
|
20
|
+
// From lastUserMatch position, find the content field
|
|
21
|
+
const afterRole = rawBody.slice(lastUserMatch.index);
|
|
22
|
+
// Find "content" field after role
|
|
23
|
+
const contentMatch = afterRole.match(/"content"\s*:\s*/);
|
|
24
|
+
if (!contentMatch || contentMatch.index === undefined) {
|
|
25
|
+
return rawBody;
|
|
26
|
+
}
|
|
27
|
+
const contentStartGlobal = lastUserMatch.index + contentMatch.index + contentMatch[0].length;
|
|
28
|
+
const afterContent = rawBody.slice(contentStartGlobal);
|
|
29
|
+
// Determine if content is string or array
|
|
30
|
+
if (afterContent.startsWith('"')) {
|
|
31
|
+
// String content - find closing quote (handling escapes)
|
|
32
|
+
let i = 1; // Skip opening quote
|
|
33
|
+
while (i < afterContent.length) {
|
|
34
|
+
if (afterContent[i] === '\\') {
|
|
35
|
+
i += 2; // Skip escaped char
|
|
36
|
+
}
|
|
37
|
+
else if (afterContent[i] === '"') {
|
|
38
|
+
// Found closing quote
|
|
39
|
+
const insertPos = contentStartGlobal + i;
|
|
40
|
+
// Insert before closing quote, escape the injection for JSON
|
|
41
|
+
const escapedInjection = injection
|
|
42
|
+
.replace(/\\/g, '\\\\')
|
|
43
|
+
.replace(/"/g, '\\"')
|
|
44
|
+
.replace(/\n/g, '\\n');
|
|
45
|
+
return rawBody.slice(0, insertPos) + '\\n\\n' + escapedInjection + rawBody.slice(insertPos);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
i++;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
else if (afterContent.startsWith('[')) {
|
|
53
|
+
// Array content - find last text block and append, or add new text block
|
|
54
|
+
// Find the closing ] of the content array
|
|
55
|
+
let depth = 1;
|
|
56
|
+
let i = 1;
|
|
57
|
+
while (i < afterContent.length && depth > 0) {
|
|
58
|
+
const char = afterContent[i];
|
|
59
|
+
if (char === '[')
|
|
60
|
+
depth++;
|
|
61
|
+
else if (char === ']')
|
|
62
|
+
depth--;
|
|
63
|
+
else if (char === '"') {
|
|
64
|
+
// Skip string
|
|
65
|
+
i++;
|
|
66
|
+
while (i < afterContent.length && afterContent[i] !== '"') {
|
|
67
|
+
if (afterContent[i] === '\\')
|
|
68
|
+
i++;
|
|
69
|
+
i++;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
i++;
|
|
73
|
+
}
|
|
74
|
+
if (depth === 0) {
|
|
75
|
+
// Found closing bracket at position i-1
|
|
76
|
+
const insertPos = contentStartGlobal + i - 1;
|
|
77
|
+
// Add new text block before closing bracket
|
|
78
|
+
const escapedInjection = injection
|
|
79
|
+
.replace(/\\/g, '\\\\')
|
|
80
|
+
.replace(/"/g, '\\"')
|
|
81
|
+
.replace(/\n/g, '\\n');
|
|
82
|
+
const newBlock = `,{"type":"text","text":"\\n\\n${escapedInjection}"}`;
|
|
83
|
+
return rawBody.slice(0, insertPos) + newBlock + rawBody.slice(insertPos);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Fallback: couldn't parse, return unchanged
|
|
87
|
+
return rawBody;
|
|
88
|
+
}
|
|
89
|
+
export function appendToSystemPrompt(body, textToAppend) {
|
|
90
|
+
if (typeof body.system === 'string') {
|
|
91
|
+
body.system = body.system + textToAppend;
|
|
92
|
+
}
|
|
93
|
+
else if (Array.isArray(body.system)) {
|
|
94
|
+
// Append as new text block WITHOUT cache_control
|
|
95
|
+
// Anthropic allows max 4 cache blocks - Claude Code already uses 2+
|
|
96
|
+
// Grov's injections are small (~2KB) so uncached is fine
|
|
97
|
+
body.system.push({
|
|
98
|
+
type: 'text',
|
|
99
|
+
text: textToAppend,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
// No system prompt yet, create as string
|
|
104
|
+
body.system = textToAppend;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
export function injectIntoRawBody(rawBody, injectionText) {
|
|
108
|
+
// Find the system array in the raw JSON
|
|
109
|
+
// Pattern: "system": [....]
|
|
110
|
+
const systemMatch = rawBody.match(/"system"\s*:\s*\[/);
|
|
111
|
+
if (!systemMatch || systemMatch.index === undefined) {
|
|
112
|
+
return { modified: rawBody, success: false };
|
|
113
|
+
}
|
|
114
|
+
// Find the matching closing bracket for the system array
|
|
115
|
+
const startIndex = systemMatch.index + systemMatch[0].length;
|
|
116
|
+
let bracketCount = 1;
|
|
117
|
+
let endIndex = startIndex;
|
|
118
|
+
for (let i = startIndex; i < rawBody.length && bracketCount > 0; i++) {
|
|
119
|
+
const char = rawBody[i];
|
|
120
|
+
if (char === '[')
|
|
121
|
+
bracketCount++;
|
|
122
|
+
else if (char === ']')
|
|
123
|
+
bracketCount--;
|
|
124
|
+
if (bracketCount === 0) {
|
|
125
|
+
endIndex = i;
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (bracketCount !== 0) {
|
|
130
|
+
return { modified: rawBody, success: false };
|
|
131
|
+
}
|
|
132
|
+
// Escape the injection text for JSON
|
|
133
|
+
const escapedText = JSON.stringify(injectionText).slice(1, -1); // Remove outer quotes
|
|
134
|
+
// Create the new block (without cache_control - will be cache_creation)
|
|
135
|
+
const newBlock = `,{"type":"text","text":"${escapedText}"}`;
|
|
136
|
+
// Insert before the closing bracket
|
|
137
|
+
const modified = rawBody.slice(0, endIndex) + newBlock + rawBody.slice(endIndex);
|
|
138
|
+
return { modified, success: true };
|
|
139
|
+
}
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Build context from team memory for injection
|
|
3
|
-
*
|
|
2
|
+
* Build context from CLOUD team memory for injection
|
|
3
|
+
* Fetches memories from Supabase via API (cloud-first approach)
|
|
4
|
+
* Uses hybrid search (semantic + lexical) when userPrompt is provided
|
|
5
|
+
*
|
|
6
|
+
* @param teamId - Team UUID from sync configuration
|
|
7
|
+
* @param projectPath - Project path to filter by
|
|
8
|
+
* @param mentionedFiles - Files mentioned in user messages (for boost)
|
|
9
|
+
* @param userPrompt - User's prompt for semantic search (optional)
|
|
10
|
+
* @returns Formatted context string or null if no memories found
|
|
4
11
|
*/
|
|
5
|
-
export declare function
|
|
12
|
+
export declare function buildTeamMemoryContextCloud(teamId: string, projectPath: string, mentionedFiles: string[], userPrompt?: string): Promise<string | null>;
|
|
6
13
|
/**
|
|
7
14
|
* Extract file paths from messages (user messages only, clean text)
|
|
8
15
|
*/
|
|
@@ -10,3 +17,11 @@ export declare function extractFilesFromMessages(messages: Array<{
|
|
|
10
17
|
role: string;
|
|
11
18
|
content: unknown;
|
|
12
19
|
}>): string[];
|
|
20
|
+
/**
|
|
21
|
+
* Extract the last user prompt from messages for semantic search
|
|
22
|
+
* Returns clean text without system tags
|
|
23
|
+
*/
|
|
24
|
+
export declare function extractLastUserPrompt(messages: Array<{
|
|
25
|
+
role: string;
|
|
26
|
+
content: unknown;
|
|
27
|
+
}>): string | undefined;
|
|
@@ -1,31 +1,98 @@
|
|
|
1
1
|
// Request processor - handles context injection from team memory
|
|
2
2
|
// Reference: plan_proxy_local.md Section 2.1
|
|
3
|
-
import { getTasksForProject, getTasksByFiles, getStepsReasoningByPath, } from '../lib/store.js';
|
|
4
3
|
import { truncate } from '../lib/utils.js';
|
|
4
|
+
import { fetchTeamMemories } from '../lib/api-client.js';
|
|
5
5
|
/**
|
|
6
|
-
* Build context from team memory for injection
|
|
7
|
-
*
|
|
6
|
+
* Build context from CLOUD team memory for injection
|
|
7
|
+
* Fetches memories from Supabase via API (cloud-first approach)
|
|
8
|
+
* Uses hybrid search (semantic + lexical) when userPrompt is provided
|
|
9
|
+
*
|
|
10
|
+
* @param teamId - Team UUID from sync configuration
|
|
11
|
+
* @param projectPath - Project path to filter by
|
|
12
|
+
* @param mentionedFiles - Files mentioned in user messages (for boost)
|
|
13
|
+
* @param userPrompt - User's prompt for semantic search (optional)
|
|
14
|
+
* @returns Formatted context string or null if no memories found
|
|
8
15
|
*/
|
|
9
|
-
export function
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
16
|
+
export async function buildTeamMemoryContextCloud(teamId, projectPath, mentionedFiles, userPrompt) {
|
|
17
|
+
const startTime = Date.now();
|
|
18
|
+
const hasContext = userPrompt && userPrompt.trim().length > 0;
|
|
19
|
+
console.log(`[CLOUD] ═══════════════════════════════════════════════════════════`);
|
|
20
|
+
console.log(`[CLOUD] buildTeamMemoryContextCloud START`);
|
|
21
|
+
console.log(`[CLOUD] Team: ${teamId.substring(0, 8)}...`);
|
|
22
|
+
console.log(`[CLOUD] Project: ${projectPath}`);
|
|
23
|
+
console.log(`[CLOUD] Prompt: "${hasContext ? userPrompt.substring(0, 60) + '...' : 'N/A'}"`);
|
|
24
|
+
console.log(`[CLOUD] Files for boost: ${mentionedFiles.length > 0 ? mentionedFiles.join(', ') : 'none'}`);
|
|
25
|
+
try {
|
|
26
|
+
// Fetch memories from cloud API (hybrid search if context provided)
|
|
27
|
+
const fetchStart = Date.now();
|
|
28
|
+
const memories = await fetchTeamMemories(teamId, projectPath, {
|
|
29
|
+
status: 'complete',
|
|
30
|
+
limit: 5, // Max 5 memories for injection (Convex Combination scoring)
|
|
31
|
+
files: mentionedFiles.length > 0 ? mentionedFiles : undefined,
|
|
32
|
+
context: hasContext ? userPrompt : undefined,
|
|
33
|
+
current_files: mentionedFiles.length > 0 ? mentionedFiles : undefined,
|
|
34
|
+
});
|
|
35
|
+
const fetchTime = Date.now() - fetchStart;
|
|
36
|
+
if (memories.length === 0) {
|
|
37
|
+
console.log(`[CLOUD] No memories found (fetch took ${fetchTime}ms)`);
|
|
38
|
+
console.log(`[CLOUD] ═══════════════════════════════════════════════════════════`);
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
console.log(`[CLOUD] ───────────────────────────────────────────────────────────`);
|
|
42
|
+
console.log(`[CLOUD] Fetched ${memories.length} memories in ${fetchTime}ms`);
|
|
43
|
+
console.log(`[CLOUD] ───────────────────────────────────────────────────────────`);
|
|
44
|
+
// Log each memory with scores (if available from hybrid search)
|
|
45
|
+
for (let i = 0; i < memories.length; i++) {
|
|
46
|
+
const mem = memories[i];
|
|
47
|
+
const semScore = typeof mem.semantic_score === 'number' ? mem.semantic_score.toFixed(3) : '-';
|
|
48
|
+
const lexScore = typeof mem.lexical_score === 'number' ? mem.lexical_score.toFixed(3) : '-';
|
|
49
|
+
const combScore = typeof mem.combined_score === 'number' ? mem.combined_score.toFixed(3) : '-';
|
|
50
|
+
const boosted = mem.file_boost_applied ? '🚀' : ' ';
|
|
51
|
+
const query = String(memories[i].original_query || '').substring(0, 50);
|
|
52
|
+
const filesCount = memories[i].files_touched?.length || 0;
|
|
53
|
+
const reasoningCount = memories[i].reasoning_trace?.length || 0;
|
|
54
|
+
console.log(`[CLOUD] ${i + 1}. ${boosted} [${combScore}] sem=${semScore} lex=${lexScore} | files=${filesCount} reasoning=${reasoningCount}`);
|
|
55
|
+
console.log(`[CLOUD] "${query}..."`);
|
|
56
|
+
}
|
|
57
|
+
console.log(`[CLOUD] ───────────────────────────────────────────────────────────`);
|
|
58
|
+
// Convert Memory[] to Task[] format for the existing formatter
|
|
59
|
+
const tasks = memories.map(memoryToTask);
|
|
60
|
+
// Reuse existing formatter (no file-level reasoning from cloud yet)
|
|
61
|
+
const context = formatTeamMemoryContext(tasks, [], mentionedFiles);
|
|
62
|
+
// Estimate tokens (~4 chars per token)
|
|
63
|
+
const estimatedTokens = Math.round(context.length / 4);
|
|
64
|
+
const totalTime = Date.now() - startTime;
|
|
65
|
+
console.log(`[CLOUD] Context built: ${context.length} chars (~${estimatedTokens} tokens)`);
|
|
66
|
+
console.log(`[CLOUD] Total time: ${totalTime}ms (fetch: ${fetchTime}ms, format: ${totalTime - fetchTime}ms)`);
|
|
67
|
+
console.log(`[CLOUD] ═══════════════════════════════════════════════════════════`);
|
|
68
|
+
return context;
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
const errorMsg = err instanceof Error ? err.message : 'Unknown error';
|
|
72
|
+
console.error(`[CLOUD] buildTeamMemoryContextCloud failed: ${errorMsg}`);
|
|
73
|
+
return null; // Fail silent - don't block Claude Code
|
|
27
74
|
}
|
|
28
|
-
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Convert Memory (cloud format) to Task (local format)
|
|
78
|
+
* Used to reuse existing formatTeamMemoryContext function
|
|
79
|
+
*/
|
|
80
|
+
function memoryToTask(memory) {
|
|
81
|
+
return {
|
|
82
|
+
id: memory.id,
|
|
83
|
+
project_path: memory.project_path,
|
|
84
|
+
user: memory.user_id || undefined,
|
|
85
|
+
original_query: memory.original_query,
|
|
86
|
+
goal: memory.goal || undefined,
|
|
87
|
+
reasoning_trace: memory.reasoning_trace || [],
|
|
88
|
+
files_touched: memory.files_touched || [],
|
|
89
|
+
decisions: memory.decisions || [],
|
|
90
|
+
constraints: memory.constraints || [],
|
|
91
|
+
status: memory.status,
|
|
92
|
+
tags: memory.tags || [],
|
|
93
|
+
linked_commit: memory.linked_commit || undefined,
|
|
94
|
+
created_at: memory.created_at,
|
|
95
|
+
};
|
|
29
96
|
}
|
|
30
97
|
/**
|
|
31
98
|
* Format team memory context for injection
|
|
@@ -46,21 +113,44 @@ function formatTeamMemoryContext(tasks, fileReasonings, files) {
|
|
|
46
113
|
}
|
|
47
114
|
lines.push('');
|
|
48
115
|
}
|
|
49
|
-
// Task context with
|
|
116
|
+
// Task context with knowledge pairs and decisions
|
|
117
|
+
// Inject up to 5 pairs (10 entries) per task for rich context
|
|
50
118
|
if (tasks.length > 0) {
|
|
51
119
|
lines.push('Related past tasks:');
|
|
52
|
-
for (const task of tasks.slice(0, 5)) {
|
|
120
|
+
for (const task of tasks.slice(0, 5)) { // Limit to 5 tasks (Convex Combination top results)
|
|
53
121
|
lines.push(`- ${truncate(task.original_query, 60)}`);
|
|
54
122
|
if (task.files_touched.length > 0) {
|
|
55
|
-
const fileList = task.files_touched.slice(0,
|
|
123
|
+
const fileList = task.files_touched.slice(0, 5).map(f => f.split('/').pop()).join(', ');
|
|
56
124
|
lines.push(` Files: ${fileList}`);
|
|
57
125
|
}
|
|
126
|
+
// Inject knowledge pairs (interleaved: conclusion, insight, conclusion, insight...)
|
|
127
|
+
// Take up to 5 pairs (10 entries) per task
|
|
58
128
|
if (task.reasoning_trace.length > 0) {
|
|
59
|
-
lines.push(
|
|
129
|
+
lines.push(' Knowledge:');
|
|
130
|
+
const maxPairs = 5;
|
|
131
|
+
const maxEntries = maxPairs * 2; // 10 entries
|
|
132
|
+
const entries = task.reasoning_trace.slice(0, maxEntries);
|
|
133
|
+
for (let i = 0; i < entries.length; i += 2) {
|
|
134
|
+
const conclusion = entries[i];
|
|
135
|
+
const insight = entries[i + 1];
|
|
136
|
+
// Format conclusion (remove prefix for brevity)
|
|
137
|
+
const cText = conclusion?.replace(/^CONCLUSION:\s*/i, '') || '';
|
|
138
|
+
if (cText) {
|
|
139
|
+
lines.push(` • ${truncate(cText, 120)}`);
|
|
140
|
+
}
|
|
141
|
+
// Format insight (indented under conclusion)
|
|
142
|
+
if (insight) {
|
|
143
|
+
const iText = insight.replace(/^INSIGHT:\s*/i, '');
|
|
144
|
+
lines.push(` → ${truncate(iText, 100)}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
60
147
|
}
|
|
61
|
-
// Include decisions
|
|
148
|
+
// Include decisions (up to 2 per task)
|
|
62
149
|
if (task.decisions && task.decisions.length > 0) {
|
|
63
|
-
|
|
150
|
+
const decisionsToShow = task.decisions.slice(0, 2);
|
|
151
|
+
for (const decision of decisionsToShow) {
|
|
152
|
+
lines.push(` Decision: ${truncate(decision.choice, 60)} (${truncate(decision.reason, 50)})`);
|
|
153
|
+
}
|
|
64
154
|
}
|
|
65
155
|
// Include constraints if available
|
|
66
156
|
if (task.constraints && task.constraints.length > 0) {
|
|
@@ -118,3 +208,36 @@ export function extractFilesFromMessages(messages) {
|
|
|
118
208
|
}
|
|
119
209
|
return [...new Set(files)];
|
|
120
210
|
}
|
|
211
|
+
/**
|
|
212
|
+
* Extract the last user prompt from messages for semantic search
|
|
213
|
+
* Returns clean text without system tags
|
|
214
|
+
*/
|
|
215
|
+
export function extractLastUserPrompt(messages) {
|
|
216
|
+
// Find last user message
|
|
217
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
218
|
+
const msg = messages[i];
|
|
219
|
+
if (msg.role !== 'user')
|
|
220
|
+
continue;
|
|
221
|
+
let textContent = '';
|
|
222
|
+
// Handle string content
|
|
223
|
+
if (typeof msg.content === 'string') {
|
|
224
|
+
textContent = msg.content;
|
|
225
|
+
}
|
|
226
|
+
// Handle array content (Claude Code API format)
|
|
227
|
+
if (Array.isArray(msg.content)) {
|
|
228
|
+
for (const block of msg.content) {
|
|
229
|
+
if (block && typeof block === 'object' && 'type' in block && block.type === 'text' && 'text' in block && typeof block.text === 'string') {
|
|
230
|
+
textContent += block.text + '\n';
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// Strip system-reminder tags to get clean user content
|
|
235
|
+
const cleanContent = textContent
|
|
236
|
+
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
|
|
237
|
+
.trim();
|
|
238
|
+
if (cleanContent) {
|
|
239
|
+
return cleanContent;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|