sessionmem 1.0.4 → 1.0.6

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 (37) 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 +120 -0
  5. package/dist/adapters/generic.js +83 -12
  6. package/dist/adapters/tools/ping.js +4 -1
  7. package/dist/cli/commands/install.js +18 -1
  8. package/dist/cli/commands/reEmbed.js +47 -0
  9. package/dist/cli/commands/run.js +28 -2
  10. package/dist/cli/commands/savings.js +75 -0
  11. package/dist/cli/commands/uninstall.js +10 -0
  12. package/dist/cli/index.js +14 -0
  13. package/dist/cli/output.js +11 -3
  14. package/dist/core/api/contracts.js +34 -10
  15. package/dist/core/api/memoryCoreService.js +188 -86
  16. package/dist/core/api/sessionLifecycleService.js +12 -2
  17. package/dist/core/config/policyConfig.js +20 -0
  18. package/dist/core/injection/formatStartupInjection.js +2 -1
  19. package/dist/core/injection/tokenBudget.js +8 -0
  20. package/dist/core/retrieve/importance.js +4 -3
  21. package/dist/core/retrieve/recencyBands.js +3 -10
  22. package/dist/core/retrieve/retrieveMemories.js +17 -4
  23. package/dist/core/retrieve/score.js +11 -1
  24. package/dist/core/schema/migrations/005_team_provenance.sql +9 -9
  25. package/dist/core/schema/migrations/006_access_pattern_boosting.sql +5 -0
  26. package/dist/core/schema/migrations/007_feedback_manual_delete.sql +23 -0
  27. package/dist/core/schema/migrations/008_fts5_search.sql +33 -0
  28. package/dist/core/storage/db.js +6 -0
  29. package/dist/core/storage/memoryFeedbackRepo.js +14 -4
  30. package/dist/core/storage/memoryRepo.js +134 -120
  31. package/dist/core/storage/memorySearchRepo.js +87 -13
  32. package/dist/core/storage/sessionEventsRepo.js +19 -9
  33. package/dist/core/storage/summarizationFailuresRepo.js +36 -26
  34. package/dist/core/storage/tokenSavingsRepo.js +20 -0
  35. package/dist/core/summarize/cloudSummarizer.js +21 -5
  36. package/dist/core/summarize/localSummarizer.js +1 -10
  37. package/package.json +50 -48
@@ -5,9 +5,19 @@ export const SCORING_WEIGHTS = {
5
5
  recency: 0.25,
6
6
  importance: 0.15,
7
7
  };
8
+ export const ACCESS_BOOST_THRESHOLD = 3;
9
+ export const ACCESS_BOOST_AMOUNT = 2;
10
+ export function computeEffectiveImportance(storedImportance, accessCount) {
11
+ if (accessCount >= ACCESS_BOOST_THRESHOLD) {
12
+ return Math.min(storedImportance + ACCESS_BOOST_AMOUNT, 10);
13
+ }
14
+ return storedImportance;
15
+ }
8
16
  export function scoreMemoryCandidate(candidate, now = new Date()) {
9
17
  const recency = getRecencyBandScore(candidate.updated_at, now);
10
- const importance = normalizeImportance(candidate.importance);
18
+ const baseImportance = candidate.decayedImportance ?? candidate.importance;
19
+ const effectiveImportance = computeEffectiveImportance(baseImportance, candidate.access_count ?? 0);
20
+ const importance = normalizeImportance(effectiveImportance);
11
21
  const weighted = {
12
22
  semantic: candidate.semantic * SCORING_WEIGHTS.semantic,
13
23
  recency: recency * SCORING_WEIGHTS.recency,
@@ -1,9 +1,9 @@
1
- -- Team-mode provenance: record who authored each memory and, for
2
- -- synced/shared rows, which project they originated in. Both columns are added
3
- -- to the existing `memories` table without rewriting it so pre-existing rows
4
- -- survive. `author` is NOT NULL with a DEFAULT '' sentinel because a
5
- -- NOT NULL ADD COLUMN requires a default and the local OS username is not
6
- -- available inside static SQL. `origin_project_id` is nullable and
7
- -- only set on rows pulled in from another project's store.
8
- ALTER TABLE memories ADD COLUMN author TEXT NOT NULL DEFAULT '';
9
- ALTER TABLE memories ADD COLUMN origin_project_id TEXT;
1
+ -- Team-mode provenance: record who authored each memory and, for
2
+ -- synced/shared rows, which project they originated in. Both columns are added
3
+ -- to the existing `memories` table without rewriting it so pre-existing rows
4
+ -- survive. `author` is NOT NULL with a DEFAULT '' sentinel because a
5
+ -- NOT NULL ADD COLUMN requires a default and the local OS username is not
6
+ -- available inside static SQL. `origin_project_id` is nullable and
7
+ -- only set on rows pulled in from another project's store.
8
+ ALTER TABLE memories ADD COLUMN author TEXT NOT NULL DEFAULT '';
9
+ ALTER TABLE memories ADD COLUMN origin_project_id TEXT;
@@ -0,0 +1,5 @@
1
+ -- Access-pattern boosting: track how often each memory is included in
2
+ -- retrieval output. access_count drives a read-time effective_importance
3
+ -- boost without mutating the stored importance score.
4
+ ALTER TABLE memories ADD COLUMN access_count INTEGER NOT NULL DEFAULT 0;
5
+ ALTER TABLE memories ADD COLUMN last_accessed TEXT;
@@ -0,0 +1,23 @@
1
+ -- Allow 'manual_delete' feedback_type and new_importance = 0 for deletion records.
2
+ -- Remove FOREIGN KEY CASCADE so feedback rows survive when a memory is deleted.
3
+ -- SQLite requires table recreation to alter CHECK constraints and FK behavior.
4
+
5
+ CREATE TABLE IF NOT EXISTS memory_feedback_new (
6
+ id TEXT PRIMARY KEY,
7
+ memory_id TEXT NOT NULL,
8
+ feedback_type TEXT NOT NULL CHECK (feedback_type IN ('auto_use', 'manual', 'manual_delete')),
9
+ previous_importance INTEGER NOT NULL CHECK (previous_importance >= 0 AND previous_importance <= 10),
10
+ new_importance INTEGER NOT NULL CHECK (new_importance >= 0 AND new_importance <= 10),
11
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
12
+ );
13
+
14
+ INSERT OR IGNORE INTO memory_feedback_new (id, memory_id, feedback_type, previous_importance, new_importance, created_at)
15
+ SELECT id, memory_id, feedback_type, previous_importance, new_importance, created_at
16
+ FROM memory_feedback;
17
+
18
+ DROP TABLE memory_feedback;
19
+
20
+ ALTER TABLE memory_feedback_new RENAME TO memory_feedback;
21
+
22
+ CREATE INDEX IF NOT EXISTS idx_memory_feedback_memory_created
23
+ ON memory_feedback(memory_id, created_at DESC);
@@ -0,0 +1,33 @@
1
+ -- FTS5 full-text search index on memory content for candidate pre-filtering.
2
+ -- Using content-sync (external content) mode: the FTS index mirrors the
3
+ -- memories table without duplicating storage. Triggers keep it in sync.
4
+ CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
5
+ content,
6
+ normalized_content,
7
+ content='memories',
8
+ content_rowid='rowid'
9
+ );
10
+
11
+ -- Populate FTS index from existing rows
12
+ INSERT INTO memories_fts(rowid, content, normalized_content)
13
+ SELECT rowid, content, normalized_content FROM memories;
14
+
15
+ -- Keep FTS index in sync: INSERT trigger
16
+ CREATE TRIGGER IF NOT EXISTS memories_fts_insert AFTER INSERT ON memories BEGIN
17
+ INSERT INTO memories_fts(rowid, content, normalized_content)
18
+ VALUES (new.rowid, new.content, new.normalized_content);
19
+ END;
20
+
21
+ -- Keep FTS index in sync: DELETE trigger
22
+ CREATE TRIGGER IF NOT EXISTS memories_fts_delete AFTER DELETE ON memories BEGIN
23
+ INSERT INTO memories_fts(memories_fts, rowid, content, normalized_content)
24
+ VALUES ('delete', old.rowid, old.content, old.normalized_content);
25
+ END;
26
+
27
+ -- Keep FTS index in sync: UPDATE trigger (content or normalized_content changed)
28
+ CREATE TRIGGER IF NOT EXISTS memories_fts_update AFTER UPDATE OF content, normalized_content ON memories BEGIN
29
+ INSERT INTO memories_fts(memories_fts, rowid, content, normalized_content)
30
+ VALUES ('delete', old.rowid, old.content, old.normalized_content);
31
+ INSERT INTO memories_fts(rowid, content, normalized_content)
32
+ VALUES (new.rowid, new.content, new.normalized_content);
33
+ END;
@@ -2,7 +2,13 @@ import BetterSqlite3 from "better-sqlite3";
2
2
  import { runMigrations } from "../schema/runMigrations.js";
3
3
  export function openDb(options = {}) {
4
4
  const db = new BetterSqlite3(options.dbPath ?? ":memory:");
5
+ // Performance pragmas — WAL enables concurrent reads during writes;
6
+ // busy_timeout prevents SQLITE_BUSY under concurrent MCP tool calls.
7
+ db.pragma("journal_mode = WAL");
8
+ db.pragma("synchronous = NORMAL");
5
9
  db.pragma("foreign_keys = ON");
10
+ db.pragma("busy_timeout = 5000");
11
+ db.pragma("cache_size = -32000");
6
12
  runMigrations(db, options.migrationsDir);
7
13
  return db;
8
14
  }
@@ -1,14 +1,24 @@
1
1
  import { randomUUID } from "node:crypto";
2
- export function insertMemoryFeedbackEvent(db, event) {
3
- const stmt = db.prepare(`
2
+ const feedbackStmtCache = new WeakMap();
3
+ function getFeedbackStatements(db) {
4
+ let stmts = feedbackStmtCache.get(db);
5
+ if (stmts)
6
+ return stmts;
7
+ stmts = {
8
+ insertFeedback: db.prepare(`
4
9
  INSERT INTO memory_feedback (
5
10
  id, memory_id, feedback_type, previous_importance, new_importance, created_at
6
11
  ) VALUES (
7
12
  @id, @memory_id, @feedback_type, @previous_importance, @new_importance,
8
13
  COALESCE(@created_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
9
14
  )
10
- `);
11
- stmt.run({
15
+ `),
16
+ };
17
+ feedbackStmtCache.set(db, stmts);
18
+ return stmts;
19
+ }
20
+ export function insertMemoryFeedbackEvent(db, event) {
21
+ getFeedbackStatements(db).insertFeedback.run({
12
22
  ...event,
13
23
  id: event.id ?? randomUUID(),
14
24
  created_at: event.created_at ?? null,
@@ -1,4 +1,107 @@
1
- import { insertMemoryFeedbackEvent, } from "./memoryFeedbackRepo.js";
1
+ const stmtCache = new WeakMap();
2
+ function getStatements(db) {
3
+ let stmts = stmtCache.get(db);
4
+ if (stmts)
5
+ return stmts;
6
+ stmts = {
7
+ insertMemory: db.prepare(`
8
+ INSERT INTO memories (
9
+ id, project_id, session_id, source_adapter, kind, content, normalized_content,
10
+ importance, embedding, embedding_dim, embedding_version, author, origin_project_id,
11
+ created_at, updated_at
12
+ ) VALUES (
13
+ @id, @project_id, @session_id, @source_adapter, @kind, @content, @normalized_content,
14
+ @importance, @embedding, @embedding_dim, @embedding_version, @author, @origin_project_id,
15
+ COALESCE(@created_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
16
+ COALESCE(@updated_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
17
+ )
18
+ `),
19
+ upsertSessionSummary: db.prepare(`
20
+ INSERT INTO memories (
21
+ id, project_id, session_id, source_adapter, kind, content, normalized_content,
22
+ importance, embedding, embedding_dim, embedding_version, author, origin_project_id,
23
+ created_at, updated_at
24
+ ) VALUES (
25
+ @id, @project_id, @session_id, @source_adapter, 'summary', @content, @normalized_content,
26
+ @importance, @embedding, @embedding_dim, @embedding_version, @author, @origin_project_id,
27
+ COALESCE(@created_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
28
+ COALESCE(@updated_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
29
+ )
30
+ ON CONFLICT(project_id, session_id, kind) WHERE kind = 'summary'
31
+ DO UPDATE SET
32
+ id = excluded.id,
33
+ source_adapter = excluded.source_adapter,
34
+ content = excluded.content,
35
+ normalized_content = excluded.normalized_content,
36
+ importance = excluded.importance,
37
+ embedding = excluded.embedding,
38
+ embedding_dim = excluded.embedding_dim,
39
+ embedding_version = excluded.embedding_version,
40
+ author = excluded.author,
41
+ origin_project_id = excluded.origin_project_id,
42
+ updated_at = COALESCE(excluded.updated_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
43
+ `),
44
+ listByProject: db.prepare(`
45
+ SELECT
46
+ id, project_id, session_id, source_adapter, kind, content, normalized_content,
47
+ importance, embedding, embedding_dim, embedding_version, author, origin_project_id,
48
+ access_count, last_accessed, created_at, updated_at
49
+ FROM memories
50
+ WHERE project_id = ?
51
+ ORDER BY updated_at DESC
52
+ `),
53
+ listAllIds: db.prepare("SELECT id FROM memories"),
54
+ countOlderThan: db.prepare(`
55
+ SELECT COUNT(*) AS count
56
+ FROM memories
57
+ WHERE project_id = ? AND created_at < ?
58
+ `),
59
+ deleteOlderThan: db.prepare(`
60
+ DELETE FROM memories
61
+ WHERE project_id = ? AND created_at < ?
62
+ `),
63
+ updateImportance: db.prepare(`
64
+ UPDATE memories
65
+ SET
66
+ importance = ?,
67
+ updated_at = COALESCE(?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
68
+ WHERE project_id = ? AND id = ?
69
+ `),
70
+ updateContent: db.prepare(`
71
+ UPDATE memories
72
+ SET
73
+ content = ?,
74
+ normalized_content = COALESCE(?, normalized_content),
75
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
76
+ WHERE project_id = ? AND id = ?
77
+ `),
78
+ selectForRecordUse: db.prepare(`
79
+ SELECT id, importance
80
+ FROM memories
81
+ WHERE project_id = ? AND id = ?
82
+ LIMIT 1
83
+ `),
84
+ incrementAccess: db.prepare(`
85
+ UPDATE memories
86
+ SET
87
+ access_count = access_count + 1,
88
+ last_accessed = COALESCE(?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
89
+ WHERE project_id = ? AND id = ?
90
+ `),
91
+ resetAccess: db.prepare(`
92
+ UPDATE memories
93
+ SET access_count = 0, last_accessed = NULL
94
+ WHERE project_id = ?
95
+ `),
96
+ countBySession: db.prepare(`
97
+ SELECT COUNT(*) AS count
98
+ FROM memories
99
+ WHERE session_id = ?
100
+ `),
101
+ };
102
+ stmtCache.set(db, stmts);
103
+ return stmts;
104
+ }
2
105
  function assertImportance(importance) {
3
106
  if (importance < 1 || importance > 10) {
4
107
  throw new Error("importance must be between 1 and 10");
@@ -18,60 +121,14 @@ function toParams(input) {
18
121
  }
19
122
  export function insertMemory(db, input) {
20
123
  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));
124
+ getStatements(db).insertMemory.run(toParams(input));
34
125
  }
35
126
  export function upsertSessionSummaryMemory(db, input) {
36
127
  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" }));
128
+ getStatements(db).upsertSessionSummary.run(toParams({ ...input, kind: "summary" }));
63
129
  }
64
130
  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);
131
+ return getStatements(db).listByProject.all(projectId);
75
132
  }
76
133
  /**
77
134
  * All memory ids across every project. `id` is a globally-unique
@@ -80,100 +137,57 @@ export function listMemoriesByProject(db, projectId) {
80
137
  * collisions as "skipped" rather than silently importing them.
81
138
  */
82
139
  export function listAllMemoryIds(db) {
83
- const rows = db.prepare("SELECT id FROM memories").all();
140
+ const rows = getStatements(db).listAllIds.all();
84
141
  return new Set(rows.map((r) => r.id));
85
142
  }
86
143
  export function countMemoriesOlderThan(db, projectId, cutoffIso) {
87
144
  // created_at is stored as strftime('%Y-%m-%dT%H:%M:%fZ') text; lexicographic
88
145
  // 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);
146
+ const row = getStatements(db).countOlderThan.get(projectId, cutoffIso);
96
147
  return row.count;
97
148
  }
98
149
  export function deleteMemoriesOlderThan(db, projectId, cutoffIso) {
99
150
  // Hard-delete scoped to the memories table only; never touches
100
151
  // session_events or memory_feedback. project_id and cutoff are bound, never
101
152
  // 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);
153
+ const result = getStatements(db).deleteOlderThan.run(projectId, cutoffIso);
108
154
  return result.changes;
109
155
  }
110
156
  export function updateMemoryImportance(db, projectId, memoryId, nextImportance, usedAt) {
111
157
  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);
158
+ const result = getStatements(db).updateImportance.run(nextImportance, usedAt ?? null, projectId, memoryId);
121
159
  if (result.changes === 0) {
122
160
  throw new Error(`Memory not found: ${memoryId}`);
123
161
  }
124
162
  }
163
+ /**
164
+ * Count the number of memories stored under a given session_id across all
165
+ * projects. Used to enforce per-session write soft limits — the count is
166
+ * checked before each storeMemory call and a warning is surfaced when the
167
+ * threshold is reached.
168
+ */
169
+ export function countMemoriesBySession(db, sessionId) {
170
+ const row = getStatements(db).countBySession.get(sessionId);
171
+ return row.count;
172
+ }
125
173
  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);
174
+ const result = getStatements(db).updateContent.run(newContent, newNormalizedContent ?? null, projectId, memoryId);
141
175
  if (result.changes === 0) {
142
176
  throw new Error(`Memory not found: ${memoryId}`);
143
177
  }
144
178
  }
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}`);
179
+ export function incrementAccessCounts(db, projectId, memoryIds, accessedAt) {
180
+ if (memoryIds.length === 0)
181
+ return;
182
+ const stmt = getStatements(db).incrementAccess;
183
+ const run = db.transaction(() => {
184
+ for (const id of memoryIds) {
185
+ stmt.run(accessedAt ?? null, projectId, id);
157
186
  }
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
187
  });
178
- return transaction(input);
188
+ run();
189
+ }
190
+ export function resetAccessCounts(db, projectId) {
191
+ const result = getStatements(db).resetAccess.run(projectId);
192
+ return result.changes;
179
193
  }
@@ -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 > datetime('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,54 @@ function parseEmbedding(value) {
13
51
  return null;
14
52
  }
15
53
  }
54
+ function mapRows(rows) {
55
+ return rows.map((row) => {
56
+ const parsed = parseEmbedding(row.embedding);
57
+ const versionMatch = row.embedding_version === EMBEDDING_VERSION;
58
+ return {
59
+ ...row,
60
+ embedding: versionMatch ? parsed : null,
61
+ };
62
+ });
63
+ }
16
64
  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
- }));
65
+ const rows = getSearchStatements(db).searchCandidates.all(projectId);
66
+ return mapRows(rows);
67
+ }
68
+ /**
69
+ * Sanitize query text for FTS5 MATCH syntax.
70
+ * Wraps each non-empty token in double quotes so special characters
71
+ * (colons, hyphens, parentheses, etc.) are treated as literals.
72
+ * Tokens are joined with implicit AND.
73
+ */
74
+ function sanitizeFtsQuery(queryText) {
75
+ return queryText
76
+ .split(/\s+/)
77
+ .filter((token) => token.length > 0)
78
+ .map((token) => `"${token.replace(/"/g, '""')}"`)
79
+ .join(" ");
80
+ }
81
+ /**
82
+ * Pre-filter candidates using FTS5 full-text search before cosine similarity.
83
+ * Returns top-50 candidates by FTS rank. Falls back to full
84
+ * searchMemoryCandidates when FTS returns fewer than 5 results
85
+ * (poor keyword overlap).
86
+ */
87
+ export function searchMemoryCandidatesFTS(db, projectId, queryText) {
88
+ const sanitized = sanitizeFtsQuery(queryText);
89
+ if (!sanitized) {
90
+ return searchMemoryCandidates(db, projectId);
91
+ }
92
+ let rows;
93
+ try {
94
+ rows = getSearchStatements(db).searchCandidatesFTS.all(sanitized, projectId, FTS_CANDIDATE_LIMIT);
95
+ }
96
+ catch {
97
+ // FTS5 MATCH can throw on malformed queries — fall back to full scan
98
+ return searchMemoryCandidates(db, projectId);
99
+ }
100
+ if (rows.length < FTS_FALLBACK_THRESHOLD) {
101
+ return searchMemoryCandidates(db, projectId);
102
+ }
103
+ return mapRows(rows);
30
104
  }
@@ -1,20 +1,30 @@
1
- export function insertSessionEvent(db, input) {
2
- const stmt = db.prepare(`
1
+ const sessionEventsStmtCache = new WeakMap();
2
+ function getSessionEventsStatements(db) {
3
+ let stmts = sessionEventsStmtCache.get(db);
4
+ if (stmts)
5
+ return stmts;
6
+ stmts = {
7
+ insertEvent: db.prepare(`
3
8
  INSERT INTO session_events (
4
9
  id, project_id, session_id, event_index, event_type, payload_json, created_at
5
10
  ) VALUES (
6
11
  @id, @project_id, @session_id, @event_index, @event_type, @payload_json,
7
12
  COALESCE(@created_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
8
13
  )
9
- `);
10
- stmt.run(input);
11
- }
12
- export function listSessionEventsBySession(db, projectId, sessionId) {
13
- const stmt = db.prepare(`
14
+ `),
15
+ listBySession: db.prepare(`
14
16
  SELECT id, project_id, session_id, event_index, event_type, payload_json, created_at
15
17
  FROM session_events
16
18
  WHERE project_id = ? AND session_id = ?
17
19
  ORDER BY event_index ASC
18
- `);
19
- return stmt.all(projectId, sessionId);
20
+ `),
21
+ };
22
+ sessionEventsStmtCache.set(db, stmts);
23
+ return stmts;
24
+ }
25
+ export function insertSessionEvent(db, input) {
26
+ getSessionEventsStatements(db).insertEvent.run(input);
27
+ }
28
+ export function listSessionEventsBySession(db, projectId, sessionId) {
29
+ return getSessionEventsStatements(db).listBySession.all(projectId, sessionId);
20
30
  }