opencode-episodic-memory 0.2.0 → 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/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.
package/src/reader.ts CHANGED
@@ -42,11 +42,20 @@ const MessageRowSchema = z.object({
42
42
  time_created: z.number(),
43
43
  data: z.string(),
44
44
  });
45
+ type SourceMessageRow = z.infer<typeof MessageRowSchema>;
46
+
47
+ const AnchorRowSchema = z.object({
48
+ id: z.string(),
49
+ time_created: z.number(),
50
+ });
45
51
 
46
52
  const PartRowSchema = z.object({
47
53
  message_id: z.string(),
48
54
  data: z.string(),
49
55
  });
56
+ type SourcePartRow = z.infer<typeof PartRowSchema>;
57
+
58
+ const PartCountSchema = z.object({ message_id: z.string(), n: z.number() });
50
59
 
51
60
  // Aggregate row for the raw marker scan (structural: throw on drift).
52
61
  const MarkerCountSchema = z.object({ n: z.number() });
@@ -70,8 +79,12 @@ export interface SourceMessage {
70
79
  role: string;
71
80
  timeCreated: number;
72
81
  parts: SourcePart[];
82
+ contextPartsOmitted?: number;
73
83
  }
74
84
 
85
+ const MAX_CONTEXT_PART_BYTES = 8_192;
86
+ const MAX_CONTEXT_PARTS_PER_MESSAGE = 20;
87
+
75
88
  export function openSource(path: string = sourceDbPath()): Database {
76
89
  return new Database(path, { readonly: true });
77
90
  }
@@ -140,30 +153,90 @@ function getTranscript(db: Database, sessionId: string): SourceMessage[] {
140
153
  )
141
154
  .all(sessionId)
142
155
  );
156
+ return materializeMessages(db, sessionId, messages);
157
+ }
143
158
 
144
- const parts = PartRowSchema.array().parse(
145
- db
146
- .prepare(
147
- `SELECT message_id, data FROM part
148
- WHERE session_id = ? ORDER BY time_created, id`
149
- )
150
- .all(sessionId)
151
- );
159
+ // Parse parts only for the supplied message rows. Full transcript reads pass
160
+ // every row; bounded context reads pass just their selected SQL window.
161
+ function materializeMessages(
162
+ db: Database,
163
+ sessionId: string,
164
+ messages: SourceMessageRow[],
165
+ contextLimits?: { maxPartBytes: number; maxPartsPerMessage: number }
166
+ ): SourceMessage[] {
167
+ if (messages.length === 0) return [];
168
+ const ids = messages.map((message) => message.id);
169
+ const placeholders = ids.map(() => "?").join(", ");
170
+ const materialized = contextLimits
171
+ ? boundedParts(db, sessionId, ids, placeholders, contextLimits)
172
+ : {
173
+ rows: PartRowSchema.array().parse(
174
+ db
175
+ .prepare(
176
+ `SELECT message_id, data FROM part
177
+ WHERE session_id = ? AND message_id IN (${placeholders}) ORDER BY time_created, id`
178
+ )
179
+ .all(sessionId, ...ids)
180
+ ),
181
+ omittedByMessage: new Map<string, number>(),
182
+ };
152
183
 
153
184
  const partsByMsg = new Map<string, SourcePart[]>();
154
- for (const p of parts) {
185
+ for (const p of materialized.rows) {
155
186
  const d = PartDataSchema.parse(safeJsonParse(p.data));
156
187
  let list = partsByMsg.get(p.message_id);
157
188
  if (!list) partsByMsg.set(p.message_id, (list = []));
158
189
  list.push(d);
159
190
  }
160
191
 
161
- return messages.map((m) => ({
162
- id: m.id,
163
- role: MessageDataSchema.parse(safeJsonParse(m.data)).role,
164
- timeCreated: m.time_created,
165
- parts: partsByMsg.get(m.id) ?? [],
166
- }));
192
+ return messages.map((m) => {
193
+ const omitted = materialized.omittedByMessage.get(m.id) ?? 0;
194
+ return {
195
+ id: m.id,
196
+ role: MessageDataSchema.parse(safeJsonParse(m.data)).role,
197
+ timeCreated: m.time_created,
198
+ parts: partsByMsg.get(m.id) ?? [],
199
+ ...(omitted > 0 ? { contextPartsOmitted: omitted } : {}),
200
+ };
201
+ });
202
+ }
203
+
204
+ // Context-only part fetch: SQL excludes oversized raw blobs before they cross
205
+ // into JS, ranks remaining parts per selected message, and records omissions.
206
+ // Full transcript reads continue through the unbounded path above.
207
+ function boundedParts(
208
+ db: Database,
209
+ sessionId: string,
210
+ ids: string[],
211
+ placeholders: string,
212
+ limits: { maxPartBytes: number; maxPartsPerMessage: number }
213
+ ): { rows: SourcePartRow[]; omittedByMessage: Map<string, number> } {
214
+ const counts = PartCountSchema.array().parse(
215
+ db.prepare(
216
+ `SELECT message_id, COUNT(*) AS n FROM part
217
+ WHERE session_id = ? AND message_id IN (${placeholders}) GROUP BY message_id`
218
+ ).all(sessionId, ...ids)
219
+ );
220
+ const rows = PartRowSchema.array().parse(
221
+ db.prepare(
222
+ `WITH ranked AS (
223
+ SELECT message_id, data, time_created, id,
224
+ ROW_NUMBER() OVER (PARTITION BY message_id ORDER BY time_created, id) AS part_rank
225
+ FROM part
226
+ WHERE session_id = ? AND message_id IN (${placeholders})
227
+ AND (data IS NULL OR length(CAST(data AS BLOB)) <= ?)
228
+ )
229
+ SELECT message_id, data FROM ranked WHERE part_rank <= ? ORDER BY time_created, id`
230
+ ).all(sessionId, ...ids, limits.maxPartBytes, limits.maxPartsPerMessage)
231
+ );
232
+ const retained = new Map<string, number>();
233
+ for (const row of rows) retained.set(row.message_id, (retained.get(row.message_id) ?? 0) + 1);
234
+ const omittedByMessage = new Map<string, number>();
235
+ for (const count of counts) {
236
+ const omitted = count.n - (retained.get(count.message_id) ?? 0);
237
+ if (omitted > 0) omittedByMessage.set(count.message_id, omitted);
238
+ }
239
+ return { rows, omittedByMessage };
167
240
  }
168
241
 
169
242
  // Discriminated result: excluded conversations never yield a transcript.
@@ -174,9 +247,88 @@ export type CheckedTranscript =
174
247
  // The single privacy-gated entry point for reading a transcript. Runs the
175
248
  // AUTHORITATIVE raw-blob exclusion check (transcriptHasMarker) BEFORE reading,
176
249
  // 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;
250
+ // All production call sites (CLI read, plugin episodic_read_session, indexer) use this;
178
251
  // the raw getTranscript is module-internal.
179
252
  export function getTranscriptChecked(db: Database, sessionId: string): CheckedTranscript {
180
253
  if (transcriptHasMarker(db, sessionId)) return { excluded: true };
181
254
  return { excluded: false, messages: getTranscript(db, sessionId) };
182
255
  }
256
+
257
+ export const MAX_CONTEXT_MESSAGES = 20;
258
+
259
+ export type TranscriptContext =
260
+ | { ok: true; session: SourceSession; messages: SourceMessage[]; anchorIndex: number; sliceStart: number; total: number }
261
+ | { ok: false; reason: "unknown_session" | "excluded" | "invalid_anchor" | "invalid_bounds" };
262
+
263
+ // Read a small, chronological live-source window around an indexed user-message
264
+ // anchor. This deliberately has no indexed fallback: an index may outlive its
265
+ // source transcript, but it cannot safely reconstruct source message context.
266
+ export function getTranscriptContext(
267
+ db: Database,
268
+ sessionId: string,
269
+ anchorMessageId: string,
270
+ before: number = 3,
271
+ after: number = 3
272
+ ): TranscriptContext {
273
+ if (!isContextBound(before) || !isContextBound(after)) return { ok: false, reason: "invalid_bounds" };
274
+ return readSnapshot(db, () => {
275
+ // Keep the whole-session raw scan first: privacy is session-wide, while
276
+ // every subsequent query stays bounded to the requested context window.
277
+ if (transcriptHasMarker(db, sessionId)) return { ok: false, reason: "excluded" };
278
+ const session = getSession(db, sessionId);
279
+ if (!session) return { ok: false, reason: "unknown_session" };
280
+ const anchorRow = db.prepare("SELECT id, time_created FROM message WHERE session_id = ? AND id = ?").get(sessionId, anchorMessageId);
281
+ if (anchorRow === null || anchorRow === undefined) return { ok: false, reason: "invalid_anchor" };
282
+ const anchor = AnchorRowSchema.parse(anchorRow);
283
+ const total = MarkerCountSchema.parse(
284
+ db.prepare("SELECT COUNT(*) AS n FROM message WHERE session_id = ?").get(sessionId)
285
+ ).n;
286
+ const anchorIndex = MarkerCountSchema.parse(
287
+ db.prepare(
288
+ `SELECT COUNT(*) AS n FROM message
289
+ WHERE session_id = ? AND (time_created < ? OR (time_created = ? AND id < ?))`
290
+ ).get(sessionId, anchor.time_created, anchor.time_created, anchor.id)
291
+ ).n;
292
+ const sliceStart = Math.max(0, anchorIndex - before);
293
+ const sliceEnd = Math.min(total, anchorIndex + after + 1);
294
+ const sliceLength = sliceEnd - sliceStart;
295
+ const rows = MessageRowSchema.array().parse(
296
+ db.prepare(
297
+ `SELECT id, time_created, data FROM message
298
+ WHERE session_id = ? ORDER BY time_created, id LIMIT ? OFFSET ?`
299
+ ).all(sessionId, sliceLength, sliceStart)
300
+ );
301
+ return {
302
+ ok: true,
303
+ session,
304
+ messages: materializeMessages(db, sessionId, rows, {
305
+ maxPartBytes: MAX_CONTEXT_PART_BYTES,
306
+ maxPartsPerMessage: MAX_CONTEXT_PARTS_PER_MESSAGE,
307
+ }),
308
+ anchorIndex,
309
+ sliceStart,
310
+ total,
311
+ };
312
+ });
313
+ }
314
+
315
+ // BEGIN is deferred, so this remains a read transaction against the readonly
316
+ // source DB while pinning all context queries to one SQLite snapshot.
317
+ function readSnapshot<T>(db: Database, read: () => T): T {
318
+ let active = false;
319
+ try {
320
+ db.run("BEGIN");
321
+ active = true;
322
+ const result = read();
323
+ db.run("COMMIT");
324
+ active = false;
325
+ return result;
326
+ } catch (error) {
327
+ if (active) db.run("ROLLBACK");
328
+ throw error;
329
+ }
330
+ }
331
+
332
+ function isContextBound(value: number): boolean {
333
+ return Number.isInteger(value) && value >= 0 && value <= MAX_CONTEXT_MESSAGES;
334
+ }