th-memory-mcp 1.2.2 → 2.0.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/README.md +209 -187
- package/README.th.md +23 -7
- package/design.md +98 -308
- package/dist/core/consolidation-engine.js +87 -0
- package/dist/core/context-engine.js +50 -0
- package/dist/core/graph-engine.js +67 -0
- package/dist/core/lifecycle-engine.js +76 -0
- package/dist/core/retrieval-engine.js +40 -0
- package/dist/core/temporal-engine.js +73 -0
- package/dist/db/index.js +111 -0
- package/dist/db/migrations.js +160 -0
- package/dist/db/repositories/memories.js +52 -0
- package/dist/db.js +3 -0
- package/dist/index.js +12 -0
- package/dist/lib/embed.js +8 -5
- package/dist/memory/conflict-resolver.js +125 -0
- package/dist/memory/decay.js +30 -0
- package/dist/memory/deduplicator.js +51 -0
- package/dist/memory/scorer.js +44 -0
- package/dist/memory/source-weights.js +13 -0
- package/dist/memory/types.js +41 -0
- package/dist/retrieval/fts.js +22 -0
- package/dist/retrieval/fusion.js +11 -0
- package/dist/retrieval/scorer.js +19 -0
- package/dist/retrieval/vector.js +29 -0
- package/dist/tools/consolidate.js +63 -0
- package/dist/tools/context.js +55 -0
- package/dist/tools/export_memory.js +1 -1
- package/dist/tools/forget.js +1 -1
- package/dist/tools/history.js +1 -1
- package/dist/tools/lesson.js +1 -1
- package/dist/tools/memory_stats.js +1 -1
- package/dist/tools/profile.js +1 -1
- package/dist/tools/recall.js +1 -1
- package/dist/tools/recent_interactions.js +1 -1
- package/dist/tools/remember.js +1 -1
- package/package.json +46 -46
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { db, nowISO } from "../db/index.js";
|
|
2
|
+
import { getMemoryById, setStatus } from "../db/repositories/memories.js";
|
|
3
|
+
const TRANSITIONS = {
|
|
4
|
+
new: ["active", "deleted"],
|
|
5
|
+
active: ["reinforced", "stale", "superseded", "archived", "deleted"],
|
|
6
|
+
reinforced: ["active", "stale", "superseded", "archived", "deleted"],
|
|
7
|
+
stale: ["active", "archived", "deleted"],
|
|
8
|
+
superseded: ["archived", "deleted"],
|
|
9
|
+
archived: ["deleted"],
|
|
10
|
+
deleted: [],
|
|
11
|
+
};
|
|
12
|
+
export class LifecycleError extends Error {
|
|
13
|
+
}
|
|
14
|
+
export function canTransition(from, to) {
|
|
15
|
+
return TRANSITIONS[from]?.includes(to) ?? false;
|
|
16
|
+
}
|
|
17
|
+
export function transitionStatus(id, to) {
|
|
18
|
+
const mem = getMemoryById(id);
|
|
19
|
+
if (!mem)
|
|
20
|
+
throw new LifecycleError(`memory ${id} not found`);
|
|
21
|
+
if (!canTransition(mem.status, to)) {
|
|
22
|
+
throw new LifecycleError(`illegal transition ${mem.status} -> ${to} for memory ${id}`);
|
|
23
|
+
}
|
|
24
|
+
setStatus(id, to);
|
|
25
|
+
return getMemoryById(id);
|
|
26
|
+
}
|
|
27
|
+
export function reinforce(id) {
|
|
28
|
+
const mem = getMemoryById(id);
|
|
29
|
+
if (!mem)
|
|
30
|
+
throw new LifecycleError(`memory ${id} not found`);
|
|
31
|
+
if (mem.status === "deleted" || mem.status === "archived") {
|
|
32
|
+
throw new LifecycleError(`cannot reinforce memory in state ${mem.status}`);
|
|
33
|
+
}
|
|
34
|
+
const ts = nowISO();
|
|
35
|
+
db.prepare(`UPDATE memories
|
|
36
|
+
SET status = 'active',
|
|
37
|
+
confidence = MIN(1.0, confidence + 0.05),
|
|
38
|
+
updated_at = ?,
|
|
39
|
+
access_count = access_count + 1,
|
|
40
|
+
last_accessed_at = ?
|
|
41
|
+
WHERE id = ?`).run(ts, ts, id);
|
|
42
|
+
return getMemoryById(id);
|
|
43
|
+
}
|
|
44
|
+
export function touch(id) {
|
|
45
|
+
const ts = nowISO();
|
|
46
|
+
db.prepare(`UPDATE memories SET access_count = access_count + 1, last_accessed_at = ? WHERE id = ?`).run(ts, id);
|
|
47
|
+
}
|
|
48
|
+
// old memory becomes superseded; new memory becomes active and points to old.
|
|
49
|
+
export function supersede(oldId, newId) {
|
|
50
|
+
const oldM = getMemoryById(oldId);
|
|
51
|
+
const newM = getMemoryById(newId);
|
|
52
|
+
if (!oldM)
|
|
53
|
+
throw new LifecycleError(`old memory ${oldId} not found`);
|
|
54
|
+
if (!newM)
|
|
55
|
+
throw new LifecycleError(`new memory ${newId} not found`);
|
|
56
|
+
const ts = nowISO();
|
|
57
|
+
const tx = db.transaction(() => {
|
|
58
|
+
db.prepare(`UPDATE memories SET status = 'superseded', updated_at = ? WHERE id = ?`).run(ts, oldId);
|
|
59
|
+
db.prepare(`UPDATE memories SET status = 'active', supersedes_id = ?, updated_at = ? WHERE id = ?`).run(oldId, ts, newId);
|
|
60
|
+
db.prepare(`INSERT INTO memory_links (source_memory_id, relation, target_memory_id, confidence, created_at)
|
|
61
|
+
VALUES (?, 'supersedes', ?, 0.9, ?)
|
|
62
|
+
ON CONFLICT(source_memory_id, relation, target_memory_id) DO UPDATE SET confidence = 0.9`).run(newId, oldId, ts);
|
|
63
|
+
});
|
|
64
|
+
tx();
|
|
65
|
+
}
|
|
66
|
+
export function archive(id) {
|
|
67
|
+
const mem = getMemoryById(id);
|
|
68
|
+
if (!mem)
|
|
69
|
+
throw new LifecycleError(`memory ${id} not found`);
|
|
70
|
+
if (mem.status === "deleted")
|
|
71
|
+
throw new LifecycleError(`cannot archive deleted memory ${id}`);
|
|
72
|
+
return transitionStatus(id, "archived");
|
|
73
|
+
}
|
|
74
|
+
export function softDelete(id) {
|
|
75
|
+
return transitionStatus(id, "deleted");
|
|
76
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { db } from "../db/index.js";
|
|
2
|
+
import { ftsSearch } from "../retrieval/fts.js";
|
|
3
|
+
import { vectorSearch } from "../retrieval/vector.js";
|
|
4
|
+
import { rrfFuse } from "../retrieval/fusion.js";
|
|
5
|
+
import { finalScore, scopeFactorFor } from "../retrieval/scorer.js";
|
|
6
|
+
import { recencyFactorFor } from "../memory/decay.js";
|
|
7
|
+
// Hybrid retrieval pipeline (spec §13): FTS + vector -> RRF fusion -> scoring/rerank -> filter -> topK
|
|
8
|
+
export function retrieve(query, opts = {}) {
|
|
9
|
+
const limit = Math.min(Math.max(opts.limit ?? 10, 1), 50);
|
|
10
|
+
const fts = ftsSearch(query, opts);
|
|
11
|
+
const vecRaw = vectorSearch(query, opts);
|
|
12
|
+
const vec = vecRaw.map((r, i) => ({
|
|
13
|
+
id: r.id,
|
|
14
|
+
rank: i + 1,
|
|
15
|
+
}));
|
|
16
|
+
const fused = rrfFuse([fts, vec]);
|
|
17
|
+
const now = new Date();
|
|
18
|
+
const out = [];
|
|
19
|
+
for (const [id, rrf] of fused.entries()) {
|
|
20
|
+
const mem = db
|
|
21
|
+
.prepare("SELECT * FROM memories WHERE id = ?")
|
|
22
|
+
.get(id);
|
|
23
|
+
if (!mem)
|
|
24
|
+
continue;
|
|
25
|
+
if (!opts.includeArchived &&
|
|
26
|
+
(mem.status === "deleted" || mem.status === "archived"))
|
|
27
|
+
continue;
|
|
28
|
+
const recency = recencyFactorFor(mem.type, mem.updated_at, now);
|
|
29
|
+
const scope = scopeFactorFor(mem, opts.projectId);
|
|
30
|
+
const fs = finalScore({
|
|
31
|
+
rrf,
|
|
32
|
+
confidence: mem.confidence,
|
|
33
|
+
importance: mem.importance,
|
|
34
|
+
recency,
|
|
35
|
+
scopeFactor: scope,
|
|
36
|
+
});
|
|
37
|
+
out.push({ ...mem, rrf, final_score: fs });
|
|
38
|
+
}
|
|
39
|
+
return out.sort((a, b) => b.final_score - a.final_score).slice(0, limit);
|
|
40
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { db } from "../db/index.js";
|
|
2
|
+
import { getMemoryById } from "../db/repositories/memories.js";
|
|
3
|
+
export function setValidity(id, validFrom, validUntil) {
|
|
4
|
+
const mem = getMemoryById(id);
|
|
5
|
+
if (!mem)
|
|
6
|
+
throw new Error(`memory ${id} not found`);
|
|
7
|
+
db.prepare(`UPDATE memories SET valid_from = ?, valid_until = ?, updated_at = ? WHERE id = ?`).run(validFrom, validUntil, new Date().toISOString(), id);
|
|
8
|
+
return getMemoryById(id);
|
|
9
|
+
}
|
|
10
|
+
// Memories that were valid at time T (inclusive). Excludes logically deleted.
|
|
11
|
+
export function memoriesValidAt(isoTs, opts = {}) {
|
|
12
|
+
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
|
|
13
|
+
const clauses = [
|
|
14
|
+
"(valid_from IS NULL OR valid_from <= @t)",
|
|
15
|
+
"(valid_until IS NULL OR valid_until >= @t)",
|
|
16
|
+
"status != 'deleted'",
|
|
17
|
+
];
|
|
18
|
+
const params = { t: isoTs, limit };
|
|
19
|
+
if (opts.projectId) {
|
|
20
|
+
clauses.push("(project_id = @projectId OR project_id IS NULL)");
|
|
21
|
+
params.projectId = opts.projectId;
|
|
22
|
+
}
|
|
23
|
+
if (opts.types && opts.types.length) {
|
|
24
|
+
const ph = opts.types.map((_, i) => `@type${i}`).join(",");
|
|
25
|
+
clauses.push(`type IN (${ph})`);
|
|
26
|
+
opts.types.forEach((t, i) => (params[`type${i}`] = t));
|
|
27
|
+
}
|
|
28
|
+
return db
|
|
29
|
+
.prepare(`SELECT * FROM memories WHERE ${clauses.join(" AND ")} ORDER BY updated_at DESC LIMIT @limit`)
|
|
30
|
+
.all(params);
|
|
31
|
+
}
|
|
32
|
+
// Walk the supersession chain: oldest -> newest, centered on the given memory.
|
|
33
|
+
export function supersessionChain(id) {
|
|
34
|
+
const start = getMemoryById(id);
|
|
35
|
+
if (!start)
|
|
36
|
+
return [];
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
// backward: follow supersedes_id to the root (oldest)
|
|
39
|
+
const backward = [];
|
|
40
|
+
let cur = start;
|
|
41
|
+
while (cur && cur.supersedes_id != null && !seen.has(cur.id)) {
|
|
42
|
+
seen.add(cur.id);
|
|
43
|
+
const prev = getMemoryById(cur.supersedes_id);
|
|
44
|
+
if (!prev)
|
|
45
|
+
break;
|
|
46
|
+
backward.unshift(prev);
|
|
47
|
+
cur = prev;
|
|
48
|
+
}
|
|
49
|
+
// forward: find memories whose supersedes_id == current newest
|
|
50
|
+
const forward = [];
|
|
51
|
+
cur = start;
|
|
52
|
+
seen.clear();
|
|
53
|
+
while (cur && !seen.has(cur.id)) {
|
|
54
|
+
seen.add(cur.id);
|
|
55
|
+
forward.push(cur);
|
|
56
|
+
const next = db
|
|
57
|
+
.prepare("SELECT id FROM memories WHERE supersedes_id = ? LIMIT 1")
|
|
58
|
+
.get(cur.id);
|
|
59
|
+
if (!next)
|
|
60
|
+
break;
|
|
61
|
+
cur = getMemoryById(next.id);
|
|
62
|
+
}
|
|
63
|
+
return [...backward, ...forward];
|
|
64
|
+
}
|
|
65
|
+
// Change detection: memories created or updated within [t1, t2].
|
|
66
|
+
export function changesBetween(t1, t2, limit = 100) {
|
|
67
|
+
return db
|
|
68
|
+
.prepare(`SELECT * FROM memories
|
|
69
|
+
WHERE updated_at >= @t1 AND updated_at <= @t2 AND status != 'deleted'
|
|
70
|
+
ORDER BY updated_at DESC
|
|
71
|
+
LIMIT @limit`)
|
|
72
|
+
.all({ t1, t2, limit });
|
|
73
|
+
}
|
package/dist/db/index.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { truncate } from "../lib/capture-core.js";
|
|
5
|
+
import { serialize } from "../lib/embed.js";
|
|
6
|
+
import { DEFAULT_DB_PATH } from "../lib/config.js";
|
|
7
|
+
import { runMigrations } from "./migrations.js";
|
|
8
|
+
export const DB_PATH = process.env.MEMORY_DB_PATH ?? DEFAULT_DB_PATH;
|
|
9
|
+
function initDb() {
|
|
10
|
+
try {
|
|
11
|
+
mkdirSync(dirname(DB_PATH), { recursive: true });
|
|
12
|
+
return new Database(DB_PATH);
|
|
13
|
+
}
|
|
14
|
+
catch (e) {
|
|
15
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
16
|
+
console.error(`[th-memory-mcp] cannot open DB at ${DB_PATH}: ${msg}`);
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export const db = initDb();
|
|
21
|
+
db.pragma("journal_mode = WAL");
|
|
22
|
+
db.pragma("busy_timeout = 5000");
|
|
23
|
+
db.exec(`
|
|
24
|
+
CREATE TABLE IF NOT EXISTS interactions (
|
|
25
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
26
|
+
ts TEXT NOT NULL,
|
|
27
|
+
session_id TEXT,
|
|
28
|
+
kind TEXT NOT NULL,
|
|
29
|
+
content TEXT NOT NULL,
|
|
30
|
+
meta TEXT
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
CREATE TABLE IF NOT EXISTS preferences (
|
|
34
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
35
|
+
category TEXT NOT NULL,
|
|
36
|
+
key TEXT NOT NULL,
|
|
37
|
+
value TEXT NOT NULL,
|
|
38
|
+
confidence REAL DEFAULT 0.5,
|
|
39
|
+
source TEXT DEFAULT 'explicit',
|
|
40
|
+
updated_at TEXT NOT NULL,
|
|
41
|
+
UNIQUE(category, key)
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
CREATE TABLE IF NOT EXISTS lessons (
|
|
45
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
46
|
+
situation TEXT NOT NULL,
|
|
47
|
+
mistake TEXT NOT NULL,
|
|
48
|
+
correction TEXT NOT NULL,
|
|
49
|
+
created_at TEXT NOT NULL
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
CREATE TABLE IF NOT EXISTS profile (
|
|
53
|
+
section TEXT PRIMARY KEY,
|
|
54
|
+
content TEXT NOT NULL,
|
|
55
|
+
updated_at TEXT NOT NULL
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
|
|
59
|
+
ref_table, ref_id, title, body
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
CREATE TABLE IF NOT EXISTS embeddings (
|
|
63
|
+
ref_table TEXT NOT NULL,
|
|
64
|
+
ref_id INTEGER NOT NULL,
|
|
65
|
+
vec BLOB NOT NULL,
|
|
66
|
+
PRIMARY KEY (ref_table, ref_id)
|
|
67
|
+
);
|
|
68
|
+
`);
|
|
69
|
+
// v2 migration engine (non-destructive: only adds new tables + backfills from v1)
|
|
70
|
+
runMigrations(db);
|
|
71
|
+
export function nowISO() {
|
|
72
|
+
return new Date().toISOString();
|
|
73
|
+
}
|
|
74
|
+
// re-exported from lib/capture-core.js (single source of truth)
|
|
75
|
+
export { truncate };
|
|
76
|
+
export function escapeLike(text) {
|
|
77
|
+
return text.replace(/[\\%_]/g, (c) => "\\" + c);
|
|
78
|
+
}
|
|
79
|
+
export function buildFtsMatch(query) {
|
|
80
|
+
const tokens = query.trim().split(/\s+/).filter(Boolean).slice(0, 8);
|
|
81
|
+
return tokens.map((t) => `"${t.replace(/"/g, "")}"`).join(" OR ");
|
|
82
|
+
}
|
|
83
|
+
const insertSearchIndex = db.prepare("INSERT INTO search_index (ref_table, ref_id, title, body) VALUES (?, ?, ?, ?)");
|
|
84
|
+
const deleteSearchIndex = db.prepare("DELETE FROM search_index WHERE ref_table = ? AND ref_id = ?");
|
|
85
|
+
export function syncSearchIndex(refTable, refId, title, body) {
|
|
86
|
+
deleteSearchIndex.run(refTable, refId);
|
|
87
|
+
insertSearchIndex.run(refTable, refId, title, body);
|
|
88
|
+
}
|
|
89
|
+
export function removeSearchIndex(refTable, refId) {
|
|
90
|
+
deleteSearchIndex.run(refTable, refId);
|
|
91
|
+
}
|
|
92
|
+
// --- vector embeddings (lightweight local semantic search) ---
|
|
93
|
+
const upsertEmbed = db.prepare(`INSERT INTO embeddings (ref_table, ref_id, vec) VALUES (?, ?, ?)
|
|
94
|
+
ON CONFLICT(ref_table, ref_id) DO UPDATE SET vec = excluded.vec`);
|
|
95
|
+
const deleteEmbed = db.prepare("DELETE FROM embeddings WHERE ref_table = ? AND ref_id = ?");
|
|
96
|
+
const allEmbeds = db.prepare("SELECT ref_table, ref_id, vec FROM embeddings");
|
|
97
|
+
export function upsertEmbedding(refTable, refId, vec) {
|
|
98
|
+
upsertEmbed.run(refTable, refId, serialize(vec));
|
|
99
|
+
}
|
|
100
|
+
export function removeEmbedding(refTable, refId) {
|
|
101
|
+
deleteEmbed.run(refTable, refId);
|
|
102
|
+
}
|
|
103
|
+
export function getAllEmbeddings() {
|
|
104
|
+
return allEmbeds.all();
|
|
105
|
+
}
|
|
106
|
+
export function ok(text) {
|
|
107
|
+
return { content: [{ type: "text", text }] };
|
|
108
|
+
}
|
|
109
|
+
export function err(text) {
|
|
110
|
+
return ok(`error: ${truncate(text, 300)}`);
|
|
111
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { embed, serialize } from "../lib/embed.js";
|
|
2
|
+
const M001_schema_meta = {
|
|
3
|
+
id: "001_schema_meta",
|
|
4
|
+
up(db) {
|
|
5
|
+
db.exec(`CREATE TABLE IF NOT EXISTS schema_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);`);
|
|
6
|
+
const existing = db
|
|
7
|
+
.prepare("SELECT value FROM schema_meta WHERE key = 'schema_version'")
|
|
8
|
+
.get();
|
|
9
|
+
if (!existing) {
|
|
10
|
+
db.prepare("INSERT INTO schema_meta (key, value) VALUES ('schema_version', '2')").run();
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
const M002_memories = {
|
|
15
|
+
id: "002_memories",
|
|
16
|
+
up(db) {
|
|
17
|
+
db.exec(`CREATE TABLE IF NOT EXISTS memories (
|
|
18
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
19
|
+
type TEXT NOT NULL,
|
|
20
|
+
content TEXT NOT NULL,
|
|
21
|
+
summary TEXT,
|
|
22
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
23
|
+
source TEXT NOT NULL DEFAULT 'explicit',
|
|
24
|
+
confidence REAL NOT NULL DEFAULT 0.5,
|
|
25
|
+
importance REAL NOT NULL DEFAULT 0.5,
|
|
26
|
+
salience REAL NOT NULL DEFAULT 0.5,
|
|
27
|
+
project_id TEXT,
|
|
28
|
+
session_id TEXT,
|
|
29
|
+
created_at TEXT NOT NULL,
|
|
30
|
+
updated_at TEXT NOT NULL,
|
|
31
|
+
last_accessed_at TEXT,
|
|
32
|
+
access_count INTEGER NOT NULL DEFAULT 0,
|
|
33
|
+
valid_from TEXT,
|
|
34
|
+
valid_until TEXT,
|
|
35
|
+
supersedes_id INTEGER,
|
|
36
|
+
metadata TEXT,
|
|
37
|
+
FOREIGN KEY (supersedes_id) REFERENCES memories(id)
|
|
38
|
+
);`);
|
|
39
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_memories_type_status ON memories(type, status);`);
|
|
40
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_memories_project_status ON memories(project_id, status);`);
|
|
41
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_memories_updated ON memories(updated_at);`);
|
|
42
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_memories_validity ON memories(valid_from, valid_until);`);
|
|
43
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_memories_supersedes ON memories(supersedes_id);`);
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
const M003_entities_relations = {
|
|
47
|
+
id: "003_entities_relations",
|
|
48
|
+
up(db) {
|
|
49
|
+
db.exec(`CREATE TABLE IF NOT EXISTS entities (
|
|
50
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
51
|
+
name TEXT NOT NULL,
|
|
52
|
+
canonical_name TEXT NOT NULL,
|
|
53
|
+
type TEXT,
|
|
54
|
+
metadata TEXT
|
|
55
|
+
);`);
|
|
56
|
+
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_entities_canonical ON entities(canonical_name);`);
|
|
57
|
+
db.exec(`CREATE TABLE IF NOT EXISTS relations (
|
|
58
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
59
|
+
source_entity_id INTEGER NOT NULL,
|
|
60
|
+
relation TEXT NOT NULL,
|
|
61
|
+
target_entity_id INTEGER NOT NULL,
|
|
62
|
+
confidence REAL NOT NULL DEFAULT 0.5,
|
|
63
|
+
valid_from TEXT,
|
|
64
|
+
valid_until TEXT,
|
|
65
|
+
source_memory_id INTEGER,
|
|
66
|
+
metadata TEXT,
|
|
67
|
+
FOREIGN KEY (source_entity_id) REFERENCES entities(id),
|
|
68
|
+
FOREIGN KEY (target_entity_id) REFERENCES entities(id),
|
|
69
|
+
FOREIGN KEY (source_memory_id) REFERENCES memories(id)
|
|
70
|
+
);`);
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
const M004_memory_links = {
|
|
74
|
+
id: "004_memory_links",
|
|
75
|
+
up(db) {
|
|
76
|
+
db.exec(`CREATE TABLE IF NOT EXISTS memory_links (
|
|
77
|
+
source_memory_id INTEGER NOT NULL,
|
|
78
|
+
relation TEXT NOT NULL,
|
|
79
|
+
target_memory_id INTEGER NOT NULL,
|
|
80
|
+
confidence REAL NOT NULL DEFAULT 0.5,
|
|
81
|
+
created_at TEXT NOT NULL,
|
|
82
|
+
PRIMARY KEY (source_memory_id, relation, target_memory_id),
|
|
83
|
+
FOREIGN KEY (source_memory_id) REFERENCES memories(id),
|
|
84
|
+
FOREIGN KEY (target_memory_id) REFERENCES memories(id)
|
|
85
|
+
);`);
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
const M005_backfill_v1 = {
|
|
89
|
+
id: "005_backfill_v1",
|
|
90
|
+
up(db) {
|
|
91
|
+
const done = db
|
|
92
|
+
.prepare("SELECT value FROM schema_meta WHERE key = 'v1_backfilled'")
|
|
93
|
+
.get();
|
|
94
|
+
if (done)
|
|
95
|
+
return;
|
|
96
|
+
const memCount = db.prepare("SELECT COUNT(*) AS c FROM memories").get().c;
|
|
97
|
+
if (memCount > 0) {
|
|
98
|
+
db.prepare("INSERT INTO schema_meta (key, value) VALUES ('v1_backfilled', '1') ON CONFLICT(key) DO UPDATE SET value = '1'").run();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const now = new Date().toISOString();
|
|
102
|
+
const insertSearch = db.prepare("INSERT INTO search_index (ref_table, ref_id, title, body) VALUES ('memories', ?, ?, ?)");
|
|
103
|
+
const insertEmbed = db.prepare("INSERT INTO embeddings (ref_table, ref_id, vec) VALUES ('memories', ?, ?) ON CONFLICT(ref_table, ref_id) DO UPDATE SET vec = excluded.vec");
|
|
104
|
+
const insPref = db.prepare(`INSERT INTO memories (type, content, status, source, confidence, project_id, created_at, updated_at, metadata)
|
|
105
|
+
VALUES ('PREFERENCE', @content, 'active', @source, @confidence, NULL, @ts, @ts, NULL)`);
|
|
106
|
+
const insLes = db.prepare(`INSERT INTO memories (type, content, status, source, confidence, project_id, created_at, updated_at, metadata)
|
|
107
|
+
VALUES ('LESSON', @content, 'active', 'corrected', 0.95, NULL, @ts, @ts, NULL)`);
|
|
108
|
+
const tx = db.transaction(() => {
|
|
109
|
+
const prefs = db
|
|
110
|
+
.prepare("SELECT category, key, value, source, confidence, updated_at FROM preferences")
|
|
111
|
+
.all();
|
|
112
|
+
for (const p of prefs) {
|
|
113
|
+
const content = `[${p.category}] ${p.key} = ${p.value}`;
|
|
114
|
+
const info = insPref.run({
|
|
115
|
+
content,
|
|
116
|
+
source: p.source ?? "explicit",
|
|
117
|
+
confidence: p.confidence ?? 0.5,
|
|
118
|
+
ts: p.updated_at ?? now,
|
|
119
|
+
});
|
|
120
|
+
const id = Number(info.lastInsertRowid);
|
|
121
|
+
insertSearch.run(id, p.category, content);
|
|
122
|
+
insertEmbed.run(id, serialize(embed(content)));
|
|
123
|
+
}
|
|
124
|
+
const lessons = db
|
|
125
|
+
.prepare("SELECT situation, mistake, correction, created_at FROM lessons")
|
|
126
|
+
.all();
|
|
127
|
+
for (const l of lessons) {
|
|
128
|
+
const content = `Situation: ${l.situation} | Mistake: ${l.mistake} | Correction: ${l.correction}`;
|
|
129
|
+
const info = insLes.run({ content, ts: l.created_at ?? now });
|
|
130
|
+
const id = Number(info.lastInsertRowid);
|
|
131
|
+
insertSearch.run(id, "LESSON", content);
|
|
132
|
+
insertEmbed.run(id, serialize(embed(content)));
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
tx();
|
|
136
|
+
db.prepare("INSERT INTO schema_meta (key, value) VALUES ('v1_backfilled', '1') ON CONFLICT(key) DO UPDATE SET value = '1'").run();
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
export const MIGRATIONS = [
|
|
140
|
+
M001_schema_meta,
|
|
141
|
+
M002_memories,
|
|
142
|
+
M003_entities_relations,
|
|
143
|
+
M004_memory_links,
|
|
144
|
+
M005_backfill_v1,
|
|
145
|
+
];
|
|
146
|
+
export function runMigrations(db) {
|
|
147
|
+
db.exec(`CREATE TABLE IF NOT EXISTS schema_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);`);
|
|
148
|
+
for (const m of MIGRATIONS) {
|
|
149
|
+
const applied = db
|
|
150
|
+
.prepare("SELECT value FROM schema_meta WHERE key = ?")
|
|
151
|
+
.get(m.id);
|
|
152
|
+
if (applied)
|
|
153
|
+
continue;
|
|
154
|
+
const tx = db.transaction(() => {
|
|
155
|
+
m.up(db);
|
|
156
|
+
db.prepare("INSERT INTO schema_meta (key, value) VALUES (?, '1') ON CONFLICT(key) DO UPDATE SET value = '1'").run(m.id);
|
|
157
|
+
});
|
|
158
|
+
tx();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { db, syncSearchIndex, removeSearchIndex, upsertEmbedding, removeEmbedding, nowISO, } from "../../db/index.js";
|
|
2
|
+
import { embed } from "../../lib/embed.js";
|
|
3
|
+
import { retrieve } from "../../core/retrieval-engine.js";
|
|
4
|
+
export function createMemory(input) {
|
|
5
|
+
const ts = nowISO();
|
|
6
|
+
const info = db
|
|
7
|
+
.prepare(`INSERT INTO memories
|
|
8
|
+
(type, content, summary, status, source, confidence, importance, salience,
|
|
9
|
+
project_id, session_id, created_at, updated_at, last_accessed_at, access_count,
|
|
10
|
+
valid_from, valid_until, metadata)
|
|
11
|
+
VALUES (@type, @content, @summary, @status, @source, @confidence, @importance, @salience,
|
|
12
|
+
@projectId, @sessionId, @ts, @ts, NULL, 0, @validFrom, @validUntil, @metadata)`)
|
|
13
|
+
.run({
|
|
14
|
+
type: input.type,
|
|
15
|
+
content: input.content,
|
|
16
|
+
summary: input.summary ?? null,
|
|
17
|
+
status: input.status ?? "active",
|
|
18
|
+
source: input.source ?? "explicit",
|
|
19
|
+
confidence: input.confidence ?? 0.5,
|
|
20
|
+
importance: input.importance ?? 0.5,
|
|
21
|
+
salience: input.salience ?? 0.5,
|
|
22
|
+
projectId: input.projectId ?? null,
|
|
23
|
+
sessionId: input.sessionId ?? null,
|
|
24
|
+
ts,
|
|
25
|
+
validFrom: input.validFrom ?? null,
|
|
26
|
+
validUntil: input.validUntil ?? null,
|
|
27
|
+
metadata: input.metadata != null ? JSON.stringify(input.metadata) : null,
|
|
28
|
+
});
|
|
29
|
+
const id = Number(info.lastInsertRowid);
|
|
30
|
+
syncMemoryIndex(id, input.type, input.content);
|
|
31
|
+
return id;
|
|
32
|
+
}
|
|
33
|
+
export function syncMemoryIndex(id, title, body) {
|
|
34
|
+
syncSearchIndex("memories", id, title, body);
|
|
35
|
+
upsertEmbedding("memories", id, embed(body));
|
|
36
|
+
}
|
|
37
|
+
export function getMemoryById(id) {
|
|
38
|
+
return db.prepare("SELECT * FROM memories WHERE id = ?").get(id);
|
|
39
|
+
}
|
|
40
|
+
export function setStatus(id, status) {
|
|
41
|
+
db.prepare("UPDATE memories SET status = ?, updated_at = ? WHERE id = ?").run(status, nowISO(), id);
|
|
42
|
+
}
|
|
43
|
+
export function softDelete(id) {
|
|
44
|
+
setStatus(id, "deleted");
|
|
45
|
+
}
|
|
46
|
+
export function removeMemoryIndex(id) {
|
|
47
|
+
removeSearchIndex("memories", id);
|
|
48
|
+
removeEmbedding("memories", id);
|
|
49
|
+
}
|
|
50
|
+
export function searchMemories(query, opts = {}) {
|
|
51
|
+
return retrieve(query, opts);
|
|
52
|
+
}
|
package/dist/db.js
CHANGED
|
@@ -4,6 +4,7 @@ import { dirname } from "node:path";
|
|
|
4
4
|
import { truncate } from "./lib/capture-core.js";
|
|
5
5
|
import { serialize } from "./lib/embed.js";
|
|
6
6
|
import { DEFAULT_DB_PATH } from "./lib/config.js";
|
|
7
|
+
import { runMigrations } from "./migrations.js";
|
|
7
8
|
export const DB_PATH = process.env.MEMORY_DB_PATH ?? DEFAULT_DB_PATH;
|
|
8
9
|
function initDb() {
|
|
9
10
|
try {
|
|
@@ -65,6 +66,8 @@ CREATE TABLE IF NOT EXISTS embeddings (
|
|
|
65
66
|
PRIMARY KEY (ref_table, ref_id)
|
|
66
67
|
);
|
|
67
68
|
`);
|
|
69
|
+
// v2 migration engine (non-destructive: only adds new tables + backfills from v1)
|
|
70
|
+
runMigrations(db);
|
|
68
71
|
export function nowISO() {
|
|
69
72
|
return new Date().toISOString();
|
|
70
73
|
}
|
package/dist/index.js
CHANGED
|
@@ -10,6 +10,8 @@ import { forgetInput, forgetHandler } from "./tools/forget.js";
|
|
|
10
10
|
import { memoryStatsHandler } from "./tools/memory_stats.js";
|
|
11
11
|
import { recentInteractionsInput, getRecentInteractionsHandler, } from "./tools/recent_interactions.js";
|
|
12
12
|
import { exportMemoryInput, exportMemoryHandler, } from "./tools/export_memory.js";
|
|
13
|
+
import { contextInput, contextHandler } from "./tools/context.js";
|
|
14
|
+
import { consolidateInput, consolidateHandler } from "./tools/consolidate.js";
|
|
13
15
|
import { VERSION } from "./lib/config.js";
|
|
14
16
|
const server = new McpServer({
|
|
15
17
|
name: "th-memory-mcp",
|
|
@@ -60,6 +62,16 @@ server.registerTool("export_memory", {
|
|
|
60
62
|
description: "Export preferences, lessons, profile (and optionally raw interactions) to a JSON file under data/exports/. Only writes inside that directory. Returns the file path, size in bytes and a JSON preview.",
|
|
61
63
|
inputSchema: exportMemoryInput,
|
|
62
64
|
}, (args) => exportMemoryHandler(args));
|
|
65
|
+
server.registerTool("get_context", {
|
|
66
|
+
title: "Get assembled context",
|
|
67
|
+
description: "Assemble relevant memories for the current task via hybrid retrieval (+ optional memory-graph expansion), with token budgeting. Use to load memory into context before a task.",
|
|
68
|
+
inputSchema: contextInput,
|
|
69
|
+
}, (args) => contextHandler(args));
|
|
70
|
+
server.registerTool("consolidate", {
|
|
71
|
+
title: "Consolidate memories",
|
|
72
|
+
description: "Cluster similar memories via embedding similarity and optionally create derived/consolidated memories linked via 'derived_from'. Use during periodic consolidation.",
|
|
73
|
+
inputSchema: consolidateInput,
|
|
74
|
+
}, (args) => consolidateHandler(args));
|
|
63
75
|
async function main() {
|
|
64
76
|
const transport = new StdioServerTransport();
|
|
65
77
|
await server.connect(transport);
|
package/dist/lib/embed.js
CHANGED
|
@@ -53,13 +53,16 @@ export function cosine(a, b) {
|
|
|
53
53
|
return dot;
|
|
54
54
|
}
|
|
55
55
|
export function serialize(vec) {
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
const buf = Buffer.alloc(EMBED_DIM * 4);
|
|
57
|
+
const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
58
|
+
for (let i = 0; i < EMBED_DIM; i++)
|
|
59
|
+
dv.setFloat32(i * 4, vec[i] ?? 0, true);
|
|
60
|
+
return buf;
|
|
59
61
|
}
|
|
60
62
|
export function deserialize(buf) {
|
|
61
63
|
const out = new Float32Array(EMBED_DIM);
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
+
const view = new DataView(buf.buffer, buf.byteOffset, Math.min(EMBED_DIM * 4, buf.byteLength));
|
|
65
|
+
for (let i = 0; i < EMBED_DIM; i++)
|
|
66
|
+
out[i] = view.getFloat32(i * 4, true);
|
|
64
67
|
return out;
|
|
65
68
|
}
|