osborn 0.9.13 → 0.9.16

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.
@@ -3,7 +3,7 @@
3
3
  Export Markdown documents as formatted PDF files.
4
4
 
5
5
  ## When to use
6
- When the user wants to create a PDF from a Markdown file, spec, or research findings.
6
+ When the user /wants to create a PDF from a Markdown file, spec, or research findings.
7
7
 
8
8
  ## How to execute
9
9
 
@@ -3,7 +3,7 @@
3
3
  Automate web browser interactions — navigate pages, click buttons, fill forms, take screenshots, and extract content.
4
4
 
5
5
  ## When to use
6
- - Navigate to a URL and interact with it
6
+ - Navigate to a - URL and interact with it
7
7
  - Click buttons or links by their text or role
8
8
  - Fill form fields and submit data
9
9
  - Take screenshots of web pages
@@ -7,7 +7,7 @@ When the user wants to add UI components (buttons, dialogs, cards, forms, tables
7
7
 
8
8
  ## Setup (first time only)
9
9
 
10
- Initialize shadcn in the project root (where package.json lives):
10
+ Initialize shadcn in the work/project root (where package.json lives):
11
11
  ```bash
12
12
  npx shadcn@latest init
13
13
  ```
@@ -78,6 +78,11 @@ export declare class ClaudeLLM extends llm.LLM {
78
78
  * Call this before sending the first message to resume from a previous session
79
79
  */
80
80
  setResumeSessionId(sessionId: string | null): void;
81
+ /**
82
+ * Set the working directory for the current session
83
+ * Call this when resuming a session from a different project slug
84
+ */
85
+ setWorkingDirectory(path: string): void;
81
86
  /**
82
87
  * Reset state for mid-conversation session switch
83
88
  * Clears pending permissions and resets conversation tracking
@@ -14,6 +14,7 @@ import { getResearchSystemPrompt, getDirectModeResearchPrompt } from './prompts.
14
14
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
15
15
  import { join, dirname } from 'node:path';
16
16
  import { fileURLToPath } from 'node:url';
17
+ import { homedir } from 'node:os';
17
18
  // Directory of this module — used to locate co-located prompt files (e.g., turn-shape reminder).
18
19
  const __claudeLlmDir = dirname(fileURLToPath(import.meta.url));
19
20
  const TURN_SHAPE_REMINDER_PATH = join(__claudeLlmDir, 'prompts', 'turn-shape-reminder.md');
@@ -85,6 +86,44 @@ function loadSkillsFromDir(agentDir) {
85
86
  console.log(`📚 Loaded ${skills.length} skill(s) from ${skillsDir}`);
86
87
  return `<available-skills>\n${skills.join('\n\n---\n\n')}\n</available-skills>`;
87
88
  }
89
+ /**
90
+ * Loads skills from both ~/.claude/skills/ (home dir) and {workingDir}/.claude/skills/ (project dir).
91
+ * Merges results, deduplicating by skill directory name — home dir wins on conflicts.
92
+ * Returns a combined <available-skills> XML block, or '' if no skills found.
93
+ */
94
+ function loadAllSkills(workingDir) {
95
+ const homeSkillsDir = join(homedir(), '.claude', 'skills');
96
+ const projectSkillsDir = join(workingDir, '.claude', 'skills');
97
+ // skill name → content; home dir loaded first so it wins on conflicts
98
+ const skillMap = new Map();
99
+ const loadFromDir = (dir) => {
100
+ if (!existsSync(dir))
101
+ return;
102
+ try {
103
+ for (const skillName of readdirSync(dir)) {
104
+ if (skillMap.has(skillName))
105
+ continue; // home dir already set this one
106
+ const skillFile = join(dir, skillName, 'SKILL.md');
107
+ if (existsSync(skillFile)) {
108
+ skillMap.set(skillName, readFileSync(skillFile, 'utf-8').trim());
109
+ }
110
+ }
111
+ }
112
+ catch (err) {
113
+ console.warn('⚠️ Failed to load skills from', dir, ':', err);
114
+ }
115
+ };
116
+ loadFromDir(homeSkillsDir);
117
+ loadFromDir(projectSkillsDir);
118
+ if (skillMap.size === 0)
119
+ return '';
120
+ const sources = [
121
+ existsSync(homeSkillsDir) ? homeSkillsDir : null,
122
+ existsSync(projectSkillsDir) ? projectSkillsDir : null,
123
+ ].filter(Boolean).join(', ');
124
+ console.log(`📚 Loaded ${skillMap.size} skill(s) from ${sources}`);
125
+ return `<available-skills>\n${[...skillMap.values()].join('\n\n---\n\n')}\n</available-skills>`;
126
+ }
88
127
  // Research mode tools — full research capabilities
89
128
  const RESEARCH_TOOLS = [
90
129
  'Read', 'Write', 'Edit', 'Glob', 'Grep',
@@ -301,6 +340,13 @@ export class ClaudeLLM extends llm.LLM {
301
340
  console.log(`🔄 Will resume session: ${sessionId}`);
302
341
  }
303
342
  }
343
+ /**
344
+ * Set the working directory for the current session
345
+ * Call this when resuming a session from a different project slug
346
+ */
347
+ setWorkingDirectory(path) {
348
+ this.#opts.workingDirectory = path;
349
+ }
304
350
  /**
305
351
  * Reset state for mid-conversation session switch
306
352
  * Clears pending permissions and resets conversation tracking
@@ -762,6 +808,7 @@ class ClaudeLLMStream extends llm.LLMStream {
762
808
  // model: this.#opts.model || 'haiku', // haiku for speed with limited tools, sonnet for full research capabilities (including tool use trace in response)
763
809
  model: this.#opts.model || 'claude-sonnet-4-6', // Sonnet orchestrator with named sub-agents (Haiku tested but ignored delegation rules)
764
810
  enableFileCheckpointing: true,
811
+ settingSources: ['project', 'user'],
765
812
  extraArgs: { 'replay-user-messages': null },
766
813
  ...(this.#abortController && { abortController: this.#abortController }),
767
814
  ...(resumeSessionId && { resume: resumeSessionId }),
@@ -773,7 +820,7 @@ class ClaudeLLMStream extends llm.LLMStream {
773
820
  this.#opts.voiceMode === 'direct'
774
821
  ? getDirectModeResearchPrompt(workspacePath)
775
822
  : getResearchSystemPrompt(workspacePath),
776
- loadSkillsFromDir(this.#opts.sessionBaseDir || this.#opts.workingDirectory || process.cwd()),
823
+ loadAllSkills(this.#opts.sessionBaseDir || this.#opts.workingDirectory || process.cwd()),
777
824
  ].filter(Boolean).join('\n\n'),
778
825
  canUseTool: async (toolName, input, _options) => {
779
826
  // Auto-approve writes to session workspace (but block spec.md and library/ — fast brain manages those)
@@ -943,7 +990,7 @@ class ClaudeLLMStream extends llm.LLMStream {
943
990
  try {
944
991
  const summary = input?.compact_summary || '';
945
992
  const { mkdirSync, writeFileSync: writeSyncFs, readFileSync: readSyncFs, existsSync: existsSyncFs } = await import('node:fs');
946
- const skillDir = this.#opts.sessionBaseDir || this.#opts.workingDirectory || process.cwd();
993
+ const skillDir = homedir();
947
994
  const today = new Date().toISOString().split('T')[0];
948
995
  const sessionId = this.#sessionId || 'unknown';
949
996
  let skillsWritten = 0;
package/dist/index.js CHANGED
@@ -15,6 +15,8 @@ import { dirname, join } from 'node:path';
15
15
  import { fileURLToPath } from 'node:url';
16
16
  import { spawn } from 'node:child_process';
17
17
  import { homedir, tmpdir } from 'node:os';
18
+ import { PassThrough } from 'node:stream';
19
+ import { createGunzip } from 'node:zlib';
18
20
  // Resolve __dirname for this ESM module so we can find sibling files (e.g.
19
21
  // meeting-output.html) relative to the compiled JS location, NOT process.cwd().
20
22
  // In production cwd is the user's workspace; the static file lives next to dist/index.js.
@@ -376,8 +378,26 @@ function startApiServer(workingDir, port) {
376
378
  const targetWorkDir = url.searchParams.get('targetWorkDir');
377
379
  const tmpDir = mkdtempSync(join(tmpdir(), 'osborn-import-'));
378
380
  const tarProc = spawn('tar', ['-xf', '-', '-C', tmpDir]);
379
- // Streaming: pipe request body directly to tar stdin no buffering
380
- req.pipe(tarProc.stdin);
381
+ // Stream-sniff the first chunk to detect gzip magic bytes (0x1f 0x8b).
382
+ // Then route through createGunzip() if gzip, otherwise pipe raw to tar.
383
+ // This avoids any reliance on Content-Type or Content-Encoding headers.
384
+ const passthrough = new PassThrough();
385
+ let sniffDone = false;
386
+ req.once('data', (firstChunk) => {
387
+ sniffDone = true;
388
+ const isGzip = firstChunk[0] === 0x1f && firstChunk[1] === 0x8b;
389
+ passthrough.write(firstChunk);
390
+ req.pipe(passthrough);
391
+ const source = isGzip ? passthrough.pipe(createGunzip()) : passthrough;
392
+ source.pipe(tarProc.stdin);
393
+ });
394
+ req.once('end', () => {
395
+ if (!sniffDone) {
396
+ // Empty body — just end tar stdin
397
+ passthrough.end();
398
+ tarProc.stdin.end();
399
+ }
400
+ });
381
401
  tarProc.stdin.on('error', (err) => {
382
402
  console.error('[import] tar stdin error', err);
383
403
  tarProc.kill('SIGTERM');
@@ -437,6 +457,10 @@ function startApiServer(workingDir, port) {
437
457
  }
438
458
  }
439
459
  filesWritten++;
460
+ const recoveredPath = effectiveSlug.replace(/^-/, '/').replace(/--/g, '/.').replace(/-/g, '/');
461
+ if (recoveredPath && recoveredPath !== '/') {
462
+ mkdirSync(recoveredPath, { recursive: true });
463
+ }
440
464
  }
441
465
  res.writeHead(200, { 'Content-Type': 'application/json' });
442
466
  res.end(JSON.stringify({ ok: true, filesWritten, remapped }));
@@ -522,22 +546,24 @@ function startApiServer(workingDir, port) {
522
546
  }
523
547
  const tmpExtractDir = mkdtempSync(join(tmpdir(), 'osborn-import-'));
524
548
  try {
525
- // Reassemble chunks into a single stream and pipe to tar
549
+ // Reassemble all chunks into a combined buffer, then sniff first 2 bytes
550
+ // to detect gzip magic (0x1f 0x8b). Route through createGunzip() if gzip,
551
+ // otherwise pass raw bytes — always using tar -xf (no -z flag).
552
+ const chunkBuffers = [];
553
+ for (const chunkFile of expectedChunks) {
554
+ chunkBuffers.push(readFileSync(join(uploadDir, chunkFile)));
555
+ }
556
+ const combined = Buffer.concat(chunkBuffers);
557
+ const isGzip = combined[0] === 0x1f && combined[1] === 0x8b;
526
558
  const tarProc = spawn('tar', ['-xf', '-', '-C', tmpExtractDir]);
527
- // Stream chunks in order to tar stdin
559
+ // Feed combined buffer through gunzip (if needed) then into tar stdin
560
+ const feedStream = new PassThrough();
561
+ const tarInput = isGzip ? feedStream.pipe(createGunzip()) : feedStream;
562
+ tarInput.pipe(tarProc.stdin);
563
+ feedStream.end(combined);
528
564
  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
- }
540
- tarProc.stdin.end();
565
+ // feeding is already initiated above; just return a resolved promise
566
+ await Promise.resolve();
541
567
  };
542
568
  streamChunks().catch(err => {
543
569
  console.error('[import-finalize] chunk stream error', err);
@@ -557,24 +583,11 @@ function startApiServer(workingDir, port) {
557
583
  // The archive should contain a 'projects' subdirectory
558
584
  const extractedProjects = join(tmpExtractDir, 'projects');
559
585
  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;
569
- }
570
- }
571
- }
572
586
  // Copy subdirectories into ~/.claude/projects/, merging and updating existing files
573
587
  let filesWritten = 0;
574
588
  const slugsInSource = readdirSync(sourceDir);
575
589
  for (const slug of slugsInSource) {
576
- const effectiveSlug = remapped[slug] ?? slug;
577
- const destSlug = join(projectsDir, effectiveSlug);
590
+ const destSlug = join(projectsDir, slug);
578
591
  mkdirSync(destSlug, { recursive: true });
579
592
  try {
580
593
  renameSync(join(sourceDir, slug), destSlug);
@@ -588,10 +601,15 @@ function startApiServer(workingDir, port) {
588
601
  throw e;
589
602
  }
590
603
  }
604
+ // Also create the corresponding workspace directory so Claude can resume
605
+ const recoveredPath = slug.replace(/^-/, '/').replace(/--/g, '/.').replace(/-/g, '/');
606
+ if (recoveredPath && recoveredPath !== '/') {
607
+ mkdirSync(recoveredPath, { recursive: true });
608
+ }
591
609
  filesWritten++;
592
610
  }
593
611
  res.writeHead(200, { 'Content-Type': 'application/json' });
594
- res.end(JSON.stringify({ ok: true, filesWritten, remapped }));
612
+ res.end(JSON.stringify({ ok: true, filesWritten }));
595
613
  }
596
614
  catch (err) {
597
615
  console.error('[import-finalize] merge error:', err);
@@ -2971,13 +2989,59 @@ async function main() {
2971
2989
  }
2972
2990
  }
2973
2991
  else {
2974
- console.error(`❌ Session not found: ${sessionId}`);
2975
- await sendToFrontend({
2976
- type: 'session_resume_set',
2977
- sessionId,
2978
- success: false,
2979
- error: 'Session not found',
2980
- });
2992
+ // Try to find the session in any slug directory
2993
+ let found = false;
2994
+ const projectsDir = join(homedir(), '.claude', 'projects');
2995
+ if (existsSync(projectsDir)) {
2996
+ const slugDirs = readdirSync(projectsDir);
2997
+ for (const slug of slugDirs) {
2998
+ const candidate = join(projectsDir, slug, `${sessionId}.jsonl`);
2999
+ if (existsSync(candidate)) {
3000
+ // Recover the original path from the slug
3001
+ const recoveredPath = slug.replace(/^-/, '/').replace(/--/g, '/.').replace(/-/g, '/');
3002
+ if (recoveredPath && recoveredPath !== '/') {
3003
+ mkdirSync(recoveredPath, { recursive: true });
3004
+ workingDir = recoveredPath;
3005
+ currentLLM.setWorkingDirectory(recoveredPath);
3006
+ console.log(`🔄 Found session in slug ${slug}, using path: ${recoveredPath}`);
3007
+ found = true;
3008
+ // Proceed with the same success path
3009
+ currentLLM.setResumeSessionId(sessionId);
3010
+ currentResumeSessionId = sessionId;
3011
+ console.log(`🔄 Will resume session: ${sessionId}`);
3012
+ await sendToFrontend({
3013
+ type: 'session_resume_set',
3014
+ sessionId,
3015
+ success: true,
3016
+ });
3017
+ const artifacts = listWorkspaceArtifacts(workingDir, sessionId);
3018
+ if (artifacts.length > 0) {
3019
+ console.log(`📁 Sending ${artifacts.length} session artifacts to frontend`);
3020
+ await sendToFrontend({
3021
+ type: 'session_artifacts',
3022
+ sessionId,
3023
+ artifacts: artifacts.map(a => ({
3024
+ filePath: a.filePath,
3025
+ fileName: a.fileName,
3026
+ type: a.type,
3027
+ updatedAt: a.updatedAt,
3028
+ }))
3029
+ });
3030
+ }
3031
+ break;
3032
+ }
3033
+ }
3034
+ }
3035
+ }
3036
+ if (!found) {
3037
+ console.error(`❌ Session not found: ${sessionId}`);
3038
+ await sendToFrontend({
3039
+ type: 'session_resume_set',
3040
+ sessionId,
3041
+ success: false,
3042
+ error: 'Session not found',
3043
+ });
3044
+ }
2981
3045
  }
2982
3046
  }
2983
3047
  else if (data.type === 'continue_session' && currentLLM) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osborn",
3
- "version": "0.9.13",
3
+ "version": "0.9.16",
4
4
  "description": "Voice AI coding assistant - local agent that connects to Osborn frontend",
5
5
  "type": "module",
6
6
  "bin": {