osborn 0.9.223 → 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.
- package/dist/boot-secrets.d.ts +16 -0
- package/dist/boot-secrets.js +17 -0
- package/dist/claude-llm.js +8 -1
- package/dist/embedder.d.ts +19 -14
- package/dist/embedder.js +95 -66
- package/dist/env-keys.d.ts +13 -0
- package/dist/env-keys.js +20 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +69 -2
- package/dist/pipeline-fastbrain.js +9 -0
- package/dist/secrets-store.d.ts +61 -0
- package/dist/secrets-store.js +168 -0
- package/dist/session-store.d.ts +7 -3
- package/dist/session-store.js +37 -9
- package/package.json +1 -2
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* boot-secrets.ts — side-effect module that hydrates user secrets from the Fly
|
|
3
|
+
* volume into process.env at the EARLIEST possible point.
|
|
4
|
+
*
|
|
5
|
+
* Why a separate module instead of a call in index.ts: ES module imports are
|
|
6
|
+
* hoisted and their bodies run in source order BEFORE any statement in the
|
|
7
|
+
* importing module. Some consumers read their keys at module-load time
|
|
8
|
+
* (recall-client.ts reads RECALL_REGION into a top-level const; claude-auth is
|
|
9
|
+
* called later but still expects env present). Importing THIS module
|
|
10
|
+
* immediately after `dotenv/config` — and before every other agent import —
|
|
11
|
+
* guarantees hydrateSecretsIntoEnv() runs before those bodies evaluate, so the
|
|
12
|
+
* volume-persisted user keys win over machine-env defaults.
|
|
13
|
+
*
|
|
14
|
+
* Keep this import positioned second in index.ts (right after dotenv/config).
|
|
15
|
+
*/
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* boot-secrets.ts — side-effect module that hydrates user secrets from the Fly
|
|
3
|
+
* volume into process.env at the EARLIEST possible point.
|
|
4
|
+
*
|
|
5
|
+
* Why a separate module instead of a call in index.ts: ES module imports are
|
|
6
|
+
* hoisted and their bodies run in source order BEFORE any statement in the
|
|
7
|
+
* importing module. Some consumers read their keys at module-load time
|
|
8
|
+
* (recall-client.ts reads RECALL_REGION into a top-level const; claude-auth is
|
|
9
|
+
* called later but still expects env present). Importing THIS module
|
|
10
|
+
* immediately after `dotenv/config` — and before every other agent import —
|
|
11
|
+
* guarantees hydrateSecretsIntoEnv() runs before those bodies evaluate, so the
|
|
12
|
+
* volume-persisted user keys win over machine-env defaults.
|
|
13
|
+
*
|
|
14
|
+
* Keep this import positioned second in index.ts (right after dotenv/config).
|
|
15
|
+
*/
|
|
16
|
+
import { hydrateSecretsIntoEnv } from './secrets-store.js';
|
|
17
|
+
hydrateSecretsIntoEnv();
|
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,48 @@
|
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
|
|
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
|
|
66
|
-
* The returned function
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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/env-keys.d.ts
CHANGED
|
@@ -25,3 +25,16 @@ export declare const AGENT_ENV_KEYS: AgentEnvKey[];
|
|
|
25
25
|
export declare const REQUIRED_AGENT_ENV_KEYS: string[];
|
|
26
26
|
/** Return the required keys that are missing from the given env (default process.env). */
|
|
27
27
|
export declare function missingRequiredEnv(env?: NodeJS.ProcessEnv): string[];
|
|
28
|
+
/**
|
|
29
|
+
* Keys a user may add/rotate live (mid-session, no reboot) via the /secrets
|
|
30
|
+
* endpoint. These are read by osborn's OWN code (not baked into a plugin SDK at
|
|
31
|
+
* import time), so writing process.env[KEY] + persisting to the volume takes
|
|
32
|
+
* effect on the next read — see secrets-store.ts. This is the allowlist: the
|
|
33
|
+
* /secrets endpoint rejects any key not in this set, so a user can never inject
|
|
34
|
+
* platform/infra keys (FLY_*, LIVEKIT_*, SMITHERY_API_KEY, tokens, etc.).
|
|
35
|
+
*
|
|
36
|
+
* Mirrors the scope:'user' entries in frontend/src/lib/platform-env.ts.
|
|
37
|
+
*/
|
|
38
|
+
export declare const USER_SECRET_KEYS: string[];
|
|
39
|
+
/** True if `key` is a user-manageable secret (allowlisted for /secrets writes). */
|
|
40
|
+
export declare function isUserSecretKey(key: string): boolean;
|
package/dist/env-keys.js
CHANGED
|
@@ -45,3 +45,23 @@ export const REQUIRED_AGENT_ENV_KEYS = AGENT_ENV_KEYS
|
|
|
45
45
|
export function missingRequiredEnv(env = process.env) {
|
|
46
46
|
return REQUIRED_AGENT_ENV_KEYS.filter((k) => !env[k]);
|
|
47
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Keys a user may add/rotate live (mid-session, no reboot) via the /secrets
|
|
50
|
+
* endpoint. These are read by osborn's OWN code (not baked into a plugin SDK at
|
|
51
|
+
* import time), so writing process.env[KEY] + persisting to the volume takes
|
|
52
|
+
* effect on the next read — see secrets-store.ts. This is the allowlist: the
|
|
53
|
+
* /secrets endpoint rejects any key not in this set, so a user can never inject
|
|
54
|
+
* platform/infra keys (FLY_*, LIVEKIT_*, SMITHERY_API_KEY, tokens, etc.).
|
|
55
|
+
*
|
|
56
|
+
* Mirrors the scope:'user' entries in frontend/src/lib/platform-env.ts.
|
|
57
|
+
*/
|
|
58
|
+
export const USER_SECRET_KEYS = [
|
|
59
|
+
'ANTHROPIC_API_KEY',
|
|
60
|
+
'OPENROUTER_API_KEY',
|
|
61
|
+
'RECALL_API_KEY',
|
|
62
|
+
'RECALL_REGION',
|
|
63
|
+
];
|
|
64
|
+
/** True if `key` is a user-manageable secret (allowlisted for /secrets writes). */
|
|
65
|
+
export function isUserSecretKey(key) {
|
|
66
|
+
return USER_SECRET_KEYS.includes(key);
|
|
67
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
// Load environment variables FIRST before any other imports
|
|
2
2
|
import 'dotenv/config';
|
|
3
|
+
// Then hydrate volume-persisted user secrets into process.env, BEFORE any
|
|
4
|
+
// module that reads its keys at load time (recall-client's RECALL_REGION const,
|
|
5
|
+
// claude-auth fallback). Side-effect import — must stay right after dotenv.
|
|
6
|
+
import './boot-secrets.js';
|
|
3
7
|
import { voice, initializeLogger } from '@livekit/agents';
|
|
4
8
|
import { CloudTurnDetector } from './turn-detector-shim.js';
|
|
5
9
|
import { Room, RoomEvent, } from '@livekit/rtc-node';
|
|
@@ -41,6 +45,7 @@ import { MCP_CATALOG } from './config.js';
|
|
|
41
45
|
import { getRecallClient } from './recall-client.js';
|
|
42
46
|
import { MeetingTranscriptPoller } from './meeting-transcript-poller.js';
|
|
43
47
|
import { missingRequiredEnv } from './env-keys.js';
|
|
48
|
+
import { listSecretStatus, setSecrets, deleteSecret } from './secrets-store.js';
|
|
44
49
|
import { llm } from '@livekit/agents';
|
|
45
50
|
// ============================================================
|
|
46
51
|
// PIPELINE MODE: STT (Deepgram) → Claude Agent SDK → TTS
|
|
@@ -304,6 +309,7 @@ const AGENT_ROUTE_PREFIXES = [
|
|
|
304
309
|
'/canvas-stream', '/events', '/room-code', '/connect-room', '/leave-room',
|
|
305
310
|
'/report-bug', '/restart', '/tts', '/webhook', '/sessions/export',
|
|
306
311
|
'/sessions/import', '/sessions/manifest',
|
|
312
|
+
'/secrets',
|
|
307
313
|
'/editor',
|
|
308
314
|
];
|
|
309
315
|
// Set true when code-server is confirmed ready; false when stopped/not running.
|
|
@@ -501,8 +507,10 @@ function startApiServer(workingDir, port) {
|
|
|
501
507
|
const server = createServer(async (req, res) => {
|
|
502
508
|
// CORS headers for cloud frontend
|
|
503
509
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
504
|
-
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
505
|
-
|
|
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');
|
|
506
514
|
if (req.method === 'OPTIONS') {
|
|
507
515
|
res.writeHead(204);
|
|
508
516
|
res.end();
|
|
@@ -1519,6 +1527,65 @@ function startApiServer(workingDir, port) {
|
|
|
1519
1527
|
}
|
|
1520
1528
|
return;
|
|
1521
1529
|
}
|
|
1530
|
+
// ── /secrets ── user-manageable API keys (live, no reboot) ───────────────
|
|
1531
|
+
// GET → status of each user key (set?/masked/source) — NEVER raw values.
|
|
1532
|
+
// POST → { KEY: value, ... } set/rotate; empty string deletes. Allowlist-
|
|
1533
|
+
// filtered (USER_SECRET_KEYS); writes the volume store + process.env.
|
|
1534
|
+
// DELETE ?key=KEY → remove one key.
|
|
1535
|
+
// Auth is REQUIRED here (unlike the soft export/import gate): if the machine
|
|
1536
|
+
// has no OSBORN_SYNC_TOKEN we refuse rather than expose an open write path.
|
|
1537
|
+
if (url.pathname === '/secrets') {
|
|
1538
|
+
if (!syncToken) {
|
|
1539
|
+
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
1540
|
+
res.end(JSON.stringify({ error: 'Secrets endpoint unavailable: no sync token configured' }));
|
|
1541
|
+
return;
|
|
1542
|
+
}
|
|
1543
|
+
const authHeader = req.headers['authorization'] ?? '';
|
|
1544
|
+
if (authHeader !== `Bearer ${syncToken}`) {
|
|
1545
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
1546
|
+
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1549
|
+
if (req.method === 'GET') {
|
|
1550
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1551
|
+
res.end(JSON.stringify({ secrets: listSecretStatus() }));
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1554
|
+
if (req.method === 'POST') {
|
|
1555
|
+
let body = '';
|
|
1556
|
+
req.on('data', (chunk) => { body += chunk.toString(); });
|
|
1557
|
+
req.on('end', () => {
|
|
1558
|
+
try {
|
|
1559
|
+
const parsed = JSON.parse(body || '{}');
|
|
1560
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
1561
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1562
|
+
res.end(JSON.stringify({ error: 'Body must be a JSON object of { KEY: value }' }));
|
|
1563
|
+
return;
|
|
1564
|
+
}
|
|
1565
|
+
const result = setSecrets(parsed);
|
|
1566
|
+
console.log(`🔑 /secrets POST — set:[${result.set.join(',')}] deleted:[${result.deleted.join(',')}] rejected:[${result.rejected.join(',')}]`);
|
|
1567
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1568
|
+
res.end(JSON.stringify({ ...result, secrets: listSecretStatus() }));
|
|
1569
|
+
}
|
|
1570
|
+
catch {
|
|
1571
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1572
|
+
res.end(JSON.stringify({ error: 'Invalid JSON body' }));
|
|
1573
|
+
}
|
|
1574
|
+
});
|
|
1575
|
+
return;
|
|
1576
|
+
}
|
|
1577
|
+
if (req.method === 'DELETE') {
|
|
1578
|
+
const key = url.searchParams.get('key') ?? '';
|
|
1579
|
+
const existed = deleteSecret(key);
|
|
1580
|
+
console.log(`🔑 /secrets DELETE ${key} — ${existed ? 'removed' : 'not present / not allowed'}`);
|
|
1581
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1582
|
+
res.end(JSON.stringify({ deleted: existed, key, secrets: listSecretStatus() }));
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
1585
|
+
res.writeHead(405, { 'Content-Type': 'application/json' });
|
|
1586
|
+
res.end(JSON.stringify({ error: 'Method not allowed' }));
|
|
1587
|
+
return;
|
|
1588
|
+
}
|
|
1522
1589
|
// ── /editor entry point ──────────────────────────────────────────────────
|
|
1523
1590
|
// Explicit door into the IDE. When code-server is ready this route mints the
|
|
1524
1591
|
// osborn_ide session cookie and 302s to / (where the proxy then forwards). If
|
|
@@ -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];
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* secrets-store.ts — volume-persisted store for user-manageable API keys.
|
|
3
|
+
*
|
|
4
|
+
* Osborn's platform keys (LiveKit, Deepgram, Smithery, Fly infra) are injected
|
|
5
|
+
* as machine env by the frontend and only change on reboot — fine, they rarely
|
|
6
|
+
* rotate. But a *user* adding their own Anthropic / OpenRouter / Recall key
|
|
7
|
+
* mid-conversation must take effect WITHOUT a reboot (the OS can't hot-inject
|
|
8
|
+
* env into a running process). So those keys live here instead of in machine
|
|
9
|
+
* env: a JSON file on the Fly volume (`~/.osborn/secrets.json` →
|
|
10
|
+
* `/workspace/.osborn/secrets.json`, survives restart AND recreate) that we
|
|
11
|
+
* ALSO mirror into process.env so osborn's own code (which reads process.env)
|
|
12
|
+
* picks them up on the next read.
|
|
13
|
+
*
|
|
14
|
+
* Security:
|
|
15
|
+
* • Writes are allowlist-filtered by USER_SECRET_KEYS — a user can never set
|
|
16
|
+
* a platform/infra key (FLY_*, LIVEKIT_*, tokens…) through this path.
|
|
17
|
+
* • File is written mode 0600.
|
|
18
|
+
* • Secret VALUES are never returned by listSecretStatus() — status only.
|
|
19
|
+
*
|
|
20
|
+
* Keep the allowlist in sync with env-keys.ts (USER_SECRET_KEYS) and the
|
|
21
|
+
* frontend platform-env.ts scope:'user' entries.
|
|
22
|
+
*/
|
|
23
|
+
export interface SecretStatus {
|
|
24
|
+
key: string;
|
|
25
|
+
/** true if a non-empty value is currently set (in the store or process.env). */
|
|
26
|
+
set: boolean;
|
|
27
|
+
/** true if this key holds a credential (masked); false = plain config (e.g. region). */
|
|
28
|
+
secret: boolean;
|
|
29
|
+
/** masked preview — never the raw value; empty string when unset. */
|
|
30
|
+
masked: string;
|
|
31
|
+
/** where the current value came from, for the UI. */
|
|
32
|
+
source: 'store' | 'env' | 'none';
|
|
33
|
+
}
|
|
34
|
+
export interface SetSecretsResult {
|
|
35
|
+
set: string[];
|
|
36
|
+
deleted: string[];
|
|
37
|
+
rejected: string[];
|
|
38
|
+
}
|
|
39
|
+
/** Return the current store (allowlist-filtered), without touching env. */
|
|
40
|
+
export declare function loadSecrets(): Record<string, string>;
|
|
41
|
+
/**
|
|
42
|
+
* Copy stored user secrets into process.env so osborn's own code sees them.
|
|
43
|
+
* Call ONCE at bootstrap, BEFORE applyAuthFallback() and before recall-client
|
|
44
|
+
* is imported (both read these keys at module/boot time). Store values win over
|
|
45
|
+
* pre-existing machine env only when non-empty. Returns the keys hydrated.
|
|
46
|
+
*/
|
|
47
|
+
export declare function hydrateSecretsIntoEnv(): string[];
|
|
48
|
+
/**
|
|
49
|
+
* Set (or delete) user secrets. An empty-string value deletes the key.
|
|
50
|
+
* Writes the volume file AND mirrors into process.env for live effect.
|
|
51
|
+
* Keys not in USER_SECRET_KEYS are rejected (never written).
|
|
52
|
+
*/
|
|
53
|
+
export declare function setSecrets(entries: Record<string, unknown>): SetSecretsResult;
|
|
54
|
+
/** Delete a single user secret. */
|
|
55
|
+
export declare function deleteSecret(key: string): boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Report which user keys are set — WITHOUT returning raw values. Considers both
|
|
58
|
+
* the store and the live process.env (so a key injected as machine env still
|
|
59
|
+
* shows as "set", sourced 'env'). Values are masked.
|
|
60
|
+
*/
|
|
61
|
+
export declare function listSecretStatus(): SecretStatus[];
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* secrets-store.ts — volume-persisted store for user-manageable API keys.
|
|
3
|
+
*
|
|
4
|
+
* Osborn's platform keys (LiveKit, Deepgram, Smithery, Fly infra) are injected
|
|
5
|
+
* as machine env by the frontend and only change on reboot — fine, they rarely
|
|
6
|
+
* rotate. But a *user* adding their own Anthropic / OpenRouter / Recall key
|
|
7
|
+
* mid-conversation must take effect WITHOUT a reboot (the OS can't hot-inject
|
|
8
|
+
* env into a running process). So those keys live here instead of in machine
|
|
9
|
+
* env: a JSON file on the Fly volume (`~/.osborn/secrets.json` →
|
|
10
|
+
* `/workspace/.osborn/secrets.json`, survives restart AND recreate) that we
|
|
11
|
+
* ALSO mirror into process.env so osborn's own code (which reads process.env)
|
|
12
|
+
* picks them up on the next read.
|
|
13
|
+
*
|
|
14
|
+
* Security:
|
|
15
|
+
* • Writes are allowlist-filtered by USER_SECRET_KEYS — a user can never set
|
|
16
|
+
* a platform/infra key (FLY_*, LIVEKIT_*, tokens…) through this path.
|
|
17
|
+
* • File is written mode 0600.
|
|
18
|
+
* • Secret VALUES are never returned by listSecretStatus() — status only.
|
|
19
|
+
*
|
|
20
|
+
* Keep the allowlist in sync with env-keys.ts (USER_SECRET_KEYS) and the
|
|
21
|
+
* frontend platform-env.ts scope:'user' entries.
|
|
22
|
+
*/
|
|
23
|
+
import { readFileSync, existsSync, mkdirSync, writeFileSync, chmodSync } from 'fs';
|
|
24
|
+
import { homedir } from 'os';
|
|
25
|
+
import { join } from 'path';
|
|
26
|
+
import { USER_SECRET_KEYS, isUserSecretKey } from './env-keys.js';
|
|
27
|
+
const SECRETS_DIR = join(homedir(), '.osborn');
|
|
28
|
+
const SECRETS_FILE = join(SECRETS_DIR, 'secrets.json');
|
|
29
|
+
/** Keys whose values must be masked in any status readout (all except RECALL_REGION). */
|
|
30
|
+
const NON_SECRET_KEYS = new Set(['RECALL_REGION']);
|
|
31
|
+
/** Read the on-disk store. Returns {} if missing/unreadable. */
|
|
32
|
+
function readStore() {
|
|
33
|
+
if (!existsSync(SECRETS_FILE))
|
|
34
|
+
return {};
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(readFileSync(SECRETS_FILE, 'utf-8'));
|
|
37
|
+
if (parsed && typeof parsed === 'object')
|
|
38
|
+
return parsed;
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
console.error(`❌ Failed to read secrets store: ${err.message}`);
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Write the store atomically-ish with 0600 perms. */
|
|
47
|
+
function writeStore(store) {
|
|
48
|
+
if (!existsSync(SECRETS_DIR)) {
|
|
49
|
+
mkdirSync(SECRETS_DIR, { recursive: true });
|
|
50
|
+
}
|
|
51
|
+
writeFileSync(SECRETS_FILE, JSON.stringify(store, null, 2), { encoding: 'utf-8', mode: 0o600 });
|
|
52
|
+
try {
|
|
53
|
+
chmodSync(SECRETS_FILE, 0o600);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// best-effort; some filesystems ignore chmod
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Return the current store (allowlist-filtered), without touching env. */
|
|
60
|
+
export function loadSecrets() {
|
|
61
|
+
const store = readStore();
|
|
62
|
+
const clean = {};
|
|
63
|
+
for (const [k, v] of Object.entries(store)) {
|
|
64
|
+
if (isUserSecretKey(k) && typeof v === 'string' && v.length > 0)
|
|
65
|
+
clean[k] = v;
|
|
66
|
+
}
|
|
67
|
+
return clean;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Copy stored user secrets into process.env so osborn's own code sees them.
|
|
71
|
+
* Call ONCE at bootstrap, BEFORE applyAuthFallback() and before recall-client
|
|
72
|
+
* is imported (both read these keys at module/boot time). Store values win over
|
|
73
|
+
* pre-existing machine env only when non-empty. Returns the keys hydrated.
|
|
74
|
+
*/
|
|
75
|
+
export function hydrateSecretsIntoEnv() {
|
|
76
|
+
const store = loadSecrets();
|
|
77
|
+
const applied = [];
|
|
78
|
+
for (const [k, v] of Object.entries(store)) {
|
|
79
|
+
process.env[k] = v;
|
|
80
|
+
applied.push(k);
|
|
81
|
+
}
|
|
82
|
+
if (applied.length > 0) {
|
|
83
|
+
console.log(`🔑 Hydrated ${applied.length} user secret(s) from volume: ${applied.join(', ')}`);
|
|
84
|
+
}
|
|
85
|
+
return applied;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Set (or delete) user secrets. An empty-string value deletes the key.
|
|
89
|
+
* Writes the volume file AND mirrors into process.env for live effect.
|
|
90
|
+
* Keys not in USER_SECRET_KEYS are rejected (never written).
|
|
91
|
+
*/
|
|
92
|
+
export function setSecrets(entries) {
|
|
93
|
+
const store = readStore();
|
|
94
|
+
const result = { set: [], deleted: [], rejected: [] };
|
|
95
|
+
for (const [rawKey, rawVal] of Object.entries(entries)) {
|
|
96
|
+
const key = rawKey.trim();
|
|
97
|
+
if (!isUserSecretKey(key)) {
|
|
98
|
+
result.rejected.push(key);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const val = typeof rawVal === 'string' ? rawVal.trim() : '';
|
|
102
|
+
if (val.length === 0) {
|
|
103
|
+
// delete
|
|
104
|
+
delete store[key];
|
|
105
|
+
delete process.env[key];
|
|
106
|
+
result.deleted.push(key);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
store[key] = val;
|
|
110
|
+
process.env[key] = val;
|
|
111
|
+
result.set.push(key);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// Only persist allowlisted keys (drop any stale non-allowlisted entries too).
|
|
115
|
+
const persisted = {};
|
|
116
|
+
for (const [k, v] of Object.entries(store)) {
|
|
117
|
+
if (isUserSecretKey(k) && typeof v === 'string' && v.length > 0)
|
|
118
|
+
persisted[k] = v;
|
|
119
|
+
}
|
|
120
|
+
writeStore(persisted);
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
/** Delete a single user secret. */
|
|
124
|
+
export function deleteSecret(key) {
|
|
125
|
+
if (!isUserSecretKey(key))
|
|
126
|
+
return false;
|
|
127
|
+
const store = readStore();
|
|
128
|
+
const existed = key in store;
|
|
129
|
+
delete store[key];
|
|
130
|
+
delete process.env[key];
|
|
131
|
+
const persisted = {};
|
|
132
|
+
for (const [k, v] of Object.entries(store)) {
|
|
133
|
+
if (isUserSecretKey(k) && typeof v === 'string' && v.length > 0)
|
|
134
|
+
persisted[k] = v;
|
|
135
|
+
}
|
|
136
|
+
writeStore(persisted);
|
|
137
|
+
return existed;
|
|
138
|
+
}
|
|
139
|
+
function mask(value) {
|
|
140
|
+
if (!value)
|
|
141
|
+
return '';
|
|
142
|
+
if (value.length <= 8)
|
|
143
|
+
return '••••';
|
|
144
|
+
return `${value.slice(0, 4)}••••${value.slice(-4)}`;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Report which user keys are set — WITHOUT returning raw values. Considers both
|
|
148
|
+
* the store and the live process.env (so a key injected as machine env still
|
|
149
|
+
* shows as "set", sourced 'env'). Values are masked.
|
|
150
|
+
*/
|
|
151
|
+
export function listSecretStatus() {
|
|
152
|
+
const store = readStore();
|
|
153
|
+
return USER_SECRET_KEYS.map((key) => {
|
|
154
|
+
const inStore = typeof store[key] === 'string' && store[key].length > 0;
|
|
155
|
+
const envVal = process.env[key];
|
|
156
|
+
const inEnv = typeof envVal === 'string' && envVal.length > 0;
|
|
157
|
+
const value = inStore ? store[key] : inEnv ? envVal : '';
|
|
158
|
+
const isSecret = !NON_SECRET_KEYS.has(key);
|
|
159
|
+
return {
|
|
160
|
+
key,
|
|
161
|
+
set: inStore || inEnv,
|
|
162
|
+
secret: isSecret,
|
|
163
|
+
// Non-secret config (region) can be shown in full; credentials are masked.
|
|
164
|
+
masked: value ? (isSecret ? mask(value) : value) : '',
|
|
165
|
+
source: inStore ? 'store' : inEnv ? 'env' : 'none',
|
|
166
|
+
};
|
|
167
|
+
});
|
|
168
|
+
}
|
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,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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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",
|