grov 0.2.3 → 0.5.2

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.
@@ -0,0 +1,176 @@
1
+ // Cloud sync logic - Upload memories from local database to API
2
+ // Handles batching, retries, and conversion from Task to Memory format
3
+ import { getSyncStatus, getAccessToken } from './credentials.js';
4
+ import { syncMemories, sleep, getApiUrl } from './api-client.js';
5
+ // Sync configuration
6
+ const SYNC_CONFIG = {
7
+ batchSize: 10, // Number of memories per batch
8
+ retryAttempts: 3, // Number of retry attempts per batch
9
+ retryDelay: 1000, // Base delay between retries (ms)
10
+ };
11
+ /**
12
+ * Convert local Task to CreateMemoryInput for API
13
+ */
14
+ export function taskToMemory(task) {
15
+ return {
16
+ client_task_id: task.id,
17
+ project_path: task.project_path,
18
+ original_query: task.original_query,
19
+ goal: task.goal,
20
+ reasoning_trace: task.reasoning_trace,
21
+ files_touched: task.files_touched,
22
+ decisions: task.decisions,
23
+ constraints: task.constraints,
24
+ tags: task.tags,
25
+ status: task.status,
26
+ linked_commit: task.linked_commit,
27
+ };
28
+ }
29
+ /**
30
+ * Check if sync is enabled and configured
31
+ */
32
+ export function isSyncEnabled() {
33
+ const status = getSyncStatus();
34
+ return status?.enabled === true && !!status.teamId;
35
+ }
36
+ /**
37
+ * Get the configured team ID for sync
38
+ */
39
+ export function getSyncTeamId() {
40
+ const status = getSyncStatus();
41
+ return status?.teamId || null;
42
+ }
43
+ /**
44
+ * Sync a single task to the cloud
45
+ * Called when a task is completed
46
+ */
47
+ export async function syncTask(task) {
48
+ if (!isSyncEnabled()) {
49
+ return false;
50
+ }
51
+ const teamId = getSyncTeamId();
52
+ if (!teamId) {
53
+ return false;
54
+ }
55
+ const token = await getAccessToken();
56
+ if (!token) {
57
+ return false;
58
+ }
59
+ try {
60
+ const memory = taskToMemory(task);
61
+ const result = await syncMemories(teamId, { memories: [memory] });
62
+ return result.synced === 1;
63
+ }
64
+ catch {
65
+ return false;
66
+ }
67
+ }
68
+ /**
69
+ * Sync multiple tasks with batching and retry
70
+ */
71
+ export async function syncTasks(tasks) {
72
+ if (!isSyncEnabled()) {
73
+ return {
74
+ synced: 0,
75
+ failed: tasks.length,
76
+ errors: [`Sync is not enabled. Run "grov sync --enable --team <team-id>" first. (API: ${getApiUrl()})`],
77
+ syncedIds: [],
78
+ failedIds: tasks.map(t => t.id),
79
+ };
80
+ }
81
+ const teamId = getSyncTeamId();
82
+ if (!teamId) {
83
+ return {
84
+ synced: 0,
85
+ failed: tasks.length,
86
+ errors: [`No team configured. Run "grov sync --enable --team <team-id>" first. (API: ${getApiUrl()})`],
87
+ syncedIds: [],
88
+ failedIds: tasks.map(t => t.id),
89
+ };
90
+ }
91
+ const token = await getAccessToken();
92
+ if (!token) {
93
+ return {
94
+ synced: 0,
95
+ failed: tasks.length,
96
+ errors: [`Not authenticated. Run "grov login" first. (API: ${getApiUrl()})`],
97
+ syncedIds: [],
98
+ failedIds: tasks.map(t => t.id),
99
+ };
100
+ }
101
+ // Convert tasks to memories
102
+ const memories = tasks.map(taskToMemory);
103
+ // Batch and sync
104
+ const batches = [];
105
+ for (let i = 0; i < memories.length; i += SYNC_CONFIG.batchSize) {
106
+ batches.push(memories.slice(i, i + SYNC_CONFIG.batchSize));
107
+ }
108
+ let totalSynced = 0;
109
+ let totalFailed = 0;
110
+ const allErrors = [];
111
+ const syncedIds = [];
112
+ const failedIds = [];
113
+ for (const batch of batches) {
114
+ const batchResult = await syncBatchWithRetry(teamId, batch);
115
+ totalSynced += batchResult.synced;
116
+ totalFailed += batchResult.failed;
117
+ if (batchResult.errors) {
118
+ allErrors.push(...batchResult.errors);
119
+ }
120
+ const batchIds = batch.map((m) => m.client_task_id || '');
121
+ if (batchResult.synced === batch.length) {
122
+ syncedIds.push(...batchIds);
123
+ }
124
+ else if (batchResult.failed === batch.length) {
125
+ failedIds.push(...batchIds);
126
+ }
127
+ }
128
+ return {
129
+ synced: totalSynced,
130
+ failed: totalFailed,
131
+ errors: allErrors,
132
+ syncedIds: syncedIds.filter(Boolean),
133
+ failedIds: failedIds.filter(Boolean),
134
+ };
135
+ }
136
+ /**
137
+ * Sync a batch with retry logic
138
+ */
139
+ async function syncBatchWithRetry(teamId, memories) {
140
+ let lastError;
141
+ for (let attempt = 0; attempt < SYNC_CONFIG.retryAttempts; attempt++) {
142
+ try {
143
+ return await syncMemories(teamId, { memories });
144
+ }
145
+ catch (err) {
146
+ lastError = err instanceof Error ? err.message : 'Unknown error';
147
+ // Exponential backoff
148
+ if (attempt < SYNC_CONFIG.retryAttempts - 1) {
149
+ const delay = SYNC_CONFIG.retryDelay * Math.pow(2, attempt);
150
+ await sleep(delay);
151
+ }
152
+ }
153
+ }
154
+ // All retries failed
155
+ return {
156
+ synced: 0,
157
+ failed: memories.length,
158
+ errors: [lastError || 'Sync failed after retries'],
159
+ };
160
+ }
161
+ /**
162
+ * Get sync status summary
163
+ */
164
+ export function getSyncStatusSummary() {
165
+ const status = getSyncStatus();
166
+ if (!status) {
167
+ return 'Not logged in';
168
+ }
169
+ if (!status.enabled) {
170
+ return 'Sync disabled';
171
+ }
172
+ if (!status.teamId) {
173
+ return 'No team configured';
174
+ }
175
+ return `Syncing to team: ${status.teamId}`;
176
+ }
@@ -0,0 +1,53 @@
1
+ export interface Credentials {
2
+ access_token: string;
3
+ refresh_token: string;
4
+ expires_at: string;
5
+ user_id: string;
6
+ email: string;
7
+ team_id?: string;
8
+ sync_enabled: boolean;
9
+ }
10
+ /**
11
+ * Read credentials from disk
12
+ * @returns Credentials or null if not found/invalid
13
+ */
14
+ export declare function readCredentials(): Credentials | null;
15
+ /**
16
+ * Write credentials to disk with secure permissions
17
+ */
18
+ export declare function writeCredentials(creds: Credentials): void;
19
+ /**
20
+ * Clear credentials (logout)
21
+ */
22
+ export declare function clearCredentials(): void;
23
+ /**
24
+ * Check if user is authenticated (has valid credentials)
25
+ */
26
+ export declare function isAuthenticated(): boolean;
27
+ /**
28
+ * Get a valid access token, refreshing if necessary
29
+ * @returns Access token or null if not authenticated
30
+ */
31
+ export declare function getAccessToken(): Promise<string | null>;
32
+ /**
33
+ * Set the team ID for sync
34
+ */
35
+ export declare function setTeamId(teamId: string): void;
36
+ /**
37
+ * Enable or disable sync
38
+ */
39
+ export declare function setSyncEnabled(enabled: boolean): void;
40
+ /**
41
+ * Get current sync status
42
+ */
43
+ export declare function getSyncStatus(): {
44
+ enabled: boolean;
45
+ teamId: string | undefined;
46
+ } | null;
47
+ /**
48
+ * Get current user info
49
+ */
50
+ export declare function getCurrentUser(): {
51
+ id: string;
52
+ email: string;
53
+ } | null;
@@ -0,0 +1,201 @@
1
+ // Secure credential storage for CLI authentication
2
+ // Stores tokens at ~/.grov/credentials.json with 0o600 permissions
3
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, chmodSync } from 'fs';
4
+ import { homedir } from 'os';
5
+ import { join } from 'path';
6
+ import { request } from 'undici';
7
+ const GROV_DIR = join(homedir(), '.grov');
8
+ const CREDENTIALS_PATH = join(GROV_DIR, 'credentials.json');
9
+ /**
10
+ * Ensure .grov directory exists with proper permissions
11
+ */
12
+ function ensureGrovDir() {
13
+ if (!existsSync(GROV_DIR)) {
14
+ mkdirSync(GROV_DIR, { recursive: true, mode: 0o700 });
15
+ }
16
+ }
17
+ /**
18
+ * Read credentials from disk
19
+ * @returns Credentials or null if not found/invalid
20
+ */
21
+ export function readCredentials() {
22
+ if (!existsSync(CREDENTIALS_PATH)) {
23
+ return null;
24
+ }
25
+ try {
26
+ const content = readFileSync(CREDENTIALS_PATH, 'utf-8');
27
+ const creds = JSON.parse(content);
28
+ // Validate required fields
29
+ if (!creds.access_token || !creds.refresh_token || !creds.expires_at) {
30
+ return null;
31
+ }
32
+ return creds;
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ /**
39
+ * Write credentials to disk with secure permissions
40
+ */
41
+ export function writeCredentials(creds) {
42
+ ensureGrovDir();
43
+ // Write with restrictive permissions (owner read/write only)
44
+ writeFileSync(CREDENTIALS_PATH, JSON.stringify(creds, null, 2), { mode: 0o600 });
45
+ // Ensure permissions are correct even if file existed
46
+ chmodSync(CREDENTIALS_PATH, 0o600);
47
+ }
48
+ /**
49
+ * Clear credentials (logout)
50
+ */
51
+ export function clearCredentials() {
52
+ if (existsSync(CREDENTIALS_PATH)) {
53
+ unlinkSync(CREDENTIALS_PATH);
54
+ }
55
+ }
56
+ /**
57
+ * Check if user is authenticated (has valid credentials)
58
+ */
59
+ export function isAuthenticated() {
60
+ const creds = readCredentials();
61
+ return creds !== null;
62
+ }
63
+ /**
64
+ * Check if access token is expired or will expire soon (within 5 minutes)
65
+ */
66
+ function isTokenExpiringSoon(expiresAt) {
67
+ const expiryTime = new Date(expiresAt).getTime();
68
+ const bufferTime = 5 * 60 * 1000; // 5 minutes
69
+ return Date.now() > expiryTime - bufferTime;
70
+ }
71
+ /**
72
+ * Refresh tokens using the API
73
+ * @returns New credentials or null if refresh failed
74
+ */
75
+ async function refreshTokens(refreshToken, apiUrl) {
76
+ try {
77
+ const response = await request(`${apiUrl}/auth/refresh`, {
78
+ method: 'POST',
79
+ headers: {
80
+ 'Content-Type': 'application/json',
81
+ },
82
+ body: JSON.stringify({ refresh_token: refreshToken }),
83
+ });
84
+ if (response.statusCode !== 200) {
85
+ return null;
86
+ }
87
+ const data = await response.body.json();
88
+ // Decode user info from new token (basic decode, no verification needed here)
89
+ const payload = decodeTokenPayload(data.access_token);
90
+ if (!payload) {
91
+ return null;
92
+ }
93
+ // Read existing credentials to preserve team_id and sync_enabled
94
+ const existing = readCredentials();
95
+ return {
96
+ access_token: data.access_token,
97
+ refresh_token: data.refresh_token,
98
+ expires_at: data.expires_at,
99
+ user_id: payload.sub,
100
+ email: payload.email,
101
+ team_id: existing?.team_id,
102
+ sync_enabled: existing?.sync_enabled ?? false,
103
+ };
104
+ }
105
+ catch {
106
+ return null;
107
+ }
108
+ }
109
+ /**
110
+ * Decode JWT payload without verification (for extracting user info)
111
+ * WARNING: Do not use for authentication - tokens are verified server-side
112
+ */
113
+ function decodeTokenPayload(token) {
114
+ try {
115
+ const parts = token.split('.');
116
+ if (parts.length !== 3)
117
+ return null;
118
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8'));
119
+ return {
120
+ sub: payload.sub,
121
+ email: payload.email,
122
+ };
123
+ }
124
+ catch {
125
+ return null;
126
+ }
127
+ }
128
+ /**
129
+ * Get a valid access token, refreshing if necessary
130
+ * @returns Access token or null if not authenticated
131
+ */
132
+ export async function getAccessToken() {
133
+ const creds = readCredentials();
134
+ if (!creds) {
135
+ return null;
136
+ }
137
+ // Check if token needs refresh
138
+ if (isTokenExpiringSoon(creds.expires_at)) {
139
+ const apiUrl = process.env.GROV_API_URL || 'https://api.grov.dev';
140
+ const newCreds = await refreshTokens(creds.refresh_token, apiUrl);
141
+ if (newCreds) {
142
+ writeCredentials(newCreds);
143
+ return newCreds.access_token;
144
+ }
145
+ // Refresh failed - user needs to login again
146
+ return null;
147
+ }
148
+ return creds.access_token;
149
+ }
150
+ /**
151
+ * Set the team ID for sync
152
+ */
153
+ export function setTeamId(teamId) {
154
+ const creds = readCredentials();
155
+ if (!creds) {
156
+ throw new Error('Not authenticated. Please run: grov login');
157
+ }
158
+ writeCredentials({
159
+ ...creds,
160
+ team_id: teamId,
161
+ });
162
+ }
163
+ /**
164
+ * Enable or disable sync
165
+ */
166
+ export function setSyncEnabled(enabled) {
167
+ const creds = readCredentials();
168
+ if (!creds) {
169
+ throw new Error('Not authenticated. Please run: grov login');
170
+ }
171
+ writeCredentials({
172
+ ...creds,
173
+ sync_enabled: enabled,
174
+ });
175
+ }
176
+ /**
177
+ * Get current sync status
178
+ */
179
+ export function getSyncStatus() {
180
+ const creds = readCredentials();
181
+ if (!creds) {
182
+ return null;
183
+ }
184
+ return {
185
+ enabled: creds.sync_enabled,
186
+ teamId: creds.team_id,
187
+ };
188
+ }
189
+ /**
190
+ * Get current user info
191
+ */
192
+ export function getCurrentUser() {
193
+ const creds = readCredentials();
194
+ if (!creds) {
195
+ return null;
196
+ }
197
+ return {
198
+ id: creds.user_id,
199
+ email: creds.email,
200
+ };
201
+ }
@@ -58,7 +58,7 @@ export declare function isSummaryAvailable(): boolean;
58
58
  * Generate session summary for CLEAR operation
59
59
  * Reference: plan_proxy_local.md Section 2.3, 4.5
60
60
  */
61
- export declare function generateSessionSummary(sessionState: SessionState, steps: StepRecord[]): Promise<string>;
61
+ export declare function generateSessionSummary(sessionState: SessionState, steps: StepRecord[], maxTokens?: number): Promise<string>;
62
62
  /**
63
63
  * Task analysis result from Haiku
64
64
  */
@@ -456,23 +456,30 @@ export function isSummaryAvailable() {
456
456
  * Generate session summary for CLEAR operation
457
457
  * Reference: plan_proxy_local.md Section 2.3, 4.5
458
458
  */
459
- export async function generateSessionSummary(sessionState, steps) {
459
+ export async function generateSessionSummary(sessionState, steps, maxTokens = 800 // Default 800, CLEAR mode uses 15000
460
+ ) {
460
461
  const client = getAnthropicClient();
462
+ // For larger summaries, include more steps
463
+ const stepLimit = maxTokens > 5000 ? 50 : 20;
464
+ const wordLimit = Math.min(Math.floor(maxTokens / 2), 10000); // ~2 tokens per word
461
465
  const stepsText = steps
462
466
  .filter(s => s.is_validated)
463
- .slice(-20)
467
+ .slice(-stepLimit)
464
468
  .map(step => {
465
469
  let desc = `- ${step.action_type}`;
466
470
  if (step.files.length > 0) {
467
471
  desc += `: ${step.files.join(', ')}`;
468
472
  }
469
473
  if (step.command) {
470
- desc += ` (${step.command.substring(0, 50)})`;
474
+ desc += ` (${step.command.substring(0, 100)})`;
475
+ }
476
+ if (step.reasoning && maxTokens > 5000) {
477
+ desc += `\n Reasoning: ${step.reasoning.substring(0, 200)}`;
471
478
  }
472
479
  return desc;
473
480
  })
474
481
  .join('\n');
475
- const prompt = `Create a concise summary of this coding session for context continuation.
482
+ const prompt = `Create a ${maxTokens > 5000 ? 'comprehensive' : 'concise'} summary of this coding session for context continuation.
476
483
 
477
484
  ORIGINAL GOAL: ${sessionState.original_goal || 'Not specified'}
478
485
 
@@ -483,18 +490,19 @@ CONSTRAINTS: ${sessionState.constraints.join(', ') || 'None'}
483
490
  ACTIONS TAKEN:
484
491
  ${stepsText || 'No actions recorded'}
485
492
 
486
- Create a summary with these sections (keep total under 500 words):
487
- 1. ORIGINAL GOAL: (1 sentence)
488
- 2. PROGRESS: (2-3 bullet points of what was accomplished)
489
- 3. KEY DECISIONS: (any important choices made)
490
- 4. FILES MODIFIED: (list of files)
491
- 5. CURRENT STATE: (where the work left off)
492
- 6. NEXT STEPS: (recommended next actions)
493
+ Create a summary with these sections (keep total under ${wordLimit} words):
494
+ 1. ORIGINAL GOAL: (1-2 sentences)
495
+ 2. PROGRESS: (${maxTokens > 5000 ? '5-10' : '2-3'} bullet points of what was accomplished)
496
+ 3. KEY DECISIONS: (important architectural/design choices made, with reasoning)
497
+ 4. FILES MODIFIED: (list of files with brief description of changes)
498
+ 5. CURRENT STATE: (detailed status of where the work left off)
499
+ 6. NEXT STEPS: (recommended next actions to continue)
500
+ ${maxTokens > 5000 ? '7. IMPORTANT CONTEXT: (any critical information that must not be lost)' : ''}
493
501
 
494
502
  Format as plain text, not JSON.`;
495
503
  const response = await client.messages.create({
496
504
  model: 'claude-haiku-4-5-20251001',
497
- max_tokens: 800,
505
+ max_tokens: maxTokens,
498
506
  messages: [{ role: 'user', content: prompt }],
499
507
  });
500
508
  const content = response.content?.[0];
@@ -22,6 +22,8 @@ export interface Task {
22
22
  turn_number?: number;
23
23
  tags: string[];
24
24
  created_at: string;
25
+ synced_at?: string | null;
26
+ sync_error?: string | null;
25
27
  }
26
28
  export interface CreateTaskInput {
27
29
  project_path: string;
@@ -89,6 +91,11 @@ interface ProxyFields {
89
91
  completed_at?: string;
90
92
  parent_session_id?: string;
91
93
  task_type?: TaskType;
94
+ pending_correction?: string;
95
+ pending_forced_recovery?: string;
96
+ pending_clear_summary?: string;
97
+ cached_injection?: string;
98
+ final_response?: string;
92
99
  }
93
100
  export interface SessionState extends SessionStateBase, HookFields, ProxyFields {
94
101
  }
@@ -217,6 +224,18 @@ export declare function updateTaskStatus(id: string, status: TaskStatus): void;
217
224
  * Get task count for a project
218
225
  */
219
226
  export declare function getTaskCount(projectPath: string): number;
227
+ /**
228
+ * Get unsynced tasks for a project (synced_at is NULL)
229
+ */
230
+ export declare function getUnsyncedTasks(projectPath: string, limit?: number): Task[];
231
+ /**
232
+ * Mark a task as synced and clear any previous sync error
233
+ */
234
+ export declare function markTaskSynced(id: string): void;
235
+ /**
236
+ * Record a sync error for a task
237
+ */
238
+ export declare function setTaskSyncError(id: string, error: string): void;
220
239
  /**
221
240
  * Create a new session state.
222
241
  * FIXED: Uses INSERT OR IGNORE to handle race conditions safely.
@@ -306,6 +325,16 @@ export declare function getRecentSteps(sessionId: string, count?: number): StepR
306
325
  * Get validated steps only (for summary generation)
307
326
  */
308
327
  export declare function getValidatedSteps(sessionId: string): StepRecord[];
328
+ /**
329
+ * Get key decision steps for a session (is_key_decision = 1)
330
+ * Used for user message injection - important decisions with reasoning
331
+ */
332
+ export declare function getKeyDecisions(sessionId: string, limit?: number): StepRecord[];
333
+ /**
334
+ * Get edited files for a session (action_type IN ('edit', 'write'))
335
+ * Used for user message injection - prevent re-work
336
+ */
337
+ export declare function getEditedFiles(sessionId: string): string[];
309
338
  /**
310
339
  * Delete steps for a session
311
340
  */
@@ -346,9 +375,10 @@ export declare function getStepsByKeywords(sessionId: string, keywords: string[]
346
375
  export declare function getKeyDecisionSteps(sessionId: string, limit?: number): StepRecord[];
347
376
  /**
348
377
  * Get steps reasoning by file path (for proxy team memory injection)
349
- * Searches across ALL sessions, returns file-level reasoning from steps table
378
+ * Searches across sessions, returns file-level reasoning from steps table
379
+ * @param excludeSessionId - Optional session ID to exclude (for filtering current session)
350
380
  */
351
- export declare function getStepsReasoningByPath(filePath: string, limit?: number): Array<{
381
+ export declare function getStepsReasoningByPath(filePath: string, limit?: number, excludeSessionId?: string): Array<{
352
382
  file_path: string;
353
383
  reasoning: string;
354
384
  anchor?: string;