osborn 0.9.224 β†’ 0.9.226

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.
@@ -2231,9 +2231,16 @@ class ClaudeLLMStream extends llm.LLMStream {
2231
2231
  if (!fromSubagent && sid && this.#opts.workingDirectory) {
2232
2232
  this.#llmRef.triggerStoreUpdate(sid, this.#opts.workingDirectory);
2233
2233
  }
2234
+ // Recall injection is best-effort and must NEVER approach the SDK's 30s hook
2235
+ // budget. Cap it hard at 1.5s β€” if the store is momentarily busy (e.g. a large
2236
+ // ingest sweep still draining), we skip recall for this turn rather than block
2237
+ // the prompt. The keyword/vector layers catch up on the next turn.
2234
2238
  const recalled = fromSubagent
2235
2239
  ? ''
2236
- : await buildRecallInjection(sid, this.#opts.workingDirectory, String(input?.prompt || ''));
2240
+ : await Promise.race([
2241
+ buildRecallInjection(sid, this.#opts.workingDirectory, String(input?.prompt || '')),
2242
+ new Promise((res) => setTimeout(() => res(''), 1500)),
2243
+ ]);
2237
2244
  const additionalContext = recalled ? `${reminder}\n\n${recalled}` : reminder;
2238
2245
  console.log(`πŸ“Œ UserPromptSubmit: injected turn-shape reminder (${reminder.length} chars)${recalled ? ` + recall (${recalled.length} chars)` : ''} for prompt="${promptPreview}..." [tool budget reset to 0/${TOOL_CALL_BUDGET}]`);
2239
2246
  return {
@@ -1,23 +1,28 @@
1
1
  /**
2
- * embedder.ts — Local text→vector embedder for the semantic (vec) layer of session-store.
2
+ * embedder.ts — Text→vector embedder for the semantic (vec) layer of session-store.
3
3
  *
4
- * Uses all-MiniLM-L6-v2 (384-dim) via @xenova/transformers, running fully local (no API,
5
- * no per-write network cost). Output is L2-normalized float32, quantized to int8[384]
6
- * (Γ—127) to match the sqlite-vec `int8[384]` column β€” 4Γ— smaller than float32 with
7
- * ~98–99% recall.
4
+ * CLOUD-FIRST (OpenRouter, OpenAI-compatible embeddings endpoint).
5
+ * Previously this ran all-MiniLM-L6-v2 locally via @xenova/transformers. That model
6
+ * inference is CPU-bound and SYNCHRONOUS on the single Node thread β€” during a large
7
+ * backfill it monopolized the event loop and starved voice, the SDK's time-boxed
8
+ * UserPromptSubmit hook, and fast-brain. Embedding over the network is async I/O:
9
+ * `await fetch(...)` frees the thread while the request is in flight, so the whole
10
+ * class of event-loop starvation from embedding is gone.
8
11
  *
9
12
  * DESIGN FOR RELIABILITY:
10
- * β€’ Lazy β€” the model is loaded on first use, never at import (startup stays fast).
11
- * β€’ Best-effort β€” if the package or model can't load, getEmbedder() returns null and
12
- * the store runs keyword-only. Embeddings never block the keyword write path.
13
- * β€’ Gated β€” set OSBORN_EMBED=0 to force keyword-only (e.g. on machines without the
14
- * model cached, or to avoid the first-run model download).
15
- *
16
- * Model cache honors TRANSFORMERS_CACHE / HF_HOME so it can be baked into the image.
13
+ * β€’ Best-effort β€” if the key is missing or a request fails/times out, the returned
14
+ * embedder yields null and the store runs keyword-only. Embeddings never block
15
+ * (or break) the keyword write/read path.
16
+ * β€’ Gated β€” OSBORN_EMBED=0 forces keyword-only.
17
+ * ‒ Consistent vectors — output is L2-normalized float→int8[EMBED_DIM] (×127) to
18
+ * match the sqlite-vec `int8[EMBED_DIM]` column. Qwen3-Embedding is Matryoshka
19
+ * (MRL) trained, so we ask for `dimensions: EMBED_DIM` AND defensively truncate
20
+ * the returned vector to EMBED_DIM and re-normalize β€” correct whether or not the
21
+ * API honors the `dimensions` passthrough.
17
22
  */
18
23
  import { type Embedder } from './session-store.js';
19
24
  /**
20
- * Returns an Embedder, or null if embeddings are unavailable/disabled.
21
- * The returned function itself also degrades to null on runtime failure.
25
+ * Returns an Embedder, or null if embeddings are disabled or no key is configured.
26
+ * The returned function degrades to null on any per-call failure (keyword-only recall).
22
27
  */
23
28
  export declare function getEmbedder(): Promise<Embedder | null>;
package/dist/embedder.js CHANGED
@@ -1,30 +1,48 @@
1
1
  /**
2
- * embedder.ts — Local text→vector embedder for the semantic (vec) layer of session-store.
2
+ * embedder.ts — Text→vector embedder for the semantic (vec) layer of session-store.
3
3
  *
4
- * Uses all-MiniLM-L6-v2 (384-dim) via @xenova/transformers, running fully local (no API,
5
- * no per-write network cost). Output is L2-normalized float32, quantized to int8[384]
6
- * (Γ—127) to match the sqlite-vec `int8[384]` column β€” 4Γ— smaller than float32 with
7
- * ~98–99% recall.
4
+ * CLOUD-FIRST (OpenRouter, OpenAI-compatible embeddings endpoint).
5
+ * Previously this ran all-MiniLM-L6-v2 locally via @xenova/transformers. That model
6
+ * inference is CPU-bound and SYNCHRONOUS on the single Node thread β€” during a large
7
+ * backfill it monopolized the event loop and starved voice, the SDK's time-boxed
8
+ * UserPromptSubmit hook, and fast-brain. Embedding over the network is async I/O:
9
+ * `await fetch(...)` frees the thread while the request is in flight, so the whole
10
+ * class of event-loop starvation from embedding is gone.
8
11
  *
9
12
  * DESIGN FOR RELIABILITY:
10
- * β€’ Lazy β€” the model is loaded on first use, never at import (startup stays fast).
11
- * β€’ Best-effort β€” if the package or model can't load, getEmbedder() returns null and
12
- * the store runs keyword-only. Embeddings never block the keyword write path.
13
- * β€’ Gated β€” set OSBORN_EMBED=0 to force keyword-only (e.g. on machines without the
14
- * model cached, or to avoid the first-run model download).
15
- *
16
- * Model cache honors TRANSFORMERS_CACHE / HF_HOME so it can be baked into the image.
13
+ * β€’ Best-effort β€” if the key is missing or a request fails/times out, the returned
14
+ * embedder yields null and the store runs keyword-only. Embeddings never block
15
+ * (or break) the keyword write/read path.
16
+ * β€’ Gated β€” OSBORN_EMBED=0 forces keyword-only.
17
+ * ‒ Consistent vectors — output is L2-normalized float→int8[EMBED_DIM] (×127) to
18
+ * match the sqlite-vec `int8[EMBED_DIM]` column. Qwen3-Embedding is Matryoshka
19
+ * (MRL) trained, so we ask for `dimensions: EMBED_DIM` AND defensively truncate
20
+ * the returned vector to EMBED_DIM and re-normalize β€” correct whether or not the
21
+ * API honors the `dimensions` passthrough.
17
22
  */
18
23
  import { EMBED_DIM, EMBED_MODEL } from './session-store.js';
24
+ const OPENROUTER_URL = process.env.OPENROUTER_EMBED_URL || 'https://openrouter.ai/api/v1/embeddings';
19
25
  const MODEL_ID = EMBED_MODEL;
20
- let pipelinePromise = null;
26
+ const MAX_BATCH = 64; // inputs per request (OpenAI-compatible arrays)
27
+ const REQUEST_TIMEOUT_MS = Number(process.env.OSBORN_EMBED_TIMEOUT_MS || 20_000);
21
28
  let disabled = false;
22
- /** Quantize a normalized float32 embedding to int8[dim] (Γ—127, clamped). */
23
- function quantizeInt8(floats) {
24
- const out = new Int8Array(EMBED_DIM);
29
+ function apiKey() {
30
+ return process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_WORKSPACE_API_KEY;
31
+ }
32
+ /**
33
+ * L2-normalize the first EMBED_DIM components of a float embedding, then quantize to
34
+ * int8[EMBED_DIM] (Γ—127, clamped). Truncating a Matryoshka vector to a prefix and
35
+ * re-normalizing is a valid lower-dim embedding in the same space.
36
+ */
37
+ function normalizeAndQuantize(floats) {
25
38
  const n = Math.min(EMBED_DIM, floats.length);
39
+ let sum = 0;
40
+ for (let i = 0; i < n; i++)
41
+ sum += floats[i] * floats[i];
42
+ const inv = sum > 0 ? 1 / Math.sqrt(sum) : 0;
43
+ const out = new Int8Array(EMBED_DIM);
26
44
  for (let i = 0; i < n; i++) {
27
- let v = Math.round(floats[i] * 127);
45
+ let v = Math.round(floats[i] * inv * 127);
28
46
  if (v > 127)
29
47
  v = 127;
30
48
  else if (v < -128)
@@ -33,66 +51,77 @@ function quantizeInt8(floats) {
33
51
  }
34
52
  return out;
35
53
  }
36
- async function loadPipeline() {
37
- if (disabled)
38
- return null;
39
- if (process.env.OSBORN_EMBED === '0') {
40
- disabled = true;
41
- return null;
42
- }
43
- if (!pipelinePromise) {
44
- pipelinePromise = (async () => {
45
- // Lazy import β€” @xenova/transformers is heavy and optional.
46
- const mod = await import('@xenova/transformers').catch(() => null);
47
- if (!mod)
48
- throw new Error('@xenova/transformers not installed');
49
- if (mod.env) {
50
- mod.env.allowLocalModels = true;
51
- // Avoid noisy multi-thread wasm issues in the agent process.
52
- if (mod.env.backends?.onnx?.wasm)
53
- mod.env.backends.onnx.wasm.numThreads = 1;
54
- }
55
- return mod.pipeline('feature-extraction', MODEL_ID);
56
- })().catch((err) => {
57
- disabled = true;
58
- pipelinePromise = null;
59
- throw err;
54
+ /** Embed one batch (≀ MAX_BATCH inputs). Returns aligned int8 vectors, or null on any failure. */
55
+ async function embedBatch(inputs, key) {
56
+ const controller = new AbortController();
57
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
58
+ try {
59
+ const res = await fetch(OPENROUTER_URL, {
60
+ method: 'POST',
61
+ headers: {
62
+ Authorization: `Bearer ${key}`,
63
+ 'Content-Type': 'application/json',
64
+ 'HTTP-Referer': 'https://voice-native.com',
65
+ 'X-Title': 'osborn',
66
+ },
67
+ body: JSON.stringify({
68
+ model: MODEL_ID,
69
+ input: inputs,
70
+ dimensions: EMBED_DIM, // MRL reduce; defensively truncated below if ignored
71
+ encoding_format: 'float',
72
+ }),
73
+ signal: controller.signal,
60
74
  });
75
+ if (!res.ok)
76
+ return null;
77
+ const json = await res.json();
78
+ const data = json?.data;
79
+ if (!Array.isArray(data) || !data.length)
80
+ return null;
81
+ // OpenAI-compatible: each entry carries its input `index`; align by it.
82
+ const byIndex = new Array(inputs.length).fill(null);
83
+ for (const d of data) {
84
+ const idx = typeof d?.index === 'number' ? d.index : -1;
85
+ const emb = d?.embedding;
86
+ if (idx >= 0 && idx < inputs.length && Array.isArray(emb)) {
87
+ byIndex[idx] = normalizeAndQuantize(emb);
88
+ }
89
+ }
90
+ if (byIndex.some((v) => v == null))
91
+ return null; // malformed / partial response
92
+ return byIndex;
93
+ }
94
+ catch {
95
+ return null; // network error, timeout/abort, bad JSON β€” degrade to keyword-only
96
+ }
97
+ finally {
98
+ clearTimeout(timer);
61
99
  }
62
- return pipelinePromise;
63
100
  }
64
101
  /**
65
- * Returns an Embedder, or null if embeddings are unavailable/disabled.
66
- * The returned function itself also degrades to null on runtime failure.
102
+ * Returns an Embedder, or null if embeddings are disabled or no key is configured.
103
+ * The returned function degrades to null on any per-call failure (keyword-only recall).
67
104
  */
68
105
  export async function getEmbedder() {
69
- if (process.env.OSBORN_EMBED === '0')
106
+ if (process.env.OSBORN_EMBED === '0' || disabled)
70
107
  return null;
71
- let pipe;
72
- try {
73
- pipe = await loadPipeline();
74
- }
75
- catch {
108
+ const key = apiKey();
109
+ if (!key) {
110
+ disabled = true; // no key on this machine β€” don't retry every turn
76
111
  return null;
77
112
  }
78
- if (!pipe)
79
- return null;
80
113
  const embed = async (texts) => {
81
- try {
82
- if (!texts.length)
83
- return [];
84
- const out = [];
85
- // Transformers.js handles batching internally; do them one-by-one to keep
86
- // memory bounded on long tool outputs.
87
- for (const t of texts) {
88
- const res = await pipe(t || ' ', { pooling: 'mean', normalize: true });
89
- out.push(quantizeInt8(res.data));
90
- }
91
- return out;
92
- }
93
- catch {
94
- return null;
114
+ if (!texts.length)
115
+ return [];
116
+ const out = [];
117
+ for (let i = 0; i < texts.length; i += MAX_BATCH) {
118
+ const chunk = texts.slice(i, i + MAX_BATCH).map((t) => t || ' ');
119
+ const vecs = await embedBatch(chunk, key);
120
+ if (!vecs)
121
+ return null; // any failure β†’ keyword-only for this call
122
+ out.push(...vecs);
95
123
  }
124
+ return out;
96
125
  };
97
126
  return embed;
98
127
  }
package/dist/index.js CHANGED
@@ -507,8 +507,10 @@ function startApiServer(workingDir, port) {
507
507
  const server = createServer(async (req, res) => {
508
508
  // CORS headers for cloud frontend
509
509
  res.setHeader('Access-Control-Allow-Origin', '*');
510
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
511
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
510
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
511
+ // Authorization is required for /secrets (Bearer OSBORN_SYNC_TOKEN); without it in the
512
+ // allow-list the browser preflight fails and the dashboard can't read/manage keys.
513
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
512
514
  if (req.method === 'OPTIONS') {
513
515
  res.writeHead(204);
514
516
  res.end();
@@ -333,7 +333,16 @@ export async function askPipelineFastBrain(workingDir, sessionId, question, opts
333
333
  ];
334
334
  try {
335
335
  let rounds = 0;
336
+ // Overall wall-clock cap. The per-request fetch timeout doesn't bound the whole
337
+ // tool loop; if the event loop is briefly busy (large ingest sweep) a search could
338
+ // otherwise stretch to 40–130s. Bail with whatever we have past this deadline.
339
+ const FASTBRAIN_DEADLINE_MS = 8000;
340
+ const startTime = Date.now();
336
341
  while (rounds < MAX_TOOL_ROUNDS) {
342
+ if (Date.now() - startTime > FASTBRAIN_DEADLINE_MS) {
343
+ console.warn(`🧠⚑ [pipeline-fb] deadline hit after ${rounds} rounds`);
344
+ break;
345
+ }
337
346
  rounds++;
338
347
  const data = await callOpenRouter(messages, tools, apiKey);
339
348
  const choice = data.choices?.[0];
@@ -29,9 +29,13 @@
29
29
  */
30
30
  import Database from 'better-sqlite3';
31
31
  export declare const STORE_VERSION = 1;
32
- export declare const EMBED_DIM = 384;
33
- /** Canonical embed model id β€” the single source of truth (embedder.ts imports this).
34
- * Recorded in meta.embed_model so a model swap is detected as a stale store. */
32
+ /** Vector width of the semantic layer. Qwen3-Embedding is Matryoshka-trained, so 1024
33
+ * is a valid MRL reduction of its native width β€” big enough for strong recall, small
34
+ * enough to keep int8 vectors ~1KB/row. Changing this rebuilds every store's vec table. */
35
+ export declare const EMBED_DIM = 1024;
36
+ /** Canonical embed model id β€” the single source of truth (embedder.ts imports this and
37
+ * sends it to OpenRouter). Recorded in meta.embed_model so a model swap is detected as a
38
+ * stale store and triggers a re-embed. */
35
39
  export declare const EMBED_MODEL: string;
36
40
  /** An embedder turns text into int8[EMBED_DIM] vectors (quantized, normalized). */
37
41
  export type Embedder = (texts: string[]) => Promise<Int8Array[] | null>;
@@ -38,10 +38,14 @@ import { getSessionPaths, getSessionSubAgents, projectPathToSlug } from './sessi
38
38
  // CONFIG / TYPES
39
39
  // ============================================================
40
40
  export const STORE_VERSION = 1;
41
- export const EMBED_DIM = 384;
42
- /** Canonical embed model id β€” the single source of truth (embedder.ts imports this).
43
- * Recorded in meta.embed_model so a model swap is detected as a stale store. */
44
- export const EMBED_MODEL = process.env.OSBORN_EMBED_MODEL || 'Xenova/all-MiniLM-L6-v2';
41
+ /** Vector width of the semantic layer. Qwen3-Embedding is Matryoshka-trained, so 1024
42
+ * is a valid MRL reduction of its native width β€” big enough for strong recall, small
43
+ * enough to keep int8 vectors ~1KB/row. Changing this rebuilds every store's vec table. */
44
+ export const EMBED_DIM = 1024;
45
+ /** Canonical embed model id β€” the single source of truth (embedder.ts imports this and
46
+ * sends it to OpenRouter). Recorded in meta.embed_model so a model swap is detected as a
47
+ * stale store and triggers a re-embed. */
48
+ export const EMBED_MODEL = process.env.OSBORN_EMBED_MODEL || 'qwen/qwen3-embedding-8b';
45
49
  // ============================================================
46
50
  // PATHS
47
51
  // ============================================================
@@ -251,22 +255,44 @@ function countLines(filePath, upToBytes) {
251
255
  c++;
252
256
  return c;
253
257
  }
258
+ /**
259
+ * Max bytes ingested from a single JSONL per sweep. A very large session file
260
+ * (100MB+) is drained gap-free across many turns instead of being read whole in
261
+ * one synchronous pass β€” that whole-file read + insert transaction is what pegged
262
+ * the single Node event loop and starved the SDK's time-boxed UserPromptSubmit hook.
263
+ */
264
+ const MAX_INGEST_BYTES = 4 * 1024 * 1024;
254
265
  /**
255
266
  * Ingest new bytes from a single JSONL file into the store.
256
- * Reads only from `fromOffset` forward. Returns rows inserted + new offset.
267
+ * Reads only from `fromOffset` forward, at most `maxBytes` per call. Returns rows
268
+ * inserted + new offset (which advances by the bytes actually consumed, so the next
269
+ * sweep resumes exactly where this one stopped).
257
270
  */
258
- function ingestFile(db, filePath, source, fromOffset) {
271
+ function ingestFile(db, filePath, source, fromOffset, maxBytes = MAX_INGEST_BYTES) {
259
272
  const inserted = [];
260
273
  if (!existsSync(filePath))
261
274
  return { inserted, newOffset: fromOffset, linesProcessed: 0 };
262
275
  const fileSize = statSync(filePath).size;
263
276
  if (fromOffset >= fileSize)
264
277
  return { inserted, newOffset: fromOffset, linesProcessed: 0 };
265
- const buf = Buffer.alloc(fileSize - fromOffset);
278
+ const remaining = fileSize - fromOffset;
279
+ const reachedEof = remaining <= maxBytes;
280
+ const readLen = Math.min(remaining, maxBytes);
281
+ const buf = Buffer.alloc(readLen);
266
282
  const fd = openSync(filePath, 'r');
267
283
  readSync(fd, buf, 0, buf.length, fromOffset);
268
284
  closeSync(fd);
269
- const rawLines = buf.toString('utf-8').split('\n');
285
+ // If we stopped short of EOF, trim back to the last complete line so a JSON record
286
+ // is never split across sweeps. If the window holds no newline at all (a single
287
+ // line longer than maxBytes), take it whole rather than stall forever.
288
+ let usable = buf;
289
+ if (!reachedEof) {
290
+ const lastNl = buf.lastIndexOf(10);
291
+ if (lastNl >= 0)
292
+ usable = buf.subarray(0, lastNl + 1);
293
+ }
294
+ const consumed = usable.length;
295
+ const rawLines = usable.toString('utf-8').split('\n');
270
296
  let lineNum = fromOffset === 0 ? 1 : countLines(filePath, fromOffset) + 1;
271
297
  let cursor = fromOffset;
272
298
  let linesProcessed = 0;
@@ -309,7 +335,7 @@ function ingestFile(db, filePath, source, fromOffset) {
309
335
  }
310
336
  lineNum++;
311
337
  }
312
- return { inserted, newOffset: fileSize, linesProcessed };
338
+ return { inserted, newOffset: fromOffset + consumed, linesProcessed };
313
339
  }
314
340
  /**
315
341
  * Build or update the session store incrementally.
@@ -391,6 +417,8 @@ async function embedRows(db, rows, embed, onProgress) {
391
417
  const BATCH = 64;
392
418
  let done = 0;
393
419
  for (let i = 0; i < rows.length; i += BATCH) {
420
+ // embed() is now a network call (OpenRouter) β€” `await` already yields the event
421
+ // loop while the request is in flight, so no manual setImmediate pump is needed.
394
422
  const batch = rows.slice(i, i + BATCH);
395
423
  // Cap embedding input length β€” long tool outputs blow up the model with no recall gain.
396
424
  const vectors = await embed(batch.map(r => r.text.slice(0, 8000)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osborn",
3
- "version": "0.9.224",
3
+ "version": "0.9.226",
4
4
  "description": "Voice AI coding assistant - local agent that connects to Osborn frontend",
5
5
  "type": "module",
6
6
  "bin": {
@@ -46,7 +46,6 @@
46
46
  "@smithery/api": "^0.48.0",
47
47
  "@types/diff": "^8.0.0",
48
48
  "@vscode/ripgrep": "^1.17.1",
49
- "@xenova/transformers": "^2.17.2",
50
49
  "better-sqlite3": "^13.0.3",
51
50
  "diff": "^8.0.4",
52
51
  "dotenv": "^16.4.0",