@benzsiangco/jarvis 1.0.2 → 1.1.0

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 (53) hide show
  1. package/dist/cli.js +478 -347
  2. package/dist/electron/main.js +160 -0
  3. package/dist/electron/preload.js +19 -0
  4. package/package.json +19 -6
  5. package/skills.md +147 -0
  6. package/src/agents/index.ts +248 -0
  7. package/src/brain/loader.ts +136 -0
  8. package/src/cli.ts +411 -0
  9. package/src/config/index.ts +363 -0
  10. package/src/core/executor.ts +222 -0
  11. package/src/core/plugins.ts +148 -0
  12. package/src/core/types.ts +217 -0
  13. package/src/electron/main.ts +192 -0
  14. package/src/electron/preload.ts +25 -0
  15. package/src/electron/types.d.ts +20 -0
  16. package/src/index.ts +12 -0
  17. package/src/providers/antigravity-loader.ts +233 -0
  18. package/src/providers/antigravity.ts +585 -0
  19. package/src/providers/index.ts +523 -0
  20. package/src/sessions/index.ts +194 -0
  21. package/src/tools/index.ts +436 -0
  22. package/src/tui/index.tsx +784 -0
  23. package/src/utils/auth-prompt.ts +394 -0
  24. package/src/utils/index.ts +180 -0
  25. package/src/utils/native-picker.ts +71 -0
  26. package/src/utils/skills.ts +99 -0
  27. package/src/utils/table-integration-examples.ts +617 -0
  28. package/src/utils/table-utils.ts +401 -0
  29. package/src/web/build-ui.ts +27 -0
  30. package/src/web/server.ts +674 -0
  31. package/src/web/ui/dist/.gitkeep +0 -0
  32. package/src/web/ui/dist/main.css +1 -0
  33. package/src/web/ui/dist/main.js +320 -0
  34. package/src/web/ui/dist/main.js.map +20 -0
  35. package/src/web/ui/index.html +46 -0
  36. package/src/web/ui/src/App.tsx +143 -0
  37. package/src/web/ui/src/Modules/Safety/GuardianModal.tsx +83 -0
  38. package/src/web/ui/src/components/Layout/ContextPanel.tsx +243 -0
  39. package/src/web/ui/src/components/Layout/Header.tsx +91 -0
  40. package/src/web/ui/src/components/Layout/ModelSelector.tsx +235 -0
  41. package/src/web/ui/src/components/Layout/SessionStats.tsx +369 -0
  42. package/src/web/ui/src/components/Layout/Sidebar.tsx +895 -0
  43. package/src/web/ui/src/components/Modules/Chat/ChatStage.tsx +620 -0
  44. package/src/web/ui/src/components/Modules/Chat/MessageItem.tsx +446 -0
  45. package/src/web/ui/src/components/Modules/Editor/CommandInspector.tsx +71 -0
  46. package/src/web/ui/src/components/Modules/Editor/DiffViewer.tsx +83 -0
  47. package/src/web/ui/src/components/Modules/Terminal/TabbedTerminal.tsx +202 -0
  48. package/src/web/ui/src/components/Settings/SettingsModal.tsx +935 -0
  49. package/src/web/ui/src/config/models.ts +70 -0
  50. package/src/web/ui/src/main.tsx +13 -0
  51. package/src/web/ui/src/store/agentStore.ts +41 -0
  52. package/src/web/ui/src/store/uiStore.ts +64 -0
  53. package/src/web/ui/src/types/index.ts +54 -0
@@ -0,0 +1,523 @@
1
+ // Provider abstraction layer for Jarvis
2
+ // Supports: Anthropic, OpenAI, Google, Groq, Mistral, Cohere, and more
3
+ import { createAnthropic } from '@ai-sdk/anthropic';
4
+ import { createOpenAI } from '@ai-sdk/openai';
5
+ import { createGoogleGenerativeAI } from '@ai-sdk/google';
6
+ import type { LanguageModel } from 'ai';
7
+ import type { JarvisConfig } from '../core/types';
8
+ import { parseModelId } from '../config';
9
+ import { existsSync, readFileSync } from 'fs';
10
+ import { join } from 'path';
11
+ import { homedir } from 'os';
12
+ import {
13
+ getNextAccount,
14
+ trackAccountUsage,
15
+ markAccountRateLimited,
16
+ clearExpiredRateLimits,
17
+ hasAntigravityAccounts,
18
+ getAccountWithValidToken,
19
+ refreshAccountToken,
20
+ type AntigravityAccount
21
+ } from './antigravity';
22
+
23
+ // Antigravity API endpoints
24
+ const ANTIGRAVITY_ENDPOINTS = [
25
+ 'https://daily-cloudcode-pa.sandbox.googleapis.com',
26
+ 'https://autopush-cloudcode-pa.sandbox.googleapis.com',
27
+ 'https://cloudcode-pa.googleapis.com',
28
+ ];
29
+
30
+ // Load saved API keys from config
31
+ const API_KEYS_FILE = join(homedir(), '.config', 'jarvis', 'api-keys.json');
32
+
33
+ function loadSavedApiKeys(): Record<string, string> {
34
+ if (!existsSync(API_KEYS_FILE)) {
35
+ return {};
36
+ }
37
+ try {
38
+ return JSON.parse(readFileSync(API_KEYS_FILE, 'utf-8'));
39
+ } catch {
40
+ return {};
41
+ }
42
+ }
43
+
44
+ // Get API key from saved keys or environment
45
+ function getApiKey(envVar: string, savedKeyId?: string): string | undefined {
46
+ // Environment variable takes precedence
47
+ const envKey = process.env[envVar];
48
+ if (envKey) return envKey;
49
+
50
+ // Fall back to saved keys
51
+ if (savedKeyId) {
52
+ const savedKeys = loadSavedApiKeys();
53
+ return savedKeys[savedKeyId];
54
+ }
55
+
56
+ return undefined;
57
+ }
58
+
59
+ export type ProviderType =
60
+ | 'anthropic'
61
+ | 'openai'
62
+ | 'google'
63
+ | 'groq'
64
+ | 'mistral'
65
+ | 'cohere'
66
+ | 'together'
67
+ | 'perplexity'
68
+ | 'fireworks'
69
+ | 'deepseek'
70
+ | 'xai'
71
+ | 'local';
72
+
73
+ interface ProviderInstance {
74
+ id: ProviderType;
75
+ name: string;
76
+ createModel: (modelId: string, options?: Record<string, unknown>) => LanguageModel;
77
+ isAvailable: boolean;
78
+ }
79
+
80
+ // Provider registry
81
+ const providers = new Map<ProviderType, ProviderInstance>();
82
+
83
+ // Track current Antigravity account for rotation
84
+ let currentAntigravityAccount: AntigravityAccount | undefined;
85
+
86
+ // Initialize providers based on available API keys
87
+ export async function initializeProviders(config: JarvisConfig): Promise<void> {
88
+ providers.clear();
89
+ clearExpiredRateLimits();
90
+
91
+ // Anthropic
92
+ const anthropicKey = getApiKey('ANTHROPIC_API_KEY', 'anthropic');
93
+ if (anthropicKey) {
94
+ const anthropic = createAnthropic({ apiKey: anthropicKey });
95
+ providers.set('anthropic', {
96
+ id: 'anthropic',
97
+ name: 'Anthropic',
98
+ createModel: (modelId, options) => anthropic(modelId, options),
99
+ isAvailable: true,
100
+ });
101
+ }
102
+
103
+ // OpenAI
104
+ const openaiKey = getApiKey('OPENAI_API_KEY', 'openai');
105
+ if (openaiKey) {
106
+ const openai = createOpenAI({ apiKey: openaiKey });
107
+ providers.set('openai', {
108
+ id: 'openai',
109
+ name: 'OpenAI',
110
+ createModel: (modelId, options) => openai(modelId, options),
111
+ isAvailable: true,
112
+ });
113
+ }
114
+
115
+ // Google AI (standard API key) - only if no Antigravity accounts
116
+ const googleKey = getApiKey('GOOGLE_API_KEY', 'google') || process.env['GOOGLE_GENERATIVE_AI_API_KEY'];
117
+
118
+ // For now, prefer API key auth as Antigravity OAuth requires complex request transformation
119
+ if (googleKey) {
120
+ const google = createGoogleGenerativeAI({ apiKey: googleKey });
121
+ providers.set('google', {
122
+ id: 'google',
123
+ name: 'Google AI',
124
+ createModel: (modelId, options) => {
125
+ const mappedModel = mapAntigravityModel(modelId);
126
+ // Extract variant and apply thinking config if present
127
+ const variant = options?.variant as string | undefined;
128
+ let finalOptions = { ...options };
129
+
130
+ if (variant) {
131
+ const { providerId, modelId: baseModelId } = parseModelId(modelId);
132
+ const modelCfg = config.provider?.[providerId]?.models?.[baseModelId];
133
+ const variantCfg = modelCfg?.variants?.[variant];
134
+ if (variantCfg) {
135
+ finalOptions = { ...finalOptions, ...variantCfg };
136
+ }
137
+ }
138
+
139
+ return google(mappedModel, finalOptions);
140
+ },
141
+ isAvailable: true,
142
+ });
143
+ } else if (hasAntigravityAccounts()) {
144
+ // Antigravity OAuth - requires request transformation
145
+ // For now, show as available but warn user they need API key for full functionality
146
+ await initializeAntigravityProvider();
147
+ }
148
+
149
+ // Groq
150
+ const groqKey = getApiKey('GROQ_API_KEY', 'groq');
151
+ if (groqKey) {
152
+ const groq = createOpenAI({
153
+ apiKey: groqKey,
154
+ baseURL: 'https://api.groq.com/openai/v1'
155
+ });
156
+ providers.set('groq', {
157
+ id: 'groq',
158
+ name: 'Groq',
159
+ createModel: (modelId, options) => groq(modelId, options),
160
+ isAvailable: true,
161
+ });
162
+ }
163
+
164
+ // Mistral
165
+ const mistralKey = getApiKey('MISTRAL_API_KEY', 'mistral');
166
+ if (mistralKey) {
167
+ const mistral = createOpenAI({
168
+ apiKey: mistralKey,
169
+ baseURL: 'https://api.mistral.ai/v1'
170
+ });
171
+ providers.set('mistral', {
172
+ id: 'mistral',
173
+ name: 'Mistral',
174
+ createModel: (modelId, options) => mistral(modelId, options),
175
+ isAvailable: true,
176
+ });
177
+ }
178
+
179
+ // Together AI
180
+ const togetherKey = getApiKey('TOGETHER_API_KEY', 'together');
181
+ if (togetherKey) {
182
+ const together = createOpenAI({
183
+ apiKey: togetherKey,
184
+ baseURL: 'https://api.together.xyz/v1'
185
+ });
186
+ providers.set('together', {
187
+ id: 'together',
188
+ name: 'Together AI',
189
+ createModel: (modelId, options) => together(modelId, options),
190
+ isAvailable: true,
191
+ });
192
+ }
193
+
194
+ // Perplexity
195
+ const perplexityKey = getApiKey('PERPLEXITY_API_KEY', 'perplexity');
196
+ if (perplexityKey) {
197
+ const perplexity = createOpenAI({
198
+ apiKey: perplexityKey,
199
+ baseURL: 'https://api.perplexity.ai'
200
+ });
201
+ providers.set('perplexity', {
202
+ id: 'perplexity',
203
+ name: 'Perplexity',
204
+ createModel: (modelId, options) => perplexity(modelId, options),
205
+ isAvailable: true,
206
+ });
207
+ }
208
+
209
+ // Fireworks
210
+ const fireworksKey = getApiKey('FIREWORKS_API_KEY', 'fireworks');
211
+ if (fireworksKey) {
212
+ const fireworks = createOpenAI({
213
+ apiKey: fireworksKey,
214
+ baseURL: 'https://api.fireworks.ai/inference/v1'
215
+ });
216
+ providers.set('fireworks', {
217
+ id: 'fireworks',
218
+ name: 'Fireworks',
219
+ createModel: (modelId, options) => fireworks(modelId, options),
220
+ isAvailable: true,
221
+ });
222
+ }
223
+
224
+ // DeepSeek
225
+ const deepseekKey = getApiKey('DEEPSEEK_API_KEY', 'deepseek');
226
+ if (deepseekKey) {
227
+ const deepseek = createOpenAI({
228
+ apiKey: deepseekKey,
229
+ baseURL: 'https://api.deepseek.com/v1'
230
+ });
231
+ providers.set('deepseek', {
232
+ id: 'deepseek',
233
+ name: 'DeepSeek',
234
+ createModel: (modelId, options) => deepseek(modelId, options),
235
+ isAvailable: true,
236
+ });
237
+ }
238
+
239
+ // xAI (Grok)
240
+ const xaiKey = getApiKey('XAI_API_KEY', 'xai');
241
+ if (xaiKey) {
242
+ const xai = createOpenAI({
243
+ apiKey: xaiKey,
244
+ baseURL: 'https://api.x.ai/v1'
245
+ });
246
+ providers.set('xai', {
247
+ id: 'xai',
248
+ name: 'xAI',
249
+ createModel: (modelId, options) => xai(modelId, options),
250
+ isAvailable: true,
251
+ });
252
+ }
253
+
254
+ // Local LLM (OpenAI Compatible - Ollama, LM Studio, etc.)
255
+ // Does not require API key strictly, but checks for base URL
256
+ const localUrl = getApiKey('LOCAL_API_URL', 'local') || process.env['LOCAL_API_URL'] || 'http://localhost:11434/v1';
257
+ // We register it even if no custom key, using default localhost
258
+ const local = createOpenAI({
259
+ apiKey: 'not-needed',
260
+ baseURL: localUrl,
261
+ });
262
+ providers.set('local', {
263
+ id: 'local',
264
+ name: 'Local LLM',
265
+ createModel: (modelId, options) => local(modelId, options),
266
+ isAvailable: true,
267
+ });
268
+ }
269
+
270
+ // Initialize Antigravity provider with OAuth tokens using the plugin's loader
271
+ async function initializeAntigravityProvider(): Promise<void> {
272
+ try {
273
+ // Use the plugin's loader to get the custom fetch function
274
+ const { initializeAntigravityLoader } = await import('./antigravity-loader.js');
275
+ const antigravityFetch = await initializeAntigravityLoader();
276
+
277
+ // Register Antigravity-enabled provider using Google Generative AI SDK
278
+ const google = createGoogleGenerativeAI({
279
+ baseURL: 'https://generativelanguage.googleapis.com/v1beta',
280
+ apiKey: '', // Empty - OAuth handled by custom fetch
281
+ fetch: antigravityFetch as typeof fetch,
282
+ });
283
+
284
+ // This provider handles google/* models
285
+ providers.set('google', {
286
+ id: 'google',
287
+ name: 'Google (Antigravity)',
288
+ createModel: (modelId, options) => {
289
+ // Use model name as-is - plugin handles the transformation
290
+ return google(modelId, options);
291
+ },
292
+ isAvailable: true,
293
+ });
294
+ } catch (error) {
295
+ console.error('Failed to initialize Antigravity provider:', error);
296
+ }
297
+ }
298
+
299
+ // Map Antigravity model names to actual model identifiers
300
+ // For Antigravity Cloud Code endpoint, use model names as-is
301
+ function mapAntigravityModel(modelId: string): string {
302
+ // Don't transform - Antigravity handles these model names directly
303
+ return modelId;
304
+ }
305
+
306
+ // Refresh Antigravity account (call before requests)
307
+ export function refreshAntigravityAccount(): AntigravityAccount | undefined {
308
+ clearExpiredRateLimits();
309
+ const account = getNextAccount();
310
+ if (account) {
311
+ currentAntigravityAccount = account;
312
+ }
313
+ return account;
314
+ }
315
+
316
+ // Get current Antigravity account
317
+ export function getCurrentAntigravityAccount(): AntigravityAccount | undefined {
318
+ return currentAntigravityAccount;
319
+ }
320
+
321
+ export function getProvider(providerId: ProviderType): ProviderInstance | undefined {
322
+ return providers.get(providerId);
323
+ }
324
+
325
+ export function getModel(modelId: string, options?: Record<string, unknown>): LanguageModel | undefined {
326
+ const { providerId, modelId: model } = parseModelId(modelId);
327
+ const provider = providers.get(providerId as ProviderType);
328
+
329
+ if (!provider) {
330
+ return undefined;
331
+ }
332
+
333
+ return provider.createModel(model, options);
334
+ }
335
+
336
+ export function listProviders(): ProviderType[] {
337
+ return Array.from(providers.keys());
338
+ }
339
+
340
+ export function hasProvider(providerId: string): boolean {
341
+ return providers.has(providerId as ProviderType);
342
+ }
343
+
344
+ // Get all available providers with status
345
+ export function getProvidersWithStatus(): Array<{
346
+ id: ProviderType;
347
+ name: string;
348
+ isAvailable: boolean;
349
+ envVar: string;
350
+ maskedKey?: string;
351
+ }> {
352
+ // Helper to mask key
353
+ const mask = (key?: string) => {
354
+ if (!key) return undefined;
355
+ if (key.length < 8) return '********';
356
+ return `${key.substring(0, 3)}...${key.substring(key.length - 4)}`;
357
+ };
358
+
359
+ const allProviders: Array<{
360
+ id: ProviderType;
361
+ name: string;
362
+ isAvailable: boolean;
363
+ envVar: string;
364
+ maskedKey?: string;
365
+ }> = [
366
+ {
367
+ id: 'anthropic',
368
+ name: 'Anthropic (Claude)',
369
+ isAvailable: providers.has('anthropic'),
370
+ envVar: 'ANTHROPIC_API_KEY',
371
+ maskedKey: mask(getApiKey('ANTHROPIC_API_KEY', 'anthropic'))
372
+ },
373
+ {
374
+ id: 'openai',
375
+ name: 'OpenAI (GPT)',
376
+ isAvailable: providers.has('openai'),
377
+ envVar: 'OPENAI_API_KEY',
378
+ maskedKey: mask(getApiKey('OPENAI_API_KEY', 'openai'))
379
+ },
380
+ {
381
+ id: 'google',
382
+ name: 'Google (Gemini)',
383
+ isAvailable: providers.has('google'),
384
+ envVar: 'GOOGLE_API_KEY or OAuth',
385
+ maskedKey: mask(getApiKey('GOOGLE_API_KEY', 'google'))
386
+ },
387
+ {
388
+ id: 'groq',
389
+ name: 'Groq',
390
+ isAvailable: providers.has('groq'),
391
+ envVar: 'GROQ_API_KEY',
392
+ maskedKey: mask(getApiKey('GROQ_API_KEY', 'groq'))
393
+ },
394
+ {
395
+ id: 'mistral',
396
+ name: 'Mistral',
397
+ isAvailable: providers.has('mistral'),
398
+ envVar: 'MISTRAL_API_KEY',
399
+ maskedKey: mask(getApiKey('MISTRAL_API_KEY', 'mistral'))
400
+ },
401
+ {
402
+ id: 'together',
403
+ name: 'Together AI',
404
+ isAvailable: providers.has('together'),
405
+ envVar: 'TOGETHER_API_KEY',
406
+ maskedKey: mask(getApiKey('TOGETHER_API_KEY', 'together'))
407
+ },
408
+ {
409
+ id: 'perplexity',
410
+ name: 'Perplexity',
411
+ isAvailable: providers.has('perplexity'),
412
+ envVar: 'PERPLEXITY_API_KEY',
413
+ maskedKey: mask(getApiKey('PERPLEXITY_API_KEY', 'perplexity'))
414
+ },
415
+ {
416
+ id: 'fireworks',
417
+ name: 'Fireworks',
418
+ isAvailable: providers.has('fireworks'),
419
+ envVar: 'FIREWORKS_API_KEY',
420
+ maskedKey: mask(getApiKey('FIREWORKS_API_KEY', 'fireworks'))
421
+ },
422
+ {
423
+ id: 'deepseek',
424
+ name: 'DeepSeek',
425
+ isAvailable: providers.has('deepseek'),
426
+ envVar: 'DEEPSEEK_API_KEY',
427
+ maskedKey: mask(getApiKey('DEEPSEEK_API_KEY', 'deepseek'))
428
+ },
429
+ {
430
+ id: 'xai',
431
+ name: 'xAI (Grok)',
432
+ isAvailable: providers.has('xai'),
433
+ envVar: 'XAI_API_KEY',
434
+ maskedKey: mask(getApiKey('XAI_API_KEY', 'xai'))
435
+ },
436
+ {
437
+ id: 'local',
438
+ name: 'Local (LM Studio / Ollama)',
439
+ isAvailable: providers.has('local'),
440
+ envVar: 'LOCAL_API_URL',
441
+ maskedKey: getApiKey('LOCAL_API_URL', 'local') || 'http://localhost:11434/v1'
442
+ },
443
+ ];
444
+
445
+ return allProviders;
446
+ }
447
+
448
+ // Fetch dynamic models from Local LLM provider (Ollama/LM Studio)
449
+ export async function fetchLocalModels(): Promise<Array<{ id: string; name: string; provider: string; isAvailable: boolean }>> {
450
+ const localUrl = getApiKey('LOCAL_API_URL', 'local') || process.env['LOCAL_API_URL'] || 'http://localhost:11434/v1';
451
+
452
+ try {
453
+ const baseUrl = localUrl.replace(/\/+$/, '');
454
+ // Handle both /v1/models (standard) and just /models if user omitted v1 but server needs it?
455
+ // Usually user provides ".../v1".
456
+ const modelsUrl = `${baseUrl}/models`;
457
+
458
+ // Add timeout to prevent hanging
459
+ const controller = new AbortController();
460
+ const timeoutId = setTimeout(() => controller.abort(), 3000); // 3 second timeout
461
+
462
+ const response = await fetch(modelsUrl, {
463
+ signal: controller.signal,
464
+ // Add some headers to make it more compatible
465
+ headers: {
466
+ 'Accept': 'application/json',
467
+ 'User-Agent': 'JARVIS-LocalLLM-Fetcher/1.0'
468
+ }
469
+ });
470
+
471
+ clearTimeout(timeoutId);
472
+
473
+ if (!response.ok) return [];
474
+
475
+ const data = await response.json();
476
+ const list = Array.isArray(data) ? data : (data.data && Array.isArray(data.data) ? data.data : []);
477
+
478
+ return list.map((m: any) => ({
479
+ id: `local/${m.id}`,
480
+ name: m.id, // Ollama/LM Studio models often use the ID as the name
481
+ provider: 'local',
482
+ isAvailable: true
483
+ }));
484
+ } catch (e) {
485
+ // Quiet fail if local is not running or times out
486
+ console.debug('[Local Models] Could not fetch:', (e as Error).message);
487
+ return [];
488
+ }
489
+ }
490
+
491
+ // Get available models from config
492
+ export function getAvailableModels(config: JarvisConfig): Array<{ id: string; name: string; provider: string; isAvailable: boolean }> {
493
+ const models: Array<{ id: string; name: string; provider: string; isAvailable: boolean }> = [];
494
+
495
+ // Always use the full provider set from config
496
+ const providersToUse = config.provider || {};
497
+
498
+ for (const [providerId, providerConfig] of Object.entries(providersToUse)) {
499
+ // Check if the provider is actually initialized in the map
500
+ const isAvailable = providers.has(providerId as ProviderType);
501
+
502
+ if (providerConfig?.models) {
503
+ for (const [modelId, modelConfig] of Object.entries(providerConfig.models)) {
504
+ models.push({
505
+ id: `${providerId}/${modelId}`,
506
+ name: modelConfig.name,
507
+ provider: providerId,
508
+ isAvailable,
509
+ });
510
+ }
511
+ }
512
+ }
513
+
514
+ // Absolute fallback if everything else fails
515
+ if (models.length === 0) {
516
+ return [
517
+ { id: 'google/antigravity-claude-sonnet-4-5', name: 'Claude Sonnet 4.5 (Antigravity)', provider: 'google', isAvailable: true },
518
+ { id: 'google/antigravity-gemini-3-pro', name: 'Gemini 3 Pro (Antigravity)', provider: 'google', isAvailable: true },
519
+ ];
520
+ }
521
+
522
+ return models;
523
+ }
@@ -0,0 +1,194 @@
1
+ // Session management for Jarvis
2
+ import { randomUUID } from 'crypto';
3
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, unlinkSync } from 'fs';
4
+ import { join } from 'path';
5
+ import { getConfigDir } from '../config';
6
+ import type { Session, Message, MessagePart } from '../core/types';
7
+
8
+ const SESSIONS_DIR = join(getConfigDir(), 'sessions');
9
+
10
+ interface TodoItem {
11
+ id: string;
12
+ content: string;
13
+ status: 'pending' | 'in_progress' | 'completed' | 'cancelled';
14
+ priority: 'high' | 'medium' | 'low';
15
+ }
16
+
17
+ interface SessionState {
18
+ todos: TodoItem[];
19
+ }
20
+
21
+ // In-memory session store
22
+ const sessions = new Map<string, Session>();
23
+ const sessionStates = new Map<string, SessionState>();
24
+
25
+ export function ensureSessionsDir(): void {
26
+ if (!existsSync(SESSIONS_DIR)) {
27
+ mkdirSync(SESSIONS_DIR, { recursive: true });
28
+ }
29
+ }
30
+
31
+ export function createSession(agentId: string, title?: string, parentId?: string, workdir?: string): Session {
32
+ const session: Session = {
33
+ id: randomUUID(),
34
+ title: title || `Session ${new Date().toLocaleString()}`,
35
+ agentId,
36
+ workdir: workdir || process.cwd(),
37
+ messages: [],
38
+ createdAt: new Date(),
39
+ updatedAt: new Date(),
40
+ parentId,
41
+ };
42
+
43
+ sessions.set(session.id, session);
44
+ sessionStates.set(session.id, { todos: [] });
45
+ saveSession(session);
46
+
47
+ return session;
48
+ }
49
+
50
+ export function getSession(id: string): Session | undefined {
51
+ // Try memory first
52
+ let session = sessions.get(id);
53
+
54
+ if (!session) {
55
+ // Try loading from disk
56
+ session = loadSessionFromDisk(id);
57
+ if (session) {
58
+ sessions.set(id, session);
59
+ }
60
+ }
61
+
62
+ return session;
63
+ }
64
+
65
+ export function updateSession(session: Session): void {
66
+ session.updatedAt = new Date();
67
+ sessions.set(session.id, session);
68
+ saveSession(session);
69
+ }
70
+
71
+ export function deleteSession(id: string): boolean {
72
+ const session = sessions.get(id);
73
+ if (!session) return false;
74
+
75
+ sessions.delete(id);
76
+ sessionStates.delete(id);
77
+
78
+ // Delete from disk
79
+ const filePath = join(SESSIONS_DIR, `${id}.json`);
80
+ if (existsSync(filePath)) {
81
+ unlinkSync(filePath);
82
+ }
83
+
84
+ return true;
85
+ }
86
+
87
+ export function listSessions(): Session[] {
88
+ ensureSessionsDir();
89
+
90
+ // Load all sessions from disk
91
+ const files = readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.json'));
92
+
93
+ const allSessions: Session[] = [];
94
+ for (const file of files) {
95
+ const id = file.replace('.json', '');
96
+ const session = getSession(id);
97
+ if (session) {
98
+ allSessions.push(session);
99
+ }
100
+ }
101
+
102
+ // Sort by updated date, newest first
103
+ return allSessions.sort((a, b) =>
104
+ new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
105
+ );
106
+ }
107
+
108
+ export function addMessage(sessionId: string, message: Omit<Message, 'id' | 'timestamp'>): Message {
109
+ const session = getSession(sessionId);
110
+ if (!session) {
111
+ throw new Error(`Session not found: ${sessionId}`);
112
+ }
113
+
114
+ const fullMessage: Message = {
115
+ ...message,
116
+ id: randomUUID(),
117
+ timestamp: new Date(),
118
+ };
119
+
120
+ session.messages.push(fullMessage);
121
+ updateSession(session);
122
+
123
+ return fullMessage;
124
+ }
125
+
126
+ export function getMessages(sessionId: string): Message[] {
127
+ const session = getSession(sessionId);
128
+ return session?.messages || [];
129
+ }
130
+
131
+ // Session state management (todos, etc.)
132
+ export function getSessionState(sessionId: string): SessionState {
133
+ return sessionStates.get(sessionId) || { todos: [] };
134
+ }
135
+
136
+ export function setTodos(sessionId: string, todos: TodoItem[]): void {
137
+ const state = getSessionState(sessionId);
138
+ state.todos = todos;
139
+ sessionStates.set(sessionId, state);
140
+ }
141
+
142
+ export function getTodos(sessionId: string): TodoItem[] {
143
+ return getSessionState(sessionId).todos;
144
+ }
145
+
146
+ // Persistence
147
+ function saveSession(session: Session): void {
148
+ ensureSessionsDir();
149
+ const filePath = join(SESSIONS_DIR, `${session.id}.json`);
150
+ writeFileSync(filePath, JSON.stringify(session, null, 2));
151
+ }
152
+
153
+ function loadSessionFromDisk(id: string): Session | undefined {
154
+ ensureSessionsDir();
155
+ const filePath = join(SESSIONS_DIR, `${id}.json`);
156
+
157
+ if (!existsSync(filePath)) {
158
+ return undefined;
159
+ }
160
+
161
+ try {
162
+ const data = readFileSync(filePath, 'utf-8');
163
+ const session = JSON.parse(data) as Session;
164
+
165
+ // Convert date strings to Date objects
166
+ session.createdAt = new Date(session.createdAt);
167
+ session.updatedAt = new Date(session.updatedAt);
168
+ session.messages.forEach(m => {
169
+ m.timestamp = new Date(m.timestamp);
170
+ });
171
+
172
+ return session;
173
+ } catch (error) {
174
+ console.error(`Error loading session ${id}:`, error);
175
+ return undefined;
176
+ }
177
+ }
178
+
179
+ // Get child sessions
180
+ export function getChildSessions(parentId: string): Session[] {
181
+ return listSessions().filter(s => s.parentId === parentId);
182
+ }
183
+
184
+ // Clear all sessions (for testing/reset)
185
+ export function clearAllSessions(): void {
186
+ sessions.clear();
187
+ sessionStates.clear();
188
+
189
+ ensureSessionsDir();
190
+ const files = readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.json'));
191
+ for (const file of files) {
192
+ unlinkSync(join(SESSIONS_DIR, file));
193
+ }
194
+ }