opencode-memory-plugin 0.1.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.
Files changed (102) hide show
  1. package/README.md +73 -0
  2. package/dist/compression/compressor.d.ts +86 -0
  3. package/dist/compression/compressor.d.ts.map +1 -0
  4. package/dist/compression/compressor.js +142 -0
  5. package/dist/compression/compressor.js.map +1 -0
  6. package/dist/compression/parser.d.ts +73 -0
  7. package/dist/compression/parser.d.ts.map +1 -0
  8. package/dist/compression/parser.js +139 -0
  9. package/dist/compression/parser.js.map +1 -0
  10. package/dist/compression/pipeline.d.ts +73 -0
  11. package/dist/compression/pipeline.d.ts.map +1 -0
  12. package/dist/compression/pipeline.js +205 -0
  13. package/dist/compression/pipeline.js.map +1 -0
  14. package/dist/compression/privacy.d.ts +8 -0
  15. package/dist/compression/privacy.d.ts.map +1 -0
  16. package/dist/compression/privacy.js +30 -0
  17. package/dist/compression/privacy.js.map +1 -0
  18. package/dist/compression/prompts.d.ts +24 -0
  19. package/dist/compression/prompts.d.ts.map +1 -0
  20. package/dist/compression/prompts.js +106 -0
  21. package/dist/compression/prompts.js.map +1 -0
  22. package/dist/compression/quality.d.ts +48 -0
  23. package/dist/compression/quality.d.ts.map +1 -0
  24. package/dist/compression/quality.js +159 -0
  25. package/dist/compression/quality.js.map +1 -0
  26. package/dist/config.d.ts +114 -0
  27. package/dist/config.d.ts.map +1 -0
  28. package/dist/config.js +265 -0
  29. package/dist/config.js.map +1 -0
  30. package/dist/context/generator.d.ts +28 -0
  31. package/dist/context/generator.d.ts.map +1 -0
  32. package/dist/context/generator.js +80 -0
  33. package/dist/context/generator.js.map +1 -0
  34. package/dist/hooks/chat-message.d.ts +14 -0
  35. package/dist/hooks/chat-message.d.ts.map +1 -0
  36. package/dist/hooks/chat-message.js +35 -0
  37. package/dist/hooks/chat-message.js.map +1 -0
  38. package/dist/hooks/compaction.d.ts +13 -0
  39. package/dist/hooks/compaction.d.ts.map +1 -0
  40. package/dist/hooks/compaction.js +22 -0
  41. package/dist/hooks/compaction.js.map +1 -0
  42. package/dist/hooks/events.d.ts +52 -0
  43. package/dist/hooks/events.d.ts.map +1 -0
  44. package/dist/hooks/events.js +138 -0
  45. package/dist/hooks/events.js.map +1 -0
  46. package/dist/hooks/system-transform.d.ts +14 -0
  47. package/dist/hooks/system-transform.d.ts.map +1 -0
  48. package/dist/hooks/system-transform.js +26 -0
  49. package/dist/hooks/system-transform.js.map +1 -0
  50. package/dist/hooks/tool-after.d.ts +26 -0
  51. package/dist/hooks/tool-after.d.ts.map +1 -0
  52. package/dist/hooks/tool-after.js +88 -0
  53. package/dist/hooks/tool-after.js.map +1 -0
  54. package/dist/index.d.ts +11 -0
  55. package/dist/index.d.ts.map +1 -0
  56. package/dist/index.js +79 -0
  57. package/dist/index.js.map +1 -0
  58. package/dist/logger.d.ts +60 -0
  59. package/dist/logger.d.ts.map +1 -0
  60. package/dist/logger.js +91 -0
  61. package/dist/logger.js.map +1 -0
  62. package/dist/storage/db.d.ts +22 -0
  63. package/dist/storage/db.d.ts.map +1 -0
  64. package/dist/storage/db.js +198 -0
  65. package/dist/storage/db.js.map +1 -0
  66. package/dist/storage/schema.d.ts +2473 -0
  67. package/dist/storage/schema.d.ts.map +1 -0
  68. package/dist/storage/schema.js +100 -0
  69. package/dist/storage/schema.js.map +1 -0
  70. package/dist/storage/store.d.ts +376 -0
  71. package/dist/storage/store.d.ts.map +1 -0
  72. package/dist/storage/store.js +1025 -0
  73. package/dist/storage/store.js.map +1 -0
  74. package/dist/tools/memory-forget.d.ts +11 -0
  75. package/dist/tools/memory-forget.d.ts.map +1 -0
  76. package/dist/tools/memory-forget.js +249 -0
  77. package/dist/tools/memory-forget.js.map +1 -0
  78. package/dist/tools/memory-get.d.ts +10 -0
  79. package/dist/tools/memory-get.d.ts.map +1 -0
  80. package/dist/tools/memory-get.js +50 -0
  81. package/dist/tools/memory-get.js.map +1 -0
  82. package/dist/tools/memory-search.d.ts +11 -0
  83. package/dist/tools/memory-search.d.ts.map +1 -0
  84. package/dist/tools/memory-search.js +38 -0
  85. package/dist/tools/memory-search.js.map +1 -0
  86. package/dist/tools/memory-stats.d.ts +39 -0
  87. package/dist/tools/memory-stats.d.ts.map +1 -0
  88. package/dist/tools/memory-stats.js +121 -0
  89. package/dist/tools/memory-stats.js.map +1 -0
  90. package/dist/tools/memory-timeline.d.ts +10 -0
  91. package/dist/tools/memory-timeline.d.ts.map +1 -0
  92. package/dist/tools/memory-timeline.js +49 -0
  93. package/dist/tools/memory-timeline.js.map +1 -0
  94. package/dist/types.d.ts +178 -0
  95. package/dist/types.d.ts.map +1 -0
  96. package/dist/types.js +4 -0
  97. package/dist/types.js.map +1 -0
  98. package/dist/utils.d.ts +130 -0
  99. package/dist/utils.d.ts.map +1 -0
  100. package/dist/utils.js +308 -0
  101. package/dist/utils.js.map +1 -0
  102. package/package.json +36 -0
@@ -0,0 +1,1025 @@
1
+ import { and, desc, eq, inArray, lte, gte, sql } from "drizzle-orm";
2
+ import { deletionLog, observations, pendingMessages, sessionSummaries, toolUsageStats, userPrompts, } from "./schema";
3
+ import { createSortableId, parseJsonValue, sanitizeFtsQuery, serializeJson } from "../utils";
4
+ /**
5
+ * Provides project-scoped persistence and retrieval operations for the memory plugin.
6
+ */
7
+ export class MemoryStore {
8
+ database;
9
+ scope;
10
+ now;
11
+ constructor(database, scope, now) {
12
+ this.database = database;
13
+ this.scope = scope;
14
+ this.now = now;
15
+ }
16
+ /**
17
+ * Closes the underlying SQLite connection.
18
+ *
19
+ * @returns Nothing.
20
+ */
21
+ close() {
22
+ this.database.sqlite.close();
23
+ }
24
+ /**
25
+ * Persists a compressed observation.
26
+ *
27
+ * @param observation - Observation to save.
28
+ * @returns A promise that resolves after insertion.
29
+ */
30
+ async saveObservation(observation) {
31
+ this.database.db.insert(observations).values({
32
+ id: observation.id,
33
+ projectId: observation.projectId,
34
+ projectRoot: observation.projectRoot,
35
+ sessionId: observation.sessionId,
36
+ type: observation.type,
37
+ title: observation.title,
38
+ subtitle: observation.subtitle,
39
+ narrative: observation.narrative,
40
+ facts: serializeJson(observation.facts),
41
+ concepts: serializeJson(observation.concepts),
42
+ filesInvolved: serializeJson(observation.filesInvolved),
43
+ rawTokenCount: observation.rawTokenCount,
44
+ compressedTokenCount: observation.compressedTokenCount,
45
+ toolName: observation.toolName,
46
+ modelUsed: observation.modelUsed,
47
+ quality: observation.quality,
48
+ rawFallback: observation.rawFallback,
49
+ createdAt: observation.createdAt,
50
+ }).run();
51
+ }
52
+ /**
53
+ * Retrieves a single observation by identifier.
54
+ *
55
+ * @param id - Observation identifier.
56
+ * @returns The observation or null.
57
+ */
58
+ async getObservation(id) {
59
+ const row = this.database.db
60
+ .select()
61
+ .from(observations)
62
+ .where(and(eq(observations.id, id), eq(observations.projectId, this.scope.projectId)))
63
+ .get();
64
+ return row ? mapObservation(row) : null;
65
+ }
66
+ /**
67
+ * Retrieves multiple observations in a single query.
68
+ *
69
+ * @param ids - Observation identifiers.
70
+ * @returns Matching observations.
71
+ */
72
+ async getObservationsBatch(ids) {
73
+ if (!ids.length) {
74
+ return [];
75
+ }
76
+ const rows = this.database.db
77
+ .select()
78
+ .from(observations)
79
+ .where(and(inArray(observations.id, ids), eq(observations.projectId, this.scope.projectId)))
80
+ .orderBy(desc(observations.createdAt))
81
+ .all();
82
+ return rows.map(mapObservation);
83
+ }
84
+ /**
85
+ * Retrieves the most recent observations for the current project.
86
+ *
87
+ * @param limit - Maximum number of rows.
88
+ * @returns Recent observations ordered from newest to oldest.
89
+ */
90
+ async getRecentObservations(limit) {
91
+ const rows = this.database.db
92
+ .select()
93
+ .from(observations)
94
+ .where(eq(observations.projectId, this.scope.projectId))
95
+ .orderBy(desc(observations.createdAt))
96
+ .limit(limit)
97
+ .all();
98
+ return rows.map(mapObservation);
99
+ }
100
+ /**
101
+ * Retrieves all observations recorded for a session.
102
+ *
103
+ * @param sessionId - OpenCode session identifier.
104
+ * @returns Observations ordered from oldest to newest.
105
+ */
106
+ async getSessionObservations(sessionId) {
107
+ const rows = this.database.db
108
+ .select()
109
+ .from(observations)
110
+ .where(and(eq(observations.projectId, this.scope.projectId), eq(observations.sessionId, sessionId)))
111
+ .orderBy(observations.createdAt)
112
+ .all();
113
+ return rows.map(mapObservation);
114
+ }
115
+ /**
116
+ * Returns the total number of stored observations for the current project.
117
+ *
118
+ * @returns Observation count.
119
+ */
120
+ async countObservations() {
121
+ const row = this.database.db
122
+ .select({ value: sql `count(*)` })
123
+ .from(observations)
124
+ .where(eq(observations.projectId, this.scope.projectId))
125
+ .get();
126
+ return row?.value ?? 0;
127
+ }
128
+ /**
129
+ * Adds a pending raw tool result to the crash-safe queue.
130
+ *
131
+ * @param pendingMessage - Pending message payload.
132
+ * @returns A promise that resolves after insertion.
133
+ */
134
+ async enqueuePending(pendingMessage) {
135
+ this.database.db.insert(pendingMessages).values({
136
+ id: pendingMessage.id,
137
+ projectId: pendingMessage.projectId,
138
+ projectRoot: pendingMessage.projectRoot,
139
+ sessionId: pendingMessage.sessionId,
140
+ toolName: pendingMessage.toolName,
141
+ title: pendingMessage.title,
142
+ rawContent: pendingMessage.rawContent,
143
+ rawMetadata: pendingMessage.rawMetadata ? serializeJson(pendingMessage.rawMetadata) : null,
144
+ status: pendingMessage.status,
145
+ retryCount: pendingMessage.retryCount,
146
+ errorMessage: pendingMessage.errorMessage,
147
+ createdAt: pendingMessage.createdAt,
148
+ processedAt: pendingMessage.processedAt,
149
+ }).run();
150
+ }
151
+ /**
152
+ * Fetches pending messages by status.
153
+ *
154
+ * @param statuses - Accepted queue statuses.
155
+ * @param limit - Maximum number of rows.
156
+ * @returns Matching pending messages.
157
+ */
158
+ async getPendingMessages(statuses, limit) {
159
+ const rows = this.database.db
160
+ .select()
161
+ .from(pendingMessages)
162
+ .where(and(eq(pendingMessages.projectId, this.scope.projectId), inArray(pendingMessages.status, statuses)))
163
+ .orderBy(pendingMessages.createdAt)
164
+ .limit(limit)
165
+ .all();
166
+ return rows.map(mapPendingMessage);
167
+ }
168
+ /**
169
+ * Finds queue items that were left in processing state past the orphan threshold.
170
+ *
171
+ * @param orphanThresholdMs - Threshold in milliseconds.
172
+ * @returns Orphaned pending messages.
173
+ */
174
+ async getOrphanedMessages(orphanThresholdMs) {
175
+ const cutoff = this.now() - orphanThresholdMs;
176
+ const rows = this.database.db
177
+ .select()
178
+ .from(pendingMessages)
179
+ .where(and(eq(pendingMessages.projectId, this.scope.projectId), eq(pendingMessages.status, "processing"), lte(pendingMessages.createdAt, cutoff)))
180
+ .orderBy(pendingMessages.createdAt)
181
+ .all();
182
+ return rows.map(mapPendingMessage);
183
+ }
184
+ /**
185
+ * Updates the status for a queued message.
186
+ *
187
+ * @param id - Pending message identifier.
188
+ * @param status - Next queue status.
189
+ * @param retryCount - Updated retry count.
190
+ * @param errorMessage - Optional error message.
191
+ * @returns A promise that resolves after the update.
192
+ */
193
+ async updatePendingStatus(id, status, retryCount, errorMessage) {
194
+ this.database.db
195
+ .update(pendingMessages)
196
+ .set({
197
+ status,
198
+ retryCount,
199
+ errorMessage,
200
+ processedAt: status === "processed" ? this.now() : null,
201
+ })
202
+ .where(and(eq(pendingMessages.id, id), eq(pendingMessages.projectId, this.scope.projectId)))
203
+ .run();
204
+ }
205
+ /**
206
+ * Counts queued items for a specific session.
207
+ *
208
+ * @param sessionId - OpenCode session identifier.
209
+ * @returns Queue size for that session.
210
+ */
211
+ async countPendingForSession(sessionId) {
212
+ const row = this.database.db
213
+ .select({ value: sql `count(*)` })
214
+ .from(pendingMessages)
215
+ .where(and(eq(pendingMessages.projectId, this.scope.projectId), eq(pendingMessages.sessionId, sessionId), inArray(pendingMessages.status, ["pending", "processing"])))
216
+ .get();
217
+ return row?.value ?? 0;
218
+ }
219
+ /**
220
+ * Saves or replaces a session summary.
221
+ *
222
+ * @param summary - Summary payload.
223
+ * @returns A promise that resolves after persistence.
224
+ */
225
+ async saveSessionSummary(summary) {
226
+ this.database.db
227
+ .insert(sessionSummaries)
228
+ .values({
229
+ id: summary.id,
230
+ projectId: summary.projectId,
231
+ projectRoot: summary.projectRoot,
232
+ sessionId: summary.sessionId,
233
+ requested: summary.requested,
234
+ investigated: summary.investigated,
235
+ learned: summary.learned,
236
+ completed: summary.completed,
237
+ nextSteps: summary.nextSteps,
238
+ observationCount: summary.observationCount,
239
+ modelUsed: summary.modelUsed,
240
+ createdAt: summary.createdAt,
241
+ })
242
+ .onConflictDoUpdate({
243
+ target: [sessionSummaries.projectId, sessionSummaries.sessionId],
244
+ set: {
245
+ requested: summary.requested,
246
+ investigated: summary.investigated,
247
+ learned: summary.learned,
248
+ completed: summary.completed,
249
+ nextSteps: summary.nextSteps,
250
+ observationCount: summary.observationCount,
251
+ modelUsed: summary.modelUsed,
252
+ createdAt: summary.createdAt,
253
+ },
254
+ })
255
+ .run();
256
+ }
257
+ /**
258
+ * Retrieves the latest summary for a session.
259
+ *
260
+ * @param sessionId - OpenCode session identifier.
261
+ * @returns The stored summary or null.
262
+ */
263
+ async getSessionSummary(sessionId) {
264
+ const row = this.database.db
265
+ .select()
266
+ .from(sessionSummaries)
267
+ .where(and(eq(sessionSummaries.projectId, this.scope.projectId), eq(sessionSummaries.sessionId, sessionId)))
268
+ .get();
269
+ return row ? mapSessionSummary(row) : null;
270
+ }
271
+ /**
272
+ * Retrieves the most recent summaries for the current project.
273
+ *
274
+ * @param limit - Maximum number of summaries.
275
+ * @returns Recent summaries ordered from newest to oldest.
276
+ */
277
+ async getRecentSummaries(limit) {
278
+ const rows = this.database.db
279
+ .select()
280
+ .from(sessionSummaries)
281
+ .where(eq(sessionSummaries.projectId, this.scope.projectId))
282
+ .orderBy(desc(sessionSummaries.createdAt))
283
+ .limit(limit)
284
+ .all();
285
+ return rows.map(mapSessionSummary);
286
+ }
287
+ /**
288
+ * Stores a user prompt for later summarization and retrieval.
289
+ *
290
+ * @param prompt - Prompt payload.
291
+ * @returns A promise that resolves after insertion.
292
+ */
293
+ async saveUserPrompt(prompt) {
294
+ this.database.db
295
+ .insert(userPrompts)
296
+ .values({
297
+ id: prompt.id,
298
+ projectId: prompt.projectId,
299
+ projectRoot: prompt.projectRoot,
300
+ sessionId: prompt.sessionId,
301
+ messageId: prompt.messageId,
302
+ content: prompt.content,
303
+ createdAt: prompt.createdAt,
304
+ })
305
+ .onConflictDoNothing({ target: userPrompts.messageId })
306
+ .run();
307
+ }
308
+ /**
309
+ * Returns prompts associated with a session.
310
+ *
311
+ * @param sessionId - OpenCode session identifier.
312
+ * @returns Session prompts ordered from oldest to newest.
313
+ */
314
+ async getSessionUserPrompts(sessionId) {
315
+ const rows = this.database.db
316
+ .select()
317
+ .from(userPrompts)
318
+ .where(and(eq(userPrompts.projectId, this.scope.projectId), eq(userPrompts.sessionId, sessionId)))
319
+ .orderBy(userPrompts.createdAt)
320
+ .all();
321
+ return rows.map(mapUserPrompt);
322
+ }
323
+ /**
324
+ * Searches observations through the FTS5 index.
325
+ *
326
+ * @param query - User-entered search text.
327
+ * @param limit - Maximum number of results.
328
+ * @param typeFilter - Optional observation type filter.
329
+ * @returns Compact search results.
330
+ */
331
+ async searchFTS(query, limit, typeFilter) {
332
+ const match = sanitizeFtsQuery(query);
333
+ if (!match) {
334
+ return [];
335
+ }
336
+ const sqlText = `
337
+ SELECT
338
+ o.id,
339
+ o.title,
340
+ o.subtitle,
341
+ o.type,
342
+ o.created_at,
343
+ o.tool_name,
344
+ o.quality
345
+ FROM observations_fts f
346
+ JOIN observations o ON o.rowid = f.rowid
347
+ WHERE observations_fts MATCH ?
348
+ AND o.project_id = ?
349
+ ${typeFilter ? "AND o.type = ?" : ""}
350
+ ORDER BY bm25(observations_fts), o.created_at DESC
351
+ LIMIT ?
352
+ `;
353
+ const parameters = typeFilter
354
+ ? [match, this.scope.projectId, typeFilter, limit]
355
+ : [match, this.scope.projectId, limit];
356
+ const rows = this.database.sqlite.query(sqlText).all(...parameters);
357
+ return rows.map((row) => ({
358
+ id: row.id,
359
+ title: row.title,
360
+ subtitle: row.subtitle,
361
+ type: row.type,
362
+ createdAt: row.created_at,
363
+ toolName: row.tool_name,
364
+ quality: row.quality,
365
+ }));
366
+ }
367
+ /**
368
+ * Retrieves a timeline page for the current project.
369
+ *
370
+ * @param query - Timeline filters and pagination options.
371
+ * @returns Timeline page with the next cursor.
372
+ */
373
+ async getTimeline(query) {
374
+ const conditions = [eq(observations.projectId, this.scope.projectId)];
375
+ if (query.before) {
376
+ conditions.push(lte(observations.createdAt, query.before));
377
+ }
378
+ if (query.after) {
379
+ conditions.push(sql `${observations.createdAt} >= ${query.after}`);
380
+ }
381
+ if (query.sessionId) {
382
+ conditions.push(eq(observations.sessionId, query.sessionId));
383
+ }
384
+ const rows = this.database.db
385
+ .select()
386
+ .from(observations)
387
+ .where(and(...conditions))
388
+ .orderBy(desc(observations.createdAt))
389
+ .limit(query.limit + 1)
390
+ .all()
391
+ .map(mapObservation);
392
+ const hasMore = rows.length > query.limit;
393
+ const observationsPage = hasMore ? rows.slice(0, query.limit) : rows;
394
+ const lastObservation = observationsPage.at(-1) ?? null;
395
+ return {
396
+ observations: observationsPage,
397
+ nextCursor: hasMore && lastObservation ? String(lastObservation.createdAt - 1) : null,
398
+ };
399
+ }
400
+ /**
401
+ * Determines whether a session has new activity after its current summary.
402
+ *
403
+ * @param sessionId - OpenCode session identifier.
404
+ * @returns True when a summary refresh is needed.
405
+ */
406
+ async hasSessionActivityAfterSummary(sessionId) {
407
+ const summary = await this.getSessionSummary(sessionId);
408
+ if (!summary) {
409
+ const observationCount = await this.countSessionObservations(sessionId);
410
+ const promptCount = await this.countSessionPrompts(sessionId);
411
+ return observationCount > 0 || promptCount > 0;
412
+ }
413
+ const lastObservation = this.getLastTimestamp(this.database.sqlite, "observations", sessionId, this.scope.projectId);
414
+ const lastPrompt = this.getLastTimestamp(this.database.sqlite, "user_prompts", sessionId, this.scope.projectId);
415
+ const lastActivity = Math.max(lastObservation, lastPrompt);
416
+ return lastActivity > summary.createdAt;
417
+ }
418
+ /**
419
+ * Counts observations for a session.
420
+ *
421
+ * @param sessionId - OpenCode session identifier.
422
+ * @returns Observation count.
423
+ */
424
+ async countSessionObservations(sessionId) {
425
+ const row = this.database.db
426
+ .select({ value: sql `count(*)` })
427
+ .from(observations)
428
+ .where(and(eq(observations.projectId, this.scope.projectId), eq(observations.sessionId, sessionId)))
429
+ .get();
430
+ return row?.value ?? 0;
431
+ }
432
+ /**
433
+ * Counts prompts for a session.
434
+ *
435
+ * @param sessionId - OpenCode session identifier.
436
+ * @returns Prompt count.
437
+ */
438
+ async countSessionPrompts(sessionId) {
439
+ const row = this.database.db
440
+ .select({ value: sql `count(*)` })
441
+ .from(userPrompts)
442
+ .where(and(eq(userPrompts.projectId, this.scope.projectId), eq(userPrompts.sessionId, sessionId)))
443
+ .get();
444
+ return row?.value ?? 0;
445
+ }
446
+ /**
447
+ * Deletes a set of observations by identifier.
448
+ *
449
+ * @param ids - Observation identifiers.
450
+ * @returns Number of deleted observations.
451
+ */
452
+ async deleteObservations(ids) {
453
+ if (!ids.length) {
454
+ return 0;
455
+ }
456
+ const row = this.database.db
457
+ .select({ value: sql `count(*)` })
458
+ .from(observations)
459
+ .where(and(eq(observations.projectId, this.scope.projectId), inArray(observations.id, ids)))
460
+ .get();
461
+ const count = row?.value ?? 0;
462
+ if (!count) {
463
+ return 0;
464
+ }
465
+ this.database.db
466
+ .delete(observations)
467
+ .where(and(eq(observations.projectId, this.scope.projectId), inArray(observations.id, ids)))
468
+ .run();
469
+ return count;
470
+ }
471
+ /**
472
+ * Deletes observations that match an FTS query.
473
+ *
474
+ * @param ftsQuery - Raw user query.
475
+ * @returns Number of deleted observations.
476
+ */
477
+ async deleteByQuery(ftsQuery) {
478
+ const match = sanitizeFtsQuery(ftsQuery);
479
+ if (!match) {
480
+ return 0;
481
+ }
482
+ const rows = this.database.sqlite
483
+ .query(`
484
+ SELECT o.id
485
+ FROM observations_fts f
486
+ JOIN observations o ON o.rowid = f.rowid
487
+ WHERE observations_fts MATCH ?
488
+ AND o.project_id = ?
489
+ `)
490
+ .all(match, this.scope.projectId);
491
+ return this.deleteObservations(rows.map((row) => row.id));
492
+ }
493
+ /**
494
+ * Deletes all observations and summary for a session.
495
+ *
496
+ * @param sessionId - OpenCode session identifier.
497
+ * @returns Number of deleted observations.
498
+ */
499
+ async deleteBySession(sessionId) {
500
+ const row = this.database.db
501
+ .select({ value: sql `count(*)` })
502
+ .from(observations)
503
+ .where(and(eq(observations.projectId, this.scope.projectId), eq(observations.sessionId, sessionId)))
504
+ .get();
505
+ const count = row?.value ?? 0;
506
+ this.database.db
507
+ .delete(observations)
508
+ .where(and(eq(observations.projectId, this.scope.projectId), eq(observations.sessionId, sessionId)))
509
+ .run();
510
+ this.database.db
511
+ .delete(sessionSummaries)
512
+ .where(and(eq(sessionSummaries.projectId, this.scope.projectId), eq(sessionSummaries.sessionId, sessionId)))
513
+ .run();
514
+ return count;
515
+ }
516
+ /**
517
+ * Deletes observations created before or at a given date.
518
+ *
519
+ * @param date - Cutoff date.
520
+ * @returns Number of deleted observations.
521
+ */
522
+ async deleteBefore(date) {
523
+ const cutoff = date.getTime();
524
+ if (!Number.isFinite(cutoff)) {
525
+ return 0;
526
+ }
527
+ const row = this.database.db
528
+ .select({ value: sql `count(*)` })
529
+ .from(observations)
530
+ .where(and(eq(observations.projectId, this.scope.projectId), lte(observations.createdAt, cutoff)))
531
+ .get();
532
+ const count = row?.value ?? 0;
533
+ if (!count) {
534
+ return 0;
535
+ }
536
+ this.database.db
537
+ .delete(observations)
538
+ .where(and(eq(observations.projectId, this.scope.projectId), lte(observations.createdAt, cutoff)))
539
+ .run();
540
+ return count;
541
+ }
542
+ /**
543
+ * Stores a deletion audit log entry.
544
+ *
545
+ * @param criteria - JSON criteria description.
546
+ * @param count - Deleted observation count.
547
+ * @param initiator - Operation initiator.
548
+ * @returns A promise that resolves after insertion.
549
+ */
550
+ async logDeletion(criteria, count, initiator) {
551
+ this.database.db.insert(deletionLog).values({
552
+ id: this.createId(),
553
+ projectId: this.scope.projectId,
554
+ projectRoot: this.scope.projectRoot,
555
+ timestamp: this.now(),
556
+ criteria,
557
+ count,
558
+ initiator,
559
+ }).run();
560
+ }
561
+ /**
562
+ * Increments tool usage counters for a session.
563
+ *
564
+ * @param sessionId - OpenCode session identifier.
565
+ * @param toolName - Tool name.
566
+ * @returns A promise that resolves after update.
567
+ */
568
+ async incrementToolUsage(sessionId, toolName) {
569
+ const existing = this.database.db
570
+ .select({ id: toolUsageStats.id, callCount: toolUsageStats.callCount })
571
+ .from(toolUsageStats)
572
+ .where(and(eq(toolUsageStats.projectId, this.scope.projectId), eq(toolUsageStats.sessionId, sessionId), eq(toolUsageStats.toolName, toolName)))
573
+ .get();
574
+ if (!existing) {
575
+ this.database.db.insert(toolUsageStats).values({
576
+ id: this.createId(),
577
+ projectId: this.scope.projectId,
578
+ projectRoot: this.scope.projectRoot,
579
+ sessionId,
580
+ toolName,
581
+ callCount: 1,
582
+ createdAt: this.now(),
583
+ }).run();
584
+ return;
585
+ }
586
+ this.database.db
587
+ .update(toolUsageStats)
588
+ .set({
589
+ callCount: existing.callCount + 1,
590
+ createdAt: this.now(),
591
+ })
592
+ .where(eq(toolUsageStats.id, existing.id))
593
+ .run();
594
+ }
595
+ /**
596
+ * Retrieves tool usage stats from the last N days.
597
+ *
598
+ * @param days - Lookback window in days.
599
+ * @returns Matching tool usage rows.
600
+ */
601
+ async getToolUsageStats(days) {
602
+ const cutoff = this.now() - Math.max(1, days) * 86_400_000;
603
+ const rows = this.database.db
604
+ .select()
605
+ .from(toolUsageStats)
606
+ .where(and(eq(toolUsageStats.projectId, this.scope.projectId), gte(toolUsageStats.createdAt, cutoff)))
607
+ .orderBy(desc(toolUsageStats.createdAt))
608
+ .all();
609
+ return rows.map(mapToolUsageStat);
610
+ }
611
+ /**
612
+ * Returns observation quality distribution counts.
613
+ *
614
+ * @returns Quality counts by bucket.
615
+ */
616
+ async getQualityDistribution() {
617
+ const rows = this.database.sqlite
618
+ .query(`
619
+ SELECT quality, COUNT(*) AS value
620
+ FROM observations
621
+ WHERE project_id = ?
622
+ GROUP BY quality
623
+ `)
624
+ .all(this.scope.projectId);
625
+ const distribution = {
626
+ high: 0,
627
+ medium: 0,
628
+ low: 0,
629
+ };
630
+ for (const row of rows) {
631
+ if (row.quality === "high" || row.quality === "medium" || row.quality === "low") {
632
+ distribution[row.quality] = row.value;
633
+ }
634
+ }
635
+ return distribution;
636
+ }
637
+ /**
638
+ * Returns success metrics for a compression model.
639
+ *
640
+ * @param modelName - Model identifier.
641
+ * @returns Total, success and rate values.
642
+ */
643
+ async getModelSuccessRate(modelName) {
644
+ const totalRow = this.database.db
645
+ .select({ value: sql `count(*)` })
646
+ .from(observations)
647
+ .where(and(eq(observations.projectId, this.scope.projectId), eq(observations.modelUsed, modelName)))
648
+ .get();
649
+ const successRow = this.database.db
650
+ .select({ value: sql `count(*)` })
651
+ .from(observations)
652
+ .where(and(eq(observations.projectId, this.scope.projectId), eq(observations.modelUsed, modelName), inArray(observations.quality, ["high", "medium"])))
653
+ .get();
654
+ const total = totalRow?.value ?? 0;
655
+ const success = successRow?.value ?? 0;
656
+ return {
657
+ total,
658
+ success,
659
+ rate: total > 0 ? success / total : 0,
660
+ };
661
+ }
662
+ /**
663
+ * Searches observations within a date range.
664
+ *
665
+ * @param from - Range start.
666
+ * @param to - Range end.
667
+ * @param limit - Maximum number of rows.
668
+ * @returns Matching observations.
669
+ */
670
+ async searchByDateRange(from, to, limit) {
671
+ const fromTimestamp = from.getTime();
672
+ const toTimestamp = to.getTime();
673
+ if (!Number.isFinite(fromTimestamp) || !Number.isFinite(toTimestamp)) {
674
+ return [];
675
+ }
676
+ const start = Math.min(fromTimestamp, toTimestamp);
677
+ const end = Math.max(fromTimestamp, toTimestamp);
678
+ const rows = this.database.db
679
+ .select()
680
+ .from(observations)
681
+ .where(and(eq(observations.projectId, this.scope.projectId), gte(observations.createdAt, start), lte(observations.createdAt, end)))
682
+ .orderBy(desc(observations.createdAt))
683
+ .limit(Math.max(1, limit))
684
+ .all();
685
+ return rows.map(mapObservation);
686
+ }
687
+ /**
688
+ * Searches observations by matching file paths against the FTS index.
689
+ *
690
+ * @param filePaths - File path patterns.
691
+ * @returns Matching observations.
692
+ */
693
+ async searchByFiles(filePaths) {
694
+ const matches = filePaths
695
+ .map((filePath) => sanitizeFtsQuery(filePath))
696
+ .filter(Boolean);
697
+ if (!matches.length) {
698
+ return [];
699
+ }
700
+ const matchQuery = matches.map((value) => `(${value})`).join(" OR ");
701
+ const rows = this.database.sqlite
702
+ .query(`
703
+ SELECT o.id
704
+ FROM observations_fts f
705
+ JOIN observations o ON o.rowid = f.rowid
706
+ WHERE observations_fts MATCH ?
707
+ AND o.project_id = ?
708
+ ORDER BY bm25(observations_fts), o.created_at DESC
709
+ LIMIT 200
710
+ `)
711
+ .all(matchQuery, this.scope.projectId);
712
+ return this.getObservationsBatch(rows.map((row) => row.id));
713
+ }
714
+ /**
715
+ * Returns the number of summaries for the current project.
716
+ *
717
+ * @returns Summary count.
718
+ */
719
+ async countSessionSummaries() {
720
+ const row = this.database.db
721
+ .select({ value: sql `count(*)` })
722
+ .from(sessionSummaries)
723
+ .where(eq(sessionSummaries.projectId, this.scope.projectId))
724
+ .get();
725
+ return row?.value ?? 0;
726
+ }
727
+ /**
728
+ * Returns pending queue counts grouped by status.
729
+ *
730
+ * @returns Status count object.
731
+ */
732
+ async getPendingStatusCounts() {
733
+ const rows = this.database.sqlite
734
+ .query(`
735
+ SELECT status, COUNT(*) AS value
736
+ FROM pending_messages
737
+ WHERE project_id = ?
738
+ GROUP BY status
739
+ `)
740
+ .all(this.scope.projectId);
741
+ const counts = {
742
+ pending: 0,
743
+ processing: 0,
744
+ processed: 0,
745
+ failed: 0,
746
+ };
747
+ for (const row of rows) {
748
+ if (row.status in counts) {
749
+ counts[row.status] = row.value;
750
+ }
751
+ }
752
+ return counts;
753
+ }
754
+ /**
755
+ * Counts observations since a timestamp.
756
+ *
757
+ * @param timestamp - Lower bound timestamp.
758
+ * @returns Observation count.
759
+ */
760
+ async countObservationsSince(timestamp) {
761
+ const row = this.database.db
762
+ .select({ value: sql `count(*)` })
763
+ .from(observations)
764
+ .where(and(eq(observations.projectId, this.scope.projectId), gte(observations.createdAt, timestamp)))
765
+ .get();
766
+ return row?.value ?? 0;
767
+ }
768
+ /**
769
+ * Calculates compression ratio and last compression timestamp.
770
+ *
771
+ * @returns Compression summary values.
772
+ */
773
+ async getCompressionStats() {
774
+ const row = this.database.sqlite
775
+ .query(`
776
+ SELECT
777
+ AVG(CASE WHEN compressed_token_count > 0 THEN CAST(raw_token_count AS REAL) / compressed_token_count END) AS average_ratio,
778
+ MAX(created_at) AS last_compressed_at
779
+ FROM observations
780
+ WHERE project_id = ?
781
+ `)
782
+ .get(this.scope.projectId);
783
+ return {
784
+ averageRatio: row?.average_ratio ?? 0,
785
+ lastCompressedAt: row?.last_compressed_at ?? null,
786
+ };
787
+ }
788
+ /**
789
+ * Returns deletion log totals for a lookback window.
790
+ *
791
+ * @param days - Lookback in days.
792
+ * @returns Operation and deletion totals.
793
+ */
794
+ async getDeletionStats(days) {
795
+ const cutoff = this.now() - Math.max(1, days) * 86_400_000;
796
+ const row = this.database.sqlite
797
+ .query(`
798
+ SELECT COUNT(*) AS operations, COALESCE(SUM(count), 0) AS removed
799
+ FROM deletion_log
800
+ WHERE project_id = ?
801
+ AND timestamp >= ?
802
+ `)
803
+ .get(this.scope.projectId, cutoff);
804
+ return {
805
+ operations: row?.operations ?? 0,
806
+ removed: row?.removed ?? 0,
807
+ };
808
+ }
809
+ /**
810
+ * Returns the current SQLite database size in bytes.
811
+ *
812
+ * @returns Database file size estimate.
813
+ */
814
+ async getDatabaseSizeBytes() {
815
+ const row = this.database.sqlite
816
+ .query("SELECT page_count AS page_count, page_size AS page_size FROM pragma_page_count(), pragma_page_size()")
817
+ .get();
818
+ if (!row) {
819
+ return 0;
820
+ }
821
+ return row.page_count * row.page_size;
822
+ }
823
+ /**
824
+ * Returns deletion log entries from the last N days.
825
+ *
826
+ * @param days - Lookback window in days.
827
+ * @returns Matching deletion entries.
828
+ */
829
+ async getDeletionLog(days) {
830
+ const cutoff = this.now() - Math.max(1, days) * 86_400_000;
831
+ const rows = this.database.db
832
+ .select()
833
+ .from(deletionLog)
834
+ .where(and(eq(deletionLog.projectId, this.scope.projectId), gte(deletionLog.timestamp, cutoff)))
835
+ .orderBy(desc(deletionLog.timestamp))
836
+ .all();
837
+ return rows.map(mapDeletionLogEntry);
838
+ }
839
+ /**
840
+ * Deletes data older than the configured retention windows.
841
+ *
842
+ * @param retentionDays - Number of days to keep observations and prompts.
843
+ * @returns A promise that resolves after cleanup.
844
+ */
845
+ async cleanupOldData(retentionDays) {
846
+ const now = this.now();
847
+ const retentionCutoff = now - retentionDays * 86_400_000;
848
+ const pendingCutoff = now - 7 * 86_400_000;
849
+ const summaryCutoff = now - retentionDays * 2 * 86_400_000;
850
+ const observationDeleteRow = this.database.db
851
+ .select({ value: sql `count(*)` })
852
+ .from(observations)
853
+ .where(and(eq(observations.projectId, this.scope.projectId), lte(observations.createdAt, retentionCutoff)))
854
+ .get();
855
+ const observationDeleteCount = observationDeleteRow?.value ?? 0;
856
+ this.database.db
857
+ .delete(observations)
858
+ .where(and(eq(observations.projectId, this.scope.projectId), lte(observations.createdAt, retentionCutoff)))
859
+ .run();
860
+ if (observationDeleteCount > 0) {
861
+ await this.logDeletion(JSON.stringify({ type: "retention", target: "observations", before: retentionCutoff }), observationDeleteCount, "retention_cleanup");
862
+ }
863
+ this.database.db
864
+ .delete(userPrompts)
865
+ .where(and(eq(userPrompts.projectId, this.scope.projectId), lte(userPrompts.createdAt, retentionCutoff)))
866
+ .run();
867
+ this.database.db
868
+ .delete(sessionSummaries)
869
+ .where(and(eq(sessionSummaries.projectId, this.scope.projectId), lte(sessionSummaries.createdAt, summaryCutoff)))
870
+ .run();
871
+ this.database.db
872
+ .delete(pendingMessages)
873
+ .where(and(eq(pendingMessages.projectId, this.scope.projectId), inArray(pendingMessages.status, ["processed", "failed"]), lte(pendingMessages.createdAt, pendingCutoff)))
874
+ .run();
875
+ this.database.sqlite.exec("VACUUM");
876
+ }
877
+ /**
878
+ * Creates a new project-scoped record identifier.
879
+ *
880
+ * @returns A sortable identifier.
881
+ */
882
+ createId() {
883
+ return createSortableId(this.now);
884
+ }
885
+ /**
886
+ * Returns the last activity timestamp for a table and session.
887
+ *
888
+ * @param sqlite - SQLite client.
889
+ * @param tableName - Table to inspect.
890
+ * @param sessionId - OpenCode session identifier.
891
+ * @param projectId - Current project identifier.
892
+ * @returns The last timestamp or zero.
893
+ */
894
+ getLastTimestamp(sqlite, tableName, sessionId, projectId) {
895
+ const row = sqlite
896
+ .query(`SELECT MAX(created_at) AS value FROM ${tableName} WHERE project_id = ? AND session_id = ?`)
897
+ .get(projectId, sessionId);
898
+ return row?.value ?? 0;
899
+ }
900
+ }
901
+ /**
902
+ * Maps an observation row into the runtime shape.
903
+ *
904
+ * @param row - Database row.
905
+ * @returns Normalized observation.
906
+ */
907
+ export function mapObservation(row) {
908
+ return {
909
+ id: row.id,
910
+ projectId: row.projectId,
911
+ projectRoot: row.projectRoot,
912
+ sessionId: row.sessionId,
913
+ type: row.type,
914
+ title: row.title,
915
+ subtitle: row.subtitle ?? null,
916
+ narrative: row.narrative,
917
+ facts: parseJsonValue(row.facts, []),
918
+ concepts: parseJsonValue(row.concepts, []),
919
+ filesInvolved: parseJsonValue(row.filesInvolved, []),
920
+ rawTokenCount: row.rawTokenCount,
921
+ compressedTokenCount: row.compressedTokenCount,
922
+ toolName: row.toolName ?? null,
923
+ modelUsed: row.modelUsed ?? null,
924
+ quality: row.quality ?? "high",
925
+ rawFallback: row.rawFallback ?? null,
926
+ createdAt: row.createdAt,
927
+ };
928
+ }
929
+ /**
930
+ * Maps a pending queue row into the runtime shape.
931
+ *
932
+ * @param row - Database row.
933
+ * @returns Normalized pending message.
934
+ */
935
+ export function mapPendingMessage(row) {
936
+ return {
937
+ id: row.id,
938
+ projectId: row.projectId,
939
+ projectRoot: row.projectRoot,
940
+ sessionId: row.sessionId,
941
+ toolName: row.toolName,
942
+ title: row.title ?? null,
943
+ rawContent: row.rawContent,
944
+ rawMetadata: parseJsonValue(row.rawMetadata, null),
945
+ status: row.status,
946
+ retryCount: row.retryCount,
947
+ errorMessage: row.errorMessage ?? null,
948
+ createdAt: row.createdAt,
949
+ processedAt: row.processedAt ?? null,
950
+ };
951
+ }
952
+ /**
953
+ * Maps a summary row into the runtime shape.
954
+ *
955
+ * @param row - Database row.
956
+ * @returns Normalized session summary.
957
+ */
958
+ export function mapSessionSummary(row) {
959
+ return {
960
+ id: row.id,
961
+ projectId: row.projectId,
962
+ projectRoot: row.projectRoot,
963
+ sessionId: row.sessionId,
964
+ requested: row.requested ?? null,
965
+ investigated: row.investigated ?? null,
966
+ learned: row.learned ?? null,
967
+ completed: row.completed ?? null,
968
+ nextSteps: row.nextSteps ?? null,
969
+ observationCount: row.observationCount,
970
+ modelUsed: row.modelUsed ?? null,
971
+ createdAt: row.createdAt,
972
+ };
973
+ }
974
+ /**
975
+ * Maps a prompt row into the runtime shape.
976
+ *
977
+ * @param row - Database row.
978
+ * @returns Normalized user prompt.
979
+ */
980
+ export function mapUserPrompt(row) {
981
+ return {
982
+ id: row.id,
983
+ projectId: row.projectId,
984
+ projectRoot: row.projectRoot,
985
+ sessionId: row.sessionId,
986
+ messageId: row.messageId,
987
+ content: row.content,
988
+ createdAt: row.createdAt,
989
+ };
990
+ }
991
+ /**
992
+ * Maps a deletion log row into the runtime shape.
993
+ *
994
+ * @param row - Database row.
995
+ * @returns Normalized deletion log entry.
996
+ */
997
+ export function mapDeletionLogEntry(row) {
998
+ return {
999
+ id: row.id,
1000
+ projectId: row.projectId,
1001
+ projectRoot: row.projectRoot,
1002
+ timestamp: row.timestamp,
1003
+ criteria: row.criteria,
1004
+ count: row.count,
1005
+ initiator: row.initiator,
1006
+ };
1007
+ }
1008
+ /**
1009
+ * Maps a tool usage stats row into the runtime shape.
1010
+ *
1011
+ * @param row - Database row.
1012
+ * @returns Normalized tool usage stats entry.
1013
+ */
1014
+ export function mapToolUsageStat(row) {
1015
+ return {
1016
+ id: row.id,
1017
+ projectId: row.projectId,
1018
+ projectRoot: row.projectRoot,
1019
+ sessionId: row.sessionId,
1020
+ toolName: row.toolName,
1021
+ callCount: row.callCount,
1022
+ createdAt: row.createdAt,
1023
+ };
1024
+ }
1025
+ //# sourceMappingURL=store.js.map