osborn 0.1.6 → 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/dist/config.js CHANGED
@@ -1,29 +1,99 @@
1
- import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'fs';
1
+ import { readFileSync, existsSync, mkdirSync, writeFileSync, renameSync } from 'fs';
2
2
  import { homedir } from 'os';
3
3
  import { join } from 'path';
4
4
  import { parse, stringify } from 'yaml';
5
5
  // Config file paths
6
6
  const CONFIG_DIR = join(homedir(), '.osborn');
7
7
  const CONFIG_FILE = join(CONFIG_DIR, 'config.yaml');
8
+ export const MCP_CATALOG = [
9
+ // ── Cloud-hosted via Smithery Connect (no local install) ──────
10
+ // URLs are Smithery proxy endpoints: api.smithery.ai/connect/{namespace}/{connectionId}/mcp
11
+ // Note: Claude Agent SDK's type:'http' has a known bug (#18296) that forces OAuth on all
12
+ // HTTP MCP servers, so these are connected via in-process proxy (smithery-proxy.ts) as type:'sdk'.
13
+ {
14
+ name: 'GitHub', description: 'Repos, issues, PRs, code search (40 tools)',
15
+ serverKey: 'github', category: 'code',
16
+ transport: 'http',
17
+ url: 'https://api.smithery.ai/connect/deer-y2fs/github-87nz/mcp',
18
+ requiredHeaders: ['SMITHERY_API_KEY'],
19
+ },
20
+ {
21
+ name: 'YouTube', description: 'Video search, transcripts, channels, playlists (7 tools)',
22
+ serverKey: 'youtube', category: 'web',
23
+ transport: 'http',
24
+ url: 'https://api.smithery.ai/connect/deer-y2fs/youtube-mcp-sfiorini-TRmB/mcp',
25
+ requiredHeaders: ['SMITHERY_API_KEY'],
26
+ },
27
+ // ── Local (stdio) ────────────────────────────────────────────
28
+ {
29
+ name: 'Filesystem', description: 'Access directories outside working dir',
30
+ serverKey: 'filesystem', category: 'utility',
31
+ command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp'],
32
+ },
33
+ {
34
+ name: 'Playwright', description: 'Browser automation & web scraping',
35
+ serverKey: 'playwright', category: 'web',
36
+ command: 'npx', args: ['-y', '@anthropic-ai/mcp-server-playwright'],
37
+ },
38
+ ];
8
39
  // Default config template
9
40
  const DEFAULT_CONFIG = {
10
41
  workingDirectory: process.cwd(),
11
- defaultProvider: 'openai',
42
+ defaultProvider: 'gemini',
12
43
  defaultCodingAgent: 'claude',
44
+ // Voice mode: 'direct' (Claude Agent SDK) or 'realtime' (OpenAI/Gemini native)
45
+ voiceMode: 'direct',
46
+ // Realtime mode config (used when voiceMode='realtime')
47
+ realtime: {
48
+ provider: 'openai',
49
+ openaiVoice: 'alloy',
50
+ geminiVoice: 'Aoede',
51
+ geminiModel: 'gemini-2.5-flash-native-audio-preview-12-2025',
52
+ },
53
+ // Direct mode config (used when voiceMode='direct')
54
+ direct: {
55
+ stt: {
56
+ provider: 'deepgram',
57
+ model: 'nova-3',
58
+ },
59
+ tts: {
60
+ provider: 'deepgram',
61
+ voice: 'aura-asteria-en',
62
+ },
63
+ },
13
64
  mcpServers: {
14
- // Example MCP servers (disabled by default)
65
+ // ─────────────────────────────────────────────────────────────────────────
66
+ // MCP Servers for Read-Only Plan Mode
67
+ // These extend Claude's capabilities with external tools
68
+ // Enable by setting 'enabled: true' and providing required env vars
69
+ // ─────────────────────────────────────────────────────────────────────────
70
+ // GitHub MCP - Repository browsing, issues, PRs, code search
71
+ // Read-only tools: get_file_contents, list_issues, get_issue, list_pull_requests,
72
+ // get_pull_request, search_repositories, search_code, get_commit, list_commits
73
+ // Edit tools (blocked in read-only mode): create_issue, create_pull_request, push_files, etc.
15
74
  // github: {
16
75
  // enabled: true,
17
76
  // command: 'npx',
18
- // args: ['@modelcontextprotocol/server-github'],
77
+ // args: ['-y', '@modelcontextprotocol/server-github'],
19
78
  // env: {
20
- // GITHUB_TOKEN: '${GITHUB_TOKEN}',
79
+ // GITHUB_PERSONAL_ACCESS_TOKEN: '${GITHUB_TOKEN}',
21
80
  // },
22
81
  // },
82
+ // YouTube MCP - Fetch video transcripts for analysis
83
+ // Read-only tools: get_transcript
84
+ // Requires: yt-dlp installed (brew install yt-dlp or pip install yt-dlp)
85
+ // youtube: {
86
+ // enabled: true,
87
+ // command: 'npx',
88
+ // args: ['mcp-youtube'],
89
+ // },
90
+ // Filesystem MCP - Access to specific directories
91
+ // Read-only tools: read_file, list_directory, get_file_info
92
+ // Edit tools (blocked in read-only mode): write_file, create_directory, delete_file
23
93
  // filesystem: {
24
94
  // enabled: true,
25
95
  // command: 'npx',
26
- // args: ['@modelcontextprotocol/server-filesystem', '/allowed/path'],
96
+ // args: ['-y', '@modelcontextprotocol/server-filesystem', '/allowed/path'],
27
97
  // },
28
98
  },
29
99
  };
@@ -98,7 +168,12 @@ export function getMcpServers(config) {
98
168
  env: resolveEnvVars(serverConfig.env),
99
169
  };
100
170
  }
101
- // Note: SSE/HTTP transport may require different handling
171
+ else if (serverConfig.url) {
172
+ servers[name] = {
173
+ type: (serverConfig.transport || 'http'),
174
+ url: serverConfig.url,
175
+ };
176
+ }
102
177
  }
103
178
  return servers;
104
179
  }
@@ -109,9 +184,71 @@ export function getEnabledMcpServerNames(config) {
109
184
  if (!config.mcpServers)
110
185
  return [];
111
186
  return Object.entries(config.mcpServers)
112
- .filter(([_, serverConfig]) => serverConfig.enabled !== false && serverConfig.command)
187
+ .filter(([_, serverConfig]) => serverConfig.enabled !== false && (serverConfig.command || serverConfig.url))
113
188
  .map(([name, _]) => name);
114
189
  }
190
+ /**
191
+ * Get pipelined config with defaults merged
192
+ */
193
+ export function getPipelinedConfig(config) {
194
+ const defaults = DEFAULT_CONFIG.pipelined;
195
+ const userConfig = config.pipelined || {};
196
+ return {
197
+ stt: {
198
+ provider: userConfig.stt?.provider || defaults.stt.provider,
199
+ model: userConfig.stt?.model,
200
+ language: userConfig.stt?.language || 'en',
201
+ },
202
+ llm: {
203
+ provider: userConfig.llm?.provider || defaults.llm.provider,
204
+ model: userConfig.llm?.model,
205
+ },
206
+ tts: {
207
+ provider: userConfig.tts?.provider || defaults.tts.provider,
208
+ model: userConfig.tts?.model,
209
+ voice: userConfig.tts?.voice || defaults.tts.voice,
210
+ },
211
+ };
212
+ }
213
+ /**
214
+ * Get voice mode from config
215
+ */
216
+ export function getVoiceMode(config) {
217
+ return config.voiceMode || 'direct';
218
+ }
219
+ /**
220
+ * Get realtime config with defaults merged
221
+ */
222
+ export function getRealtimeConfig(config) {
223
+ const defaults = DEFAULT_CONFIG.realtime;
224
+ const userConfig = config.realtime || {};
225
+ return {
226
+ provider: userConfig.provider || defaults.provider,
227
+ openaiVoice: userConfig.openaiVoice || defaults.openaiVoice,
228
+ openaiModel: userConfig.openaiModel || 'gpt-4o-realtime-preview',
229
+ geminiVoice: userConfig.geminiVoice || defaults.geminiVoice,
230
+ geminiModel: userConfig.geminiModel || defaults.geminiModel,
231
+ };
232
+ }
233
+ /**
234
+ * Get direct mode config with defaults merged
235
+ */
236
+ export function getDirectConfig(config) {
237
+ const defaults = DEFAULT_CONFIG.direct;
238
+ const userConfig = config.direct || {};
239
+ return {
240
+ stt: {
241
+ provider: userConfig.stt?.provider || defaults.stt.provider,
242
+ model: userConfig.stt?.model || defaults.stt.model,
243
+ language: userConfig.stt?.language || 'en',
244
+ },
245
+ tts: {
246
+ provider: userConfig.tts?.provider || defaults.tts.provider,
247
+ model: userConfig.tts?.model || defaults.tts.model,
248
+ voice: userConfig.tts?.voice || defaults.tts.voice,
249
+ },
250
+ };
251
+ }
115
252
  /**
116
253
  * Save config to file
117
254
  */
@@ -125,3 +262,633 @@ export function saveConfig(config) {
125
262
  console.error(`❌ Failed to save config: ${err.message}`);
126
263
  }
127
264
  }
265
+ /**
266
+ * Get MCP tool patterns for allowedTools based on configured servers
267
+ * Returns wildcard patterns like 'mcp__github__.*' for each enabled server
268
+ */
269
+ export function getMcpToolPatterns(config) {
270
+ if (!config.mcpServers)
271
+ return [];
272
+ return Object.entries(config.mcpServers)
273
+ .filter(([_, serverConfig]) => serverConfig.enabled !== false && (serverConfig.command || serverConfig.url))
274
+ .map(([name, _]) => `mcp__${name}__*`);
275
+ }
276
+ /**
277
+ * Get MCP server status list — merges catalog entries with user config.
278
+ * Checks env var availability so the UI can show disabled toggles.
279
+ */
280
+ export function getMcpServerStatusList(config) {
281
+ const result = [];
282
+ const seenKeys = new Set();
283
+ // 1. Catalog entries — enriched with user config overrides
284
+ for (const entry of MCP_CATALOG) {
285
+ seenKeys.add(entry.serverKey);
286
+ const userConfig = config.mcpServers?.[entry.serverKey];
287
+ const transport = entry.transport || 'stdio';
288
+ // Check required env vars (stdio) or required headers (http/sse)
289
+ const requiredVars = transport === 'stdio'
290
+ ? (entry.requiredEnvVars || [])
291
+ : (entry.requiredHeaders || []);
292
+ const missingVars = requiredVars.filter(v => !process.env[v]);
293
+ const available = missingVars.length === 0;
294
+ result.push({
295
+ serverKey: entry.serverKey,
296
+ name: entry.name,
297
+ description: entry.description,
298
+ category: entry.category,
299
+ transport,
300
+ enabled: userConfig?.enabled === true,
301
+ available,
302
+ missingEnvVars: missingVars.length > 0 ? missingVars : undefined,
303
+ source: 'catalog',
304
+ });
305
+ }
306
+ // 2. User-config-only entries (not in catalog)
307
+ if (config.mcpServers) {
308
+ for (const [key, serverConfig] of Object.entries(config.mcpServers)) {
309
+ if (seenKeys.has(key))
310
+ continue;
311
+ if (!serverConfig.command && !serverConfig.url)
312
+ continue;
313
+ const transport = serverConfig.transport || (serverConfig.url ? 'http' : 'stdio');
314
+ result.push({
315
+ serverKey: key,
316
+ name: key,
317
+ description: 'Custom MCP server',
318
+ category: 'utility',
319
+ transport,
320
+ enabled: serverConfig.enabled !== false,
321
+ available: true,
322
+ source: 'config',
323
+ });
324
+ }
325
+ }
326
+ return result;
327
+ }
328
+ /**
329
+ * Build McpServerConfig records for a given set of enabled keys.
330
+ * Merges catalog defaults with user config overrides.
331
+ */
332
+ export function buildMcpServersForKeys(config, enabledKeys) {
333
+ const servers = {};
334
+ for (const key of enabledKeys) {
335
+ // Try catalog first
336
+ const catalogEntry = MCP_CATALOG.find(e => e.serverKey === key);
337
+ // Then user config
338
+ const userConfig = config.mcpServers?.[key];
339
+ if (catalogEntry) {
340
+ const transport = userConfig?.transport || catalogEntry.transport || 'stdio';
341
+ if (transport === 'http' || transport === 'sse') {
342
+ const resolvedHeaders = resolveEnvVars(catalogEntry.headers);
343
+ const config = {
344
+ type: transport,
345
+ url: userConfig?.url || catalogEntry.url,
346
+ };
347
+ if (resolvedHeaders && Object.keys(resolvedHeaders).length > 0) {
348
+ config.headers = resolvedHeaders;
349
+ }
350
+ console.log(`🔌 MCP config for ${key}:`, JSON.stringify(config));
351
+ servers[key] = config;
352
+ }
353
+ else {
354
+ servers[key] = {
355
+ command: userConfig?.command || catalogEntry.command,
356
+ args: userConfig?.args || catalogEntry.args,
357
+ env: resolveEnvVars(userConfig?.env || catalogEntry.env),
358
+ };
359
+ }
360
+ }
361
+ else if (userConfig?.command) {
362
+ servers[key] = {
363
+ command: userConfig.command,
364
+ args: userConfig.args,
365
+ env: resolveEnvVars(userConfig.env),
366
+ };
367
+ }
368
+ else if (userConfig?.url) {
369
+ servers[key] = {
370
+ type: (userConfig.transport || 'http'),
371
+ url: userConfig.url,
372
+ };
373
+ }
374
+ }
375
+ return servers;
376
+ }
377
+ // ============================================================
378
+ // SESSION MANAGEMENT - For resuming previous conversations
379
+ // ============================================================
380
+ import * as readline from 'readline';
381
+ import { createReadStream, statSync, readdirSync } from 'fs';
382
+ /**
383
+ * Get the .claude projects directory
384
+ */
385
+ export function getClaudeProjectsDir() {
386
+ return join(homedir(), '.claude', 'projects');
387
+ }
388
+ /**
389
+ * Convert a project path to the .claude folder naming format
390
+ * e.g., /Users/foo/bar -> -Users-foo-bar
391
+ */
392
+ function projectPathToClaudeFolderName(projectPath) {
393
+ return projectPath.replace(/\//g, '-');
394
+ }
395
+ /**
396
+ * Get the session storage directory for a specific project
397
+ */
398
+ export function getSessionDir(projectPath) {
399
+ const claudeFolderName = projectPathToClaudeFolderName(projectPath);
400
+ return join(getClaudeProjectsDir(), claudeFolderName);
401
+ }
402
+ /**
403
+ * List all available sessions for the current project
404
+ */
405
+ export async function listSessions(projectPath) {
406
+ const targetPath = projectPath || process.cwd();
407
+ const sessionDir = getSessionDir(targetPath);
408
+ if (!existsSync(sessionDir)) {
409
+ return [];
410
+ }
411
+ const files = readdirSync(sessionDir);
412
+ // Session files are UUIDs ending in .jsonl (no dashes except in UUID itself)
413
+ const sessionFiles = files.filter(f => f.endsWith('.jsonl') &&
414
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jsonl$/i.test(f));
415
+ const sessions = [];
416
+ for (const file of sessionFiles) {
417
+ const sessionId = file.replace('.jsonl', '');
418
+ const filePath = join(sessionDir, file);
419
+ try {
420
+ const stats = statSync(filePath);
421
+ // Only include non-empty sessions with real conversation (> 2 messages)
422
+ if (stats.size > 0) {
423
+ const info = await getSessionPreview(filePath);
424
+ if (info.messageCount > 2) {
425
+ sessions.push({
426
+ sessionId,
427
+ projectPath: targetPath,
428
+ timestamp: stats.mtime,
429
+ lastMessage: info.lastMessage,
430
+ messageCount: info.messageCount,
431
+ filePath,
432
+ });
433
+ }
434
+ }
435
+ }
436
+ catch {
437
+ // Skip invalid sessions
438
+ }
439
+ }
440
+ // Sort by timestamp, most recent first
441
+ sessions.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
442
+ return sessions;
443
+ }
444
+ /**
445
+ * Get a preview of a session (last message and count)
446
+ */
447
+ async function getSessionPreview(filePath) {
448
+ return new Promise((resolve) => {
449
+ let messageCount = 0;
450
+ let lastUserMessage;
451
+ const fileStream = createReadStream(filePath);
452
+ const rl = readline.createInterface({
453
+ input: fileStream,
454
+ crlfDelay: Infinity,
455
+ });
456
+ rl.on('line', (line) => {
457
+ if (!line.trim())
458
+ return;
459
+ try {
460
+ const entry = JSON.parse(line);
461
+ if (entry.type === 'user' || entry.type === 'assistant') {
462
+ messageCount++;
463
+ }
464
+ if (entry.type === 'user' && entry.message?.content) {
465
+ // Extract text from user message
466
+ const content = entry.message.content;
467
+ if (typeof content === 'string') {
468
+ lastUserMessage = content.substring(0, 100);
469
+ }
470
+ else if (Array.isArray(content)) {
471
+ const textPart = content.find((p) => p.type === 'text');
472
+ if (textPart?.text) {
473
+ lastUserMessage = textPart.text.substring(0, 100);
474
+ }
475
+ }
476
+ }
477
+ }
478
+ catch {
479
+ // Skip malformed lines
480
+ }
481
+ });
482
+ rl.on('close', () => {
483
+ resolve({ lastMessage: lastUserMessage, messageCount });
484
+ });
485
+ rl.on('error', () => {
486
+ resolve({ messageCount: 0 });
487
+ });
488
+ });
489
+ }
490
+ /**
491
+ * Get the most recent session ID for the current project
492
+ */
493
+ export async function getMostRecentSessionId(projectPath) {
494
+ const sessions = await listSessions(projectPath);
495
+ return sessions.length > 0 ? sessions[0].sessionId : null;
496
+ }
497
+ /**
498
+ * Check if a session exists
499
+ */
500
+ export function sessionExists(sessionId, projectPath) {
501
+ const targetPath = projectPath || process.cwd();
502
+ const sessionDir = getSessionDir(targetPath);
503
+ const sessionFile = join(sessionDir, `${sessionId}.jsonl`);
504
+ return existsSync(sessionFile);
505
+ }
506
+ /**
507
+ * Get a summary of a session for context briefing
508
+ * Extracts last few messages and mode info for realtime agent
509
+ */
510
+ export async function getSessionSummary(sessionId, projectPath) {
511
+ const sessionDir = getSessionDir(projectPath);
512
+ const filePath = join(sessionDir, `${sessionId}.jsonl`);
513
+ if (!existsSync(filePath))
514
+ return null;
515
+ // Parse JSONL to extract last messages
516
+ const lastMessages = [];
517
+ let messageCount = 0;
518
+ return new Promise((resolve) => {
519
+ const fileStream = createReadStream(filePath);
520
+ const rl = readline.createInterface({
521
+ input: fileStream,
522
+ crlfDelay: Infinity,
523
+ });
524
+ rl.on('line', (line) => {
525
+ if (!line.trim())
526
+ return;
527
+ try {
528
+ const entry = JSON.parse(line);
529
+ if (entry.type === 'user' || entry.type === 'assistant') {
530
+ messageCount++;
531
+ }
532
+ // Collect user messages for context summary
533
+ if (entry.type === 'user' && entry.message?.content) {
534
+ const content = entry.message.content;
535
+ let text;
536
+ if (typeof content === 'string') {
537
+ text = content;
538
+ }
539
+ else if (Array.isArray(content)) {
540
+ const textPart = content.find((p) => p.type === 'text');
541
+ text = textPart?.text;
542
+ }
543
+ if (text) {
544
+ lastMessages.push(text.substring(0, 100));
545
+ }
546
+ }
547
+ }
548
+ catch {
549
+ // Skip malformed lines
550
+ }
551
+ });
552
+ rl.on('close', () => {
553
+ resolve({
554
+ sessionId,
555
+ messageCount,
556
+ lastMessages: lastMessages.slice(-5), // Last 5 user messages
557
+ });
558
+ });
559
+ rl.on('error', () => {
560
+ resolve(null);
561
+ });
562
+ });
563
+ }
564
+ /**
565
+ * Extract text content from a message (may be string or array of content blocks)
566
+ * Handles both user and assistant messages with tool_use/tool_result blocks
567
+ */
568
+ function extractTextContent(content) {
569
+ if (typeof content === 'string') {
570
+ return content;
571
+ }
572
+ if (Array.isArray(content)) {
573
+ // Collect all text blocks, skip tool_use/tool_result/image blocks
574
+ const texts = [];
575
+ for (const block of content) {
576
+ if (block && typeof block === 'object' && 'type' in block) {
577
+ if (block.type === 'text' && 'text' in block) {
578
+ texts.push(String(block.text));
579
+ }
580
+ }
581
+ }
582
+ return texts.length > 0 ? texts.join('\n') : null;
583
+ }
584
+ return null;
585
+ }
586
+ /**
587
+ * Get conversation history from a session file
588
+ * Returns the last N exchanges (user/assistant pairs)
589
+ */
590
+ export async function getConversationHistory(sessionId, projectPath, limit = 10) {
591
+ const sessionDir = getSessionDir(projectPath);
592
+ const sessionFile = join(sessionDir, `${sessionId}.jsonl`);
593
+ if (!existsSync(sessionFile)) {
594
+ return [];
595
+ }
596
+ try {
597
+ const content = readFileSync(sessionFile, 'utf-8');
598
+ const lines = content.trim().split('\n').filter(Boolean);
599
+ const exchanges = [];
600
+ for (const line of lines) {
601
+ try {
602
+ const msg = JSON.parse(line);
603
+ // Extract user messages
604
+ if (msg.type === 'user' && msg.message?.content) {
605
+ const text = extractTextContent(msg.message.content);
606
+ if (text) {
607
+ exchanges.push({
608
+ role: 'user',
609
+ content: text.substring(0, 2000) // Allow longer content for richer context
610
+ });
611
+ }
612
+ }
613
+ // Extract assistant messages
614
+ if (msg.type === 'assistant' && msg.message?.content) {
615
+ const text = extractTextContent(msg.message.content);
616
+ if (text) {
617
+ exchanges.push({
618
+ role: 'assistant',
619
+ content: text.substring(0, 2000) // Allow longer content for richer context
620
+ });
621
+ }
622
+ }
623
+ }
624
+ catch {
625
+ // Skip malformed lines
626
+ }
627
+ }
628
+ // Return last N exchanges
629
+ return exchanges.slice(-limit);
630
+ }
631
+ catch {
632
+ return [];
633
+ }
634
+ }
635
+ /**
636
+ * Get the session metadata file path for a project
637
+ */
638
+ function getSessionMetadataPath(projectPath) {
639
+ const sessionDir = getSessionDir(projectPath);
640
+ return join(sessionDir, '.session-meta.json');
641
+ }
642
+ /**
643
+ * Load all session metadata for a project
644
+ */
645
+ function loadSessionMetadataStore(projectPath) {
646
+ const metaPath = getSessionMetadataPath(projectPath);
647
+ if (!existsSync(metaPath)) {
648
+ return { sessions: {} };
649
+ }
650
+ try {
651
+ const content = readFileSync(metaPath, 'utf-8');
652
+ return JSON.parse(content);
653
+ }
654
+ catch {
655
+ console.warn(`⚠️ Failed to load session metadata, starting fresh`);
656
+ return { sessions: {} };
657
+ }
658
+ }
659
+ /**
660
+ * Save session metadata store to disk
661
+ */
662
+ function saveSessionMetadataStore(projectPath, store) {
663
+ const metaPath = getSessionMetadataPath(projectPath);
664
+ const sessionDir = getSessionDir(projectPath);
665
+ // Ensure directory exists
666
+ if (!existsSync(sessionDir)) {
667
+ mkdirSync(sessionDir, { recursive: true });
668
+ }
669
+ try {
670
+ writeFileSync(metaPath, JSON.stringify(store, null, 2), 'utf-8');
671
+ }
672
+ catch (err) {
673
+ console.error(`❌ Failed to save session metadata: ${err.message}`);
674
+ }
675
+ }
676
+ /**
677
+ * Save metadata for a specific session
678
+ */
679
+ export function saveSessionMetadata(projectPath, metadata) {
680
+ const store = loadSessionMetadataStore(projectPath);
681
+ store.sessions[metadata.sessionId] = metadata;
682
+ saveSessionMetadataStore(projectPath, store);
683
+ console.log(`💾 Saved session metadata: ${metadata.sessionId}`);
684
+ }
685
+ /**
686
+ * Get metadata for a specific session
687
+ */
688
+ export function getSessionMetadataById(projectPath, sessionId) {
689
+ const store = loadSessionMetadataStore(projectPath);
690
+ return store.sessions[sessionId] || null;
691
+ }
692
+ /**
693
+ * Get metadata for the most recent session
694
+ */
695
+ export async function getMostRecentSessionMetadata(projectPath) {
696
+ const recentSessionId = await getMostRecentSessionId(projectPath);
697
+ if (!recentSessionId) {
698
+ return null;
699
+ }
700
+ return getSessionMetadataById(projectPath, recentSessionId);
701
+ }
702
+ /**
703
+ * Delete metadata for a specific session
704
+ */
705
+ export function deleteSessionMetadata(projectPath, sessionId) {
706
+ const store = loadSessionMetadataStore(projectPath);
707
+ if (store.sessions[sessionId]) {
708
+ delete store.sessions[sessionId];
709
+ saveSessionMetadataStore(projectPath, store);
710
+ console.log(`🗑️ Deleted session metadata: ${sessionId}`);
711
+ }
712
+ }
713
+ /**
714
+ * Clean up metadata for sessions that no longer exist
715
+ */
716
+ export async function cleanupOrphanedMetadata(projectPath) {
717
+ const store = loadSessionMetadataStore(projectPath);
718
+ let cleanedCount = 0;
719
+ for (const sessionId of Object.keys(store.sessions)) {
720
+ if (!sessionExists(sessionId, projectPath)) {
721
+ delete store.sessions[sessionId];
722
+ cleanedCount++;
723
+ }
724
+ }
725
+ if (cleanedCount > 0) {
726
+ saveSessionMetadataStore(projectPath, store);
727
+ console.log(`🧹 Cleaned up ${cleanedCount} orphaned session metadata entries`);
728
+ }
729
+ return cleanedCount;
730
+ }
731
+ // ============================================================
732
+ // SESSION WORKSPACE - For research artifacts and session library
733
+ // ============================================================
734
+ export function getSessionWorkspace(projectPath, sessionId) {
735
+ return join(projectPath, '.osborn', 'sessions', sessionId);
736
+ }
737
+ export function ensureSessionWorkspace(projectPath, sessionId) {
738
+ const dir = getSessionWorkspace(projectPath, sessionId);
739
+ const libraryDir = join(dir, 'library');
740
+ mkdirSync(libraryDir, { recursive: true });
741
+ // Create default spec.md if it doesn't exist (won't overwrite on resumed sessions)
742
+ const specPath = join(dir, 'spec.md');
743
+ if (!existsSync(specPath)) {
744
+ writeFileSync(specPath, `# Session Spec
745
+
746
+ ## Goal
747
+ <!-- What the user wants to achieve — the driving purpose of this session -->
748
+
749
+ ## User Context
750
+ <!-- Where the user is at, what they have, their preferences, constraints, resources -->
751
+ <!-- e.g., budget, tools available, skill level, timeline -->
752
+
753
+ ## Open Questions
754
+
755
+ ### From User (unanswered)
756
+ <!-- Questions the user has asked that research hasn't answered yet -->
757
+ <!-- Format: - [ ] Question (asked at timestamp) -->
758
+ <!-- When answered: - [x] Question → Answer (source) -->
759
+
760
+ ### From Agent (needs user input)
761
+ <!-- Clarifying questions the agent needs answered to proceed -->
762
+ <!-- Format: - [ ] Question (why it matters) [priority: high/medium/low] -->
763
+ <!-- When answered: - [x] Question → User's answer -->
764
+
765
+ ## Decisions
766
+ <!-- Locked-in answers — moved from Open Questions when resolved -->
767
+ <!-- Format: - Decision (rationale / source) -->
768
+
769
+ ## Findings & Resources
770
+ <!-- Key facts, patterns, links, code examples discovered during research -->
771
+ <!-- Concrete details: names, versions, URLs, configurations -->
772
+ <!-- Resources: websites to visit, tools to use, documentation links -->
773
+
774
+ ## Plan
775
+ <!-- Step-by-step execution guide — the portable output -->
776
+ <!-- Actionable steps someone can follow to go from problem to solution -->
777
+ <!-- Include: what to do, what tools/resources needed, in what order -->
778
+ `, 'utf-8');
779
+ }
780
+ return dir;
781
+ }
782
+ /**
783
+ * Rename a session workspace folder to match the SDK session ID.
784
+ * Returns the new path, or null if rename was not needed/possible.
785
+ */
786
+ export function renameSessionWorkspace(projectPath, oldSessionId, newSessionId) {
787
+ if (oldSessionId === newSessionId)
788
+ return null;
789
+ const oldDir = getSessionWorkspace(projectPath, oldSessionId);
790
+ const newDir = getSessionWorkspace(projectPath, newSessionId);
791
+ if (!existsSync(oldDir))
792
+ return null;
793
+ if (existsSync(newDir))
794
+ return null; // target already exists
795
+ try {
796
+ renameSync(oldDir, newDir);
797
+ return newDir;
798
+ }
799
+ catch (err) {
800
+ console.error(`⚠️ Failed to rename workspace ${oldSessionId} → ${newSessionId}:`, err);
801
+ return null;
802
+ }
803
+ }
804
+ // Deprecated aliases for backward compatibility
805
+ export function getResearchDir(projectPath, sessionId) {
806
+ return getSessionWorkspace(projectPath, sessionId);
807
+ }
808
+ export function ensureResearchDir(projectPath, sessionId) {
809
+ return ensureSessionWorkspace(projectPath, sessionId);
810
+ }
811
+ /**
812
+ * Read the session spec document (spec.md) if it exists
813
+ */
814
+ export function readSessionSpec(projectPath, sessionId) {
815
+ const specPath = join(getSessionWorkspace(projectPath, sessionId), 'spec.md');
816
+ if (!existsSync(specPath))
817
+ return null;
818
+ try {
819
+ return readFileSync(specPath, 'utf-8');
820
+ }
821
+ catch {
822
+ return null;
823
+ }
824
+ }
825
+ /**
826
+ * List files in the session library directory
827
+ */
828
+ export function listLibraryFiles(projectPath, sessionId) {
829
+ const libraryDir = join(getSessionWorkspace(projectPath, sessionId), 'library');
830
+ if (!existsSync(libraryDir))
831
+ return [];
832
+ try {
833
+ return readdirSync(libraryDir);
834
+ }
835
+ catch {
836
+ return [];
837
+ }
838
+ }
839
+ function classifyFile(fileName) {
840
+ const ext = fileName.split('.').pop()?.toLowerCase() || '';
841
+ if (fileName.includes('plan'))
842
+ return 'plan';
843
+ if (ext === 'mmd' || ext === 'mermaid')
844
+ return 'diagram';
845
+ if (ext === 'html' || ext === 'htm')
846
+ return 'html';
847
+ if (ext === 'md')
848
+ return 'notes';
849
+ if (['png', 'jpg', 'jpeg', 'svg', 'gif', 'webp'].includes(ext))
850
+ return 'image';
851
+ if (fileName.includes('summary'))
852
+ return 'summary';
853
+ return 'other';
854
+ }
855
+ function scanDirForArtifacts(dir) {
856
+ const results = [];
857
+ function scan(scanPath) {
858
+ try {
859
+ for (const entry of readdirSync(scanPath)) {
860
+ const fullPath = join(scanPath, entry);
861
+ const stat = statSync(fullPath);
862
+ if (stat.isDirectory()) {
863
+ scan(fullPath);
864
+ }
865
+ else {
866
+ results.push({
867
+ fileName: entry,
868
+ filePath: fullPath,
869
+ type: classifyFile(entry),
870
+ size: stat.size,
871
+ updatedAt: stat.mtime.toISOString(),
872
+ });
873
+ }
874
+ }
875
+ }
876
+ catch { /* ignore */ }
877
+ }
878
+ scan(dir);
879
+ return results;
880
+ }
881
+ export function listResearchArtifacts(projectPath, sessionId) {
882
+ return scanDirForArtifacts(getSessionWorkspace(projectPath, sessionId));
883
+ }
884
+ /**
885
+ * List artifacts in a session workspace.
886
+ * When sessionId is provided, scans the per-session folder (.osborn/sessions/{sessionId}/).
887
+ * Without sessionId, falls back to the flat .osborn/sessions/ directory (legacy).
888
+ */
889
+ export function listWorkspaceArtifacts(projectPath, sessionId) {
890
+ const dir = sessionId
891
+ ? join(projectPath, '.osborn', 'sessions', sessionId)
892
+ : join(projectPath, '.osborn', 'sessions');
893
+ return scanDirForArtifacts(dir);
894
+ }