osborn 0.9.224 β 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/claude-llm.js +8 -1
- package/dist/embedder.d.ts +19 -14
- package/dist/embedder.js +117 -65
- package/dist/index.js +4 -2
- package/dist/pipeline-fastbrain.js +9 -0
- package/dist/session-store.d.ts +7 -3
- package/dist/session-store.js +84 -23
- package/package.json +1 -2
package/dist/claude-llm.js
CHANGED
|
@@ -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
|
|
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 {
|
package/dist/embedder.d.ts
CHANGED
|
@@ -1,23 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* embedder.ts β
|
|
2
|
+
* embedder.ts β Textβvector embedder for the semantic (vec) layer of session-store.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
* β’
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* β’ Gated β
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
|
21
|
-
* The returned function
|
|
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,57 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* embedder.ts β
|
|
2
|
+
* embedder.ts β Textβvector embedder for the semantic (vec) layer of session-store.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
* β’
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* β’ Gated β
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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);
|
|
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
|
+
}
|
|
38
|
+
function apiKey() {
|
|
39
|
+
return process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_WORKSPACE_API_KEY;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* L2-normalize the first EMBED_DIM components of a float embedding, then quantize to
|
|
43
|
+
* int8[EMBED_DIM] (Γ127, clamped). Truncating a Matryoshka vector to a prefix and
|
|
44
|
+
* re-normalizing is a valid lower-dim embedding in the same space.
|
|
45
|
+
*/
|
|
46
|
+
function normalizeAndQuantize(floats) {
|
|
25
47
|
const n = Math.min(EMBED_DIM, floats.length);
|
|
48
|
+
let sum = 0;
|
|
49
|
+
for (let i = 0; i < n; i++)
|
|
50
|
+
sum += floats[i] * floats[i];
|
|
51
|
+
const inv = sum > 0 ? 1 / Math.sqrt(sum) : 0;
|
|
52
|
+
const out = new Int8Array(EMBED_DIM);
|
|
26
53
|
for (let i = 0; i < n; i++) {
|
|
27
|
-
let v = Math.round(floats[i] * 127);
|
|
54
|
+
let v = Math.round(floats[i] * inv * 127);
|
|
28
55
|
if (v > 127)
|
|
29
56
|
v = 127;
|
|
30
57
|
else if (v < -128)
|
|
@@ -33,66 +60,91 @@ function quantizeInt8(floats) {
|
|
|
33
60
|
}
|
|
34
61
|
return out;
|
|
35
62
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if
|
|
53
|
-
|
|
63
|
+
/** Embed one batch (β€ MAX_BATCH inputs). Returns aligned int8 vectors, or null on any failure. */
|
|
64
|
+
async function embedBatch(inputs, key) {
|
|
65
|
+
const controller = new AbortController();
|
|
66
|
+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
67
|
+
try {
|
|
68
|
+
const res = await fetch(OPENROUTER_URL, {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: {
|
|
71
|
+
Authorization: `Bearer ${key}`,
|
|
72
|
+
'Content-Type': 'application/json',
|
|
73
|
+
'HTTP-Referer': 'https://voice-native.com',
|
|
74
|
+
'X-Title': 'osborn',
|
|
75
|
+
},
|
|
76
|
+
body: JSON.stringify({
|
|
77
|
+
model: MODEL_ID,
|
|
78
|
+
input: inputs,
|
|
79
|
+
dimensions: EMBED_DIM, // MRL reduce; defensively truncated below if ignored
|
|
80
|
+
encoding_format: 'float',
|
|
81
|
+
}),
|
|
82
|
+
signal: controller.signal,
|
|
83
|
+
});
|
|
84
|
+
if (!res.ok) {
|
|
85
|
+
const body = await res.text().catch(() => '');
|
|
86
|
+
embedWarn(`OpenRouter ${res.status} ${res.statusText}: ${body.slice(0, 200)}`);
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
const json = await res.json();
|
|
90
|
+
const data = json?.data;
|
|
91
|
+
if (!Array.isArray(data) || !data.length) {
|
|
92
|
+
embedWarn('empty/invalid embeddings response');
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
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.
|
|
97
|
+
const byIndex = new Array(inputs.length).fill(null);
|
|
98
|
+
data.forEach((d, pos) => {
|
|
99
|
+
const idx = typeof d?.index === 'number' ? d.index : pos;
|
|
100
|
+
const emb = d?.embedding;
|
|
101
|
+
if (idx >= 0 && idx < inputs.length && Array.isArray(emb)) {
|
|
102
|
+
byIndex[idx] = normalizeAndQuantize(emb);
|
|
54
103
|
}
|
|
55
|
-
return mod.pipeline('feature-extraction', MODEL_ID);
|
|
56
|
-
})().catch((err) => {
|
|
57
|
-
disabled = true;
|
|
58
|
-
pipelinePromise = null;
|
|
59
|
-
throw err;
|
|
60
104
|
});
|
|
105
|
+
if (byIndex.some((v) => v == null)) {
|
|
106
|
+
embedWarn('response missing/misaligned embeddings');
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
return byIndex;
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
embedWarn(`request failed: ${err?.name || ''} ${err?.message || err}`);
|
|
113
|
+
return null; // network error, timeout/abort, bad JSON β degrade to keyword-only
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
clearTimeout(timer);
|
|
61
117
|
}
|
|
62
|
-
return pipelinePromise;
|
|
63
118
|
}
|
|
64
119
|
/**
|
|
65
|
-
* Returns an Embedder, or null if embeddings are
|
|
66
|
-
* The returned function
|
|
120
|
+
* Returns an Embedder, or null if embeddings are disabled or no key is configured.
|
|
121
|
+
* The returned function degrades to null on any per-call failure (keyword-only recall).
|
|
67
122
|
*/
|
|
68
123
|
export async function getEmbedder() {
|
|
69
124
|
if (process.env.OSBORN_EMBED === '0')
|
|
70
125
|
return null;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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.
|
|
131
|
+
const key = apiKey();
|
|
132
|
+
if (!key) {
|
|
133
|
+
embedWarn('no OPENROUTER_API_KEY in env yet β keyword-only for this call');
|
|
76
134
|
return null;
|
|
77
135
|
}
|
|
78
|
-
if (!pipe)
|
|
79
|
-
return null;
|
|
80
136
|
const embed = async (texts) => {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
return out;
|
|
92
|
-
}
|
|
93
|
-
catch {
|
|
94
|
-
return null;
|
|
137
|
+
if (!texts.length)
|
|
138
|
+
return [];
|
|
139
|
+
const out = [];
|
|
140
|
+
for (let i = 0; i < texts.length; i += MAX_BATCH) {
|
|
141
|
+
const chunk = texts.slice(i, i + MAX_BATCH).map((t) => t || ' ');
|
|
142
|
+
const vecs = await embedBatch(chunk, key);
|
|
143
|
+
if (!vecs)
|
|
144
|
+
return null; // any failure β keyword-only for this call
|
|
145
|
+
out.push(...vecs);
|
|
95
146
|
}
|
|
147
|
+
return out;
|
|
96
148
|
};
|
|
97
149
|
return embed;
|
|
98
150
|
}
|
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
|
-
|
|
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];
|
package/dist/session-store.d.ts
CHANGED
|
@@ -29,9 +29,13 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import Database from 'better-sqlite3';
|
|
31
31
|
export declare const STORE_VERSION = 1;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
*
|
|
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>;
|
package/dist/session-store.js
CHANGED
|
@@ -38,10 +38,14 @@ import { getSessionPaths, getSessionSubAgents, projectPathToSlug } from './sessi
|
|
|
38
38
|
// CONFIG / TYPES
|
|
39
39
|
// ============================================================
|
|
40
40
|
export const STORE_VERSION = 1;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
*
|
|
44
|
-
export const
|
|
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,54 @@ function countLines(filePath, upToBytes) {
|
|
|
251
255
|
c++;
|
|
252
256
|
return c;
|
|
253
257
|
}
|
|
258
|
+
/**
|
|
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.
|
|
262
|
+
*/
|
|
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;
|
|
254
275
|
/**
|
|
255
276
|
* Ingest new bytes from a single JSONL file into the store.
|
|
256
|
-
* Reads only from `fromOffset` forward
|
|
277
|
+
* Reads only from `fromOffset` forward, at most `maxBytes` per call. Returns rows
|
|
278
|
+
* inserted + new offset (which advances by the bytes actually consumed, so the next
|
|
279
|
+
* sweep resumes exactly where this one stopped).
|
|
257
280
|
*/
|
|
258
|
-
function ingestFile(db, filePath, source, fromOffset) {
|
|
281
|
+
function ingestFile(db, filePath, source, fromOffset, maxBytes = MAX_INGEST_BYTES) {
|
|
259
282
|
const inserted = [];
|
|
260
283
|
if (!existsSync(filePath))
|
|
261
284
|
return { inserted, newOffset: fromOffset, linesProcessed: 0 };
|
|
262
285
|
const fileSize = statSync(filePath).size;
|
|
263
286
|
if (fromOffset >= fileSize)
|
|
264
287
|
return { inserted, newOffset: fromOffset, linesProcessed: 0 };
|
|
265
|
-
const
|
|
288
|
+
const remaining = fileSize - fromOffset;
|
|
289
|
+
const reachedEof = remaining <= maxBytes;
|
|
290
|
+
const readLen = Math.min(remaining, maxBytes);
|
|
291
|
+
const buf = Buffer.alloc(readLen);
|
|
266
292
|
const fd = openSync(filePath, 'r');
|
|
267
293
|
readSync(fd, buf, 0, buf.length, fromOffset);
|
|
268
294
|
closeSync(fd);
|
|
269
|
-
|
|
295
|
+
// If we stopped short of EOF, trim back to the last complete line so a JSON record
|
|
296
|
+
// is never split across sweeps. If the window holds no newline at all (a single
|
|
297
|
+
// line longer than maxBytes), take it whole rather than stall forever.
|
|
298
|
+
let usable = buf;
|
|
299
|
+
if (!reachedEof) {
|
|
300
|
+
const lastNl = buf.lastIndexOf(10);
|
|
301
|
+
if (lastNl >= 0)
|
|
302
|
+
usable = buf.subarray(0, lastNl + 1);
|
|
303
|
+
}
|
|
304
|
+
const consumed = usable.length;
|
|
305
|
+
const rawLines = usable.toString('utf-8').split('\n');
|
|
270
306
|
let lineNum = fromOffset === 0 ? 1 : countLines(filePath, fromOffset) + 1;
|
|
271
307
|
let cursor = fromOffset;
|
|
272
308
|
let linesProcessed = 0;
|
|
@@ -309,7 +345,7 @@ function ingestFile(db, filePath, source, fromOffset) {
|
|
|
309
345
|
}
|
|
310
346
|
lineNum++;
|
|
311
347
|
}
|
|
312
|
-
return { inserted, newOffset:
|
|
348
|
+
return { inserted, newOffset: fromOffset + consumed, linesProcessed };
|
|
313
349
|
}
|
|
314
350
|
/**
|
|
315
351
|
* Build or update the session store incrementally.
|
|
@@ -344,21 +380,44 @@ export async function updateSessionStore(sessionId, workingDir, opts) {
|
|
|
344
380
|
const upSrc = db.prepare(`INSERT INTO sources(source, jsonl_path, byte_offset, line_count) VALUES (@source,@path,@off,@lines)
|
|
345
381
|
ON CONFLICT(source) DO UPDATE SET jsonl_path=@path, byte_offset=@off, line_count=@lines`);
|
|
346
382
|
const freshRows = [];
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
lines: (prev?.line_count ?? 0) + res.linesProcessed,
|
|
356
|
-
});
|
|
357
|
-
}
|
|
358
|
-
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));
|
|
359
391
|
}
|
|
360
|
-
|
|
361
|
-
|
|
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
|
+
}
|
|
362
421
|
opts?.onProgress?.(`content: +${freshRows.length} rows across ${sources.length} sources`);
|
|
363
422
|
// ββ Embeddings (best-effort, never blocks the keyword layer) ββ
|
|
364
423
|
let embeddedRows = 0;
|
|
@@ -391,6 +450,8 @@ async function embedRows(db, rows, embed, onProgress) {
|
|
|
391
450
|
const BATCH = 64;
|
|
392
451
|
let done = 0;
|
|
393
452
|
for (let i = 0; i < rows.length; i += BATCH) {
|
|
453
|
+
// embed() is now a network call (OpenRouter) β `await` already yields the event
|
|
454
|
+
// loop while the request is in flight, so no manual setImmediate pump is needed.
|
|
394
455
|
const batch = rows.slice(i, i + BATCH);
|
|
395
456
|
// Cap embedding input length β long tool outputs blow up the model with no recall gain.
|
|
396
457
|
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.
|
|
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": {
|
|
@@ -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",
|