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.
@@ -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 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 { getTranscript, listSessions, transcriptHasMarker, type SourceSession } from "./reader";
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: raw part blobs. The parsed-text scan inside
28
- // parseTranscript would miss a marker in an unparseable blob.
29
- const excludedRaw = transcriptHasMarker(source, s.id);
30
- const { exchanges, excluded } = excludedRaw
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(getTranscript(source, s.id));
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
- const SKIP_PART_TYPES = new Set(["reasoning", "step-start", "step-finish", "file", "patch", "snapshot"]);
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 && !SKIP_PART_TYPES.has(p.type))
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
- export function getTranscript(db: Database, sessionId: string): SourceMessage[] {
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
+ }