osborn 0.9.189 → 0.9.191

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.
@@ -62,6 +62,11 @@ function stripMarkdownForTTS(text) {
62
62
  .replace(/ +/g, ' ')
63
63
  .trim();
64
64
  }
65
+ function getSubagentsDir(workingDir) {
66
+ const dir = join(workingDir, 'subagents');
67
+ mkdirSync(dir, { recursive: true });
68
+ return dir;
69
+ }
65
70
  /**
66
71
  * Load skill files from agent/.claude/skills/{name}/SKILL.md
67
72
  * Injects into system prompt so Claude sees them as available capabilities.
@@ -1332,7 +1337,7 @@ export class ClaudeLLM extends llm.LLM {
1332
1337
  // agents roster prevents any SubagentStop(agent_type==='writer') from
1333
1338
  // firing inside this one-shot query and re-arming the backstop loop.
1334
1339
  const reviewerOptions = {
1335
- cwd: this.#opts.workingDirectory,
1340
+ cwd: getSubagentsDir(this.#opts.workingDirectory),
1336
1341
  permissionMode: 'default',
1337
1342
  systemPrompt: NAMED_AGENTS.reviewer.prompt,
1338
1343
  allowedTools: ['Read', 'Glob', 'Grep', 'Bash', 'Write', 'Edit'],
@@ -1441,7 +1446,7 @@ export class ClaudeLLM extends llm.LLM {
1441
1446
  gitDiff,
1442
1447
  ].join('\n');
1443
1448
  const testerOptions = {
1444
- cwd: this.#opts.workingDirectory,
1449
+ cwd: getSubagentsDir(this.#opts.workingDirectory),
1445
1450
  permissionMode: 'default',
1446
1451
  systemPrompt: NAMED_AGENTS.tester.prompt,
1447
1452
  allowedTools: ['Read', 'Glob', 'Grep', 'Bash', 'Write', 'Edit'],
@@ -1528,7 +1533,7 @@ export class ClaudeLLM extends llm.LLM {
1528
1533
  // an agents roster would allow delegation back to the writer, which would
1529
1534
  // fire SubagentStop(agent_type==='writer') and re-arm the backstop.
1530
1535
  const gateOptions = {
1531
- cwd: this.#opts.workingDirectory,
1536
+ cwd: getSubagentsDir(this.#opts.workingDirectory),
1532
1537
  permissionMode: 'default',
1533
1538
  systemPrompt: NAMED_AGENTS.reasoner.prompt,
1534
1539
  hooks: {
package/dist/config.d.ts CHANGED
@@ -146,7 +146,7 @@ export declare function invalidateSessionListCache(): void;
146
146
  *
147
147
  * @param limit - Max sessions to return (default 100, sorted by recency)
148
148
  */
149
- export declare function listAllClaudeSessions(limit?: number): Promise<ClaudeSessionEntry[]>;
149
+ export declare function listAllClaudeSessions(limit?: number, includeSubagents?: boolean): Promise<ClaudeSessionEntry[]>;
150
150
  /**
151
151
  * Session summary for context briefing when switching sessions
152
152
  */
package/dist/config.js CHANGED
@@ -555,15 +555,23 @@ export function invalidateSessionListCache() {
555
555
  *
556
556
  * @param limit - Max sessions to return (default 100, sorted by recency)
557
557
  */
558
- export async function listAllClaudeSessions(limit = 1000) {
558
+ export async function listAllClaudeSessions(limit = 1000, includeSubagents = false) {
559
559
  if (_sessionListCache && Date.now() < _sessionListCache.expiresAt) {
560
- return _sessionListCache.data.slice(0, limit);
560
+ const cached = includeSubagents
561
+ ? _sessionListCache.data
562
+ : _sessionListCache.data.filter(s => !s.projectSlug.endsWith('-subagents'));
563
+ return cached.slice(0, limit);
561
564
  }
562
565
  const projectsDir = getClaudeProjectsDir();
563
566
  if (!existsSync(projectsDir))
564
567
  return [];
565
- // 1. Discover all project folders
568
+ // 1. Discover all project folders — skip subagent slugs unless explicitly requested.
569
+ // Subagent sessions live in {workingDir}/subagents, which slugifies to a folder
570
+ // ending in "-subagents". Filtering here keeps the session limit budget for
571
+ // real user sessions and prevents subagent noise from crowding out other projects.
566
572
  const projectFolders = readdirSync(projectsDir).filter(name => {
573
+ if (!includeSubagents && name.endsWith('-subagents'))
574
+ return false;
567
575
  const fullPath = join(projectsDir, name);
568
576
  try {
569
577
  return statSync(fullPath).isDirectory();
package/dist/index.js CHANGED
@@ -29,7 +29,7 @@ import { createGunzip } from 'node:zlib';
29
29
  const __filename = fileURLToPath(import.meta.url);
30
30
  const __dirname = dirname(__filename);
31
31
  import { createPatch } from 'diff';
32
- import { loadConfig, getMcpServers, getEnabledMcpServerNames, getVoiceMode, getDirectConfig, listAllClaudeSessions, invalidateSessionListCache, getMostRecentSessionId, sessionExists, getSessionSummary, getConversationHistory, ensureSessionWorkspace, getMcpServerStatusList, buildMcpServersForKeys, listWorkspaceArtifacts } from './config.js';
32
+ import { loadConfig, getMcpServers, getEnabledMcpServerNames, getVoiceMode, getDirectConfig, listSessions, listAllClaudeSessions, invalidateSessionListCache, getMostRecentSessionId, sessionExists, getSessionSummary, getConversationHistory, ensureSessionWorkspace, getMcpServerStatusList, buildMcpServersForKeys, listWorkspaceArtifacts } from './config.js';
33
33
  import { createSTT, createTTS, DIRECT_MODE_STT, DIRECT_MODE_TTS } from './voice-io.js';
34
34
  import { createClaudeLLM, NAMED_AGENTS, applyTurbo } from './claude-llm.js';
35
35
  import { clearPipelineFastBrainSession, prewarmBM25Index } from './pipeline-fastbrain.js';
@@ -3603,9 +3603,19 @@ async function main() {
3603
3603
  // discardAudioIfUninterruptible: true, ttsReadIdleTimeout: 10000,
3604
3604
  // maxUnrecoverableErrors: 3) are what was silently running via caret-resolved
3605
3605
  // 1.4.5 throughout the user's working month. Restoring them.
3606
- const turnDetector = process.env.LIVEKIT_REMOTE_EOT_URL ? new CloudTurnDetector() : undefined;
3606
+ // STT endpointing is the default (nova-3, 25ms silence-based VAD — reliable, fast).
3607
+ // CloudTurnDetector (ML semantic EOT) is used ONLY if LIVEKIT_REMOTE_EOT_URL is set
3608
+ // AND the endpoint passes a startup probe (returns valid JSON probability).
3609
+ // If the probe fails (wrong URL, no auth, non-JSON response), STT is kept.
3610
+ let turnDetection = 'stt';
3611
+ if (process.env.LIVEKIT_REMOTE_EOT_URL) {
3612
+ const detector = new CloudTurnDetector();
3613
+ const eotLive = await detector.probe();
3614
+ if (eotLive)
3615
+ turnDetection = detector;
3616
+ }
3607
3617
  const session = new voice.AgentSession({
3608
- turnDetection: (turnDetector ?? 'stt'),
3618
+ turnDetection,
3609
3619
  preemptiveGeneration: false, // Only fire LLM on final committed transcript, not partial preemptives
3610
3620
  // Commented out — kept for reference. These were added across 0.9.60/0.9.61
3611
3621
  // to try to harden interrupt + TTS handling, but evidence (osbornojure
@@ -4553,11 +4563,32 @@ async function main() {
4553
4563
  console.log('💓 Sending agent_ready signal...');
4554
4564
  let readySent = false;
4555
4565
  // (realtime provider removed)
4556
- // Session list state — populated async so it doesn't block the first sendReady
4566
+ // Session list state — populated in two tiers (fast then full)
4557
4567
  let sessionsForFrontend = [];
4558
4568
  let recentSessionId = null;
4559
4569
  let hasRecentSession = false;
4560
4570
  let sessionsLoaded = false;
4571
+ // Tier 1: fast scan — current project only, ~5ms
4572
+ // Gives the frontend enough to show "Resume last session?" immediately.
4573
+ try {
4574
+ const recentSessions = await listSessions(workingDir);
4575
+ if (recentSessions.length > 0) {
4576
+ const top = recentSessions[0];
4577
+ recentSessionId = top.sessionId;
4578
+ hasRecentSession = true;
4579
+ sessionsForFrontend = [{
4580
+ sessionId: top.sessionId,
4581
+ projectSlug: top.projectPath,
4582
+ projectPath: top.projectPath,
4583
+ cwd: top.projectPath,
4584
+ timestamp: top.timestamp.toISOString(),
4585
+ lastMessage: top.lastMessage,
4586
+ messageCount: top.messageCount,
4587
+ fileSize: 0,
4588
+ }];
4589
+ }
4590
+ }
4591
+ catch { }
4561
4592
  const sendReady = async () => {
4562
4593
  if (readySent)
4563
4594
  return;
@@ -4580,13 +4611,13 @@ async function main() {
4580
4611
  });
4581
4612
  };
4582
4613
  const readyInterval = setInterval(sendReady, 2000);
4583
- // Fire immediately (sessionsLoaded:false) so the frontend unblocks right away
4614
+ // Fire immediately with the fast-scanned recent session (sessionsLoaded still false)
4584
4615
  await sendReady();
4585
4616
  setTimeout(() => {
4586
4617
  clearInterval(readyInterval);
4587
4618
  console.log('✅ agent_ready retries complete');
4588
4619
  }, 20000);
4589
- // Load session list in background — re-send agent_ready once populated
4620
+ // Tier 2: full cross-project scan in background — no blocking
4590
4621
  listAllClaudeSessions().then(allSessions => {
4591
4622
  recentSessionId = allSessions.length > 0 ? allSessions[0].sessionId : null;
4592
4623
  hasRecentSession = allSessions.length > 0;
@@ -5,14 +5,14 @@
5
5
  * because it calls getJobContext(). This shim implements the same _TurnDetector
6
6
  * interface but makes the remote HTTP call directly — no worker framework needed.
7
7
  *
8
+ * Auth: LiveKit Cloud inference requires a signed JWT (same format as room access
9
+ * tokens). We cache the token and refresh it before expiry so we're not signing
10
+ * on every turn.
11
+ *
8
12
  * On LiveKit Cloud (LIVEKIT_REMOTE_EOT_URL set): HTTP call to inference gateway.
9
13
  * Without the URL: Returns 1.0 (always end of turn — STT endpointing handles it).
10
14
  */
11
15
  import type { llm } from '@livekit/agents';
12
- /**
13
- * Implements _TurnDetector interface for LiveKit Cloud remote inference
14
- * without requiring JobContext / worker framework.
15
- */
16
16
  export declare class CloudTurnDetector {
17
17
  #private;
18
18
  readonly model = "lk_end_of_utterance_multilingual";
@@ -20,5 +20,11 @@ export declare class CloudTurnDetector {
20
20
  constructor();
21
21
  unlikelyThreshold(_language?: string): Promise<number | undefined>;
22
22
  supportsLanguage(_language?: string): Promise<boolean>;
23
+ /**
24
+ * Startup probe — send a minimal request to confirm the endpoint returns
25
+ * valid JSON probability. Returns true if the EOT service is live and real.
26
+ * Called once at session init; if false, index.ts falls back to 'stt'.
27
+ */
28
+ probe(): Promise<boolean>;
23
29
  predictEndOfTurn(chatCtx: llm.ChatContext, _timeout?: number): Promise<number>;
24
30
  }
@@ -5,19 +5,24 @@
5
5
  * because it calls getJobContext(). This shim implements the same _TurnDetector
6
6
  * interface but makes the remote HTTP call directly — no worker framework needed.
7
7
  *
8
+ * Auth: LiveKit Cloud inference requires a signed JWT (same format as room access
9
+ * tokens). We cache the token and refresh it before expiry so we're not signing
10
+ * on every turn.
11
+ *
8
12
  * On LiveKit Cloud (LIVEKIT_REMOTE_EOT_URL set): HTTP call to inference gateway.
9
13
  * Without the URL: Returns 1.0 (always end of turn — STT endpointing handles it).
10
14
  */
11
15
  import { log } from '@livekit/agents';
16
+ import { AccessToken } from 'livekit-server-sdk';
12
17
  const REMOTE_INFERENCE_TIMEOUT = 2000;
13
18
  const MAX_HISTORY_TURNS = 15;
14
- /**
15
- * Implements _TurnDetector interface for LiveKit Cloud remote inference
16
- * without requiring JobContext / worker framework.
17
- */
19
+ const TOKEN_TTL_SECONDS = 600; // 10-minute JWT
20
+ const TOKEN_REFRESH_BUFFER = 60; // refresh 60s before expiry
18
21
  export class CloudTurnDetector {
19
22
  #remoteUrl;
20
23
  #logger = log();
24
+ #cachedToken;
25
+ #tokenExpiresAt = 0;
21
26
  model = 'lk_end_of_utterance_multilingual';
22
27
  provider = 'livekit';
23
28
  constructor() {
@@ -30,18 +35,78 @@ export class CloudTurnDetector {
30
35
  console.log(`🧠 Turn detector: LiveKit Cloud remote inference (${this.#remoteUrl})`);
31
36
  }
32
37
  else {
33
- console.log('🧠 Turn detector: No LIVEKIT_REMOTE_EOT_URL — STT endpointing fallback');
38
+ console.log('🧠 Turn detector: No LIVEKIT_REMOTE_EOT_URL — STT endpointing only');
39
+ }
40
+ }
41
+ async #getAuthToken() {
42
+ const apiKey = process.env.LIVEKIT_API_KEY;
43
+ const apiSecret = process.env.LIVEKIT_API_SECRET;
44
+ if (!apiKey || !apiSecret)
45
+ return undefined;
46
+ const nowSec = Math.floor(Date.now() / 1000);
47
+ if (this.#cachedToken && nowSec < this.#tokenExpiresAt - TOKEN_REFRESH_BUFFER) {
48
+ return this.#cachedToken;
34
49
  }
50
+ // Sign a fresh token — identity is the agent, no room grants needed for inference
51
+ const at = new AccessToken(apiKey, apiSecret, {
52
+ identity: 'osborn-eot-agent',
53
+ ttl: `${TOKEN_TTL_SECONDS}s`,
54
+ });
55
+ this.#cachedToken = await at.toJwt();
56
+ this.#tokenExpiresAt = nowSec + TOKEN_TTL_SECONDS;
57
+ return this.#cachedToken;
35
58
  }
36
59
  async unlikelyThreshold(_language) {
37
- return undefined; // Let the framework use defaults
60
+ return undefined;
38
61
  }
39
62
  async supportsLanguage(_language) {
40
- return true; // Multilingual model supports all languages
63
+ return true;
64
+ }
65
+ /**
66
+ * Startup probe — send a minimal request to confirm the endpoint returns
67
+ * valid JSON probability. Returns true if the EOT service is live and real.
68
+ * Called once at session init; if false, index.ts falls back to 'stt'.
69
+ */
70
+ async probe() {
71
+ if (!this.#remoteUrl)
72
+ return false;
73
+ try {
74
+ const token = await this.#getAuthToken();
75
+ const headers = { 'Content-Type': 'application/json' };
76
+ if (token)
77
+ headers['Authorization'] = `Bearer ${token}`;
78
+ const resp = await fetch(`${this.#remoteUrl}/eot/multi`, {
79
+ method: 'POST',
80
+ body: JSON.stringify({
81
+ messages: [{ role: 'user', content: 'test' }],
82
+ jobId: 'osborn-probe',
83
+ workerId: 'osborn-direct',
84
+ }),
85
+ headers,
86
+ signal: AbortSignal.timeout(3000),
87
+ });
88
+ if (!resp.ok)
89
+ return false;
90
+ const text = await resp.text();
91
+ try {
92
+ const data = JSON.parse(text);
93
+ const ok = typeof data.probability === 'number';
94
+ console.log(`🧠 EOT probe: ${ok ? '✅ live' : '❌ non-JSON ("' + text.slice(0, 30) + '")'} — ${ok ? 'using CloudTurnDetector' : 'falling back to STT'}`);
95
+ return ok;
96
+ }
97
+ catch {
98
+ console.log(`🧠 EOT probe: ❌ non-JSON response ("${text.slice(0, 40)}") — falling back to STT endpointing`);
99
+ return false;
100
+ }
101
+ }
102
+ catch (err) {
103
+ console.log(`🧠 EOT probe: ❌ unreachable — ${err instanceof Error ? err.message : err} — falling back to STT`);
104
+ return false;
105
+ }
41
106
  }
42
107
  async predictEndOfTurn(chatCtx, _timeout) {
43
108
  if (!this.#remoteUrl) {
44
- return 1.0; // No remote URL = always end of turn (STT handles it)
109
+ return 1.0;
45
110
  }
46
111
  try {
47
112
  const messages = chatCtx
@@ -57,31 +122,41 @@ export class CloudTurnDetector {
57
122
  excludeAudio: true,
58
123
  excludeTimestamp: true,
59
124
  }),
60
- // Dummy IDs — LiveKit Cloud uses these for routing/logging, not auth
61
125
  jobId: `osborn-${Date.now()}`,
62
126
  workerId: 'osborn-direct',
63
127
  };
64
128
  const agentId = process.env.LIVEKIT_AGENT_ID;
65
- if (agentId) {
129
+ if (agentId)
66
130
  request.agentId = agentId;
67
- }
131
+ const token = await this.#getAuthToken();
132
+ const headers = { 'Content-Type': 'application/json' };
133
+ if (token)
134
+ headers['Authorization'] = `Bearer ${token}`;
68
135
  const resp = await fetch(`${this.#remoteUrl}/eot/multi`, {
69
136
  method: 'POST',
70
137
  body: JSON.stringify(request),
71
- headers: { 'Content-Type': 'application/json' },
138
+ headers,
72
139
  signal: AbortSignal.timeout(REMOTE_INFERENCE_TIMEOUT),
73
140
  });
74
141
  if (!resp.ok) {
75
- return 1.0; // Failed — default to end of turn
142
+ this.#logger.warn(`EOT inference returned ${resp.status} — falling back to STT`);
143
+ return 1.0;
76
144
  }
77
- const data = (await resp.json());
78
- if (typeof data.probability === 'number' && data.probability >= 0) {
79
- return data.probability;
145
+ const text = await resp.text();
146
+ try {
147
+ const data = JSON.parse(text);
148
+ if (typeof data.probability === 'number' && data.probability >= 0) {
149
+ return data.probability;
150
+ }
151
+ }
152
+ catch {
153
+ // Non-JSON response (e.g. "OK") — log once then fall through
154
+ this.#logger.warn(`EOT inference returned non-JSON: "${text.slice(0, 40)}" — auth may be wrong`);
80
155
  }
81
156
  return 1.0;
82
157
  }
83
158
  catch {
84
- return 1.0; // Timeout/error — default to end of turn
159
+ return 1.0;
85
160
  }
86
161
  }
87
162
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osborn",
3
- "version": "0.9.189",
3
+ "version": "0.9.191",
4
4
  "description": "Voice AI coding assistant - local agent that connects to Osborn frontend",
5
5
  "type": "module",
6
6
  "bin": {