sessionmem 1.0.5 → 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.
- package/LICENSE +21 -21
- package/README.md +372 -365
- package/dist/adapters/capabilities/fallbackTools.js +33 -18
- package/dist/adapters/claudeMdInjector.js +120 -0
- package/dist/adapters/generic.js +83 -12
- package/dist/adapters/tools/ping.js +4 -1
- package/dist/cli/commands/install.js +18 -1
- package/dist/cli/commands/reEmbed.js +47 -0
- package/dist/cli/commands/run.js +28 -2
- package/dist/cli/commands/savings.js +75 -0
- package/dist/cli/commands/uninstall.js +10 -0
- package/dist/cli/index.js +14 -0
- package/dist/cli/output.js +11 -3
- package/dist/core/api/contracts.js +34 -10
- package/dist/core/api/memoryCoreService.js +188 -86
- package/dist/core/api/sessionLifecycleService.js +12 -2
- package/dist/core/config/policyConfig.js +20 -0
- package/dist/core/injection/formatStartupInjection.js +2 -1
- package/dist/core/injection/tokenBudget.js +8 -0
- package/dist/core/retrieve/importance.js +4 -3
- package/dist/core/retrieve/recencyBands.js +3 -10
- package/dist/core/retrieve/retrieveMemories.js +17 -4
- package/dist/core/retrieve/score.js +11 -1
- package/dist/core/schema/migrations/005_team_provenance.sql +9 -9
- package/dist/core/schema/migrations/006_access_pattern_boosting.sql +5 -0
- package/dist/core/schema/migrations/007_feedback_manual_delete.sql +23 -0
- package/dist/core/schema/migrations/008_fts5_search.sql +33 -0
- package/dist/core/storage/db.js +6 -0
- package/dist/core/storage/memoryFeedbackRepo.js +14 -4
- package/dist/core/storage/memoryRepo.js +134 -120
- package/dist/core/storage/memorySearchRepo.js +87 -13
- package/dist/core/storage/sessionEventsRepo.js +19 -9
- package/dist/core/storage/summarizationFailuresRepo.js +36 -26
- package/dist/core/storage/tokenSavingsRepo.js +20 -0
- package/dist/core/summarize/cloudSummarizer.js +21 -5
- package/dist/core/summarize/localSummarizer.js +1 -10
- 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
|
|
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;
|
package/dist/core/storage/db.js
CHANGED
|
@@ -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
|
-
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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}`);
|
|
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
|
-
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
2
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|