opencode-episodic-memory 0.1.3 → 0.2.0
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/README.md +20 -2
- package/package.json +1 -1
- package/plugin/episodic-memory.ts +18 -9
- package/src/cli.ts +29 -1
- package/src/embed-inline.ts +28 -0
- package/src/embed-sidecar.mjs +110 -0
- package/src/embed.ts +287 -34
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ of the OpenCode memory-plugin landscape? See
|
|
|
18
18
|
|
|
19
19
|
1. **Read** — sessions/messages/parts from OpenCode's `~/.local/share/opencode/opencode.db` (read-only)
|
|
20
20
|
2. **Parse** — condensed exchanges (user text, assistant text, tool names; no reasoning blobs or tool output)
|
|
21
|
-
3. **Embed** — local, offline embeddings via Transformers.js (`Snowflake/snowflake-arctic-embed-m-v1.5` q8, 768 dims; retrieval prefix on search queries). Chosen by empirical eval on a real corpus — see [docs/embedding-model-eval.md](docs/embedding-model-eval.md)
|
|
21
|
+
3. **Embed** — local, offline embeddings via Transformers.js in a persistent system-Node sidecar (`Snowflake/snowflake-arctic-embed-m-v1.5` q8, 768 dims; retrieval prefix on search queries). Chosen by empirical eval on a real corpus — see [docs/embedding-model-eval.md](docs/embedding-model-eval.md)
|
|
22
22
|
4. **Index** — plain SQLite at `~/.local/share/opencode-episodic-memory/index.db`; brute-force cosine over Float32 blobs, plus a built-in FTS5 BM25 index for lexical/hybrid search
|
|
23
23
|
5. **Recall** — native plugin tools `episodic_search` / `episodic_read`, plus a `remembering-conversations` skill that teaches the agent when to search
|
|
24
24
|
6. **Stay fresh** — the plugin re-indexes each session on the `session.idle` event
|
|
@@ -52,7 +52,13 @@ Or edit `~/.config/opencode/opencode.json` manually:
|
|
|
52
52
|
}
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
Default sidecar-mode semantic indexing and vector/hybrid search require a system
|
|
56
|
+
**Node 20+** binary (`node` by default). The first embedding run downloads the
|
|
57
|
+
model (~100 MB, cached afterward). The model and its native runtime live in
|
|
58
|
+
that Node sidecar, not inside OpenCode's Bun/TUI process. Explicit
|
|
59
|
+
`EPISODIC_EMBED_MODE=inline` works without Node but is unsafe in affected
|
|
60
|
+
OpenCode/Bun versions. `episodic_read` and lexical text search also remain
|
|
61
|
+
available without Node.
|
|
56
62
|
|
|
57
63
|
Install the skill so the agent knows when to search, via the
|
|
58
64
|
[`skills` CLI](https://github.com/vercel-labs/skills):
|
|
@@ -123,6 +129,18 @@ instruction-tag match — the intent is the same, but our matching is literal.
|
|
|
123
129
|
| `EPISODIC_SOURCE_DB` | `~/.local/share/opencode/opencode.db` | OpenCode session store |
|
|
124
130
|
| `EPISODIC_INDEX_DB` | `~/.local/share/opencode-episodic-memory/index.db` | Index location |
|
|
125
131
|
| `EPISODIC_EMBED_MODEL` | `Snowflake/snowflake-arctic-embed-m-v1.5` | Transformers.js embedding model |
|
|
132
|
+
| `EPISODIC_EMBED_MODE` | `sidecar` | `sidecar` runs embeddings in Node; `inline` is an explicit escape hatch |
|
|
133
|
+
| `EPISODIC_NODE_BINARY` | `node` | Node 20+ executable used by sidecar mode |
|
|
134
|
+
| `EPISODIC_EMBED_BATCH_SIZE` | `32` | Texts per sidecar request (1-64) |
|
|
135
|
+
| `EPISODIC_EMBED_READY_TIMEOUT_MS` | `600000` | Maximum wait for sidecar/model startup |
|
|
136
|
+
| `EPISODIC_EMBED_REQUEST_TIMEOUT_MS` | `120000` | Maximum wait for a post-startup embedding request |
|
|
137
|
+
|
|
138
|
+
`EPISODIC_EMBED_MODE=inline` loads Transformers.js native addons directly in
|
|
139
|
+
OpenCode's embedded Bun process. It exists only as an explicit compatibility
|
|
140
|
+
escape hatch and is unsafe with affected OpenCode/Bun releases that can crash
|
|
141
|
+
during native-addon teardown. It is never selected automatically if sidecar
|
|
142
|
+
startup fails. Run `bun run src/cli.ts doctor` to diagnose the selected mode,
|
|
143
|
+
Node version, and a real embedding.
|
|
126
144
|
|
|
127
145
|
## Not yet implemented (deliberate)
|
|
128
146
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-episodic-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Semantic search over past OpenCode conversations — local embeddings (Transformers.js), SQLite index, native plugin tools. Inspired by obra/episodic-memory, rebuilt natively for OpenCode.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -77,16 +77,25 @@ export const EpisodicMemory: Plugin = async ({ client }) => {
|
|
|
77
77
|
before: before.ms,
|
|
78
78
|
text: args.text,
|
|
79
79
|
};
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
if (isIndexEmpty(index)) return "No matching past conversations found. The index is empty — run `bun run src/cli.ts sync` to index conversations.";
|
|
88
|
-
return "No matching past conversations found.";
|
|
80
|
+
const noHits = () => isIndexEmpty(index)
|
|
81
|
+
? "No matching past conversations found. The index is empty — run `bun run src/cli.ts sync` to index conversations."
|
|
82
|
+
: "No matching past conversations found.";
|
|
83
|
+
if (args.mode === "text") {
|
|
84
|
+
const hits = textSearch(index, args.query, opts);
|
|
85
|
+
if (hits.length === 0) return noHits();
|
|
86
|
+
return formatHits(hits, 400, "score");
|
|
89
87
|
}
|
|
88
|
+
let vector: Float32Array;
|
|
89
|
+
try {
|
|
90
|
+
vector = (await embedQuery(args.query))[0];
|
|
91
|
+
} catch (e) {
|
|
92
|
+
await log("warn", `episodic_search embedding failed: ${e instanceof Error ? e.message : e}`);
|
|
93
|
+
return 'Semantic search unavailable: the embedding backend failed. Use mode: "text" for embedding-free lexical search, or run `bun run src/cli.ts doctor` for details.';
|
|
94
|
+
}
|
|
95
|
+
const hits = args.mode === "hybrid"
|
|
96
|
+
? search(index, vector, { ...opts, queryText: args.query, hybrid: true })
|
|
97
|
+
: search(index, vector, opts);
|
|
98
|
+
if (hits.length === 0) return noHits();
|
|
90
99
|
// Hybrid hits carry RRF scores (~0.03), not cosine — label them "rrf".
|
|
91
100
|
return formatHits(hits, 400, args.mode === "hybrid" ? "rrf" : "score");
|
|
92
101
|
},
|
package/src/cli.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { parseArgs } from "node:util";
|
|
|
15
15
|
import { openSource, sourceDbPath, getSession, getTranscriptChecked } from "./reader";
|
|
16
16
|
import { openIndex, indexDbPath, search, textSearch, stats, isIndexEmpty } from "./store";
|
|
17
17
|
import { syncAll } from "./indexer";
|
|
18
|
-
import { embed, embedQuery } from "./embed";
|
|
18
|
+
import { embed, embedQuery, getEmbedMode } from "./embed";
|
|
19
19
|
import { parseDateArg, fmtDate, renderTranscript, formatHits } from "./format";
|
|
20
20
|
|
|
21
21
|
const [, , command, ...rest] = process.argv;
|
|
@@ -157,6 +157,34 @@ async function main() {
|
|
|
157
157
|
|
|
158
158
|
case "doctor": {
|
|
159
159
|
let ok = true;
|
|
160
|
+
let mode: "sidecar" | "inline";
|
|
161
|
+
try {
|
|
162
|
+
mode = getEmbedMode();
|
|
163
|
+
console.log(`✓ embedding mode: ${mode}`);
|
|
164
|
+
} catch (e) {
|
|
165
|
+
console.error(`✗ embedding mode: ${e}`);
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
if (mode === "sidecar") {
|
|
169
|
+
const nodeBinary = process.env.EPISODIC_NODE_BINARY ?? "node";
|
|
170
|
+
try {
|
|
171
|
+
const node = Bun.spawnSync([nodeBinary, "--version"], { stdout: "pipe", stderr: "pipe" });
|
|
172
|
+
const version = new TextDecoder().decode(node.stdout).trim();
|
|
173
|
+
const match = /^v(\d+)\./.exec(version);
|
|
174
|
+
if (!node.success || !match || Number(match[1]) < 20) {
|
|
175
|
+
const detail = new TextDecoder().decode(node.stderr).trim();
|
|
176
|
+
console.error(`✗ Node 20+ required for sidecar mode (${JSON.stringify(nodeBinary)} ${version || detail || "not found"}). Set EPISODIC_NODE_BINARY to a Node 20+ executable.`);
|
|
177
|
+
ok = false;
|
|
178
|
+
} else {
|
|
179
|
+
console.log(`✓ sidecar Node: ${nodeBinary} ${version}`);
|
|
180
|
+
}
|
|
181
|
+
} catch (e) {
|
|
182
|
+
console.error(`✗ Node 20+ required for sidecar mode (${JSON.stringify(nodeBinary)} could not start: ${e}). Set EPISODIC_NODE_BINARY to a Node 20+ executable.`);
|
|
183
|
+
ok = false;
|
|
184
|
+
}
|
|
185
|
+
} else {
|
|
186
|
+
console.warn("! inline embedding mode loads native ML addons into Bun; it is unsafe on affected OpenCode/Bun versions. Prefer sidecar mode.");
|
|
187
|
+
}
|
|
160
188
|
const src = sourceDbPath();
|
|
161
189
|
if (existsSync(src)) console.log(`✓ source DB: ${src}`);
|
|
162
190
|
else { console.error(`✗ source DB missing: ${src}`); ok = false; }
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Explicit escape hatch for hosts where loading native ML addons in Bun is safe.
|
|
2
|
+
// Keep this module dynamically imported by embed.ts so the normal plugin import
|
|
3
|
+
// path cannot load Transformers.js.
|
|
4
|
+
import type { FeatureExtractionPipeline } from "@huggingface/transformers";
|
|
5
|
+
|
|
6
|
+
import { DEFAULT_MODEL } from "./embed.ts";
|
|
7
|
+
|
|
8
|
+
let cached: Promise<FeatureExtractionPipeline> | null = null;
|
|
9
|
+
|
|
10
|
+
async function getEmbedder(): Promise<FeatureExtractionPipeline> {
|
|
11
|
+
if (!cached) {
|
|
12
|
+
cached = import("@huggingface/transformers")
|
|
13
|
+
.then(({ pipeline }) => pipeline("feature-extraction", process.env.EPISODIC_EMBED_MODEL ?? DEFAULT_MODEL, { dtype: "q8" }) as Promise<FeatureExtractionPipeline>);
|
|
14
|
+
// A rejected promise (for example, a failed model download) must not poison
|
|
15
|
+
// the cache for the lifetime of the process.
|
|
16
|
+
cached.catch(() => { cached = null; });
|
|
17
|
+
}
|
|
18
|
+
return cached;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function embedInline(texts: string[]): Promise<Float32Array[]> {
|
|
22
|
+
const embedder = await getEmbedder();
|
|
23
|
+
const output = await embedder(texts, { pooling: "cls", normalize: true });
|
|
24
|
+
const dimensions: number = output.dims[output.dims.length - 1];
|
|
25
|
+
// A normalized feature-extraction tensor is Float32Array at runtime.
|
|
26
|
+
const flat = new Float32Array(output.data as Float32Array);
|
|
27
|
+
return texts.map((_, index) => flat.subarray(index * dimensions, (index + 1) * dimensions));
|
|
28
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Persistent Node-side embedding server. stdout is reserved for NDJSON protocol
|
|
2
|
+
// messages; all diagnostics (including dependency chatter) go to stderr.
|
|
3
|
+
const originalConsole = globalThis.console;
|
|
4
|
+
globalThis.console = {
|
|
5
|
+
...originalConsole,
|
|
6
|
+
log: (...args) => originalConsole.error(...args),
|
|
7
|
+
info: (...args) => originalConsole.error(...args),
|
|
8
|
+
debug: (...args) => originalConsole.error(...args),
|
|
9
|
+
warn: (...args) => originalConsole.error(...args),
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// Keep this fallback synchronized with DEFAULT_MODEL in embed.ts; embed.test.ts
|
|
13
|
+
// guards against accidental drift between the Bun host and Node sidecar.
|
|
14
|
+
const model = process.env.EPISODIC_EMBED_MODEL ?? "Snowflake/snowflake-arctic-embed-m-v1.5";
|
|
15
|
+
const MAX_REQUEST_TEXTS = 64;
|
|
16
|
+
const batchSize = positiveIntegerEnv("EPISODIC_EMBED_BATCH_SIZE", 32, MAX_REQUEST_TEXTS);
|
|
17
|
+
let embedder;
|
|
18
|
+
let queue = Promise.resolve();
|
|
19
|
+
|
|
20
|
+
function positiveIntegerEnv(name, defaultValue, maximum) {
|
|
21
|
+
const value = process.env[name];
|
|
22
|
+
if (value === undefined) return defaultValue;
|
|
23
|
+
if (!/^[1-9]\d*$/.test(value)) throw new Error(`Invalid ${name} ${JSON.stringify(value)}; expected an integer from 1 to ${maximum}.`);
|
|
24
|
+
const parsed = Number(value);
|
|
25
|
+
if (!Number.isSafeInteger(parsed) || parsed > maximum) {
|
|
26
|
+
throw new Error(`Invalid ${name} ${JSON.stringify(value)}; expected an integer from 1 to ${maximum}.`);
|
|
27
|
+
}
|
|
28
|
+
return parsed;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function send(response) {
|
|
32
|
+
process.stdout.write(`${JSON.stringify(response)}\n`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function requestError(id, error) {
|
|
36
|
+
send({ id, error: error instanceof Error ? error.message : String(error) });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function validRequest(value) {
|
|
40
|
+
return value && typeof value === "object" && Number.isSafeInteger(value.id)
|
|
41
|
+
&& Array.isArray(value.texts) && value.texts.length <= MAX_REQUEST_TEXTS
|
|
42
|
+
&& value.texts.every((text) => typeof text === "string");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function embed(texts) {
|
|
46
|
+
const vectors = [];
|
|
47
|
+
for (let offset = 0; offset < texts.length; offset += batchSize) {
|
|
48
|
+
const batch = texts.slice(offset, offset + batchSize);
|
|
49
|
+
const output = await embedder(batch, { pooling: "cls", normalize: true });
|
|
50
|
+
const dimensions = output.dims.at(-1);
|
|
51
|
+
if (!Number.isSafeInteger(dimensions) || dimensions <= 0) throw new Error("model returned invalid embedding dimensions");
|
|
52
|
+
const data = output.data;
|
|
53
|
+
if (data.length !== batch.length * dimensions) throw new Error("model returned an invalid embedding batch");
|
|
54
|
+
for (let index = 0; index < batch.length; index++) {
|
|
55
|
+
vectors.push(Array.from(data.slice(index * dimensions, (index + 1) * dimensions)));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return vectors;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function initialize() {
|
|
62
|
+
try {
|
|
63
|
+
const { pipeline } = await import("@huggingface/transformers");
|
|
64
|
+
embedder = await pipeline("feature-extraction", model, { dtype: "q8" });
|
|
65
|
+
send({ ready: true });
|
|
66
|
+
} catch (error) {
|
|
67
|
+
send({ ready: false, error: error instanceof Error ? error.message : String(error) });
|
|
68
|
+
process.exitCode = 1;
|
|
69
|
+
process.stdin.destroy();
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const initialization = initialize();
|
|
75
|
+
let remainder = "";
|
|
76
|
+
process.stdin.setEncoding("utf8");
|
|
77
|
+
process.stdin.on("data", (chunk) => {
|
|
78
|
+
remainder += chunk;
|
|
79
|
+
let newline;
|
|
80
|
+
while ((newline = remainder.indexOf("\n")) >= 0) {
|
|
81
|
+
const line = remainder.slice(0, newline);
|
|
82
|
+
remainder = remainder.slice(newline + 1);
|
|
83
|
+
if (!line) continue;
|
|
84
|
+
let request;
|
|
85
|
+
try {
|
|
86
|
+
request = JSON.parse(line);
|
|
87
|
+
} catch {
|
|
88
|
+
requestError(null, "request must be valid JSON");
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (!validRequest(request)) {
|
|
92
|
+
requestError(request && typeof request === "object" && "id" in request ? request.id : null, `request must have an integer id and at most ${MAX_REQUEST_TEXTS} string texts`);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
queue = queue.then(async () => {
|
|
96
|
+
try {
|
|
97
|
+
await initialization;
|
|
98
|
+
send({ id: request.id, vectors: await embed(request.texts) });
|
|
99
|
+
} catch (error) {
|
|
100
|
+
requestError(request.id, error);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
process.stdin.on("error", () => process.exit());
|
|
106
|
+
process.stdin.on("end", () => process.exit());
|
|
107
|
+
process.on("SIGTERM", () => process.exit());
|
|
108
|
+
process.on("SIGINT", () => process.exit());
|
|
109
|
+
|
|
110
|
+
await initialization.catch(() => {});
|
package/src/embed.ts
CHANGED
|
@@ -1,14 +1,7 @@
|
|
|
1
|
-
// Local, offline embeddings
|
|
2
|
-
// so
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
// official ONNX export in the model repo. Chosen over Xenova/bge-small-en-v1.5
|
|
6
|
-
// by empirical eval on our real corpus (2026-07-22, see
|
|
7
|
-
// docs/embedding-model-eval.md): equal top-1, better top-3, and far better
|
|
8
|
-
// score separation (negatives max ~0.33 vs bge's ~0.66), so minScore
|
|
9
|
-
// thresholding is meaningful. Asymmetric retriever: queries get a task
|
|
10
|
-
// prefix, documents go through unmodified.
|
|
11
|
-
import { pipeline, type FeatureExtractionPipeline } from "@huggingface/transformers";
|
|
1
|
+
// Local, offline embeddings. The default backend lives in a system-Node
|
|
2
|
+
// sidecar so importing the OpenCode plugin never loads ML native addons into
|
|
3
|
+
// its embedded Bun process.
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
12
5
|
|
|
13
6
|
export const DEFAULT_MODEL = "Snowflake/snowflake-arctic-embed-m-v1.5";
|
|
14
7
|
|
|
@@ -20,34 +13,295 @@ export const QUERY_PREFIX = "Represent this sentence for searching relevant pass
|
|
|
20
13
|
// degrade embeddings (and this model's window is 512 tokens anyway).
|
|
21
14
|
export const MAX_CHARS = 2000;
|
|
22
15
|
|
|
23
|
-
|
|
16
|
+
const DEFAULT_BATCH_SIZE = 32;
|
|
17
|
+
const MAX_BATCH_SIZE = 64;
|
|
18
|
+
const DEFAULT_READY_TIMEOUT_MS = 10 * 60 * 1000;
|
|
19
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 2 * 60 * 1000;
|
|
20
|
+
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
24
21
|
|
|
25
|
-
export
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
22
|
+
export type EmbedMode = "sidecar" | "inline";
|
|
23
|
+
|
|
24
|
+
type PendingRequest = {
|
|
25
|
+
resolve: (vectors: Float32Array[]) => void;
|
|
26
|
+
reject: (error: Error) => void;
|
|
27
|
+
count: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type Sidecar = {
|
|
31
|
+
process: Bun.Subprocess<"pipe", "pipe", "pipe">;
|
|
32
|
+
pending: Map<number, PendingRequest>;
|
|
33
|
+
ready: Promise<void>;
|
|
34
|
+
resolveReady: () => void;
|
|
35
|
+
rejectReady: (error: Error) => void;
|
|
36
|
+
stderr: string;
|
|
37
|
+
stdout: string;
|
|
38
|
+
dimensions: number | null;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
class SidecarUnavailableError extends Error {}
|
|
42
|
+
|
|
43
|
+
let sidecar: Sidecar | null = null;
|
|
44
|
+
let nextRequestId = 1;
|
|
45
|
+
|
|
46
|
+
export function getEmbedMode(): EmbedMode {
|
|
47
|
+
const mode = process.env.EPISODIC_EMBED_MODE ?? "sidecar";
|
|
48
|
+
if (mode === "sidecar" || mode === "inline") return mode;
|
|
49
|
+
throw new Error(`Invalid EPISODIC_EMBED_MODE ${JSON.stringify(mode)}; expected "sidecar" or "inline".`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function tail(value: string, addition: string): string {
|
|
53
|
+
return (value + addition).slice(-8_192);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function positiveIntegerEnv(name: string, defaultValue: number, maximum: number): number {
|
|
57
|
+
const value = process.env[name];
|
|
58
|
+
if (value === undefined) return defaultValue;
|
|
59
|
+
if (!/^[1-9]\d*$/.test(value)) throw new Error(`Invalid ${name} ${JSON.stringify(value)}; expected an integer from 1 to ${maximum}.`);
|
|
60
|
+
const parsed = Number(value);
|
|
61
|
+
if (!Number.isSafeInteger(parsed) || parsed > maximum) {
|
|
62
|
+
throw new Error(`Invalid ${name} ${JSON.stringify(value)}; expected an integer from 1 to ${maximum}.`);
|
|
63
|
+
}
|
|
64
|
+
return parsed;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function sidecarError(message: string, child: Sidecar): Error {
|
|
68
|
+
const details = child.stderr.trim();
|
|
69
|
+
return new SidecarUnavailableError(details ? `${message}: ${details}` : message);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function rejectAll(child: Sidecar, error: Error): void {
|
|
73
|
+
for (const { reject } of child.pending.values()) reject(error);
|
|
74
|
+
child.pending.clear();
|
|
75
|
+
child.rejectReady(error);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function sidecarGone(child: Sidecar, error: Error): void {
|
|
79
|
+
if (sidecar === child) sidecar = null;
|
|
80
|
+
rejectAll(child, error);
|
|
81
|
+
child.process.kill();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function protocolFailure(child: Sidecar, message: string): void {
|
|
85
|
+
const error = new Error(`Embedding sidecar protocol error: ${message}`);
|
|
86
|
+
sidecarGone(child, error);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function vectorsFromResponse(value: unknown, expectedCount: number, child: Sidecar): Float32Array[] {
|
|
90
|
+
if (!Array.isArray(value) || value.length !== expectedCount) {
|
|
91
|
+
throw new Error(`expected ${expectedCount} vectors, got ${Array.isArray(value) ? value.length : "a non-array"}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const vectors = value.map((vector) => {
|
|
95
|
+
if (!Array.isArray(vector) || vector.length === 0 || !vector.every((n) => typeof n === "number" && Number.isFinite(n))) {
|
|
96
|
+
throw new Error("vectors must be non-empty arrays of finite numbers");
|
|
97
|
+
}
|
|
98
|
+
if (child.dimensions !== null && vector.length !== child.dimensions) {
|
|
99
|
+
throw new Error(`expected ${child.dimensions} dimensions, got ${vector.length}`);
|
|
100
|
+
}
|
|
101
|
+
return new Float32Array(vector);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const dimensions = vectors[0]?.length;
|
|
105
|
+
if (vectors.some((vector) => vector.length !== dimensions)) throw new Error("vectors have inconsistent dimensions");
|
|
106
|
+
child.dimensions ??= dimensions ?? null;
|
|
107
|
+
return vectors;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function handleLine(child: Sidecar, line: string): void {
|
|
111
|
+
let response: unknown;
|
|
112
|
+
try {
|
|
113
|
+
response = JSON.parse(line);
|
|
114
|
+
} catch {
|
|
115
|
+
protocolFailure(child, "stdout contained invalid JSON");
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (!response || typeof response !== "object" || Array.isArray(response)) {
|
|
119
|
+
protocolFailure(child, "response must be an object");
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const record = response as Record<string, unknown>;
|
|
124
|
+
if ("ready" in record) {
|
|
125
|
+
if (record.ready === true) child.resolveReady();
|
|
126
|
+
else {
|
|
127
|
+
sidecarGone(child, new SidecarUnavailableError(`Embedding sidecar failed to start: ${typeof record.error === "string" ? record.error : "unknown error"}`));
|
|
128
|
+
}
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (typeof record.id !== "number" || !Number.isSafeInteger(record.id)) {
|
|
132
|
+
protocolFailure(child, "response has no valid request id");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const pending = child.pending.get(record.id);
|
|
136
|
+
if (!pending) {
|
|
137
|
+
protocolFailure(child, `response has unknown request id ${record.id}`);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
child.pending.delete(record.id);
|
|
141
|
+
if (typeof record.error === "string") {
|
|
142
|
+
pending.reject(new Error(`Embedding sidecar request failed: ${record.error}`));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
pending.resolve(vectorsFromResponse(record.vectors, pending.count, child));
|
|
147
|
+
} catch (error) {
|
|
148
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
149
|
+
pending.reject(new Error(`Embedding sidecar protocol error: ${message}`));
|
|
150
|
+
protocolFailure(child, message);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function drainStdout(child: Sidecar): Promise<void> {
|
|
155
|
+
const reader = child.process.stdout.getReader();
|
|
156
|
+
const decoder = new TextDecoder();
|
|
157
|
+
try {
|
|
158
|
+
while (true) {
|
|
159
|
+
const { done, value } = await reader.read();
|
|
160
|
+
if (done) break;
|
|
161
|
+
child.stdout += decoder.decode(value, { stream: true });
|
|
162
|
+
let newline: number;
|
|
163
|
+
while ((newline = child.stdout.indexOf("\n")) >= 0) {
|
|
164
|
+
const line = child.stdout.slice(0, newline);
|
|
165
|
+
child.stdout = child.stdout.slice(newline + 1);
|
|
166
|
+
if (line) handleLine(child, line);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (child.stdout.trim()) protocolFailure(child, "stdout ended with an incomplete response");
|
|
170
|
+
} catch (error) {
|
|
171
|
+
if (sidecar === child) sidecarGone(child, sidecarError(`Could not read embedding sidecar output (${String(error)})`, child));
|
|
172
|
+
} finally {
|
|
173
|
+
reader.releaseLock();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function drainStderr(child: Sidecar): Promise<void> {
|
|
178
|
+
const reader = child.process.stderr.getReader();
|
|
179
|
+
const decoder = new TextDecoder();
|
|
180
|
+
try {
|
|
181
|
+
while (true) {
|
|
182
|
+
const { done, value } = await reader.read();
|
|
183
|
+
if (done) break;
|
|
184
|
+
child.stderr = tail(child.stderr, decoder.decode(value, { stream: true }));
|
|
185
|
+
}
|
|
186
|
+
} finally {
|
|
187
|
+
reader.releaseLock();
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function startSidecar(): Sidecar {
|
|
192
|
+
if (sidecar) return sidecar;
|
|
193
|
+
const nodeBinary = process.env.EPISODIC_NODE_BINARY ?? "node";
|
|
194
|
+
const sidecarPath = fileURLToPath(new URL("./embed-sidecar.mjs", import.meta.url));
|
|
195
|
+
let resolveReady!: () => void;
|
|
196
|
+
let rejectReady!: (error: Error) => void;
|
|
197
|
+
const ready = new Promise<void>((resolve, reject) => {
|
|
198
|
+
resolveReady = resolve;
|
|
199
|
+
rejectReady = reject;
|
|
200
|
+
});
|
|
201
|
+
// The rejected ready promise is also observed by each request; suppress a
|
|
202
|
+
// transient unhandled-rejection warning while the first request is starting.
|
|
203
|
+
ready.catch(() => {});
|
|
204
|
+
|
|
205
|
+
let childProcess: Bun.Subprocess<"pipe", "pipe", "pipe">;
|
|
206
|
+
try {
|
|
207
|
+
childProcess = Bun.spawn([nodeBinary, sidecarPath], {
|
|
208
|
+
env: process.env,
|
|
209
|
+
stdin: "pipe",
|
|
210
|
+
stdout: "pipe",
|
|
211
|
+
stderr: "pipe",
|
|
212
|
+
detached: true,
|
|
213
|
+
});
|
|
214
|
+
} catch (error) {
|
|
215
|
+
throw new Error(`Could not start embedding sidecar using ${JSON.stringify(nodeBinary)}. Install Node 20+ or set EPISODIC_NODE_BINARY: ${String(error)}`);
|
|
216
|
+
}
|
|
217
|
+
childProcess.unref();
|
|
218
|
+
const child: Sidecar = { process: childProcess, pending: new Map(), ready, resolveReady, rejectReady, stderr: "", stdout: "", dimensions: null };
|
|
219
|
+
sidecar = child;
|
|
220
|
+
void drainStdout(child);
|
|
221
|
+
void drainStderr(child);
|
|
222
|
+
void childProcess.exited.then(() => {
|
|
223
|
+
if (sidecar === child) {
|
|
224
|
+
sidecar = null;
|
|
225
|
+
rejectAll(child, sidecarError("Embedding sidecar exited unexpectedly", child));
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
return child;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function awaitReady(child: Sidecar, timeoutMs: number): Promise<void> {
|
|
232
|
+
return new Promise((resolve, reject) => {
|
|
233
|
+
const timeout = setTimeout(() => {
|
|
234
|
+
const error = new SidecarUnavailableError(`Embedding sidecar did not become ready within ${timeoutMs}ms`);
|
|
235
|
+
sidecarGone(child, error);
|
|
236
|
+
reject(error);
|
|
237
|
+
}, timeoutMs);
|
|
238
|
+
child.ready.then(
|
|
239
|
+
() => {
|
|
240
|
+
clearTimeout(timeout);
|
|
241
|
+
resolve();
|
|
242
|
+
},
|
|
243
|
+
(error) => {
|
|
244
|
+
clearTimeout(timeout);
|
|
245
|
+
reject(error);
|
|
246
|
+
},
|
|
247
|
+
);
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function requestSidecar(texts: string[], readyTimeoutMs: number, requestTimeoutMs: number, retried = false): Promise<Float32Array[]> {
|
|
252
|
+
let child: Sidecar;
|
|
253
|
+
try {
|
|
254
|
+
child = startSidecar();
|
|
255
|
+
await awaitReady(child, readyTimeoutMs);
|
|
256
|
+
const id = nextRequestId++;
|
|
257
|
+
return await new Promise<Float32Array[]>((resolve, reject) => {
|
|
258
|
+
const timeout = setTimeout(() => {
|
|
259
|
+
sidecarGone(child, new SidecarUnavailableError(`Embedding sidecar request timed out after ${requestTimeoutMs}ms`));
|
|
260
|
+
}, requestTimeoutMs);
|
|
261
|
+
const resolveRequest = (vectors: Float32Array[]) => {
|
|
262
|
+
clearTimeout(timeout);
|
|
263
|
+
resolve(vectors);
|
|
264
|
+
};
|
|
265
|
+
const rejectRequest = (error: Error) => {
|
|
266
|
+
clearTimeout(timeout);
|
|
267
|
+
reject(error);
|
|
268
|
+
};
|
|
269
|
+
child.pending.set(id, { resolve: resolveRequest, reject: rejectRequest, count: texts.length });
|
|
270
|
+
if (sidecar !== child) {
|
|
271
|
+
child.pending.delete(id);
|
|
272
|
+
rejectRequest(new SidecarUnavailableError("Embedding sidecar became unavailable before the request was sent"));
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
try {
|
|
276
|
+
child.process.stdin.write(`${JSON.stringify({ id, texts })}\n`);
|
|
277
|
+
} catch (error) {
|
|
278
|
+
child.pending.delete(id);
|
|
279
|
+
const unavailable = new SidecarUnavailableError(`Could not write to embedding sidecar: ${String(error)}`);
|
|
280
|
+
sidecarGone(child, unavailable);
|
|
281
|
+
rejectRequest(unavailable);
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
} catch (error) {
|
|
285
|
+
if (!retried && error instanceof SidecarUnavailableError) return requestSidecar(texts, readyTimeoutMs, requestTimeoutMs, true);
|
|
286
|
+
throw error;
|
|
36
287
|
}
|
|
37
|
-
return cached;
|
|
38
288
|
}
|
|
39
289
|
|
|
40
290
|
async function embedRaw(texts: string[]): Promise<Float32Array[]> {
|
|
41
291
|
if (texts.length === 0) return [];
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
292
|
+
const prepared = texts.map((text) => text.slice(0, MAX_CHARS));
|
|
293
|
+
if (getEmbedMode() === "inline") {
|
|
294
|
+
// Unsafe under affected OpenCode/Bun versions: this intentionally loads
|
|
295
|
+
// Transformers.js only when the caller explicitly opts in.
|
|
296
|
+
const { embedInline } = await import("./embed-inline.ts");
|
|
297
|
+
return embedInline(prepared);
|
|
298
|
+
}
|
|
299
|
+
const batchSize = positiveIntegerEnv("EPISODIC_EMBED_BATCH_SIZE", DEFAULT_BATCH_SIZE, MAX_BATCH_SIZE);
|
|
300
|
+
const readyTimeoutMs = positiveIntegerEnv("EPISODIC_EMBED_READY_TIMEOUT_MS", DEFAULT_READY_TIMEOUT_MS, MAX_TIMEOUT_MS);
|
|
301
|
+
const requestTimeoutMs = positiveIntegerEnv("EPISODIC_EMBED_REQUEST_TIMEOUT_MS", DEFAULT_REQUEST_TIMEOUT_MS, MAX_TIMEOUT_MS);
|
|
48
302
|
const vectors: Float32Array[] = [];
|
|
49
|
-
for (let
|
|
50
|
-
vectors.push(
|
|
303
|
+
for (let index = 0; index < prepared.length; index += batchSize) {
|
|
304
|
+
vectors.push(...await requestSidecar(prepared.slice(index, index + batchSize), readyTimeoutMs, requestTimeoutMs));
|
|
51
305
|
}
|
|
52
306
|
return vectors;
|
|
53
307
|
}
|
|
@@ -57,6 +311,5 @@ export const embed = embedRaw;
|
|
|
57
311
|
|
|
58
312
|
/** Embed a search query. Prepends the retrieval prefix. */
|
|
59
313
|
export function embedQuery(query: string): Promise<Float32Array[]> {
|
|
60
|
-
|
|
61
|
-
return embedRaw([q]);
|
|
314
|
+
return embedRaw([query.startsWith(QUERY_PREFIX) ? query : QUERY_PREFIX + query]);
|
|
62
315
|
}
|