opencode-episodic-memory 0.1.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
+ }