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.
@@ -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 via Transformers.js. CLS-pooled + L2-normalized,
2
- // so cosine similarity is a plain dot product.
3
- //
4
- // Model: Snowflake/snowflake-arctic-embed-m-v1.5 (q8) 768 dims, Apache-2.0,
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
- let cached: Promise<FeatureExtractionPipeline> | null = null;
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 function getEmbedder(): Promise<FeatureExtractionPipeline> {
26
- if (!cached) {
27
- // transformers.js declares pipeline<"feature-extraction"> as its task-metadata
28
- // record, not the FeatureExtractionPipeline instance it returns at runtime, so
29
- // this cast restores the documented return type (matches HuggingFace's examples).
30
- cached = pipeline("feature-extraction", process.env.EPISODIC_EMBED_MODEL ?? DEFAULT_MODEL, {
31
- dtype: "q8",
32
- }) as Promise<FeatureExtractionPipeline>;
33
- // A rejected promise (e.g. failed model download) would poison the cache
34
- // for the lifetime of the process; reset so the next call retries.
35
- cached.catch(() => { if (cached) cached = null; });
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 e = await getEmbedder();
43
- const out = await e(texts.map((t) => t.slice(0, MAX_CHARS)), { pooling: "cls", normalize: true });
44
- const dims: number = out.dims[out.dims.length - 1];
45
- // out.data is DataArray (a union incl. bigint typed arrays); a feature-extraction
46
- // tensor with normalize:true is a Float32Array at runtime, so this cast is safe.
47
- const flat = new Float32Array(out.data as Float32Array);
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 i = 0; i < texts.length; i++) {
50
- vectors.push(flat.subarray(i * dims, (i + 1) * dims));
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
- const q = query.startsWith(QUERY_PREFIX) ? query : QUERY_PREFIX + query;
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
- return `## ${fmtDate(h.time_created)} ${h.title}\nsession: ${h.session_id} ${scoreLabel}: ${h.score.toFixed(3)}\n${h.directory}\n> ${snippet}`;
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 { getIndexedSession, replaceSessionChunks } from "./store";
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: Database,
20
+ index: IndexStore,
21
21
  s: SourceSession,
22
22
  force = false
23
23
  ): Promise<"indexed" | "fresh" | "excluded" | "empty"> {
24
- const prior = getIndexedSession(index, s.id);
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 checked = getTranscriptChecked(source, s.id);
31
- const { exchanges, excluded } = checked.excluded
43
+ const transcript = checked ?? getTranscriptChecked(source, s.id);
44
+ const { exchanges, excluded } = transcript.excluded
32
45
  ? { exchanges: [], excluded: true }
33
- : parseTranscript(checked.messages);
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
- replaceSessionChunks(index, meta, [], "excluded");
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
- replaceSessionChunks(index, meta, [], "empty");
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
- replaceSessionChunks(
53
- index,
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) => ({ seq: i, time_created: e.time, text: texts[i], embedding: vectors[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: Database,
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: Database, knownSource?: SourceSession[]): number {
89
- const sourceIds = new Set((knownSource ?? listSessions(source)).map((s) => s.id));
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 episodic_read). Capped at 4000 chars
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.