osborn 0.9.121 → 0.9.123
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 +107 -75
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -271,38 +271,35 @@ function interruptMeetingSpeech(reason) {
|
|
|
271
271
|
// Recall native output_audio, which requires mp3.
|
|
272
272
|
async function synthMp3(text) {
|
|
273
273
|
const t0 = Date.now();
|
|
274
|
-
const dgKey = process.env.DEEPGRAM_API_KEY;
|
|
275
|
-
if (dgKey) {
|
|
276
|
-
try {
|
|
277
|
-
const dg = await fetch('https://api.deepgram.com/v1/speak?model=aura-2-asteria-en&encoding=mp3&bit_rate=48000', {
|
|
278
|
-
method: 'POST',
|
|
279
|
-
headers: { 'Authorization': `Token ${dgKey}`, 'Content-Type': 'application/json' },
|
|
280
|
-
body: JSON.stringify({ text: text.slice(0, 4000) }),
|
|
281
|
-
signal: AbortSignal.timeout(12000),
|
|
282
|
-
});
|
|
283
|
-
if (dg.ok) {
|
|
284
|
-
const buf = Buffer.from(await dg.arrayBuffer());
|
|
285
|
-
console.log(`🗣️ synthMp3 deepgram ${buf.length}b in ${Date.now() - t0}ms`);
|
|
286
|
-
return buf;
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
catch (e) {
|
|
290
|
-
console.warn(`⚠️ synthMp3 deepgram: ${e.message}`);
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
274
|
const oa = process.env.OPENAI_API_KEY;
|
|
294
|
-
if (oa) {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
275
|
+
if (!oa) {
|
|
276
|
+
console.warn('⚠️ synthMp3: no OPENAI_API_KEY — meeting has no voice');
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
// Meeting voice = the SAME OpenAI model/voice as the website's regular TTS
|
|
280
|
+
// (DIRECT_MODE_TTS), so the bot sounds IDENTICAL on both fronts (user directive
|
|
281
|
+
// 2026-08-04: Deepgram aura sounded "cheap and inconsistent"). Deepgram removed
|
|
282
|
+
// from the meeting path entirely. Pulls model/voice from DIRECT_MODE_TTS when
|
|
283
|
+
// it's an OpenAI config so the two never drift.
|
|
284
|
+
const model = DIRECT_MODE_TTS.provider === 'openai' ? (DIRECT_MODE_TTS.model || 'tts-1-hd') : 'tts-1-hd';
|
|
285
|
+
const voice = DIRECT_MODE_TTS.provider === 'openai' ? (DIRECT_MODE_TTS.voice || 'fable') : 'fable';
|
|
286
|
+
try {
|
|
287
|
+
const r = await fetch('https://api.openai.com/v1/audio/speech', {
|
|
288
|
+
method: 'POST',
|
|
289
|
+
headers: { 'Authorization': `Bearer ${oa}`, 'Content-Type': 'application/json' },
|
|
290
|
+
body: JSON.stringify({ model, voice, input: text.slice(0, 4000), response_format: 'mp3' }),
|
|
291
|
+
signal: AbortSignal.timeout(20000),
|
|
292
|
+
});
|
|
293
|
+
if (r.ok) {
|
|
294
|
+
const buf = Buffer.from(await r.arrayBuffer());
|
|
295
|
+
console.log(`🗣️ synthMp3 openai ${model}/${voice} ${buf.length}b in ${Date.now() - t0}ms`);
|
|
296
|
+
return buf;
|
|
304
297
|
}
|
|
305
|
-
|
|
298
|
+
const e = await r.text().catch(() => '');
|
|
299
|
+
console.warn(`⚠️ synthMp3 openai ${r.status}: ${e.slice(0, 120)}`);
|
|
300
|
+
}
|
|
301
|
+
catch (e) {
|
|
302
|
+
console.warn(`⚠️ synthMp3 openai: ${e.message}`);
|
|
306
303
|
}
|
|
307
304
|
return null;
|
|
308
305
|
}
|
|
@@ -710,46 +707,23 @@ function startApiServer(workingDir, port) {
|
|
|
710
707
|
return;
|
|
711
708
|
}
|
|
712
709
|
const t0 = Date.now();
|
|
713
|
-
//
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
//
|
|
717
|
-
const dgKey = process.env.DEEPGRAM_API_KEY;
|
|
718
|
-
if (dgKey) {
|
|
719
|
-
try {
|
|
720
|
-
// WAV/linear16, not mp3: mp3 files carry encoder padding (leading/
|
|
721
|
-
// trailing silence + boundary click) — back-to-back sentence clips
|
|
722
|
-
// were heard as "cracking". WAV is gapless-safe and cheaper to decode.
|
|
723
|
-
const dg = await fetch('https://api.deepgram.com/v1/speak?model=aura-2-asteria-en&encoding=linear16&sample_rate=48000&container=wav', {
|
|
724
|
-
method: 'POST',
|
|
725
|
-
headers: { 'Authorization': `Token ${dgKey}`, 'Content-Type': 'application/json' },
|
|
726
|
-
body: JSON.stringify({ text }),
|
|
727
|
-
});
|
|
728
|
-
if (dg.ok) {
|
|
729
|
-
const buf = Buffer.from(await dg.arrayBuffer());
|
|
730
|
-
console.log(`🗣️ /tts deepgram wav ${buf.length}b in ${Date.now() - t0}ms (${text.length} chars) t=${new Date().toISOString()}`);
|
|
731
|
-
res.writeHead(200, { 'Content-Type': 'audio/wav', 'Cache-Control': 'no-store', 'Content-Length': buf.length });
|
|
732
|
-
res.end(buf);
|
|
733
|
-
return;
|
|
734
|
-
}
|
|
735
|
-
console.warn(`⚠️ /tts deepgram ${dg.status} — falling back to OpenAI`);
|
|
736
|
-
}
|
|
737
|
-
catch (e) {
|
|
738
|
-
console.warn(`⚠️ /tts deepgram error: ${e.message} — falling back to OpenAI`);
|
|
739
|
-
}
|
|
740
|
-
}
|
|
741
|
-
const voice = url.searchParams.get('voice') || 'alloy';
|
|
710
|
+
// Meeting voice = the SAME OpenAI model/voice as the website's regular TTS
|
|
711
|
+
// (DIRECT_MODE_TTS) — user directive 2026-08-04: Deepgram aura removed, it
|
|
712
|
+
// sounded cheap/inconsistent. Consistency over the ~2-4s latency Deepgram
|
|
713
|
+
// saved. mp3 out (the canvas <audio> element plays it into the meeting).
|
|
742
714
|
const key = process.env.OPENAI_API_KEY;
|
|
743
715
|
if (!key) {
|
|
744
716
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
745
|
-
res.end(JSON.stringify({ error: 'no
|
|
717
|
+
res.end(JSON.stringify({ error: 'no OPENAI_API_KEY' }));
|
|
746
718
|
return;
|
|
747
719
|
}
|
|
720
|
+
const model = DIRECT_MODE_TTS.provider === 'openai' ? (DIRECT_MODE_TTS.model || 'tts-1-hd') : 'tts-1-hd';
|
|
721
|
+
const voice = url.searchParams.get('voice') || (DIRECT_MODE_TTS.provider === 'openai' ? (DIRECT_MODE_TTS.voice || 'fable') : 'fable');
|
|
748
722
|
try {
|
|
749
723
|
const tts = await fetch('https://api.openai.com/v1/audio/speech', {
|
|
750
724
|
method: 'POST',
|
|
751
725
|
headers: { 'Authorization': `Bearer ${key}`, 'Content-Type': 'application/json' },
|
|
752
|
-
body: JSON.stringify({ model
|
|
726
|
+
body: JSON.stringify({ model, voice, input: text, response_format: 'mp3' }),
|
|
753
727
|
});
|
|
754
728
|
if (!tts.ok) {
|
|
755
729
|
const e = await tts.text().catch(() => '');
|
|
@@ -758,7 +732,7 @@ function startApiServer(workingDir, port) {
|
|
|
758
732
|
return;
|
|
759
733
|
}
|
|
760
734
|
const buf = Buffer.from(await tts.arrayBuffer());
|
|
761
|
-
console.log(`🗣️ /tts openai ${buf.length}b in ${Date.now() - t0}ms t=${new Date().toISOString()}`);
|
|
735
|
+
console.log(`🗣️ /tts openai ${model}/${voice} ${buf.length}b in ${Date.now() - t0}ms t=${new Date().toISOString()}`);
|
|
762
736
|
res.writeHead(200, { 'Content-Type': 'audio/mpeg', 'Cache-Control': 'no-store', 'Content-Length': buf.length });
|
|
763
737
|
res.end(buf);
|
|
764
738
|
}
|
|
@@ -841,6 +815,60 @@ function startApiServer(workingDir, port) {
|
|
|
841
815
|
res.destroy(); });
|
|
842
816
|
return;
|
|
843
817
|
}
|
|
818
|
+
// GET /sessions/export-one?sessionId=X — tar.gz of a SINGLE session's files
|
|
819
|
+
// (the .jsonl, its sidecar dir, and its osb/ workspace), for SESSION SHARING
|
|
820
|
+
// (0.9.123): the frontend fetches this from the owner's machine, uploads it
|
|
821
|
+
// to Supabase Storage, and the recipient's machine imports it via the
|
|
822
|
+
// existing POST /sessions/import (slug-remapped into the recipient's
|
|
823
|
+
// workspace). Note: gated only by knowing the machine URL + the session UUID
|
|
824
|
+
// for the MVP — a per-share access token is a follow-up (tracked in
|
|
825
|
+
// shared_sessions). sessionId is regex-validated to block path traversal.
|
|
826
|
+
if (req.method === 'GET' && url.pathname === '/sessions/export-one') {
|
|
827
|
+
const sessionId = (url.searchParams.get('sessionId') || '').trim();
|
|
828
|
+
if (!sessionId || !/^[a-zA-Z0-9._-]+$/.test(sessionId)) {
|
|
829
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
830
|
+
res.end(JSON.stringify({ error: 'bad or missing sessionId' }));
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
const claudeDir = join(homedir(), '.claude');
|
|
834
|
+
const projectsDir = join(claudeDir, 'projects');
|
|
835
|
+
if (!existsSync(projectsDir)) {
|
|
836
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
837
|
+
res.end(JSON.stringify({ error: 'no projects dir' }));
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
// Find the project slug whose folder contains {sessionId}.jsonl.
|
|
841
|
+
let foundSlug = null;
|
|
842
|
+
for (const slug of readdirSync(projectsDir)) {
|
|
843
|
+
if (existsSync(join(projectsDir, slug, `${sessionId}.jsonl`))) {
|
|
844
|
+
foundSlug = slug;
|
|
845
|
+
break;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
if (!foundSlug) {
|
|
849
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
850
|
+
res.end(JSON.stringify({ error: 'session not found' }));
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
// Only tar the paths that exist (tar errors on a missing member).
|
|
854
|
+
const members = [join('projects', foundSlug, `${sessionId}.jsonl`)];
|
|
855
|
+
if (existsSync(join(projectsDir, foundSlug, sessionId)))
|
|
856
|
+
members.push(join('projects', foundSlug, sessionId));
|
|
857
|
+
if (existsSync(join(projectsDir, foundSlug, 'osb', sessionId)))
|
|
858
|
+
members.push(join('projects', foundSlug, 'osb', sessionId));
|
|
859
|
+
console.log(`📤 export-one session ${sessionId} (slug ${foundSlug}, ${members.length} member(s))`);
|
|
860
|
+
res.writeHead(200, {
|
|
861
|
+
'Content-Type': 'application/gzip',
|
|
862
|
+
'Content-Disposition': `attachment; filename="session-${sessionId}.tar.gz"`,
|
|
863
|
+
'Access-Control-Allow-Origin': '*',
|
|
864
|
+
});
|
|
865
|
+
const tar = spawn('tar', ['-czf', '-', '-C', claudeDir, ...members]);
|
|
866
|
+
tar.stdout.pipe(res);
|
|
867
|
+
tar.stderr.on('data', (d) => console.error('[export-one]', d.toString()));
|
|
868
|
+
tar.on('close', (code) => { if (code !== 0)
|
|
869
|
+
res.destroy(); });
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
844
872
|
// GET /sessions/manifest — return mtime+size for all .jsonl files per slug (public, no auth)
|
|
845
873
|
// Helper: merge an extracted tar directory into ~/.claude/{projects,skills}/.
|
|
846
874
|
//
|
|
@@ -1829,13 +1857,14 @@ async function main() {
|
|
|
1829
1857
|
// from muting the audio path. Reset by endMeeting.
|
|
1830
1858
|
meetingAddressedUntil = Date.now() + 6 * 60 * 60 * 1000;
|
|
1831
1859
|
}
|
|
1832
|
-
//
|
|
1833
|
-
//
|
|
1834
|
-
//
|
|
1835
|
-
//
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1860
|
+
// NO meeting-specific reply coaching (user directive 2026-08-04): the reply
|
|
1861
|
+
// must be exactly what the main Claude Code agent would say — no brevity
|
|
1862
|
+
// rules, no "spoken out loud" framing. Just a bare [MEETING — id] routing
|
|
1863
|
+
// tag, which is all the plumbing needs: PipelineDirectLLM keys
|
|
1864
|
+
// suppressMeetingTTS off the `[MEETING` prefix, and whether the reply is
|
|
1865
|
+
// SPOKEN is decided in CODE (activeMeetingBotId + meetingAddressedUntil),
|
|
1866
|
+
// not by any prompt instruction. Addressed vs observer is the code boolean.
|
|
1867
|
+
const header = `[MEETING — ${botId}]:`;
|
|
1839
1868
|
// Prepend + consume any interruption context (bot was cut off mid-sentence).
|
|
1840
1869
|
const interrupt = meetingInterruptContext ? `${meetingInterruptContext}\n` : '';
|
|
1841
1870
|
meetingInterruptContext = '';
|
|
@@ -5352,19 +5381,22 @@ async function main() {
|
|
|
5352
5381
|
recallJoin.registerBot(botId, sessionId);
|
|
5353
5382
|
activeMeetingBotId = botId;
|
|
5354
5383
|
await sendToFrontend({ type: 'meeting_joined', botId, message: 'Osborn has joined the meeting' });
|
|
5355
|
-
//
|
|
5356
|
-
//
|
|
5357
|
-
//
|
|
5358
|
-
//
|
|
5384
|
+
// Minimal awareness injection (user directive 2026-08-04): tell the
|
|
5385
|
+
// LLM it's in a meeting and how transcripts are tagged — nothing
|
|
5386
|
+
// more. NO "do NOT speak / silent observer" coaching (that fought
|
|
5387
|
+
// the goal of the reply being exactly the main agent's response).
|
|
5388
|
+
// The agent responds to meeting turns the same way it responds on
|
|
5389
|
+
// the website; note-taking is an optional background task, not a
|
|
5390
|
+
// replacement for responding.
|
|
5359
5391
|
if (currentLLM) {
|
|
5360
5392
|
try {
|
|
5361
5393
|
const sysCtx = new llm.ChatContext();
|
|
5362
5394
|
sysCtx.addMessage({
|
|
5363
5395
|
role: 'user',
|
|
5364
|
-
content: `[SYSTEM] You are now in a meeting (Recall bot ID: ${botId}, URL: ${meetingUrl}).
|
|
5396
|
+
content: `[SYSTEM] You are now in a meeting (Recall bot ID: ${botId}, URL: ${meetingUrl}). Live transcript arrives tagged \`[MEETING — ${botId}]:\`. Respond to what's said exactly as you naturally would — same as on the website, no special meeting phrasing. You may keep meeting-todos.md updated in the workspace in the background, but responding comes first.`,
|
|
5365
5397
|
});
|
|
5366
5398
|
currentLLM.chat({ chatCtx: sysCtx });
|
|
5367
|
-
console.log('📓 Meeting
|
|
5399
|
+
console.log('📓 Meeting awareness injection sent to LLM');
|
|
5368
5400
|
}
|
|
5369
5401
|
catch (sysErr) {
|
|
5370
5402
|
console.warn('⚠️ Meeting system injection failed:', sysErr.message);
|