osborn 0.9.4 → 0.9.6

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/index.js CHANGED
@@ -10,11 +10,11 @@ initializeLogger({ pretty: true, level: 'info' });
10
10
  import { setMaxListeners } from 'node:events';
11
11
  setMaxListeners(50);
12
12
  import { createServer } from 'http';
13
- import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, mkdtempSync, cpSync } from 'node:fs';
13
+ import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, mkdtempSync, cpSync, rmSync, renameSync, statSync, createWriteStream } from 'node:fs';
14
14
  import { dirname, join } from 'node:path';
15
15
  import { fileURLToPath } from 'node:url';
16
16
  import { spawn } from 'node:child_process';
17
- import { homedir } from 'node:os';
17
+ import { homedir, tmpdir } from 'node:os';
18
18
  // Resolve __dirname for this ESM module so we can find sibling files (e.g.
19
19
  // meeting-output.html) relative to the compiled JS location, NOT process.cwd().
20
20
  // In production cwd is the user's workspace; the static file lives next to dist/index.js.
@@ -331,6 +331,38 @@ function startApiServer(workingDir, port) {
331
331
  res.destroy(); });
332
332
  return;
333
333
  }
334
+ // GET /sessions/manifest — return mtime+size for all .jsonl files per slug (public, no auth)
335
+ if (req.method === 'GET' && url.pathname === '/sessions/manifest') {
336
+ const claudeDir = join(homedir(), '.claude', 'projects');
337
+ const slugMap = {};
338
+ try {
339
+ const slugs = readdirSync(claudeDir, { withFileTypes: true })
340
+ .filter(d => d.isDirectory())
341
+ .map(d => d.name);
342
+ for (const slug of slugs) {
343
+ const slugDir = join(claudeDir, slug);
344
+ try {
345
+ const jsonlFiles = readdirSync(slugDir)
346
+ .filter(f => f.endsWith('.jsonl'));
347
+ const fileStats = {};
348
+ for (const file of jsonlFiles) {
349
+ const st = statSync(join(slugDir, file));
350
+ fileStats[file] = { mtime: st.mtimeMs, size: st.size };
351
+ }
352
+ slugMap[slug] = { files: fileStats };
353
+ }
354
+ catch {
355
+ // skip unreadable dirs
356
+ }
357
+ }
358
+ }
359
+ catch {
360
+ // projects dir doesn't exist yet — return empty
361
+ }
362
+ res.writeHead(200, { 'Content-Type': 'application/json' });
363
+ res.end(JSON.stringify({ slugs: slugMap }));
364
+ return;
365
+ }
334
366
  // POST /sessions/import — accept a gzipped tar and extract into ~/.claude/projects/
335
367
  if (req.method === 'POST' && url.pathname === '/sessions/import') {
336
368
  if (syncToken) {
@@ -342,79 +374,250 @@ function startApiServer(workingDir, port) {
342
374
  }
343
375
  }
344
376
  const targetWorkDir = url.searchParams.get('targetWorkDir');
345
- const chunks = [];
346
- req.on('data', (chunk) => chunks.push(chunk));
347
- req.on('end', () => {
348
- const body = Buffer.concat(chunks);
349
- const tmpDir = mkdtempSync('/tmp/osborn-import-');
377
+ const tmpDir = mkdtempSync(join(tmpdir(), 'osborn-import-'));
378
+ const tarProc = spawn('tar', ['-xf', '-', '-C', tmpDir]);
379
+ // Streaming: pipe request body directly to tar stdin — no buffering
380
+ req.pipe(tarProc.stdin);
381
+ tarProc.stdin.on('error', (err) => {
382
+ console.error('[import] tar stdin error', err);
383
+ tarProc.kill('SIGTERM');
384
+ rmSync(tmpDir, { recursive: true, force: true });
385
+ if (!res.headersSent) {
386
+ res.writeHead(500);
387
+ res.end(JSON.stringify({ error: 'upload error' }));
388
+ }
389
+ });
390
+ req.on('aborted', () => {
391
+ tarProc.kill('SIGTERM');
392
+ rmSync(tmpDir, { recursive: true, force: true });
393
+ });
394
+ tarProc.stderr.on('data', (d) => console.error('[import]', d.toString()));
395
+ tarProc.on('close', async (code) => {
350
396
  try {
351
- // Extract archive into temp dir
352
- const tarProc = spawn('tar', ['-xzf', '-', '-C', tmpDir]);
353
- tarProc.stdin.write(body);
397
+ if (code !== 0) {
398
+ res.writeHead(500);
399
+ res.end(JSON.stringify({ error: 'tar extraction failed', code }));
400
+ return;
401
+ }
402
+ const claudeDir = join(homedir(), '.claude');
403
+ const projectsDir = join(claudeDir, 'projects');
404
+ mkdirSync(projectsDir, { recursive: true });
405
+ // The archive should contain a 'projects' subdirectory
406
+ const extractedProjects = join(tmpDir, 'projects');
407
+ const sourceDir = existsSync(extractedProjects) ? extractedProjects : tmpDir;
408
+ // Optionally remap slug: if targetWorkDir is provided, find slug(s)
409
+ // that don't match the target and rename them
410
+ const remapped = {};
411
+ if (targetWorkDir) {
412
+ const targetSlug = targetWorkDir.replace(/\//g, '-');
413
+ const sourceSlugs = readdirSync(sourceDir);
414
+ for (const slug of sourceSlugs) {
415
+ if (slug !== targetSlug && !slug.startsWith('.')) {
416
+ remapped[slug] = targetSlug;
417
+ }
418
+ }
419
+ }
420
+ // Copy subdirectories into ~/.claude/projects/, merging and updating existing files
421
+ let filesWritten = 0;
422
+ const slugsInSource = readdirSync(sourceDir);
423
+ for (const slug of slugsInSource) {
424
+ const effectiveSlug = remapped[slug] ?? slug;
425
+ const destSlug = join(projectsDir, effectiveSlug);
426
+ mkdirSync(destSlug, { recursive: true });
427
+ try {
428
+ renameSync(join(sourceDir, slug), destSlug);
429
+ }
430
+ catch (e) {
431
+ if (e.code === 'EXDEV') {
432
+ // Cross-filesystem fallback
433
+ cpSync(join(sourceDir, slug), destSlug, { recursive: true, force: true, errorOnExist: false });
434
+ }
435
+ else {
436
+ throw e;
437
+ }
438
+ }
439
+ filesWritten++;
440
+ }
441
+ res.writeHead(200, { 'Content-Type': 'application/json' });
442
+ res.end(JSON.stringify({ ok: true, filesWritten, remapped }));
443
+ }
444
+ catch (err) {
445
+ console.error('[import] merge error:', err);
446
+ if (!res.headersSent) {
447
+ res.writeHead(500);
448
+ res.end(JSON.stringify({ error: 'Failed to merge sessions', detail: String(err) }));
449
+ }
450
+ }
451
+ finally {
452
+ rmSync(tmpDir, { recursive: true, force: true });
453
+ }
454
+ });
455
+ return;
456
+ }
457
+ // POST /sessions/import-chunk — accept a single chunk of a multi-part upload
458
+ if (req.method === 'POST' && url.pathname === '/sessions/import-chunk') {
459
+ if (syncToken) {
460
+ const authHeader = req.headers['authorization'] ?? '';
461
+ if (authHeader !== `Bearer ${syncToken}`) {
462
+ res.writeHead(401, { 'Content-Type': 'application/json' });
463
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
464
+ return;
465
+ }
466
+ }
467
+ const uploadId = url.searchParams.get('uploadId');
468
+ const chunkIndex = parseInt(url.searchParams.get('chunk') || '0');
469
+ if (!uploadId) {
470
+ res.writeHead(400);
471
+ res.end(JSON.stringify({ error: 'uploadId required' }));
472
+ return;
473
+ }
474
+ // Chunk storage dir: /tmp/osborn-upload-<uploadId>/
475
+ const uploadDir = join(tmpdir(), `osborn-upload-${uploadId}`);
476
+ mkdirSync(uploadDir, { recursive: true });
477
+ // Write chunk to padded filename for correct sort order
478
+ const chunkPath = join(uploadDir, `chunk-${String(chunkIndex).padStart(6, '0')}`);
479
+ const writeStream = createWriteStream(chunkPath);
480
+ req.pipe(writeStream);
481
+ writeStream.on('finish', () => {
482
+ res.writeHead(200);
483
+ res.end(JSON.stringify({ ok: true, chunk: chunkIndex }));
484
+ });
485
+ writeStream.on('error', (err) => {
486
+ console.error('[import-chunk] write error', err);
487
+ if (!res.headersSent) {
488
+ res.writeHead(500);
489
+ res.end(JSON.stringify({ error: 'write failed' }));
490
+ }
491
+ });
492
+ req.on('aborted', () => { writeStream.destroy(); });
493
+ return;
494
+ }
495
+ // POST /sessions/import-finalize — reassemble chunks, extract, apply slug merge
496
+ if (req.method === 'POST' && url.pathname === '/sessions/import-finalize') {
497
+ if (syncToken) {
498
+ const authHeader = req.headers['authorization'] ?? '';
499
+ if (authHeader !== `Bearer ${syncToken}`) {
500
+ res.writeHead(401, { 'Content-Type': 'application/json' });
501
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
502
+ return;
503
+ }
504
+ }
505
+ const uploadId = url.searchParams.get('uploadId');
506
+ const total = parseInt(url.searchParams.get('total') || '0');
507
+ const targetWorkDir = url.searchParams.get('targetWorkDir') ?? undefined;
508
+ if (!uploadId || total === 0) {
509
+ res.writeHead(400);
510
+ res.end(JSON.stringify({ error: 'uploadId and total required' }));
511
+ return;
512
+ }
513
+ const uploadDir = join(tmpdir(), `osborn-upload-${uploadId}`);
514
+ // Verify all chunks present
515
+ const expectedChunks = Array.from({ length: total }, (_, i) => `chunk-${String(i).padStart(6, '0')}`);
516
+ const presentChunks = existsSync(uploadDir) ? readdirSync(uploadDir).filter(f => f.startsWith('chunk-')).sort() : [];
517
+ const missing = expectedChunks.filter(c => !presentChunks.includes(c));
518
+ if (missing.length > 0) {
519
+ res.writeHead(400);
520
+ res.end(JSON.stringify({ error: 'missing chunks', missing }));
521
+ return;
522
+ }
523
+ const tmpExtractDir = mkdtempSync(join(tmpdir(), 'osborn-import-'));
524
+ try {
525
+ // Reassemble chunks into a single stream and pipe to tar
526
+ const tarProc = spawn('tar', ['-xf', '-', '-C', tmpExtractDir]);
527
+ // Stream chunks in order to tar stdin
528
+ const streamChunks = async () => {
529
+ for (const chunkFile of expectedChunks) {
530
+ const chunkData = readFileSync(join(uploadDir, chunkFile));
531
+ await new Promise((resolve, reject) => {
532
+ tarProc.stdin.write(chunkData, (err) => {
533
+ if (err)
534
+ reject(err);
535
+ else
536
+ resolve();
537
+ });
538
+ });
539
+ }
354
540
  tarProc.stdin.end();
355
- tarProc.stderr.on('data', (d) => console.error('[import]', d.toString()));
356
- tarProc.on('close', (code) => {
541
+ };
542
+ streamChunks().catch(err => {
543
+ console.error('[import-finalize] chunk stream error', err);
544
+ tarProc.kill('SIGTERM');
545
+ });
546
+ tarProc.stderr.on('data', (d) => console.error('[import-finalize]', d.toString()));
547
+ tarProc.on('close', async (code) => {
548
+ try {
357
549
  if (code !== 0) {
358
- res.writeHead(500, { 'Content-Type': 'application/json' });
550
+ res.writeHead(500);
359
551
  res.end(JSON.stringify({ error: 'tar extraction failed', code }));
360
552
  return;
361
553
  }
362
- try {
363
- const claudeDir = join(homedir(), '.claude');
364
- const projectsDir = join(claudeDir, 'projects');
365
- mkdirSync(projectsDir, { recursive: true });
366
- // The archive should contain a 'projects' subdirectory
367
- const extractedProjects = join(tmpDir, 'projects');
368
- const sourceDir = existsSync(extractedProjects) ? extractedProjects : tmpDir;
369
- // Optionally remap slug: if targetWorkDir is provided, find slug(s)
370
- // that don't match the target and rename them
371
- let remapped = {};
372
- if (targetWorkDir) {
373
- const targetSlug = targetWorkDir.replace(/\//g, '-');
374
- const sourceSlugs = readdirSync(sourceDir);
375
- for (const slug of sourceSlugs) {
376
- if (slug !== targetSlug && !slug.startsWith('.')) {
377
- const newSlug = targetSlug;
378
- remapped[slug] = newSlug;
379
- }
554
+ const claudeDir = join(homedir(), '.claude');
555
+ const projectsDir = join(claudeDir, 'projects');
556
+ mkdirSync(projectsDir, { recursive: true });
557
+ // The archive should contain a 'projects' subdirectory
558
+ const extractedProjects = join(tmpExtractDir, 'projects');
559
+ const sourceDir = existsSync(extractedProjects) ? extractedProjects : tmpExtractDir;
560
+ // Optionally remap slug: if targetWorkDir is provided, find slug(s)
561
+ // that don't match the target and rename them
562
+ const remapped = {};
563
+ if (targetWorkDir) {
564
+ const targetSlug = targetWorkDir.replace(/\//g, '-');
565
+ const sourceSlugs = readdirSync(sourceDir);
566
+ for (const slug of sourceSlugs) {
567
+ if (slug !== targetSlug && !slug.startsWith('.')) {
568
+ remapped[slug] = targetSlug;
380
569
  }
381
570
  }
382
- // Copy subdirectories into ~/.claude/projects/, merging without overwriting
383
- let filesWritten = 0;
384
- const slugsInSource = readdirSync(sourceDir);
385
- for (const slug of slugsInSource) {
386
- const effectiveSlug = remapped[slug] ?? slug;
387
- const destSlug = join(projectsDir, effectiveSlug);
388
- mkdirSync(destSlug, { recursive: true });
389
- cpSync(join(sourceDir, slug), destSlug, {
390
- recursive: true,
391
- force: false, // don't overwrite existing files
392
- errorOnExist: false,
393
- });
394
- filesWritten++;
571
+ }
572
+ // Copy subdirectories into ~/.claude/projects/, merging and updating existing files
573
+ let filesWritten = 0;
574
+ const slugsInSource = readdirSync(sourceDir);
575
+ for (const slug of slugsInSource) {
576
+ const effectiveSlug = remapped[slug] ?? slug;
577
+ const destSlug = join(projectsDir, effectiveSlug);
578
+ mkdirSync(destSlug, { recursive: true });
579
+ try {
580
+ renameSync(join(sourceDir, slug), destSlug);
581
+ }
582
+ catch (e) {
583
+ if (e.code === 'EXDEV') {
584
+ // Cross-filesystem fallback
585
+ cpSync(join(sourceDir, slug), destSlug, { recursive: true, force: true, errorOnExist: false });
586
+ }
587
+ else {
588
+ throw e;
589
+ }
395
590
  }
396
- res.writeHead(200, { 'Content-Type': 'application/json' });
397
- res.end(JSON.stringify({ ok: true, filesWritten, remapped }));
591
+ filesWritten++;
398
592
  }
399
- catch (err) {
400
- console.error('[import] merge error:', err);
401
- res.writeHead(500, { 'Content-Type': 'application/json' });
593
+ res.writeHead(200, { 'Content-Type': 'application/json' });
594
+ res.end(JSON.stringify({ ok: true, filesWritten, remapped }));
595
+ }
596
+ catch (err) {
597
+ console.error('[import-finalize] merge error:', err);
598
+ if (!res.headersSent) {
599
+ res.writeHead(500);
402
600
  res.end(JSON.stringify({ error: 'Failed to merge sessions', detail: String(err) }));
403
601
  }
404
- });
405
- }
406
- catch (err) {
407
- console.error('[import] spawn error:', err);
408
- res.writeHead(500, { 'Content-Type': 'application/json' });
409
- res.end(JSON.stringify({ error: 'Failed to start extraction', detail: String(err) }));
410
- }
411
- });
602
+ }
603
+ finally {
604
+ rmSync(uploadDir, { recursive: true, force: true });
605
+ rmSync(tmpExtractDir, { recursive: true, force: true });
606
+ }
607
+ });
608
+ }
609
+ catch (err) {
610
+ rmSync(uploadDir, { recursive: true, force: true });
611
+ rmSync(tmpExtractDir, { recursive: true, force: true });
612
+ throw err;
613
+ }
412
614
  return;
413
615
  }
414
616
  res.writeHead(404, { 'Content-Type': 'application/json' });
415
617
  res.end(JSON.stringify({ error: 'Not found' }));
416
618
  });
417
619
  const host = process.env.HOST || '0.0.0.0';
620
+ server.requestTimeout = 0; // no timeout — large uploads can take minutes
418
621
  server.listen(port, host, () => {
419
622
  console.log(`🌐 API server listening on http://${host}:${port}`);
420
623
  console.log(` Sessions: http://${host}:${port}/sessions`);
@@ -106,7 +106,7 @@ export class PipelineDirectLLM extends llm.LLM {
106
106
  `- If it's a quick side question, answer it then continue where you left off`,
107
107
  `- If they want to change direction, acknowledge and follow their lead`,
108
108
  `- Clarify when asked to or the question requires going over what you just said`,
109
- `- Reference unspoken content naturally if relevant`,
109
+ `- If relevant details were cut off — whether they answer the current question or an earlier one — weave them back in naturally so the user stays in context without having to ask again.`,
110
110
  ].join('\n');
111
111
  // Modify the last user message in chatCtx
112
112
  for (let i = chatCtx.items.length - 1; i >= 0; i--) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osborn",
3
- "version": "0.9.4",
3
+ "version": "0.9.6",
4
4
  "description": "Voice AI coding assistant - local agent that connects to Osborn frontend",
5
5
  "type": "module",
6
6
  "bin": {