sessionmem 1.0.5 → 1.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 (58) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +372 -365
  3. package/dist/adapters/capabilities/fallbackTools.js +33 -18
  4. package/dist/adapters/claudeMdInjector.js +164 -0
  5. package/dist/adapters/factory.js +68 -9
  6. package/dist/adapters/generic.js +221 -15
  7. package/dist/adapters/global/antigravity.js +14 -7
  8. package/dist/adapters/global/claudeCode.js +46 -10
  9. package/dist/adapters/global/codex.js +73 -13
  10. package/dist/adapters/global/qcoder.js +18 -5
  11. package/dist/adapters/ide/cline.js +54 -9
  12. package/dist/adapters/ide/cursor.js +15 -13
  13. package/dist/adapters/ide/installer.js +201 -8
  14. package/dist/adapters/ide/windsurf.js +14 -13
  15. package/dist/adapters/tools/ping.js +4 -1
  16. package/dist/cli/commands/config.js +10 -1
  17. package/dist/cli/commands/import.js +6 -1
  18. package/dist/cli/commands/install.js +63 -5
  19. package/dist/cli/commands/ping.js +42 -8
  20. package/dist/cli/commands/reEmbed.js +48 -0
  21. package/dist/cli/commands/run.js +18 -2
  22. package/dist/cli/commands/savings.js +91 -0
  23. package/dist/cli/commands/sessionEnd.js +124 -0
  24. package/dist/cli/commands/sessionStart.js +52 -0
  25. package/dist/cli/commands/sync.js +39 -9
  26. package/dist/cli/commands/uninstall.js +37 -1
  27. package/dist/cli/context.js +14 -18
  28. package/dist/cli/index.js +30 -4
  29. package/dist/cli/output.js +11 -3
  30. package/dist/cli/projectId.js +69 -0
  31. package/dist/core/api/contracts.js +182 -45
  32. package/dist/core/api/errors.js +4 -7
  33. package/dist/core/api/memoryCoreService.js +409 -240
  34. package/dist/core/api/sessionLifecycleService.js +20 -2
  35. package/dist/core/config/policyConfig.js +53 -6
  36. package/dist/core/injection/formatStartupInjection.js +55 -10
  37. package/dist/core/injection/tokenBudget.js +8 -0
  38. package/dist/core/retrieve/importance.js +4 -3
  39. package/dist/core/retrieve/recencyBands.js +6 -10
  40. package/dist/core/retrieve/retrieveMemories.js +19 -4
  41. package/dist/core/retrieve/score.js +11 -1
  42. package/dist/core/schema/migrations/005_team_provenance.sql +14 -9
  43. package/dist/core/schema/migrations/006_access_pattern_boosting.sql +10 -0
  44. package/dist/core/schema/migrations/007_feedback_manual_delete.sql +23 -0
  45. package/dist/core/schema/migrations/008_fts5_search.sql +37 -0
  46. package/dist/core/schema/migrations/009_session_events_unique.sql +24 -0
  47. package/dist/core/schema/runMigrations.js +64 -2
  48. package/dist/core/storage/db.js +6 -0
  49. package/dist/core/storage/memoryFeedbackRepo.js +14 -4
  50. package/dist/core/storage/memoryRepo.js +292 -121
  51. package/dist/core/storage/memorySearchRepo.js +125 -13
  52. package/dist/core/storage/sessionEventsRepo.js +33 -10
  53. package/dist/core/storage/summarizationFailuresRepo.js +36 -26
  54. package/dist/core/storage/tokenSavingsRepo.js +20 -0
  55. package/dist/core/summarize/cloudSummarizer.js +34 -5
  56. package/dist/core/summarize/localSummarizer.js +1 -10
  57. package/dist/core/summarize/redaction.js +45 -8
  58. package/package.json +50 -48
@@ -1,4 +1,186 @@
1
- import { insertMemoryFeedbackEvent, } from "./memoryFeedbackRepo.js";
1
+ // Shared INSERT ... ON CONFLICT(id) upsert column lists. The import and team-pull
2
+ // paths differ only in how they resolve `importance` on conflict (import takes the
3
+ // incoming value; pull preserves MAX(local, incoming)), so the surrounding SQL is
4
+ // factored out to keep the two prepared statements byte-for-byte aligned.
5
+ const UPSERT_INSERT_HEAD = `
6
+ INSERT INTO memories (
7
+ id, project_id, session_id, source_adapter, kind, content, normalized_content,
8
+ importance, embedding, embedding_dim, embedding_version, author, origin_project_id,
9
+ created_at, updated_at
10
+ ) VALUES (
11
+ @id, @project_id, @session_id, @source_adapter, @kind, @content, @normalized_content,
12
+ @importance, @embedding, @embedding_dim, @embedding_version, @author, @origin_project_id,
13
+ COALESCE(@created_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
14
+ COALESCE(@updated_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
15
+ )
16
+ ON CONFLICT(id) DO UPDATE SET
17
+ project_id = excluded.project_id,
18
+ session_id = excluded.session_id,
19
+ source_adapter = excluded.source_adapter,
20
+ kind = excluded.kind,
21
+ content = excluded.content,
22
+ normalized_content = excluded.normalized_content,`;
23
+ const UPSERT_INSERT_TAIL = `
24
+ embedding = excluded.embedding,
25
+ embedding_dim = excluded.embedding_dim,
26
+ embedding_version = excluded.embedding_version,
27
+ author = excluded.author,
28
+ origin_project_id = excluded.origin_project_id,
29
+ created_at = excluded.created_at,
30
+ updated_at = excluded.updated_at
31
+ `;
32
+ const stmtCache = new WeakMap();
33
+ function getStatements(db) {
34
+ let stmts = stmtCache.get(db);
35
+ if (stmts)
36
+ return stmts;
37
+ stmts = {
38
+ insertMemory: db.prepare(`
39
+ INSERT INTO memories (
40
+ id, project_id, session_id, source_adapter, kind, content, normalized_content,
41
+ importance, embedding, embedding_dim, embedding_version, author, origin_project_id,
42
+ created_at, updated_at
43
+ ) VALUES (
44
+ @id, @project_id, @session_id, @source_adapter, @kind, @content, @normalized_content,
45
+ @importance, @embedding, @embedding_dim, @embedding_version, @author, @origin_project_id,
46
+ COALESCE(@created_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
47
+ COALESCE(@updated_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
48
+ )
49
+ `),
50
+ upsertSessionSummary: db.prepare(`
51
+ INSERT INTO memories (
52
+ id, project_id, session_id, source_adapter, kind, content, normalized_content,
53
+ importance, embedding, embedding_dim, embedding_version, author, origin_project_id,
54
+ created_at, updated_at
55
+ ) VALUES (
56
+ @id, @project_id, @session_id, @source_adapter, 'summary', @content, @normalized_content,
57
+ @importance, @embedding, @embedding_dim, @embedding_version, @author, @origin_project_id,
58
+ COALESCE(@created_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
59
+ COALESCE(@updated_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
60
+ )
61
+ ON CONFLICT(project_id, session_id, kind) WHERE kind = 'summary'
62
+ DO UPDATE SET
63
+ id = excluded.id,
64
+ source_adapter = excluded.source_adapter,
65
+ content = excluded.content,
66
+ normalized_content = excluded.normalized_content,
67
+ importance = excluded.importance,
68
+ embedding = excluded.embedding,
69
+ embedding_dim = excluded.embedding_dim,
70
+ embedding_version = excluded.embedding_version,
71
+ author = excluded.author,
72
+ origin_project_id = excluded.origin_project_id,
73
+ updated_at = COALESCE(excluded.updated_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
74
+ ON CONFLICT(id)
75
+ DO UPDATE SET
76
+ project_id = excluded.project_id,
77
+ session_id = excluded.session_id,
78
+ source_adapter = excluded.source_adapter,
79
+ content = excluded.content,
80
+ normalized_content = excluded.normalized_content,
81
+ importance = excluded.importance,
82
+ embedding = excluded.embedding,
83
+ embedding_dim = excluded.embedding_dim,
84
+ embedding_version = excluded.embedding_version,
85
+ author = excluded.author,
86
+ origin_project_id = excluded.origin_project_id,
87
+ updated_at = COALESCE(excluded.updated_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
88
+ `),
89
+ listByProject: db.prepare(`
90
+ SELECT
91
+ id, project_id, session_id, source_adapter, kind, content, normalized_content,
92
+ importance, embedding, embedding_dim, embedding_version, author, origin_project_id,
93
+ access_count, last_accessed, created_at, updated_at
94
+ FROM memories
95
+ WHERE project_id = ?
96
+ ORDER BY updated_at DESC
97
+ `),
98
+ // Lightweight projection for token-savings accounting: only `content` is
99
+ // needed to count tokens, so we deliberately avoid pulling the (potentially
100
+ // multi-KB) embedding JSON and normalized_content for every row. Matters for
101
+ // large projects where `savings` would otherwise load the whole table.
102
+ listContentByProject: db.prepare("SELECT content FROM memories WHERE project_id = ?"),
103
+ importUpsert: db.prepare(`${UPSERT_INSERT_HEAD}\n importance = excluded.importance,${UPSERT_INSERT_TAIL}`),
104
+ // Importance-preserving merge for team pulls: a teammate can never lower a
105
+ // locally-boosted importance. better-sqlite3@12 bundles a SQLite that accepts
106
+ // the two-arg scalar MAX() inside DO UPDATE.
107
+ pullUpsert: db.prepare(`${UPSERT_INSERT_HEAD}\n importance = MAX(memories.importance, excluded.importance),${UPSERT_INSERT_TAIL}`),
108
+ listAllIds: db.prepare("SELECT id FROM memories"),
109
+ selectById: db.prepare(`
110
+ SELECT
111
+ id, project_id, session_id, source_adapter, kind, content, normalized_content,
112
+ importance, embedding, embedding_dim, embedding_version, author, origin_project_id,
113
+ access_count, last_accessed, created_at, updated_at
114
+ FROM memories
115
+ WHERE project_id = ? AND id = ?
116
+ LIMIT 1
117
+ `),
118
+ selectOwner: db.prepare("SELECT project_id FROM memories WHERE id = ?"),
119
+ deleteById: db.prepare("DELETE FROM memories WHERE project_id = ? AND id = ?"),
120
+ countOlderThan: db.prepare(`
121
+ SELECT COUNT(*) AS count
122
+ FROM memories
123
+ WHERE project_id = ? AND created_at < ?
124
+ `),
125
+ deleteOlderThan: db.prepare(`
126
+ DELETE FROM memories
127
+ WHERE project_id = ? AND created_at < ?
128
+ `),
129
+ updateImportance: db.prepare(`
130
+ UPDATE memories
131
+ SET
132
+ importance = ?,
133
+ updated_at = COALESCE(?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
134
+ WHERE project_id = ? AND id = ?
135
+ `),
136
+ updateContent: db.prepare(`
137
+ UPDATE memories
138
+ SET
139
+ content = ?,
140
+ normalized_content = COALESCE(?, normalized_content),
141
+ embedding = COALESCE(?, embedding),
142
+ embedding_dim = COALESCE(?, embedding_dim),
143
+ embedding_version = COALESCE(?, embedding_version),
144
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
145
+ WHERE project_id = ? AND id = ?
146
+ `),
147
+ selectForRecordUse: db.prepare(`
148
+ SELECT id, importance
149
+ FROM memories
150
+ WHERE project_id = ? AND id = ?
151
+ LIMIT 1
152
+ `),
153
+ incrementAccess: db.prepare(`
154
+ UPDATE memories
155
+ SET
156
+ access_count = access_count + 1,
157
+ last_accessed = COALESCE(?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
158
+ WHERE project_id = ? AND id = ?
159
+ `),
160
+ resetAccess: db.prepare(`
161
+ UPDATE memories
162
+ SET access_count = 0, last_accessed = NULL
163
+ WHERE project_id = ?
164
+ `),
165
+ countBySession: db.prepare(`
166
+ SELECT COUNT(*) AS count
167
+ FROM memories
168
+ WHERE session_id = ? AND project_id = ?
169
+ `),
170
+ countAll: db.prepare("SELECT COUNT(*) AS count FROM memories WHERE project_id = ?"),
171
+ // Memories whose stored embedding does not match the supplied current
172
+ // embedding version (NULL counts as stale). Used to surface a re-embed
173
+ // hint; the actual re-embed is the `sessionmem re-embed` command.
174
+ countStaleEmbeddings: db.prepare(`
175
+ SELECT COUNT(*) AS count
176
+ FROM memories
177
+ WHERE project_id = ?
178
+ AND (embedding_version IS NULL OR embedding_version != ?)
179
+ `),
180
+ };
181
+ stmtCache.set(db, stmts);
182
+ return stmts;
183
+ }
2
184
  function assertImportance(importance) {
3
185
  if (importance < 1 || importance > 10) {
4
186
  throw new Error("importance must be between 1 and 10");
@@ -18,60 +200,67 @@ function toParams(input) {
18
200
  }
19
201
  export function insertMemory(db, input) {
20
202
  assertImportance(input.importance);
21
- const stmt = db.prepare(`
22
- INSERT INTO memories (
23
- id, project_id, session_id, source_adapter, kind, content, normalized_content,
24
- importance, embedding, embedding_dim, embedding_version, author, origin_project_id,
25
- created_at, updated_at
26
- ) VALUES (
27
- @id, @project_id, @session_id, @source_adapter, @kind, @content, @normalized_content,
28
- @importance, @embedding, @embedding_dim, @embedding_version, @author, @origin_project_id,
29
- COALESCE(@created_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
30
- COALESCE(@updated_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
31
- )
32
- `);
33
- stmt.run(toParams(input));
203
+ getStatements(db).insertMemory.run(toParams(input));
34
204
  }
35
205
  export function upsertSessionSummaryMemory(db, input) {
36
206
  assertImportance(input.importance);
37
- const stmt = db.prepare(`
38
- INSERT INTO memories (
39
- id, project_id, session_id, source_adapter, kind, content, normalized_content,
40
- importance, embedding, embedding_dim, embedding_version, author, origin_project_id,
41
- created_at, updated_at
42
- ) VALUES (
43
- @id, @project_id, @session_id, @source_adapter, 'summary', @content, @normalized_content,
44
- @importance, @embedding, @embedding_dim, @embedding_version, @author, @origin_project_id,
45
- COALESCE(@created_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
46
- COALESCE(@updated_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
47
- )
48
- ON CONFLICT(project_id, session_id, kind) WHERE kind = 'summary'
49
- DO UPDATE SET
50
- id = excluded.id,
51
- source_adapter = excluded.source_adapter,
52
- content = excluded.content,
53
- normalized_content = excluded.normalized_content,
54
- importance = excluded.importance,
55
- embedding = excluded.embedding,
56
- embedding_dim = excluded.embedding_dim,
57
- embedding_version = excluded.embedding_version,
58
- author = excluded.author,
59
- origin_project_id = excluded.origin_project_id,
60
- updated_at = COALESCE(excluded.updated_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
61
- `);
62
- stmt.run(toParams({ ...input, kind: "summary" }));
207
+ getStatements(db).upsertSessionSummary.run(toParams({ ...input, kind: "summary" }));
208
+ }
209
+ /**
210
+ * Upsert a memory imported from an external export. On `id` conflict the incoming
211
+ * record wins on every column (including importance). Cross-project ownership
212
+ * collisions are filtered by the caller via {@link getMemoryOwnerProjectId} before
213
+ * this runs, so this never reassigns another project's row.
214
+ */
215
+ export function upsertImportedMemory(db, input) {
216
+ assertImportance(input.importance);
217
+ getStatements(db).importUpsert.run(toParams(input));
218
+ }
219
+ /**
220
+ * Upsert a memory pulled from a teammate. Identical to {@link upsertImportedMemory}
221
+ * except importance is merged as MAX(local, incoming) so a pull can never lower a
222
+ * locally-boosted importance.
223
+ */
224
+ export function upsertPulledMemory(db, input) {
225
+ assertImportance(input.importance);
226
+ getStatements(db).pullUpsert.run(toParams(input));
63
227
  }
64
228
  export function listMemoriesByProject(db, projectId) {
65
- const stmt = db.prepare(`
66
- SELECT
67
- id, project_id, session_id, source_adapter, kind, content, normalized_content,
68
- importance, embedding, embedding_dim, embedding_version, author, origin_project_id,
69
- created_at, updated_at
70
- FROM memories
71
- WHERE project_id = ?
72
- ORDER BY updated_at DESC
73
- `);
74
- return stmt.all(projectId);
229
+ return getStatements(db).listByProject.all(projectId);
230
+ }
231
+ /**
232
+ * Return just the `content` of every memory in a project. Used by the
233
+ * token-savings command, which only needs `content` to count tokens and must
234
+ * not pay to load embedding JSON / normalized_content for the whole table.
235
+ */
236
+ export function listMemoryContentsByProject(db, projectId) {
237
+ const rows = getStatements(db).listContentByProject.all(projectId);
238
+ return rows.map((r) => r.content);
239
+ }
240
+ /**
241
+ * Fetch a single memory row scoped to a project. Returns undefined when no row
242
+ * matches (caller maps that to NOT_FOUND). Uses a WeakMap-cached prepared
243
+ * statement — this is a high-frequency path (every store/get/forget and each
244
+ * batch item re-reads the inserted row).
245
+ */
246
+ export function getMemoryRecordById(db, projectId, memoryId) {
247
+ return getStatements(db).selectById.get(projectId, memoryId);
248
+ }
249
+ /**
250
+ * Resolve the project that currently owns a globally-unique memory `id`, or
251
+ * undefined when the id is unused. Import/pull use this to skip (never overwrite)
252
+ * an id already owned by a different project.
253
+ */
254
+ export function getMemoryOwnerProjectId(db, memoryId) {
255
+ const row = getStatements(db).selectOwner.get(memoryId);
256
+ return row?.project_id;
257
+ }
258
+ /**
259
+ * Hard-delete a single memory scoped to a project. Returns the number of rows
260
+ * removed (0 when the id does not exist in this project).
261
+ */
262
+ export function deleteMemoryById(db, projectId, memoryId) {
263
+ return getStatements(db).deleteById.run(projectId, memoryId).changes;
75
264
  }
76
265
  /**
77
266
  * All memory ids across every project. `id` is a globally-unique
@@ -80,100 +269,82 @@ export function listMemoriesByProject(db, projectId) {
80
269
  * collisions as "skipped" rather than silently importing them.
81
270
  */
82
271
  export function listAllMemoryIds(db) {
83
- const rows = db.prepare("SELECT id FROM memories").all();
272
+ const rows = getStatements(db).listAllIds.all();
84
273
  return new Set(rows.map((r) => r.id));
85
274
  }
86
275
  export function countMemoriesOlderThan(db, projectId, cutoffIso) {
87
276
  // created_at is stored as strftime('%Y-%m-%dT%H:%M:%fZ') text; lexicographic
88
277
  // comparison against an ISO-8601 UTC cutoff is correct for this fixed format.
89
- const row = db
90
- .prepare(`
91
- SELECT COUNT(*) AS count
92
- FROM memories
93
- WHERE project_id = ? AND created_at < ?
94
- `)
95
- .get(projectId, cutoffIso);
278
+ const row = getStatements(db).countOlderThan.get(projectId, cutoffIso);
96
279
  return row.count;
97
280
  }
98
281
  export function deleteMemoriesOlderThan(db, projectId, cutoffIso) {
99
282
  // Hard-delete scoped to the memories table only; never touches
100
283
  // session_events or memory_feedback. project_id and cutoff are bound, never
101
284
  // string-concatenated, to prevent SQL injection.
102
- const result = db
103
- .prepare(`
104
- DELETE FROM memories
105
- WHERE project_id = ? AND created_at < ?
106
- `)
107
- .run(projectId, cutoffIso);
285
+ const result = getStatements(db).deleteOlderThan.run(projectId, cutoffIso);
108
286
  return result.changes;
109
287
  }
288
+ // NOTE: no MCP tool, CLI command, or service method currently calls this. It is
289
+ // retained as intentional repository API surface (the importance-update
290
+ // counterpart to updateMemoryContent) for a future importance-adjustment tool,
291
+ // not forgotten code. The `updateImportance` prepared statement above is wired
292
+ // solely for this function. Keep or remove deliberately — do not delete on a
293
+ // "looks unused" pass.
110
294
  export function updateMemoryImportance(db, projectId, memoryId, nextImportance, usedAt) {
111
295
  assertImportance(nextImportance);
112
- const result = db
113
- .prepare(`
114
- UPDATE memories
115
- SET
116
- importance = ?,
117
- updated_at = COALESCE(?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
118
- WHERE project_id = ? AND id = ?
119
- `)
120
- .run(nextImportance, usedAt ?? null, projectId, memoryId);
296
+ const result = getStatements(db).updateImportance.run(nextImportance, usedAt ?? null, projectId, memoryId);
121
297
  if (result.changes === 0) {
122
298
  throw new Error(`Memory not found: ${memoryId}`);
123
299
  }
124
300
  }
125
- export function updateMemoryContent(db, projectId, memoryId, newContent, newNormalizedContent) {
126
- // In-place content rewrite for the one-time redaction scrub. All
127
- // values are bound parameters projectId, memoryId, and content are never
128
- // string-concatenated mirroring updateMemoryImportance to prevent SQL
129
- // injection. normalized_content is only overwritten when a new
130
- // value is supplied so embeddings stay consistent with the redacted text.
131
- const result = db
132
- .prepare(`
133
- UPDATE memories
134
- SET
135
- content = ?,
136
- normalized_content = COALESCE(?, normalized_content),
137
- updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
138
- WHERE project_id = ? AND id = ?
139
- `)
140
- .run(newContent, newNormalizedContent ?? null, projectId, memoryId);
301
+ /**
302
+ * Count all memories stored in a project. Used to enforce per-session write
303
+ * soft limitsthe count is
304
+ * checked before each storeMemory call and a warning is surfaced when the
305
+ * threshold is reached.
306
+ */
307
+ export function countAllMemoriesByProject(db, projectId) {
308
+ const row = getStatements(db).countAll.get(projectId);
309
+ return row.count;
310
+ }
311
+ export function countMemoriesBySession(db, sessionId, projectId) {
312
+ const row = getStatements(db).countBySession.get(sessionId, projectId);
313
+ return row.count;
314
+ }
315
+ /**
316
+ * Count memories in a project whose embedding version differs from
317
+ * `currentVersion` (NULL counts as stale). Drives the startup re-embed hint;
318
+ * the fix is the `sessionmem re-embed` command.
319
+ */
320
+ export function countStaleEmbeddings(db, projectId, currentVersion) {
321
+ const row = getStatements(db).countStaleEmbeddings.get(projectId, currentVersion);
322
+ return row.count;
323
+ }
324
+ export function updateMemoryContent(db, projectId, memoryId, newContent, newNormalizedContent,
325
+ // Optional re-embedding: when content is rewritten (e.g. a redactExisting
326
+ // scrub) the stored embedding vector — computed from the PRE-edit text —
327
+ // becomes stale and inconsistent with the new normalized_content. Pass the
328
+ // recomputed embedding so the vector tracks the redacted text; omit to leave
329
+ // the existing embedding untouched (COALESCE keeps the prior value on null).
330
+ newEmbedding) {
331
+ const result = getStatements(db).updateContent.run(newContent, newNormalizedContent ?? null, newEmbedding ? JSON.stringify(newEmbedding.vector) : null, newEmbedding?.dimension ?? null, newEmbedding?.embeddingVersion ?? null, projectId, memoryId);
141
332
  if (result.changes === 0) {
142
333
  throw new Error(`Memory not found: ${memoryId}`);
143
334
  }
144
335
  }
145
- export function recordUse(db, input) {
146
- const transaction = db.transaction((txInput) => {
147
- const memory = db
148
- .prepare(`
149
- SELECT id, importance
150
- FROM memories
151
- WHERE project_id = ? AND id = ?
152
- LIMIT 1
153
- `)
154
- .get(txInput.project_id, txInput.memory_id);
155
- if (!memory) {
156
- throw new Error(`Memory not found: ${txInput.memory_id}`);
336
+ export function incrementAccessCounts(db, projectId, memoryIds, accessedAt) {
337
+ if (memoryIds.length === 0)
338
+ return;
339
+ const stmt = getStatements(db).incrementAccess;
340
+ const run = db.transaction(() => {
341
+ for (const id of memoryIds) {
342
+ stmt.run(accessedAt ?? null, projectId, id);
157
343
  }
158
- const feedbackType = txInput.feedback_type ?? "auto_use";
159
- const nextImportance = txInput.next_importance ??
160
- (feedbackType === "auto_use"
161
- ? Math.min(memory.importance + 1, 9)
162
- : memory.importance);
163
- updateMemoryImportance(db, txInput.project_id, txInput.memory_id, nextImportance, txInput.used_at);
164
- insertMemoryFeedbackEvent(db, {
165
- id: txInput.feedback_id,
166
- memory_id: txInput.memory_id,
167
- feedback_type: feedbackType,
168
- previous_importance: memory.importance,
169
- new_importance: nextImportance,
170
- created_at: txInput.used_at,
171
- });
172
- return {
173
- memory_id: txInput.memory_id,
174
- previous_importance: memory.importance,
175
- new_importance: nextImportance,
176
- };
177
344
  });
178
- return transaction(input);
345
+ run();
346
+ }
347
+ export function resetAccessCounts(db, projectId) {
348
+ const result = getStatements(db).resetAccess.run(projectId);
349
+ return result.changes;
179
350
  }
@@ -1,3 +1,41 @@
1
+ import { EMBEDDING_VERSION } from "../embed/embeddingVersion.js";
2
+ const FTS_CANDIDATE_LIMIT = 50;
3
+ const FTS_FALLBACK_THRESHOLD = 5;
4
+ const searchStmtCache = new WeakMap();
5
+ function getSearchStatements(db) {
6
+ let stmts = searchStmtCache.get(db);
7
+ if (stmts)
8
+ return stmts;
9
+ stmts = {
10
+ searchCandidates: db.prepare(`
11
+ SELECT
12
+ id, project_id, session_id, source_adapter, kind, content, normalized_content,
13
+ importance, author, origin_project_id, access_count, created_at, updated_at,
14
+ embedding, embedding_dim, embedding_version
15
+ FROM memories
16
+ WHERE project_id = ?
17
+ AND (
18
+ importance >= 8
19
+ OR updated_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-90 days')
20
+ )
21
+ `),
22
+ searchCandidatesFTS: db.prepare(`
23
+ SELECT
24
+ m.id, m.project_id, m.session_id, m.source_adapter, m.kind, m.content,
25
+ m.normalized_content, m.importance, m.author, m.origin_project_id,
26
+ m.access_count, m.created_at, m.updated_at,
27
+ m.embedding, m.embedding_dim, m.embedding_version
28
+ FROM memories_fts
29
+ JOIN memories m ON m.rowid = memories_fts.rowid
30
+ WHERE memories_fts MATCH ?
31
+ AND m.project_id = ?
32
+ ORDER BY rank
33
+ LIMIT ?
34
+ `),
35
+ };
36
+ searchStmtCache.set(db, stmts);
37
+ return stmts;
38
+ }
1
39
  function parseEmbedding(value) {
2
40
  if (!value) {
3
41
  return null;
@@ -13,18 +51,92 @@ function parseEmbedding(value) {
13
51
  return null;
14
52
  }
15
53
  }
54
+ function dedupById(candidates) {
55
+ // Defensive — FTS should not emit duplicates, but this guards backfill
56
+ // corruption (e.g. a double-run 008 migration) from inflating results.
57
+ const seen = new Set();
58
+ return candidates.filter((candidate) => {
59
+ if (seen.has(candidate.id))
60
+ return false;
61
+ seen.add(candidate.id);
62
+ return true;
63
+ });
64
+ }
65
+ function mapRows(rows) {
66
+ return rows.map((row) => {
67
+ const parsed = parseEmbedding(row.embedding);
68
+ const versionMatch = row.embedding_version === EMBEDDING_VERSION;
69
+ return {
70
+ ...row,
71
+ embedding: versionMatch ? parsed : null,
72
+ };
73
+ });
74
+ }
16
75
  export function searchMemoryCandidates(db, projectId) {
17
- const stmt = db.prepare(`
18
- SELECT
19
- id, project_id, session_id, source_adapter, kind, content, normalized_content,
20
- importance, author, origin_project_id, created_at, updated_at, embedding,
21
- embedding_dim, embedding_version
22
- FROM memories
23
- WHERE project_id = ?
24
- `);
25
- const rows = stmt.all(projectId);
26
- return rows.map((row) => ({
27
- ...row,
28
- embedding: parseEmbedding(row.embedding),
29
- }));
76
+ const rows = getSearchStatements(db).searchCandidates.all(projectId);
77
+ return mapRows(rows);
78
+ }
79
+ /**
80
+ * Sanitize query text for FTS5 MATCH syntax.
81
+ * Wraps each non-empty token in double quotes so special characters
82
+ * (colons, hyphens, parentheses, etc.) are treated as literals.
83
+ * Tokens are joined with implicit AND.
84
+ */
85
+ function sanitizeFtsQuery(queryText) {
86
+ return queryText
87
+ .split(/\s+/)
88
+ .filter((token) => token.length > 0)
89
+ .map((token) => `"${token.replace(/"/g, '""')}"`)
90
+ .join(" ");
91
+ }
92
+ /**
93
+ * Pre-filter candidates using FTS5 full-text search before cosine similarity.
94
+ * Returns up to FTS_CANDIDATE_LIMIT candidates.
95
+ *
96
+ * FTS keyword overlap can be sparse, so the fallback recency/importance scan is
97
+ * UNIONed with (never substituted for) the FTS hits:
98
+ * - >= FTS_FALLBACK_THRESHOLD FTS hits: use the FTS hits as-is (well-matched).
99
+ * - 0 FTS hits: use the fallback scan only.
100
+ * - 1..threshold-1 FTS hits: UNION the FTS hits with the fallback scan,
101
+ * deduplicated by id (FTS hits first), capped at FTS_CANDIDATE_LIMIT.
102
+ *
103
+ * The previous behavior REPLACED a small FTS hit set with the fallback scan,
104
+ * which silently dropped genuine matches that were old (>90d) and low-importance
105
+ * (<8) — exactly the rows the fallback's filter excludes — returning zero
106
+ * candidates for a query that matched only such rows.
107
+ */
108
+ export function searchMemoryCandidatesFTS(db, projectId, queryText) {
109
+ const sanitized = sanitizeFtsQuery(queryText);
110
+ if (!sanitized) {
111
+ return searchMemoryCandidates(db, projectId);
112
+ }
113
+ let rows;
114
+ try {
115
+ rows = getSearchStatements(db).searchCandidatesFTS.all(sanitized, projectId, FTS_CANDIDATE_LIMIT);
116
+ }
117
+ catch {
118
+ // FTS5 MATCH can throw on malformed queries — fall back to full scan
119
+ return searchMemoryCandidates(db, projectId);
120
+ }
121
+ if (rows.length >= FTS_FALLBACK_THRESHOLD) {
122
+ return dedupById(mapRows(rows));
123
+ }
124
+ const fallback = searchMemoryCandidates(db, projectId);
125
+ if (rows.length === 0) {
126
+ return fallback;
127
+ }
128
+ // UNION FTS hits (first) with the fallback scan, deduplicated by id and
129
+ // capped at the same total limit FTS would have returned.
130
+ const ftsHits = mapRows(rows);
131
+ const seen = new Set(ftsHits.map((candidate) => candidate.id));
132
+ const merged = [...ftsHits];
133
+ for (const candidate of fallback) {
134
+ if (merged.length >= FTS_CANDIDATE_LIMIT)
135
+ break;
136
+ if (seen.has(candidate.id))
137
+ continue;
138
+ seen.add(candidate.id);
139
+ merged.push(candidate);
140
+ }
141
+ return merged;
30
142
  }
@@ -1,20 +1,43 @@
1
- export function insertSessionEvent(db, input) {
2
- const stmt = db.prepare(`
3
- INSERT INTO session_events (
1
+ const sessionEventsStmtCache = new WeakMap();
2
+ function getSessionEventsStatements(db) {
3
+ let stmts = sessionEventsStmtCache.get(db);
4
+ if (stmts)
5
+ return stmts;
6
+ stmts = {
7
+ // INSERT OR IGNORE so re-ingesting an event with the same logical key
8
+ // (project_id, session_id, event_index) — now a UNIQUE index, migration 009
9
+ // — is a no-op rather than a duplicate row or a PK error.
10
+ insertEvent: db.prepare(`
11
+ INSERT OR IGNORE INTO session_events (
4
12
  id, project_id, session_id, event_index, event_type, payload_json, created_at
5
13
  ) VALUES (
6
14
  @id, @project_id, @session_id, @event_index, @event_type, @payload_json,
7
15
  COALESCE(@created_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
8
16
  )
9
- `);
10
- stmt.run(input);
11
- }
12
- export function listSessionEventsBySession(db, projectId, sessionId) {
13
- const stmt = db.prepare(`
17
+ `),
18
+ listBySession: db.prepare(`
14
19
  SELECT id, project_id, session_id, event_index, event_type, payload_json, created_at
15
20
  FROM session_events
16
21
  WHERE project_id = ? AND session_id = ?
17
22
  ORDER BY event_index ASC
18
- `);
19
- return stmt.all(projectId, sessionId);
23
+ `),
24
+ countAll: db.prepare("SELECT COUNT(*) AS count FROM session_events WHERE project_id = ?"),
25
+ };
26
+ sessionEventsStmtCache.set(db, stmts);
27
+ return stmts;
28
+ }
29
+ /**
30
+ * Insert a session event. Returns the number of rows written (1, or 0 when the
31
+ * (project_id, session_id, event_index) key already exists and the insert was
32
+ * ignored).
33
+ */
34
+ export function insertSessionEvent(db, input) {
35
+ return getSessionEventsStatements(db).insertEvent.run(input).changes;
36
+ }
37
+ export function countAllSessionEvents(db, projectId) {
38
+ const row = getSessionEventsStatements(db).countAll.get(projectId);
39
+ return row.count;
40
+ }
41
+ export function listSessionEventsBySession(db, projectId, sessionId) {
42
+ return getSessionEventsStatements(db).listBySession.all(projectId, sessionId);
20
43
  }