opencode-episodic-memory 0.1.3 → 0.3.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 +80 -19
- package/package.json +2 -1
- package/plugin/episodic-memory.ts +96 -43
- package/skills/remembering-conversations/SKILL.md +13 -8
- package/src/cli.ts +56 -21
- package/src/embed-inline.ts +28 -0
- package/src/embed-sidecar.mjs +110 -0
- package/src/embed.ts +287 -34
- package/src/format.ts +49 -1
- package/src/indexer.ts +38 -25
- package/src/parser.ts +3 -2
- package/src/reader.ts +168 -16
- package/src/store.ts +397 -6
|
@@ -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
|
}
|
package/src/format.ts
CHANGED
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
// here so the two front-ends can't drift apart.
|
|
4
4
|
import type { SourceMessage } from "./reader";
|
|
5
5
|
import type { SearchHit } from "./store";
|
|
6
|
+
import { Buffer } from "node:buffer";
|
|
7
|
+
|
|
8
|
+
const MAX_CONTEXT_BODY_BYTES = 600;
|
|
9
|
+
const MAX_CONTEXT_TOOLS_BYTES = 200;
|
|
10
|
+
const MAX_CONTEXT_FIELD_BYTES = 100;
|
|
11
|
+
const MAX_CONTEXT_SESSION_FIELD_BYTES = 300;
|
|
6
12
|
|
|
7
13
|
// Discriminated result so callers handle the parse error explicitly (no cast to
|
|
8
14
|
// strip the error arm off a union). `ms` is undefined when no date was given.
|
|
@@ -55,6 +61,46 @@ export function renderTranscript(
|
|
|
55
61
|
return lines.join("\n");
|
|
56
62
|
}
|
|
57
63
|
|
|
64
|
+
// Render a bounded live-source window around a search-hit anchor. The helper in
|
|
65
|
+
// reader.ts has already applied the privacy gate and validated the anchor.
|
|
66
|
+
export function renderTranscriptContext(
|
|
67
|
+
meta: { title: string; time_created: number; directory: string; id: string },
|
|
68
|
+
context: { messages: SourceMessage[]; anchorIndex: number; sliceStart: number; total: number }
|
|
69
|
+
): string {
|
|
70
|
+
const lines = [
|
|
71
|
+
`# ${truncateContext(meta.title, MAX_CONTEXT_SESSION_FIELD_BYTES)}`,
|
|
72
|
+
`${fmtDate(meta.time_created)} — ${truncateContext(meta.directory, MAX_CONTEXT_SESSION_FIELD_BYTES)} — ${truncateContext(meta.id, MAX_CONTEXT_FIELD_BYTES)}`,
|
|
73
|
+
`Context around message ${context.anchorIndex + 1}/${context.total}`,
|
|
74
|
+
"",
|
|
75
|
+
];
|
|
76
|
+
for (const [offset, message] of context.messages.entries()) {
|
|
77
|
+
const position = context.sliceStart + offset;
|
|
78
|
+
const text = message.parts.filter((part) => part.type === "text" && part.text).map((part) => part.text).join("\n");
|
|
79
|
+
const tools = message.parts.filter((part) => part.type === "tool" && part.tool).map((part) => part.tool);
|
|
80
|
+
lines.push(`## ${truncateContext(message.role, MAX_CONTEXT_FIELD_BYTES)} — ${truncateContext(message.id, MAX_CONTEXT_FIELD_BYTES)} — ${position + 1}/${context.total}${position === context.anchorIndex ? " (anchor)" : ""}`);
|
|
81
|
+
if (text) lines.push(truncateContext(text, MAX_CONTEXT_BODY_BYTES));
|
|
82
|
+
if (tools.length) lines.push(truncateContext(`*(tools: ${tools.join(", ")})*`, MAX_CONTEXT_TOOLS_BYTES));
|
|
83
|
+
if (message.contextPartsOmitted) lines.push(`*(${message.contextPartsOmitted} parts omitted from bounded context)*`);
|
|
84
|
+
lines.push("");
|
|
85
|
+
}
|
|
86
|
+
return lines.join("\n");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function truncateContext(value: string, byteLimit: number): string {
|
|
90
|
+
if (Buffer.byteLength(value, "utf8") <= byteLimit) return value;
|
|
91
|
+
const suffix = "... [truncated]";
|
|
92
|
+
const contentBudget = byteLimit - Buffer.byteLength(suffix, "utf8");
|
|
93
|
+
let bytes = 0;
|
|
94
|
+
let result = "";
|
|
95
|
+
for (const codePoint of value) {
|
|
96
|
+
const codePointBytes = Buffer.byteLength(codePoint, "utf8");
|
|
97
|
+
if (bytes + codePointBytes > contentBudget) break;
|
|
98
|
+
result += codePoint;
|
|
99
|
+
bytes += codePointBytes;
|
|
100
|
+
}
|
|
101
|
+
return result + suffix;
|
|
102
|
+
}
|
|
103
|
+
|
|
58
104
|
// One search hit as a markdown block. snippetLength defaults to 400 (plugin
|
|
59
105
|
// tool output); the CLI passes 220 to keep terminal output brief. scoreLabel
|
|
60
106
|
// names the score field: "score" for vector (cosine ~0.4–0.7) and BM25, "rrf"
|
|
@@ -62,7 +108,9 @@ export function renderTranscript(
|
|
|
62
108
|
// AGENTS.md) so the number isn't misread against the cosine thresholds.
|
|
63
109
|
export function formatHit(h: SearchHit, snippetLength = 400, scoreLabel = "score"): string {
|
|
64
110
|
const snippet = h.text.replace(/\s+/g, " ").slice(0, snippetLength);
|
|
65
|
-
|
|
111
|
+
const anchor = h.anchor_message_id ?? "unavailable (refresh/reindex required)";
|
|
112
|
+
const source = h.source_id ? `source: ${h.source_id}\n` : "";
|
|
113
|
+
return `## ${fmtDate(h.time_created)} — ${h.title}\n${source}session: ${h.session_id} ${scoreLabel}: ${h.score.toFixed(3)}\nanchor: ${anchor}\n${h.directory}\n> ${snippet}`;
|
|
66
114
|
}
|
|
67
115
|
|
|
68
116
|
export function formatHits(hits: SearchHit[], snippetLength = 400, scoreLabel = "score"): string {
|
package/src/indexer.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// Incremental, idempotent indexer. Watermark = session.time_updated; a session
|
|
2
2
|
// is re-embedded only when the source changed since we last indexed it.
|
|
3
3
|
import type { Database } from "bun:sqlite";
|
|
4
|
-
import { getTranscriptChecked, listSessions, type SourceSession } from "./reader";
|
|
4
|
+
import { getTranscriptChecked, listSessions, transcriptHasMarker, type SourceSession } from "./reader";
|
|
5
5
|
import { parseTranscript, exchangeText } from "./parser";
|
|
6
6
|
import { embed } from "./embed";
|
|
7
|
-
import {
|
|
7
|
+
import type { IndexStore } from "./store";
|
|
8
8
|
|
|
9
9
|
export interface SyncResult {
|
|
10
10
|
scanned: number;
|
|
@@ -17,20 +17,33 @@ export interface SyncResult {
|
|
|
17
17
|
|
|
18
18
|
export async function syncSession(
|
|
19
19
|
source: Database,
|
|
20
|
-
index:
|
|
20
|
+
index: IndexStore,
|
|
21
21
|
s: SourceSession,
|
|
22
22
|
force = false
|
|
23
23
|
): Promise<"indexed" | "fresh" | "excluded" | "empty"> {
|
|
24
|
-
const
|
|
24
|
+
const removeIfRemoteExcluded = async (): Promise<boolean> => {
|
|
25
|
+
if (!index.remote || !transcriptHasMarker(source, s.id)) return false;
|
|
26
|
+
await index.removeSession(s.id);
|
|
27
|
+
return true;
|
|
28
|
+
};
|
|
29
|
+
// Remote freshness must never preserve metadata for a newly excluded session.
|
|
30
|
+
// Local mode intentionally keeps its established cheap freshness-first path.
|
|
31
|
+
const checked = index.remote ? getTranscriptChecked(source, s.id) : undefined;
|
|
32
|
+
if (checked?.excluded) {
|
|
33
|
+
await index.removeSession(s.id);
|
|
34
|
+
return "excluded";
|
|
35
|
+
}
|
|
36
|
+
const prior = await index.getIndexedSession(s.id);
|
|
37
|
+
if (await removeIfRemoteExcluded()) return "excluded";
|
|
25
38
|
if (!force && prior && prior.source_time_updated >= s.time_updated) return "fresh";
|
|
26
39
|
|
|
27
40
|
// Authoritative opt-out gate lives inside getTranscriptChecked (raw-blob
|
|
28
41
|
// scan before any read); parseTranscript's own parsed-text check is a
|
|
29
42
|
// harmless redundant fast path for the non-excluded branch.
|
|
30
|
-
const
|
|
31
|
-
const { exchanges, excluded } =
|
|
43
|
+
const transcript = checked ?? getTranscriptChecked(source, s.id);
|
|
44
|
+
const { exchanges, excluded } = transcript.excluded
|
|
32
45
|
? { exchanges: [], excluded: true }
|
|
33
|
-
: parseTranscript(
|
|
46
|
+
: parseTranscript(transcript.messages);
|
|
34
47
|
const meta = {
|
|
35
48
|
id: s.id, project_id: s.project_id, parent_id: s.parent_id,
|
|
36
49
|
title: s.title, directory: s.directory,
|
|
@@ -38,28 +51,37 @@ export async function syncSession(
|
|
|
38
51
|
};
|
|
39
52
|
|
|
40
53
|
if (excluded) {
|
|
41
|
-
|
|
54
|
+
// A remote index is an opt-in upload boundary: unlike the local index's
|
|
55
|
+
// useful excluded tombstone, it must retain no metadata for marked chats.
|
|
56
|
+
if (index.remote) await index.removeSession(s.id);
|
|
57
|
+
else await index.replaceSessionChunks(meta, [], "excluded");
|
|
42
58
|
return "excluded";
|
|
43
59
|
}
|
|
44
60
|
if (exchanges.length === 0) {
|
|
45
|
-
|
|
61
|
+
if (await removeIfRemoteExcluded()) return "excluded";
|
|
62
|
+
await index.replaceSessionChunks(meta, [], "empty");
|
|
46
63
|
return "empty";
|
|
47
64
|
}
|
|
48
65
|
|
|
49
66
|
const date = new Date(s.time_created).toISOString().slice(0, 10);
|
|
50
67
|
const texts = exchanges.map((e) => exchangeText(s.title, date, e));
|
|
51
68
|
const vectors = await embed(texts);
|
|
52
|
-
|
|
53
|
-
|
|
69
|
+
// Embedding can take long enough for the source conversation to change. Run
|
|
70
|
+
// the cheap authoritative raw-marker check again immediately before a remote
|
|
71
|
+
// upload so a marker added during embedding never exports the prepared data.
|
|
72
|
+
if (await removeIfRemoteExcluded()) return "excluded";
|
|
73
|
+
await index.replaceSessionChunks(
|
|
54
74
|
meta,
|
|
55
|
-
exchanges.map((e, i) => ({
|
|
75
|
+
exchanges.map((e, i) => ({
|
|
76
|
+
seq: i, time_created: e.time, text: texts[i], embedding: vectors[i], anchor_message_id: e.anchorMessageId,
|
|
77
|
+
}))
|
|
56
78
|
);
|
|
57
79
|
return "indexed";
|
|
58
80
|
}
|
|
59
81
|
|
|
60
82
|
export async function syncAll(
|
|
61
83
|
source: Database,
|
|
62
|
-
index:
|
|
84
|
+
index: IndexStore,
|
|
63
85
|
opts: { force?: boolean; onProgress?: (done: number, total: number, title: string) => void } = {}
|
|
64
86
|
): Promise<SyncResult> {
|
|
65
87
|
const sessions = listSessions(source);
|
|
@@ -76,7 +98,7 @@ export async function syncAll(
|
|
|
76
98
|
|
|
77
99
|
// Prune index rows whose session no longer exists in the source DB;
|
|
78
100
|
// otherwise their stale (possibly wrong-dims) chunks linger forever.
|
|
79
|
-
result.pruned = pruneOrphans(source, index, sessions);
|
|
101
|
+
result.pruned = await pruneOrphans(source, index, sessions);
|
|
80
102
|
|
|
81
103
|
return result;
|
|
82
104
|
}
|
|
@@ -85,15 +107,6 @@ export async function syncAll(
|
|
|
85
107
|
// source DB. Extracted so the plugin's full-reindex path can call it without
|
|
86
108
|
// re-running the whole sync. Pass already-fetched sessions to avoid a redundant
|
|
87
109
|
// query in syncAll; omitted, it re-reads the source.
|
|
88
|
-
export function pruneOrphans(source: Database, index:
|
|
89
|
-
|
|
90
|
-
const indexedIds = index.prepare<{ id: string }, []>("SELECT id FROM sessions").all();
|
|
91
|
-
let pruned = 0;
|
|
92
|
-
for (const { id } of indexedIds) {
|
|
93
|
-
if (sourceIds.has(id)) continue;
|
|
94
|
-
index.run("DELETE FROM chunks WHERE session_id = ?", [id]);
|
|
95
|
-
index.run("DELETE FROM sessions WHERE id = ?", [id]);
|
|
96
|
-
pruned++;
|
|
97
|
-
}
|
|
98
|
-
return pruned;
|
|
110
|
+
export async function pruneOrphans(source: Database, index: IndexStore, knownSource?: SourceSession[]): Promise<number> {
|
|
111
|
+
return index.pruneOrphans((knownSource ?? listSessions(source)).map((s) => s.id));
|
|
99
112
|
}
|
package/src/parser.ts
CHANGED
|
@@ -23,6 +23,7 @@ export function hasExcludeMarker(messages: SourceMessage[]): boolean {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export interface Exchange {
|
|
26
|
+
anchorMessageId: string;
|
|
26
27
|
user: string;
|
|
27
28
|
assistant: string;
|
|
28
29
|
tools: string[];
|
|
@@ -60,7 +61,7 @@ export function parseTranscript(messages: SourceMessage[]): {
|
|
|
60
61
|
if (m.role === "user") {
|
|
61
62
|
const text = textOf(m.parts);
|
|
62
63
|
if (!text) continue; // e.g. pure tool-result turns
|
|
63
|
-
current = { user: text, assistant: "", tools: [], time: m.timeCreated };
|
|
64
|
+
current = { anchorMessageId: m.id, user: text, assistant: "", tools: [], time: m.timeCreated };
|
|
64
65
|
exchanges.push(current);
|
|
65
66
|
} else if (m.role === "assistant" && current) {
|
|
66
67
|
const text = textOf(m.parts);
|
|
@@ -73,7 +74,7 @@ export function parseTranscript(messages: SourceMessage[]): {
|
|
|
73
74
|
return { exchanges: exchanges.filter((e) => e.user || e.assistant), excluded: false };
|
|
74
75
|
}
|
|
75
76
|
|
|
76
|
-
// Text stored per chunk (also displayed by
|
|
77
|
+
// Text stored per chunk (also displayed by episodic_read_session). Capped at 4000 chars
|
|
77
78
|
// to keep storage sane; the embedding step (embed.ts) further truncates to 2000
|
|
78
79
|
// chars where retrieval quality peaks. The head of an exchange carries the
|
|
79
80
|
// most signal.
|