osborn 0.9.226 → 0.9.227

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/embedder.js CHANGED
@@ -25,7 +25,16 @@ const OPENROUTER_URL = process.env.OPENROUTER_EMBED_URL || 'https://openrouter.a
25
25
  const MODEL_ID = EMBED_MODEL;
26
26
  const MAX_BATCH = 64; // inputs per request (OpenAI-compatible arrays)
27
27
  const REQUEST_TIMEOUT_MS = Number(process.env.OSBORN_EMBED_TIMEOUT_MS || 20_000);
28
- let disabled = false;
28
+ let lastEmbedWarn = 0;
29
+ /** Throttled failure log (max ~1/30s) so a bulk re-embed can't spam, but embedded=0 is
30
+ * never silent — every failure surfaces a reason (status/network) in the machine logs. */
31
+ function embedWarn(msg) {
32
+ const now = Date.now();
33
+ if (now - lastEmbedWarn > 30_000) {
34
+ lastEmbedWarn = now;
35
+ console.warn(`[embedder] ${msg}`);
36
+ }
37
+ }
29
38
  function apiKey() {
30
39
  return process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_WORKSPACE_API_KEY;
31
40
  }
@@ -72,26 +81,35 @@ async function embedBatch(inputs, key) {
72
81
  }),
73
82
  signal: controller.signal,
74
83
  });
75
- if (!res.ok)
84
+ if (!res.ok) {
85
+ const body = await res.text().catch(() => '');
86
+ embedWarn(`OpenRouter ${res.status} ${res.statusText}: ${body.slice(0, 200)}`);
76
87
  return null;
88
+ }
77
89
  const json = await res.json();
78
90
  const data = json?.data;
79
- if (!Array.isArray(data) || !data.length)
91
+ if (!Array.isArray(data) || !data.length) {
92
+ embedWarn('empty/invalid embeddings response');
80
93
  return null;
94
+ }
81
95
  // OpenAI-compatible: each entry carries its input `index`; align by it.
96
+ // Fall back to array order if `index` is absent so a missing field never voids the batch.
82
97
  const byIndex = new Array(inputs.length).fill(null);
83
- for (const d of data) {
84
- const idx = typeof d?.index === 'number' ? d.index : -1;
98
+ data.forEach((d, pos) => {
99
+ const idx = typeof d?.index === 'number' ? d.index : pos;
85
100
  const emb = d?.embedding;
86
101
  if (idx >= 0 && idx < inputs.length && Array.isArray(emb)) {
87
102
  byIndex[idx] = normalizeAndQuantize(emb);
88
103
  }
104
+ });
105
+ if (byIndex.some((v) => v == null)) {
106
+ embedWarn('response missing/misaligned embeddings');
107
+ return null;
89
108
  }
90
- if (byIndex.some((v) => v == null))
91
- return null; // malformed / partial response
92
109
  return byIndex;
93
110
  }
94
- catch {
111
+ catch (err) {
112
+ embedWarn(`request failed: ${err?.name || ''} ${err?.message || err}`);
95
113
  return null; // network error, timeout/abort, bad JSON — degrade to keyword-only
96
114
  }
97
115
  finally {
@@ -103,11 +121,16 @@ async function embedBatch(inputs, key) {
103
121
  * The returned function degrades to null on any per-call failure (keyword-only recall).
104
122
  */
105
123
  export async function getEmbedder() {
106
- if (process.env.OSBORN_EMBED === '0' || disabled)
124
+ if (process.env.OSBORN_EMBED === '0')
107
125
  return null;
126
+ // Re-read the key EVERY call and never latch off on a missing key: the platform
127
+ // OpenRouter key is hydrated into the env by the secrets layer, which can finish
128
+ // AFTER the first (boot-time) warm call. A permanent latch there disabled embedding
129
+ // for the whole session (embedded=0) even though fast-brain — which reads the key
130
+ // fresh per request — kept working. Re-reading recovers as soon as the key lands.
108
131
  const key = apiKey();
109
132
  if (!key) {
110
- disabled = true; // no key on this machine — don't retry every turn
133
+ embedWarn('no OPENROUTER_API_KEY in env yet — keyword-only for this call');
111
134
  return null;
112
135
  }
113
136
  const embed = async (texts) => {
@@ -256,12 +256,22 @@ function countLines(filePath, upToBytes) {
256
256
  return c;
257
257
  }
258
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.
259
+ * Max bytes ingested from a SINGLE JSONL per sweep. Kept small so each synchronous
260
+ * read+parse+gzip+FTS-insert block is short (~sub-300ms) and never stutters realtime
261
+ * voice (VAD) between event-loop yields.
263
262
  */
264
- const MAX_INGEST_BYTES = 4 * 1024 * 1024;
263
+ const MAX_INGEST_BYTES = 1 * 1024 * 1024;
264
+ /**
265
+ * Max bytes ingested across ALL sources in one turn. This is the cap that actually
266
+ * matters: a session can have HUNDREDS of subagent JSONL files, and on a store
267
+ * migration the offsets reset so every source re-ingests from 0 at once. The old
268
+ * code looped all sources inside ONE synchronous db.transaction — hundreds of MB of
269
+ * synchronous work that pegged the single Node thread for minutes and starved VAD,
270
+ * the SDK's UserPromptSubmit hook (30s timeout), and fast-brain. Bounding the total
271
+ * per turn (and yielding between sources) drains large sessions over many turns
272
+ * without ever monopolizing the loop.
273
+ */
274
+ const MAX_INGEST_BYTES_PER_TURN = 4 * 1024 * 1024;
265
275
  /**
266
276
  * Ingest new bytes from a single JSONL file into the store.
267
277
  * Reads only from `fromOffset` forward, at most `maxBytes` per call. Returns rows
@@ -370,21 +380,44 @@ export async function updateSessionStore(sessionId, workingDir, opts) {
370
380
  const upSrc = db.prepare(`INSERT INTO sources(source, jsonl_path, byte_offset, line_count) VALUES (@source,@path,@off,@lines)
371
381
  ON CONFLICT(source) DO UPDATE SET jsonl_path=@path, byte_offset=@off, line_count=@lines`);
372
382
  const freshRows = [];
373
- const txn = db.transaction(() => {
374
- for (const s of sources) {
375
- const prev = getSrc.get(s.source);
376
- const fromOffset = prev?.byte_offset ?? 0;
377
- const res = ingestFile(db, s.path, s.source, fromOffset);
378
- if (res.inserted.length || res.newOffset !== fromOffset) {
379
- upSrc.run({
380
- source: s.source, path: s.path, off: res.newOffset,
381
- lines: (prev?.line_count ?? 0) + res.linesProcessed,
382
- });
383
- }
384
- freshRows.push(...res.inserted);
383
+ let budget = MAX_INGEST_BYTES_PER_TURN;
384
+ let sinceYield = 0;
385
+ for (const s of sources) {
386
+ // Yield to the event loop periodically even across drained sources, so iterating
387
+ // a large source list (statSync + SELECT each) can't itself stall voice/hook.
388
+ if (++sinceYield >= 8) {
389
+ sinceYield = 0;
390
+ await new Promise((r) => setImmediate(r));
385
391
  }
386
- });
387
- txn();
392
+ if (budget <= 0)
393
+ break; // per-turn cap reached — remaining sources drain next turn
394
+ const prev = getSrc.get(s.source);
395
+ const fromOffset = prev?.byte_offset ?? 0;
396
+ const cap = Math.min(budget, MAX_INGEST_BYTES);
397
+ let res;
398
+ try {
399
+ // Per-source transaction (not one giant txn over all sources) so we can yield
400
+ // between them and never hold the thread for the whole fleet of subagent files.
401
+ res = db.transaction(() => ingestFile(db, s.path, s.source, fromOffset, cap))();
402
+ }
403
+ catch {
404
+ continue; // a bad source never blocks the rest
405
+ }
406
+ if (res.inserted.length || res.newOffset !== fromOffset) {
407
+ upSrc.run({
408
+ source: s.source, path: s.path, off: res.newOffset,
409
+ lines: (prev?.line_count ?? 0) + res.linesProcessed,
410
+ });
411
+ }
412
+ const consumed = res.newOffset - fromOffset;
413
+ budget -= consumed;
414
+ freshRows.push(...res.inserted);
415
+ // Yield after any source that did real synchronous work.
416
+ if (consumed > 0) {
417
+ sinceYield = 0;
418
+ await new Promise((r) => setImmediate(r));
419
+ }
420
+ }
388
421
  opts?.onProgress?.(`content: +${freshRows.length} rows across ${sources.length} sources`);
389
422
  // ── Embeddings (best-effort, never blocks the keyword layer) ──
390
423
  let embeddedRows = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osborn",
3
- "version": "0.9.226",
3
+ "version": "0.9.227",
4
4
  "description": "Voice AI coding assistant - local agent that connects to Osborn frontend",
5
5
  "type": "module",
6
6
  "bin": {