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,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 (PAST sessions only)
3
- * Queries tasks and file_reasoning tables, excluding current session data
4
- * @param currentSessionId - Session ID to exclude (ensures only past session data)
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 buildTeamMemoryContext(projectPath: string, mentionedFiles: string[], currentSessionId?: string): string | null;
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 (PAST sessions only)
7
- * Queries tasks and file_reasoning tables, excluding current session data
8
- * @param currentSessionId - Session ID to exclude (ensures only past session data)
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 buildTeamMemoryContext(projectPath, mentionedFiles, currentSessionId) {
11
- // Get recent completed tasks for this project
12
- const tasks = getTasksForProject(projectPath, {
13
- status: 'complete',
14
- limit: 10,
15
- });
16
- // Get tasks that touched mentioned files
17
- const fileTasks = mentionedFiles.length > 0
18
- ? getTasksByFiles(projectPath, mentionedFiles, { status: 'complete', limit: 5 })
19
- : [];
20
- // Get file-level reasoning from steps table (PAST sessions only)
21
- // Pass currentSessionId to exclude current session data
22
- const fileReasonings = mentionedFiles.length > 0
23
- ? mentionedFiles.flatMap(f => getStepsReasoningByPath(f, 3, currentSessionId))
24
- : [];
25
- // Combine unique tasks
26
- const allTasks = [...new Map([...tasks, ...fileTasks].map(t => [t.id, t])).values()];
27
- if (allTasks.length === 0 && fileReasonings.length === 0) {
28
- return null;
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
- return formatTeamMemoryContext(allTasks, fileReasonings, mentionedFiles);
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 decisions and constraints
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, 3).map(f => f.split('/').pop()).join(', ');
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(` Key: ${truncate(task.reasoning_trace[0], 80)}`);
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 if available
148
+ // Include decisions (up to 2 per task)
64
149
  if (task.decisions && task.decisions.length > 0) {
65
- lines.push(` Decision: ${task.decisions[0].choice} (${truncate(task.decisions[0].reason, 50)})`);
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 { extractReasoning, isLLMAvailable, extractReasoningAndDecisions, isReasoningExtractionAvailable, } from '../lib/llm-extractor.js';
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
- // Collect reasoning from steps + final response
76
- const stepsReasoning = steps
77
- .map(s => s.reasoning)
78
- .filter((r) => !!r && r.length > 10);
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
- stepsReasoning.push(sessionState.final_response);
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 (stepsReasoning.length > 0) {
84
- const extracted = await extractReasoningAndDecisions(stepsReasoning, sessionState.original_goal || '');
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
  */
@@ -1,5 +1,4 @@
1
1
  import { FastifyInstance } from 'fastify';
2
- export declare function setDebugMode(enabled: boolean): void;
3
2
  /**
4
3
  * Create and configure the Fastify server
5
4
  */