opencode-episodic-memory 0.1.2 → 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 +90 -26
- package/package.json +4 -3
- package/plugin/episodic-memory.ts +33 -51
- package/skills/remembering-conversations/SKILL.md +2 -1
- package/src/cli.ts +107 -81
- package/src/embed-inline.ts +28 -0
- package/src/embed-sidecar.mjs +110 -0
- package/src/embed.ts +287 -34
- package/src/format.ts +70 -0
- package/src/indexer.ts +7 -6
- package/src/parser.ts +4 -3
- package/src/reader.ts +19 -1
- package/src/store.ts +222 -33
- package/src/parser.test.ts +0 -64
- package/src/reader.test.ts +0 -205
- package/src/store.test.ts +0 -72
|
@@ -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
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Shared presentation layer for the CLI and the plugin. Both stay thin: date
|
|
2
|
+
// parsing, date formatting, transcript→markdown, and search-hit formatting live
|
|
3
|
+
// here so the two front-ends can't drift apart.
|
|
4
|
+
import type { SourceMessage } from "./reader";
|
|
5
|
+
import type { SearchHit } from "./store";
|
|
6
|
+
|
|
7
|
+
// Discriminated result so callers handle the parse error explicitly (no cast to
|
|
8
|
+
// strip the error arm off a union). `ms` is undefined when no date was given.
|
|
9
|
+
export type ParsedDate = { ok: true; ms?: number } | { ok: false; error: string };
|
|
10
|
+
|
|
11
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
12
|
+
// Require strict YYYY-MM-DD, then round-trip to reject impossible calendar dates
|
|
13
|
+
// (`new Date("2024-02-31")` silently normalizes to March 2 rather than failing).
|
|
14
|
+
export function parseDateArg(s?: string): ParsedDate {
|
|
15
|
+
if (!s) return { ok: true };
|
|
16
|
+
const ms = new Date(s).getTime();
|
|
17
|
+
if (!DATE_RE.test(s) || Number.isNaN(ms) || new Date(ms).toISOString().slice(0, 10) !== s) {
|
|
18
|
+
return { ok: false, error: `Invalid date "${s}" (expected YYYY-MM-DD).` };
|
|
19
|
+
}
|
|
20
|
+
return { ok: true, ms };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function fmtDate(ms: number): string {
|
|
24
|
+
return new Date(ms).toISOString().slice(0, 10);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Render a full transcript as markdown:
|
|
28
|
+
// # title
|
|
29
|
+
// date — directory — id
|
|
30
|
+
//
|
|
31
|
+
// ## role
|
|
32
|
+
// text
|
|
33
|
+
// *(tools: …)*
|
|
34
|
+
//
|
|
35
|
+
// A blank line follows every rendered message. Callers decide truncation (the
|
|
36
|
+
// plugin caps at 50k chars; the CLI prints in full).
|
|
37
|
+
export function renderTranscript(
|
|
38
|
+
meta: { title: string; time_created: number; directory: string; id: string },
|
|
39
|
+
messages: SourceMessage[]
|
|
40
|
+
): string {
|
|
41
|
+
const lines: string[] = [
|
|
42
|
+
`# ${meta.title}`,
|
|
43
|
+
`${fmtDate(meta.time_created)} — ${meta.directory} — ${meta.id}`,
|
|
44
|
+
"",
|
|
45
|
+
];
|
|
46
|
+
for (const m of messages) {
|
|
47
|
+
const text = m.parts.filter((p) => p.type === "text" && p.text).map((p) => p.text).join("\n");
|
|
48
|
+
const tools = m.parts.filter((p) => p.type === "tool" && p.tool).map((p) => p.tool);
|
|
49
|
+
if (!text && tools.length === 0) continue;
|
|
50
|
+
lines.push(`## ${m.role}`);
|
|
51
|
+
if (text) lines.push(text);
|
|
52
|
+
if (tools.length) lines.push(`*(tools: ${tools.join(", ")})*`);
|
|
53
|
+
lines.push("");
|
|
54
|
+
}
|
|
55
|
+
return lines.join("\n");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// One search hit as a markdown block. snippetLength defaults to 400 (plugin
|
|
59
|
+
// tool output); the CLI passes 220 to keep terminal output brief. scoreLabel
|
|
60
|
+
// names the score field: "score" for vector (cosine ~0.4–0.7) and BM25, "rrf"
|
|
61
|
+
// for hybrid (fused reciprocal-rank scores ~0.03, a different scale — see
|
|
62
|
+
// AGENTS.md) so the number isn't misread against the cosine thresholds.
|
|
63
|
+
export function formatHit(h: SearchHit, snippetLength = 400, scoreLabel = "score"): string {
|
|
64
|
+
const snippet = h.text.replace(/\s+/g, " ").slice(0, snippetLength);
|
|
65
|
+
return `## ${fmtDate(h.time_created)} — ${h.title}\nsession: ${h.session_id} ${scoreLabel}: ${h.score.toFixed(3)}\n${h.directory}\n> ${snippet}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function formatHits(hits: SearchHit[], snippetLength = 400, scoreLabel = "score"): string {
|
|
69
|
+
return hits.map((h) => formatHit(h, snippetLength, scoreLabel)).join("\n\n");
|
|
70
|
+
}
|
package/src/indexer.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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 {
|
|
4
|
+
import { getTranscriptChecked, listSessions, type SourceSession } from "./reader";
|
|
5
5
|
import { parseTranscript, exchangeText } from "./parser";
|
|
6
6
|
import { embed } from "./embed";
|
|
7
7
|
import { getIndexedSession, replaceSessionChunks } from "./store";
|
|
@@ -24,12 +24,13 @@ export async function syncSession(
|
|
|
24
24
|
const prior = getIndexedSession(index, s.id);
|
|
25
25
|
if (!force && prior && prior.source_time_updated >= s.time_updated) return "fresh";
|
|
26
26
|
|
|
27
|
-
// Authoritative opt-out gate
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
const
|
|
27
|
+
// Authoritative opt-out gate lives inside getTranscriptChecked (raw-blob
|
|
28
|
+
// scan before any read); parseTranscript's own parsed-text check is a
|
|
29
|
+
// harmless redundant fast path for the non-excluded branch.
|
|
30
|
+
const checked = getTranscriptChecked(source, s.id);
|
|
31
|
+
const { exchanges, excluded } = checked.excluded
|
|
31
32
|
? { exchanges: [], excluded: true }
|
|
32
|
-
: parseTranscript(
|
|
33
|
+
: parseTranscript(checked.messages);
|
|
33
34
|
const meta = {
|
|
34
35
|
id: s.id, project_id: s.project_id, parent_id: s.parent_id,
|
|
35
36
|
title: s.title, directory: s.directory,
|
package/src/parser.ts
CHANGED
|
@@ -29,8 +29,9 @@ export interface Exchange {
|
|
|
29
29
|
time: number;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
// reasoning blobs, step markers, and every other non-text/-tool part type are
|
|
33
|
+
// excluded implicitly: textOf keeps only type === "text" and toolNames only
|
|
34
|
+
// type === "tool", so nothing else can slip through.
|
|
34
35
|
function textOf(parts: SourcePart[]): string {
|
|
35
36
|
return parts
|
|
36
37
|
.filter((p) => p.type === "text" && p.text)
|
|
@@ -41,7 +42,7 @@ function textOf(parts: SourcePart[]): string {
|
|
|
41
42
|
|
|
42
43
|
function toolNames(parts: SourcePart[]): string[] {
|
|
43
44
|
return parts
|
|
44
|
-
.filter((p) => p.type === "tool" && p.tool
|
|
45
|
+
.filter((p) => p.type === "tool" && p.tool)
|
|
45
46
|
.map((p) => p.tool!);
|
|
46
47
|
}
|
|
47
48
|
|
package/src/reader.ts
CHANGED
|
@@ -128,7 +128,10 @@ export function transcriptHasMarker(db: Database, sessionId: string): boolean {
|
|
|
128
128
|
return row.n > 0;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
|
|
131
|
+
// Module-internal: the raw read with no privacy gate. Production code must go
|
|
132
|
+
// through getTranscriptChecked so the exclusion marker can never be bypassed by
|
|
133
|
+
// forgetting a manual transcriptHasMarker() call. Not exported.
|
|
134
|
+
function getTranscript(db: Database, sessionId: string): SourceMessage[] {
|
|
132
135
|
const messages = MessageRowSchema.array().parse(
|
|
133
136
|
db
|
|
134
137
|
.prepare(
|
|
@@ -162,3 +165,18 @@ export function getTranscript(db: Database, sessionId: string): SourceMessage[]
|
|
|
162
165
|
parts: partsByMsg.get(m.id) ?? [],
|
|
163
166
|
}));
|
|
164
167
|
}
|
|
168
|
+
|
|
169
|
+
// Discriminated result: excluded conversations never yield a transcript.
|
|
170
|
+
export type CheckedTranscript =
|
|
171
|
+
| { excluded: true }
|
|
172
|
+
| { excluded: false; messages: SourceMessage[] };
|
|
173
|
+
|
|
174
|
+
// The single privacy-gated entry point for reading a transcript. Runs the
|
|
175
|
+
// AUTHORITATIVE raw-blob exclusion check (transcriptHasMarker) BEFORE reading,
|
|
176
|
+
// so the opt-out marker cannot be bypassed by a caller forgetting to check.
|
|
177
|
+
// All production call sites (CLI read, plugin episodic_read, indexer) use this;
|
|
178
|
+
// the raw getTranscript is module-internal.
|
|
179
|
+
export function getTranscriptChecked(db: Database, sessionId: string): CheckedTranscript {
|
|
180
|
+
if (transcriptHasMarker(db, sessionId)) return { excluded: true };
|
|
181
|
+
return { excluded: false, messages: getTranscript(db, sessionId) };
|
|
182
|
+
}
|