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/.env.example +8 -1
- package/dist/bridge-llm.d.ts +22 -0
- package/dist/bridge-llm.js +39 -0
- package/dist/claude-handler.d.ts +6 -0
- package/dist/claude-handler.js +43 -1
- package/dist/claude-llm.d.ts +128 -0
- package/dist/claude-llm.js +623 -0
- package/dist/codex-llm.d.ts +40 -0
- package/dist/codex-llm.js +144 -0
- package/dist/config.d.ts +227 -1
- package/dist/config.js +775 -8
- package/dist/conversation-brain.d.ts +92 -0
- package/dist/conversation-brain.js +360 -0
- package/dist/fast-brain.d.ts +122 -0
- package/dist/fast-brain.js +1404 -0
- package/dist/index.js +1997 -322
- package/dist/prompts.d.ts +19 -0
- package/dist/prompts.js +610 -0
- package/dist/session-access.d.ts +399 -0
- package/dist/session-access.js +775 -0
- package/dist/smithery-proxy.d.ts +57 -0
- package/dist/smithery-proxy.js +195 -0
- package/dist/status-manager.d.ts +90 -0
- package/dist/status-manager.js +187 -0
- package/dist/voice-io.d.ts +70 -0
- package/dist/voice-io.js +152 -0
- package/package.json +17 -6
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: '
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
+
}
|