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.
- package/README.md +19 -1
- package/dist/cli.js +8 -0
- package/dist/lib/api-client.d.ts +18 -1
- package/dist/lib/api-client.js +57 -0
- package/dist/lib/llm-extractor.d.ts +14 -38
- package/dist/lib/llm-extractor.js +380 -406
- 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 -436
- package/dist/lib/store.js +2 -1478
- 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/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 -4
- package/dist/proxy/request-processor.js +151 -30
- package/dist/proxy/response-processor.js +93 -45
- package/dist/proxy/server.d.ts +0 -1
- package/dist/proxy/server.js +342 -566
- 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 +5 -2
|
@@ -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,9 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Build context from team memory for injection
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
|
5
11
|
*/
|
|
6
|
-
export declare function
|
|
12
|
+
export declare function buildTeamMemoryContextCloud(teamId: string, projectPath: string, mentionedFiles: string[], userPrompt?: string): Promise<string | null>;
|
|
7
13
|
/**
|
|
8
14
|
* Extract file paths from messages (user messages only, clean text)
|
|
9
15
|
*/
|
|
@@ -11,3 +17,11 @@ export declare function extractFilesFromMessages(messages: Array<{
|
|
|
11
17
|
role: string;
|
|
12
18
|
content: unknown;
|
|
13
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,33 +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
|
-
*
|
|
8
|
-
*
|
|
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
|
|
9
15
|
*/
|
|
10
|
-
export function
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
29
74
|
}
|
|
30
|
-
|
|
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
|
+
};
|
|
31
96
|
}
|
|
32
97
|
/**
|
|
33
98
|
* Format team memory context for injection
|
|
@@ -48,21 +113,44 @@ function formatTeamMemoryContext(tasks, fileReasonings, files) {
|
|
|
48
113
|
}
|
|
49
114
|
lines.push('');
|
|
50
115
|
}
|
|
51
|
-
// Task context with
|
|
116
|
+
// Task context with knowledge pairs and decisions
|
|
117
|
+
// Inject up to 5 pairs (10 entries) per task for rich context
|
|
52
118
|
if (tasks.length > 0) {
|
|
53
119
|
lines.push('Related past tasks:');
|
|
54
|
-
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)
|
|
55
121
|
lines.push(`- ${truncate(task.original_query, 60)}`);
|
|
56
122
|
if (task.files_touched.length > 0) {
|
|
57
|
-
const fileList = task.files_touched.slice(0,
|
|
123
|
+
const fileList = task.files_touched.slice(0, 5).map(f => f.split('/').pop()).join(', ');
|
|
58
124
|
lines.push(` Files: ${fileList}`);
|
|
59
125
|
}
|
|
126
|
+
// Inject knowledge pairs (interleaved: conclusion, insight, conclusion, insight...)
|
|
127
|
+
// Take up to 5 pairs (10 entries) per task
|
|
60
128
|
if (task.reasoning_trace.length > 0) {
|
|
61
|
-
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
|
+
}
|
|
62
147
|
}
|
|
63
|
-
// Include decisions
|
|
148
|
+
// Include decisions (up to 2 per task)
|
|
64
149
|
if (task.decisions && task.decisions.length > 0) {
|
|
65
|
-
|
|
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
|
+
}
|
|
66
154
|
}
|
|
67
155
|
// Include constraints if available
|
|
68
156
|
if (task.constraints && task.constraints.length > 0) {
|
|
@@ -120,3 +208,36 @@ export function extractFilesFromMessages(messages) {
|
|
|
120
208
|
}
|
|
121
209
|
return [...new Set(files)];
|
|
122
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
|
+
}
|
|
@@ -2,7 +2,80 @@
|
|
|
2
2
|
// Reference: plan_proxy_local.md Section 4.6
|
|
3
3
|
import { getSessionState, getValidatedSteps, deleteStepsForSession, deleteSessionState, createTask, markTaskSynced, setTaskSyncError, } from '../lib/store.js';
|
|
4
4
|
import { syncTask } from '../lib/cloud-sync.js';
|
|
5
|
-
import {
|
|
5
|
+
import { extractReasoningAndDecisions, isReasoningExtractionAvailable, } from '../lib/llm-extractor.js';
|
|
6
|
+
/**
|
|
7
|
+
* Group steps by reasoning (non-NULL starts a group, NULLs continue it)
|
|
8
|
+
* Steps from the same Claude response share identical reasoning, stored only on the first.
|
|
9
|
+
*/
|
|
10
|
+
function groupStepsByReasoning(steps) {
|
|
11
|
+
const groups = [];
|
|
12
|
+
let currentGroup = null;
|
|
13
|
+
for (const step of steps) {
|
|
14
|
+
if (step.reasoning && step.reasoning.length > 0) {
|
|
15
|
+
// Step with reasoning = start new group
|
|
16
|
+
if (currentGroup) {
|
|
17
|
+
groups.push(currentGroup);
|
|
18
|
+
}
|
|
19
|
+
currentGroup = {
|
|
20
|
+
reasoning: step.reasoning,
|
|
21
|
+
actions: [{
|
|
22
|
+
type: step.action_type,
|
|
23
|
+
files: step.files || [],
|
|
24
|
+
folders: step.folders || [],
|
|
25
|
+
command: step.command ?? undefined,
|
|
26
|
+
}],
|
|
27
|
+
allFiles: [...(step.files || [])],
|
|
28
|
+
allFolders: [...(step.folders || [])],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
else if (currentGroup) {
|
|
32
|
+
// Step without reasoning = continue current group
|
|
33
|
+
currentGroup.actions.push({
|
|
34
|
+
type: step.action_type,
|
|
35
|
+
files: step.files || [],
|
|
36
|
+
folders: step.folders || [],
|
|
37
|
+
command: step.command ?? undefined,
|
|
38
|
+
});
|
|
39
|
+
currentGroup.allFiles.push(...(step.files || []));
|
|
40
|
+
currentGroup.allFolders.push(...(step.folders || []));
|
|
41
|
+
}
|
|
42
|
+
// Edge case: step without reasoning and no current group = skip (shouldn't happen with new code)
|
|
43
|
+
}
|
|
44
|
+
// Push last group
|
|
45
|
+
if (currentGroup) {
|
|
46
|
+
groups.push(currentGroup);
|
|
47
|
+
}
|
|
48
|
+
return groups;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Format grouped steps for Haiku prompt
|
|
52
|
+
* Provides structured XML with reasoning + associated actions and files
|
|
53
|
+
*/
|
|
54
|
+
function formatGroupsForHaiku(groups) {
|
|
55
|
+
if (groups.length === 0) {
|
|
56
|
+
return '';
|
|
57
|
+
}
|
|
58
|
+
return groups.map((g, i) => {
|
|
59
|
+
const actionLines = g.actions.map(a => {
|
|
60
|
+
let line = `- ${a.type}`;
|
|
61
|
+
if (a.files.length > 0)
|
|
62
|
+
line += `: ${a.files.join(', ')}`;
|
|
63
|
+
if (a.command)
|
|
64
|
+
line += ` (command: ${a.command.substring(0, 50)})`;
|
|
65
|
+
return line;
|
|
66
|
+
}).join('\n');
|
|
67
|
+
const uniqueFiles = [...new Set(g.allFiles)];
|
|
68
|
+
return `<response index="${i + 1}">
|
|
69
|
+
<reasoning>
|
|
70
|
+
${g.reasoning}
|
|
71
|
+
</reasoning>
|
|
72
|
+
<actions_performed>
|
|
73
|
+
${actionLines}
|
|
74
|
+
</actions_performed>
|
|
75
|
+
<files_touched>${uniqueFiles.join(', ') || 'none'}</files_touched>
|
|
76
|
+
</response>`;
|
|
77
|
+
}).join('\n\n###\n\n');
|
|
78
|
+
}
|
|
6
79
|
/**
|
|
7
80
|
* Save session to team memory
|
|
8
81
|
* Called on: task complete, subtask complete, session abandoned
|
|
@@ -23,18 +96,24 @@ export async function saveToTeamMemory(sessionId, triggerReason) {
|
|
|
23
96
|
// Create task in team memory
|
|
24
97
|
const task = createTask(taskData);
|
|
25
98
|
// Fire-and-forget cloud sync; never block capture path
|
|
99
|
+
// NOTE: Do NOT invalidate cache after sync - cache persists until CLEAR/Summary/restart
|
|
100
|
+
// Next SESSION will get fresh data, current session keeps its context
|
|
26
101
|
syncTask(task)
|
|
27
102
|
.then((success) => {
|
|
28
103
|
if (success) {
|
|
29
104
|
markTaskSynced(task.id);
|
|
105
|
+
console.log(`[SYNC] Task ${task.id.substring(0, 8)} synced to cloud`);
|
|
30
106
|
}
|
|
31
107
|
else {
|
|
32
108
|
setTaskSyncError(task.id, 'Sync not enabled or team not configured');
|
|
109
|
+
console.log(`[SYNC] Task ${task.id.substring(0, 8)} sync skipped (not enabled)`);
|
|
33
110
|
}
|
|
34
111
|
})
|
|
35
112
|
.catch((err) => {
|
|
36
113
|
const message = err instanceof Error ? err.message : 'Unknown sync error';
|
|
37
114
|
setTaskSyncError(task.id, message);
|
|
115
|
+
console.error(`[SYNC] Task ${task.id.substring(0, 8)} sync failed: ${message}`);
|
|
116
|
+
// NOTE: Do NOT invalidate cache - data not in cloud
|
|
38
117
|
});
|
|
39
118
|
}
|
|
40
119
|
/**
|
|
@@ -72,16 +151,21 @@ async function buildTaskFromSession(sessionState, steps, triggerReason) {
|
|
|
72
151
|
let constraints = sessionState.constraints || [];
|
|
73
152
|
if (isReasoningExtractionAvailable()) {
|
|
74
153
|
try {
|
|
75
|
-
//
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
// Include final response (contains the actual analysis/conclusion)
|
|
154
|
+
// Group steps by reasoning to avoid duplicates and preserve action context
|
|
155
|
+
const groups = groupStepsByReasoning(steps);
|
|
156
|
+
let formattedSteps = formatGroupsForHaiku(groups);
|
|
157
|
+
// Add final_response as separate section if exists and is different from grouped reasoning
|
|
80
158
|
if (sessionState.final_response && sessionState.final_response.length > 100) {
|
|
81
|
-
|
|
159
|
+
const finalAlreadyIncluded = groups.some(g => g.reasoning.includes(sessionState.final_response.substring(0, 100)));
|
|
160
|
+
if (!finalAlreadyIncluded) {
|
|
161
|
+
if (formattedSteps.length > 0) {
|
|
162
|
+
formattedSteps += '\n\n###\n\n';
|
|
163
|
+
}
|
|
164
|
+
formattedSteps += `<final_response>\n${sessionState.final_response.substring(0, 8000)}\n</final_response>`;
|
|
165
|
+
}
|
|
82
166
|
}
|
|
83
|
-
if (
|
|
84
|
-
const extracted = await extractReasoningAndDecisions(
|
|
167
|
+
if (formattedSteps.length > 50) {
|
|
168
|
+
const extracted = await extractReasoningAndDecisions(formattedSteps, sessionState.original_goal || '');
|
|
85
169
|
if (extracted.reasoning_trace.length > 0) {
|
|
86
170
|
reasoningTrace = extracted.reasoning_trace;
|
|
87
171
|
}
|
|
@@ -94,22 +178,6 @@ async function buildTaskFromSession(sessionState, steps, triggerReason) {
|
|
|
94
178
|
// Fall back to basic extraction
|
|
95
179
|
}
|
|
96
180
|
}
|
|
97
|
-
else if (isLLMAvailable() && steps.length > 0) {
|
|
98
|
-
// Fallback to OpenAI-based extraction if Anthropic not available
|
|
99
|
-
try {
|
|
100
|
-
const pseudoSession = buildPseudoSession(sessionState, steps);
|
|
101
|
-
const extracted = await extractReasoning(pseudoSession);
|
|
102
|
-
if (extracted.decisions.length > 0) {
|
|
103
|
-
decisions = extracted.decisions;
|
|
104
|
-
}
|
|
105
|
-
if (extracted.constraints.length > 0) {
|
|
106
|
-
constraints = [...new Set([...constraints, ...extracted.constraints])];
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
catch {
|
|
110
|
-
// Fall back to basic extraction
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
181
|
return {
|
|
114
182
|
project_path: sessionState.project_path,
|
|
115
183
|
user: sessionState.user_id,
|
|
@@ -123,26 +191,6 @@ async function buildTaskFromSession(sessionState, steps, triggerReason) {
|
|
|
123
191
|
trigger_reason: triggerReason,
|
|
124
192
|
};
|
|
125
193
|
}
|
|
126
|
-
/**
|
|
127
|
-
* Build pseudo ParsedSession for LLM extraction
|
|
128
|
-
*/
|
|
129
|
-
function buildPseudoSession(sessionState, steps) {
|
|
130
|
-
return {
|
|
131
|
-
sessionId: sessionState.session_id,
|
|
132
|
-
projectPath: sessionState.project_path,
|
|
133
|
-
startTime: sessionState.start_time,
|
|
134
|
-
endTime: sessionState.last_update,
|
|
135
|
-
userMessages: [sessionState.original_goal || ''],
|
|
136
|
-
assistantMessages: steps.map(s => `[${s.action_type}] ${s.files.join(', ')}`),
|
|
137
|
-
toolCalls: steps.map(s => ({
|
|
138
|
-
name: s.action_type,
|
|
139
|
-
input: { files: s.files, command: s.command },
|
|
140
|
-
})),
|
|
141
|
-
filesRead: steps.filter(s => s.action_type === 'read').flatMap(s => s.files),
|
|
142
|
-
filesWritten: steps.filter(s => s.action_type === 'write' || s.action_type === 'edit').flatMap(s => s.files),
|
|
143
|
-
rawEntries: [],
|
|
144
|
-
};
|
|
145
|
-
}
|
|
146
194
|
/**
|
|
147
195
|
* Clean up session data after save
|
|
148
196
|
*/
|