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.
- package/LICENSE +21 -21
- package/README.md +372 -365
- package/dist/adapters/capabilities/fallbackTools.js +33 -18
- package/dist/adapters/claudeMdInjector.js +164 -0
- package/dist/adapters/factory.js +68 -9
- package/dist/adapters/generic.js +221 -15
- package/dist/adapters/global/antigravity.js +14 -7
- package/dist/adapters/global/claudeCode.js +46 -10
- package/dist/adapters/global/codex.js +73 -13
- package/dist/adapters/global/qcoder.js +18 -5
- package/dist/adapters/ide/cline.js +54 -9
- package/dist/adapters/ide/cursor.js +15 -13
- package/dist/adapters/ide/installer.js +201 -8
- package/dist/adapters/ide/windsurf.js +14 -13
- package/dist/adapters/tools/ping.js +4 -1
- package/dist/cli/commands/config.js +10 -1
- package/dist/cli/commands/import.js +6 -1
- package/dist/cli/commands/install.js +63 -5
- package/dist/cli/commands/ping.js +42 -8
- package/dist/cli/commands/reEmbed.js +48 -0
- package/dist/cli/commands/run.js +18 -2
- package/dist/cli/commands/savings.js +91 -0
- package/dist/cli/commands/sessionEnd.js +124 -0
- package/dist/cli/commands/sessionStart.js +52 -0
- package/dist/cli/commands/sync.js +39 -9
- package/dist/cli/commands/uninstall.js +37 -1
- package/dist/cli/context.js +14 -18
- package/dist/cli/index.js +30 -4
- package/dist/cli/output.js +11 -3
- package/dist/cli/projectId.js +69 -0
- package/dist/core/api/contracts.js +182 -45
- package/dist/core/api/errors.js +4 -7
- package/dist/core/api/memoryCoreService.js +409 -240
- package/dist/core/api/sessionLifecycleService.js +20 -2
- package/dist/core/config/policyConfig.js +53 -6
- package/dist/core/injection/formatStartupInjection.js +55 -10
- package/dist/core/injection/tokenBudget.js +8 -0
- package/dist/core/retrieve/importance.js +4 -3
- package/dist/core/retrieve/recencyBands.js +6 -10
- package/dist/core/retrieve/retrieveMemories.js +19 -4
- package/dist/core/retrieve/score.js +11 -1
- package/dist/core/schema/migrations/005_team_provenance.sql +14 -9
- package/dist/core/schema/migrations/006_access_pattern_boosting.sql +10 -0
- package/dist/core/schema/migrations/007_feedback_manual_delete.sql +23 -0
- package/dist/core/schema/migrations/008_fts5_search.sql +37 -0
- package/dist/core/schema/migrations/009_session_events_unique.sql +24 -0
- package/dist/core/schema/runMigrations.js +64 -2
- package/dist/core/storage/db.js +6 -0
- package/dist/core/storage/memoryFeedbackRepo.js +14 -4
- package/dist/core/storage/memoryRepo.js +292 -121
- package/dist/core/storage/memorySearchRepo.js +125 -13
- package/dist/core/storage/sessionEventsRepo.js +33 -10
- package/dist/core/storage/summarizationFailuresRepo.js +36 -26
- package/dist/core/storage/tokenSavingsRepo.js +20 -0
- package/dist/core/summarize/cloudSummarizer.js +34 -5
- package/dist/core/summarize/localSummarizer.js +1 -10
- package/dist/core/summarize/redaction.js +45 -8
- package/package.json +50 -48
|
@@ -1,4 +1,186 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
return
|
|
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
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
301
|
+
/**
|
|
302
|
+
* Count all memories stored in a project. Used to enforce per-session write
|
|
303
|
+
* soft limits — the 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
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|