th-memory-mcp 2.2.8 → 2.2.9
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/ARCHITECTURE_v2.md +1 -1
- package/README.md +3 -3
- package/README.th.md +1 -1
- package/design.md +1 -1
- package/dist/db/migrations.js +17 -12
- package/dist/index.js +1 -1
- package/dist/lib/config.js +3 -1
- package/dist/lib/iso.js +11 -0
- package/dist/retrieval/fts.js +3 -8
- package/dist/tools/export_memory.js +43 -0
- package/dist/tools/forget.js +25 -4
- package/dist/tools/import_memory.js +228 -29
- package/dist/tools/update_memory.js +29 -3
- package/package.json +2 -2
- package/dist/db.js +0 -111
package/ARCHITECTURE_v2.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# th-memory-mcp v2 — Architecture & Implementation Specification
|
|
2
2
|
|
|
3
3
|
**Status:** ✅ Released — `th-memory-mcp v2.0.0` is published (npm + Official MCP Registry + Glama).
|
|
4
|
-
**Baseline:** v1.2.2 → **Current:** v2.2.
|
|
4
|
+
**Baseline:** v1.2.2 → **Current:** v2.2.9
|
|
5
5
|
**Primary goal:** evolve th-memory-mcp from a structured local memory MCP into a durable, temporal, conflict-aware, hybrid-retrieval memory engine for AI agents.
|
|
6
6
|
|
|
7
7
|
> **Audience guide:** End users should read [README.md](README.md) (install, tools, usage). This document is the **canonical architecture & agent-rules spec** for developers and AI coding agents — the single source of truth for structure and behavior. The former `design.md` build log has been folded into §40 Implementation Status.
|
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
[](https://glama.ai/mcp/servers/worakorn-prince/th-memory-mcp)
|
|
12
12
|
|
|
13
13
|
|
|
14
|
-
**Status:** v2.2.
|
|
14
|
+
**Status:** v2.2.9 — a temporal, conflict-aware, hybrid-retrieval memory engine. 16 MCP tools, 25 passing test suites. Non-destructive schema migration from v1 (all v1 data preserved). New in v2.2: lifecycle states, temporal validity, conflict/dedup resolution with USER/SESSION/PROJECT/GLOBAL scope, hybrid FTS+vector retrieval (RRF), memory graph, `get_context` assembly, periodic consolidation, and `link_memory` / `merge_memory` / `update_memory` / `import_memory` / `extract_memories`. New in v2.2.3: scope-enforced retrieval, graph scope isolation, export/import round-trip, hardened import path (realpath), strict import validation, N+1 query elimination, cold/ablation benchmark, and `MEMORY_RETRIEVAL_MODE` switch. New in v2.2.7: synced secret filter between Claude hook and capture-core (6-pattern redact instead of line-drop), fixed `err()` to return `isError:true` per MCP spec, fixed backup rotation (backup only when migrations pending + prune to 5 files), and added hook error logging for SessionEnd distill. New in v2.2.8: fixed scope contamination 0.75→0 (critical) and conflict false 0→1 (GLOBAL leak), fixed graph hop1 0.52→1.0 via includeGraph, and rescaled benchmark profiles to 5K/20K/100K/500K/1M (pre-commit now quick 5K + normal 20K only). New in v2.2.9: extended export to entities/users/relations, forget now removes associated links, fail-closed migration with shared ISO helper, eliminated FTS N+1, included smoke in test script, and synced viewer to rescaled profiles 5K/20K/100K/500K/1M.
|
|
15
15
|
|
|
16
16
|
## Requirements
|
|
17
17
|
|
|
@@ -255,9 +255,9 @@ npm run benchmark:viewer
|
|
|
255
255
|
# open http://localhost:3000/benchmark/viewer/ or http://localhost:3000/result/viewer.html
|
|
256
256
|
```
|
|
257
257
|
|
|
258
|
-
The viewer loads `benchmark/results/history.jsonl`, groups by version, takes the **latest run of the 3 most recent versions** (e.g. 2.2.
|
|
258
|
+
The viewer loads `benchmark/results/history.jsonl`, groups by version, takes the **latest run of the 3 most recent versions** (e.g. 2.2.6 / 2.2.7 / 2.2.8) and shows a highlighted table (1 row per version) + bar charts for `Recall@5 / MRR / NDCG@5` and `Latency p95`. Results are also saved per version in `result/v*_benchmark_result.md` and `benchmark/results/versions/<ver>/`.
|
|
259
259
|
|
|
260
|
-
Last internal run (v2.2.
|
|
260
|
+
Last internal run (v2.2.8, warm, normal profile, a2dcbce — not third-party): Recall@5=0.9060, Precision@5=0.9060, MRR=1, NDCG@5=0.9347 over 100 topics/600 records. See `result/v2.2.8_benchmark_result.md` and `repro/README.md` for details and caveats (internal, single-machine self-run).
|
|
261
261
|
|
|
262
262
|
## Known Limitations
|
|
263
263
|
|
package/README.th.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
MCP server ความจำระยะยาวสำหรับ OpenCode — เก็บ preferences, lessons, ประวัติการใช้งาน ลง SQLite ไฟล์เดียว (local 100%, ไม่มี external API) เพื่อให้ AI "จำและปรับตัว" กับผู้ใช้ผ่าน context-based learning
|
|
9
9
|
|
|
10
|
-
**สถานะ:** v2.2.
|
|
10
|
+
**สถานะ:** v2.2.9 — engine ความจำแบบ temporal, conflict-aware, hybrid-retrieval 16 MCP tools, 25 ชุดเทสผ่าน อัปเกรด schema แบบ non-destructive จาก v1 (ข้อมูล v1 ทั้งหมดถูกเก็บรักษา) ฟีเจอร์ใหม่ใน v2.2: lifecycle states, temporal validity, การแก้ conflict/dedup พร้อม scope USER/SESSION/PROJECT/GLOBAL, hybrid FTS+vector retrieval (RRF), memory graph, ประกอบ `get_context`, consolidation, และ `link_memory` / `merge_memory` / `update_memory` / `import_memory` / `extract_memories` ใหม่ใน v2.2.3: บังคับ scope ใน retrieval, แยก scope ใน graph, export/import แบบ round-trip, ป้องกัน symlink ด้วย realpath, ตรวจ import เข้มงวด, เลิก N+1 query, benchmark cold/ablation และสวิตช์ `MEMORY_RETRIEVAL_MODE` ใหม่ใน v2.2.7: ซิงค์ secret filter ระหว่าง Claude hook กับ capture-core (redact 6 patterns แทนทิ้งบรรทัด), แก้ `err()` ให้ส่ง `isError:true` ตาม MCP spec, แก้ backup rotation (backup เฉพาะเมื่อมี migration pending + prune เก็บ 5 ไฟล์ล่าสุด) และเพิ่ม hook error logging สำหรับ SessionEnd distill ใหม่ใน v2.2.8: แก้บัค scope contamination 0.75→0 (ร้ายแรง), แก้ conflict false 0→1, ปรับ benchmark rescale 5K/20K/100K/500K/1M และ viewer เทียบ profiles ภายใน version, workflow ก่อน commit เหลือ 2 profiles (quick 5K + normal 20K) ใหม่ใน v2.2.9: ขยาย export ให้ครอบคลุม entities/users/relations, forget ลบลิงก์ที่เกี่ยวข้อง, migration แบบ fail-closed พร้อม ISO helper กลาง, เลิก N+1 ใน FTS, รวม smoke ในสคริปต์เทส และซิงค์ viewer กับโปรไฟล์ rescale 5K/20K/100K/500K/1M
|
|
11
11
|
|
|
12
12
|
> English: [README.md](README.md)
|
|
13
13
|
|
package/design.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
This document is up-to-date with the actual codebase state (after completing all future feature plans except AI-assisted extraction, which was removed). The full specification is at `ARCHITECTURE_v2.md` (canonical spec); this file summarizes the overview and current status for convenience.
|
|
4
4
|
|
|
5
5
|
## Current Status
|
|
6
|
-
- **Version:** `package.json` = `2.2.
|
|
6
|
+
- **Version:** `package.json` = `2.2.9`
|
|
7
7
|
- **MCP tools:** 16 tools (`remember`, `recall`, `get_context`, `link_memory`, `merge_memory`, `update_memory`, `import_memory`, `extract_memories`, `consolidate`, `forget`, `history`, `recent_interactions`, `profile`, `lesson`, `memory_stats`, `export_memory`)
|
|
8
8
|
- **Test suites:** 25 suites passing (0 fail) — run via `npm test` (CI on GitHub Actions)
|
|
9
9
|
|
package/dist/db/migrations.js
CHANGED
|
@@ -209,21 +209,26 @@ export function runMigrations(db) {
|
|
|
209
209
|
const shouldBackup = process.env.MEMORY_BACKUP_ON_MIGRATE !== "0" &&
|
|
210
210
|
process.env.MEMORY_BACKUP_ON_MIGRATE !== "false";
|
|
211
211
|
if (shouldBackup) {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
212
|
+
const dbPath = db.name;
|
|
213
|
+
if (dbPath && dbPath !== ":memory:") {
|
|
214
|
+
try {
|
|
215
|
+
// A plain copy of the main file is not a valid snapshot while recent
|
|
216
|
+
// commits are still in the WAL. Checkpoint first, then fail closed.
|
|
217
|
+
db.pragma("wal_checkpoint(TRUNCATE)");
|
|
218
|
+
// NOTE: better-sqlite3 exposes .backup(dest) but it is async
|
|
219
|
+
// (Promise-based) while runMigrations is sync and runs inside the
|
|
220
|
+
// DB singleton init path. Awaiting it would require making the whole
|
|
221
|
+
// migration/startup chain async and risks a half-applied state, so we
|
|
222
|
+
// keep the synchronous copyFileSync snapshot here on purpose.
|
|
223
|
+
// No new dependency added.
|
|
224
|
+
copyFileSync(dbPath, `${dbPath}.backup-${Date.now()}`);
|
|
223
225
|
pruneOldBackups(dbPath);
|
|
224
226
|
}
|
|
227
|
+
catch (e) {
|
|
228
|
+
const reason = e instanceof Error ? e.message : String(e);
|
|
229
|
+
throw new Error(`migration backup failed for ${dbPath}: ${reason}`);
|
|
230
|
+
}
|
|
225
231
|
}
|
|
226
|
-
catch { }
|
|
227
232
|
}
|
|
228
233
|
for (const m of pending) {
|
|
229
234
|
const tx = db.transaction(() => {
|
package/dist/index.js
CHANGED
|
@@ -89,7 +89,7 @@ server.registerTool("merge_memory", {
|
|
|
89
89
|
}, (args) => mergeMemoryHandler(args));
|
|
90
90
|
server.registerTool("update_memory", {
|
|
91
91
|
title: "Update a memory",
|
|
92
|
-
description: "Update mutable fields (summary/importance/confidence/valid_until/metadata) in place. If content changes, a superseding memory is created by default (set supersede=false to edit in place).",
|
|
92
|
+
description: "Update mutable fields (summary/importance/confidence/valid_from/valid_until/metadata) in place. If content changes, a superseding memory is created by default (set supersede=false to edit in place).",
|
|
93
93
|
inputSchema: updateMemoryInput,
|
|
94
94
|
}, (args) => updateMemoryHandler(args));
|
|
95
95
|
server.registerTool("import_memory", {
|
package/dist/lib/config.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// config: shared constants. Pure module — no side effects, no I/O.
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
|
-
|
|
3
|
+
// Keep this in sync with package.json. It is surfaced in the MCP handshake
|
|
4
|
+
// and in export files, so a stale value makes backups harder to diagnose.
|
|
5
|
+
export const VERSION = "2.2.9";
|
|
4
6
|
// dist/lib/config.js -> <project>/data/memory.db (independent of cwd).
|
|
5
7
|
export const DEFAULT_DB_PATH = fileURLToPath(new URL("../../data/memory.db", import.meta.url));
|
|
6
8
|
export const EXPORTS_DIRNAME = "exports";
|
package/dist/lib/iso.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
// Single source of truth for strict ISO datetime validation.
|
|
3
|
+
// Requires a full datetime with timezone offset (e.g. 2024-01-01T00:00:00.000Z).
|
|
4
|
+
// Date-only strings like "2024-01-01" are intentionally rejected: they are
|
|
5
|
+
// ambiguous (no timezone) and break validFrom<=validUntil comparisons.
|
|
6
|
+
export const isoDateTimeSchema = z.string().datetime({ offset: true });
|
|
7
|
+
export function isIsoDateString(s) {
|
|
8
|
+
if (typeof s !== "string")
|
|
9
|
+
return false;
|
|
10
|
+
return isoDateTimeSchema.safeParse(s).success;
|
|
11
|
+
}
|
package/dist/retrieval/fts.js
CHANGED
|
@@ -6,7 +6,7 @@ export function ftsSearch(query, opts = {}) {
|
|
|
6
6
|
? ""
|
|
7
7
|
: "AND m.status NOT IN ('deleted','archived','superseded')";
|
|
8
8
|
const rows = db
|
|
9
|
-
.prepare(`SELECT m.id FROM memories m
|
|
9
|
+
.prepare(`SELECT m.id, m.scope, m.project_id, m.session_id, m.user_id FROM memories m
|
|
10
10
|
JOIN search_index ON search_index.ref_table = 'memories' AND search_index.ref_id = m.id
|
|
11
11
|
WHERE search_index MATCH @match ${statusClause}
|
|
12
12
|
ORDER BY rank
|
|
@@ -15,16 +15,11 @@ export function ftsSearch(query, opts = {}) {
|
|
|
15
15
|
match: buildFtsMatch(query),
|
|
16
16
|
limit,
|
|
17
17
|
});
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
.prepare("SELECT scope, project_id, session_id, user_id FROM memories WHERE id = ?")
|
|
21
|
-
.get(id);
|
|
22
|
-
if (!m)
|
|
23
|
-
return false;
|
|
18
|
+
const uid = opts.userId == null ? null : resolveUserId(opts.userId);
|
|
19
|
+
const filtered = rows.filter((m) => {
|
|
24
20
|
if (m.scope === "USER") {
|
|
25
21
|
if (opts.userId == null)
|
|
26
22
|
return false;
|
|
27
|
-
const uid = resolveUserId(opts.userId);
|
|
28
23
|
return m.user_id === uid;
|
|
29
24
|
}
|
|
30
25
|
if (m.scope === "SESSION") {
|
|
@@ -21,6 +21,36 @@ const selectPrefs = db.prepare("SELECT id, category, key, value, confidence, sou
|
|
|
21
21
|
const selectLessons = db.prepare("SELECT id, situation, mistake, correction, created_at FROM lessons ORDER BY id");
|
|
22
22
|
const selectProfile = db.prepare("SELECT section, content, updated_at FROM profile ORDER BY section");
|
|
23
23
|
const selectInteractions = db.prepare("SELECT id, ts, session_id, kind, content, meta FROM interactions ORDER BY id");
|
|
24
|
+
const selectMemories = db.prepare(`
|
|
25
|
+
SELECT m.id, m.type, m.content, m.summary, m.status, m.source,
|
|
26
|
+
m.confidence, m.importance, m.salience, m.project_id AS projectId,
|
|
27
|
+
m.session_id AS sessionId, u.external_id AS userId,
|
|
28
|
+
m.valid_from AS validFrom, m.valid_until AS validUntil, m.metadata
|
|
29
|
+
FROM memories m
|
|
30
|
+
LEFT JOIN users u ON u.id = m.user_id
|
|
31
|
+
ORDER BY m.id
|
|
32
|
+
`);
|
|
33
|
+
const selectMemoryLinks = db.prepare("SELECT source_memory_id AS sourceId, relation, target_memory_id AS targetId, confidence, created_at AS createdAt FROM memory_links ORDER BY source_memory_id, target_memory_id");
|
|
34
|
+
// Batch A gap-close: export users/entities/relations so backup/restore is complete.
|
|
35
|
+
// FORMAT DECISION: keep `th-memory-mcp/v2` (do NOT bump to v3) and add the three
|
|
36
|
+
// new top-level fields as additive/optional. Rationale: existing consumers and
|
|
37
|
+
// test/export_v2.test.mjs assert format === v2 with a strict equality check; a v3
|
|
38
|
+
// bump would break them and any downstream parser that allow-lists v2. Old v2
|
|
39
|
+
// files simply lack these keys and import treats missing as empty (backward compat).
|
|
40
|
+
const selectUsers = db.prepare("SELECT id, external_id AS externalId, name, created_at AS createdAt FROM users ORDER BY id");
|
|
41
|
+
const selectEntities = db.prepare("SELECT id, name, canonical_name AS canonicalName, type, metadata FROM entities ORDER BY id");
|
|
42
|
+
const selectRelations = db.prepare("SELECT id, source_entity_id AS sourceEntityId, relation, target_entity_id AS targetEntityId, confidence, valid_from AS validFrom, valid_until AS validUntil, source_memory_id AS sourceMemoryId, metadata FROM relations ORDER BY id");
|
|
43
|
+
function parseMetadata(raw) {
|
|
44
|
+
if (raw == null)
|
|
45
|
+
return null;
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(raw);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// Preserve malformed legacy values rather than making an export fail.
|
|
51
|
+
return raw;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
24
54
|
function timestamp(d = new Date()) {
|
|
25
55
|
const p = (n) => String(n).padStart(2, "0");
|
|
26
56
|
return (`${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}` +
|
|
@@ -65,9 +95,22 @@ export async function exportMemoryHandler(args) {
|
|
|
65
95
|
const interactionsIncluded = includeInteractions
|
|
66
96
|
? selectInteractions.all()
|
|
67
97
|
: undefined;
|
|
98
|
+
const memories = selectMemories.all()
|
|
99
|
+
.map((m) => ({ ...m, metadata: parseMetadata(m.metadata) }));
|
|
100
|
+
const users = selectUsers.all();
|
|
101
|
+
const entities = selectEntities.all().map((e) => ({ ...e, metadata: parseMetadata(e.metadata) }));
|
|
102
|
+
const relations = selectRelations.all().map((r) => ({ ...r, metadata: parseMetadata(r.metadata) }));
|
|
68
103
|
const payload = {
|
|
69
104
|
exported_at: nowISO(),
|
|
70
105
|
version: VERSION,
|
|
106
|
+
format: "th-memory-mcp/v2",
|
|
107
|
+
memories,
|
|
108
|
+
memoryLinks: selectMemoryLinks.all(),
|
|
109
|
+
// Additive v2 fields (optional for backward compat): full backup of
|
|
110
|
+
// M007 users + M003 entities/relations. Old importers ignore unknown keys.
|
|
111
|
+
users,
|
|
112
|
+
entities,
|
|
113
|
+
relations,
|
|
71
114
|
preferences: selectPrefs.all(),
|
|
72
115
|
lessons: selectLessons.all(),
|
|
73
116
|
profile: selectProfile.all(),
|
package/dist/tools/forget.js
CHANGED
|
@@ -7,7 +7,7 @@ export const forgetInput = {
|
|
|
7
7
|
.positive()
|
|
8
8
|
.describe("Row id to delete (id returned by remember/save_lesson)"),
|
|
9
9
|
type: z
|
|
10
|
-
.enum(["preference", "lesson", "interaction"])
|
|
10
|
+
.enum(["memory", "preference", "lesson", "interaction"])
|
|
11
11
|
.optional()
|
|
12
12
|
.describe("Which table the id belongs to. Recommended whenever known, because numeric ids can coincide across tables."),
|
|
13
13
|
};
|
|
@@ -15,10 +15,14 @@ const indexTables = db.prepare("SELECT DISTINCT ref_table FROM search_index WHER
|
|
|
15
15
|
const existsPreference = db.prepare("SELECT id FROM preferences WHERE id = ?");
|
|
16
16
|
const existsLesson = db.prepare("SELECT id FROM lessons WHERE id = ?");
|
|
17
17
|
const existsInteraction = db.prepare("SELECT id FROM interactions WHERE id = ?");
|
|
18
|
+
const existsMemory = db.prepare("SELECT id FROM memories WHERE id = ? AND status <> 'deleted'");
|
|
18
19
|
const delPreference = db.prepare("DELETE FROM preferences WHERE id = ?");
|
|
19
20
|
const delLesson = db.prepare("DELETE FROM lessons WHERE id = ?");
|
|
20
21
|
const delInteraction = db.prepare("DELETE FROM interactions WHERE id = ?");
|
|
22
|
+
const delMemoryLinks = db.prepare("DELETE FROM memory_links WHERE source_memory_id = ? OR target_memory_id = ?");
|
|
21
23
|
function existsIn(kind, id) {
|
|
24
|
+
if (kind === "memories")
|
|
25
|
+
return !!existsMemory.get(id);
|
|
22
26
|
if (kind === "preferences") {
|
|
23
27
|
return !!existsPreference.get(id);
|
|
24
28
|
}
|
|
@@ -31,6 +35,7 @@ export async function forgetHandler(args) {
|
|
|
31
35
|
try {
|
|
32
36
|
const id = args.target_id;
|
|
33
37
|
const kindOf = {
|
|
38
|
+
memory: "memories",
|
|
34
39
|
preference: "preferences",
|
|
35
40
|
lesson: "lessons",
|
|
36
41
|
interaction: "interactions",
|
|
@@ -44,21 +49,37 @@ export async function forgetHandler(args) {
|
|
|
44
49
|
const indexed = indexTables.all(id);
|
|
45
50
|
const evidence = indexed
|
|
46
51
|
.map((r) => r.ref_table)
|
|
47
|
-
.filter((t) => t === "preferences" || t === "lessons" || t === "interactions");
|
|
52
|
+
.filter((t) => t === "memories" || t === "preferences" || t === "lessons" || t === "interactions");
|
|
48
53
|
const candidates = evidence.length > 0
|
|
49
54
|
? evidence
|
|
50
|
-
: ["preferences", "lessons", "interactions"];
|
|
55
|
+
: ["memories", "preferences", "lessons", "interactions"];
|
|
51
56
|
targets = candidates.filter((k) => existsIn(k, id));
|
|
52
57
|
}
|
|
53
58
|
if (targets.length === 0) {
|
|
54
59
|
return ok(`nothing found with id=${id}`);
|
|
55
60
|
}
|
|
56
61
|
const removed = [];
|
|
62
|
+
let linksRemoved = 0;
|
|
57
63
|
db.transaction(() => {
|
|
58
64
|
for (const kind of targets) {
|
|
59
65
|
if (!existsIn(kind, id))
|
|
60
66
|
continue;
|
|
61
|
-
if (kind === "
|
|
67
|
+
if (kind === "memories") {
|
|
68
|
+
db.prepare("UPDATE memories SET status = 'deleted' WHERE id = ?").run(id);
|
|
69
|
+
removeSearchIndex("memories", id);
|
|
70
|
+
removeEmbedding("memories", id);
|
|
71
|
+
// Batch A: cascade-delete memory_links referencing this id on BOTH sides
|
|
72
|
+
// in the same transaction so forget never leaves dangling graph edges.
|
|
73
|
+
// NOTE: entities/relations are intentionally NOT deleted here — they are
|
|
74
|
+
// shared graph nodes that may still be referenced by other memories;
|
|
75
|
+
// deleting them would corrupt the graph. Orphaned rows stay harmless
|
|
76
|
+
// and can be reclaimed by consolidate/GC later.
|
|
77
|
+
const info = delMemoryLinks.run(id, id);
|
|
78
|
+
const n = Number(info.changes ?? 0);
|
|
79
|
+
linksRemoved += n;
|
|
80
|
+
removed.push(`memory #${id} (${n} link(s) removed)`);
|
|
81
|
+
}
|
|
82
|
+
else if (kind === "preferences") {
|
|
62
83
|
delPreference.run(id);
|
|
63
84
|
removeSearchIndex("preferences", id);
|
|
64
85
|
removeEmbedding("preferences", id);
|
|
@@ -4,8 +4,9 @@ import { join, dirname, resolve, sep } from "node:path";
|
|
|
4
4
|
import { db, DB_PATH, nowISO, syncSearchIndex, upsertEmbedding, ok, err, } from "../db/index.js";
|
|
5
5
|
import { createMemory } from "../db/repositories/memories.js";
|
|
6
6
|
import { deduplicate } from "../memory/deduplicator.js";
|
|
7
|
-
import { MEMORY_TYPES, SOURCE_TYPES } from "../memory/types.js";
|
|
7
|
+
import { MEMORY_TYPES, SOURCE_TYPES, LIFECYCLE_STATES, LINK_RELATIONS } from "../memory/types.js";
|
|
8
8
|
import { EXPORTS_DIRNAME } from "../lib/config.js";
|
|
9
|
+
import { isIsoDateString } from "../lib/iso.js";
|
|
9
10
|
import { embed } from "../lib/embed.js";
|
|
10
11
|
export const importMemoryInput = {
|
|
11
12
|
file: z
|
|
@@ -26,20 +27,6 @@ export const importMemoryInput = {
|
|
|
26
27
|
.optional()
|
|
27
28
|
.describe("Scope imported memories to a user (USER scope)"),
|
|
28
29
|
};
|
|
29
|
-
function isIsoDateString(s) {
|
|
30
|
-
if (typeof s !== "string")
|
|
31
|
-
return false;
|
|
32
|
-
const d = Date.parse(s);
|
|
33
|
-
if (Number.isNaN(d))
|
|
34
|
-
return false;
|
|
35
|
-
try {
|
|
36
|
-
const iso = new Date(s).toISOString();
|
|
37
|
-
return iso.length >= 10;
|
|
38
|
-
}
|
|
39
|
-
catch {
|
|
40
|
-
return false;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
30
|
function isValidSource(s) {
|
|
44
31
|
return SOURCE_TYPES.includes(s);
|
|
45
32
|
}
|
|
@@ -86,19 +73,15 @@ export function importMemoryHandler(args) {
|
|
|
86
73
|
let exportPrefs = null;
|
|
87
74
|
let exportLessons = null;
|
|
88
75
|
let exportProfile = null;
|
|
76
|
+
let exportLinks = null;
|
|
77
|
+
// Batch A: additive backup fields (may be absent in legacy v2 files -> null = backward compat).
|
|
78
|
+
let exportUsers = null;
|
|
79
|
+
let exportEntities = null;
|
|
80
|
+
let exportRelations = null;
|
|
89
81
|
if (Array.isArray(parsed)) {
|
|
90
82
|
memoryItems = parsed;
|
|
91
83
|
}
|
|
92
|
-
else if (parsed &&
|
|
93
|
-
typeof parsed === "object" &&
|
|
94
|
-
Array.isArray(parsed.memories)) {
|
|
95
|
-
memoryItems = parsed.memories;
|
|
96
|
-
}
|
|
97
|
-
else if (parsed &&
|
|
98
|
-
typeof parsed === "object" &&
|
|
99
|
-
("preferences" in parsed ||
|
|
100
|
-
"lessons" in parsed ||
|
|
101
|
-
"profile" in parsed)) {
|
|
84
|
+
else if (parsed && typeof parsed === "object") {
|
|
102
85
|
const p = parsed;
|
|
103
86
|
if (Array.isArray(p.preferences))
|
|
104
87
|
exportPrefs = p.preferences;
|
|
@@ -108,15 +91,58 @@ export function importMemoryHandler(args) {
|
|
|
108
91
|
exportProfile = p.profile;
|
|
109
92
|
if (Array.isArray(p.memories))
|
|
110
93
|
memoryItems = p.memories;
|
|
94
|
+
if (Array.isArray(p.memoryLinks))
|
|
95
|
+
exportLinks = p.memoryLinks;
|
|
96
|
+
// Accept both new keys and legacy absence (missing => null => skip, still pass).
|
|
97
|
+
if (Array.isArray(p.users))
|
|
98
|
+
exportUsers = p.users;
|
|
99
|
+
if (Array.isArray(p.entities))
|
|
100
|
+
exportEntities = p.entities;
|
|
101
|
+
if (Array.isArray(p.relations))
|
|
102
|
+
exportRelations = p.relations;
|
|
111
103
|
}
|
|
112
104
|
else {
|
|
113
105
|
memoryItems = [];
|
|
114
106
|
}
|
|
107
|
+
// Restore users FIRST (before memories) so createMemory()->ensureUser reuses
|
|
108
|
+
// the restored row (with original name/created_at) instead of auto-creating
|
|
109
|
+
// a bare row. Idempotent via UNIQUE(external_id) + INSERT OR IGNORE.
|
|
110
|
+
let usersImported = 0;
|
|
111
|
+
let usersInvalid = 0;
|
|
112
|
+
if (exportUsers != null) {
|
|
113
|
+
const insertUser = db.prepare("INSERT OR IGNORE INTO users (external_id, name, created_at) VALUES (?, ?, ?)");
|
|
114
|
+
for (const u of exportUsers) {
|
|
115
|
+
const external = typeof u.externalId === "string"
|
|
116
|
+
? u.externalId
|
|
117
|
+
: typeof u.external_id === "string"
|
|
118
|
+
? u.external_id
|
|
119
|
+
: null;
|
|
120
|
+
if (!u ||
|
|
121
|
+
!external ||
|
|
122
|
+
external.length === 0 ||
|
|
123
|
+
external.length > 200 ||
|
|
124
|
+
/[\x00-\x1f]/.test(external)) {
|
|
125
|
+
usersInvalid++;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const created = (typeof u.createdAt === "string" && isIsoDateString(u.createdAt)
|
|
129
|
+
? u.createdAt
|
|
130
|
+
: typeof u.created_at === "string" && isIsoDateString(u.created_at)
|
|
131
|
+
? u.created_at
|
|
132
|
+
: null) ?? nowISO();
|
|
133
|
+
const name = typeof u.name === "string" ? u.name.slice(0, 500) : null;
|
|
134
|
+
usersImported++;
|
|
135
|
+
if (args.apply === true) {
|
|
136
|
+
insertUser.run(external, name, created);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
115
140
|
let wouldImport = 0;
|
|
116
141
|
let skipped = 0;
|
|
117
142
|
let invalid = 0;
|
|
118
143
|
const log = [];
|
|
119
|
-
|
|
144
|
+
const importedIds = new Map();
|
|
145
|
+
for (const [idx, it] of memoryItems.entries()) {
|
|
120
146
|
if (!it ||
|
|
121
147
|
typeof it.content !== "string" ||
|
|
122
148
|
it.content.length === 0 ||
|
|
@@ -141,6 +167,14 @@ export function importMemoryHandler(args) {
|
|
|
141
167
|
invalid++;
|
|
142
168
|
continue;
|
|
143
169
|
}
|
|
170
|
+
if (it.salience != null && (typeof it.salience !== "number" || it.salience < 0 || it.salience > 1)) {
|
|
171
|
+
invalid++;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (it.status != null && (typeof it.status !== "string" || !LIFECYCLE_STATES.includes(it.status))) {
|
|
175
|
+
invalid++;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
144
178
|
if (it.projectId != null && (typeof it.projectId !== "string" || it.projectId.length === 0 || it.projectId.length > 200)) {
|
|
145
179
|
invalid++;
|
|
146
180
|
continue;
|
|
@@ -155,10 +189,19 @@ export function importMemoryHandler(args) {
|
|
|
155
189
|
}
|
|
156
190
|
if (it.validFrom != null && (typeof it.validFrom !== "string" || !isIsoDateString(it.validFrom))) {
|
|
157
191
|
invalid++;
|
|
192
|
+
log.push(`invalid row ${idx}: validFrom '${String(it.validFrom)}' rejected — must be a full ISO datetime with timezone offset (e.g. 2024-01-01T00:00:00.000Z), date-only '2024-01-01' is not accepted`);
|
|
158
193
|
continue;
|
|
159
194
|
}
|
|
160
195
|
if (it.validUntil != null && (typeof it.validUntil !== "string" || !isIsoDateString(it.validUntil))) {
|
|
161
196
|
invalid++;
|
|
197
|
+
log.push(`invalid row ${idx}: validUntil '${String(it.validUntil)}' rejected — must be a full ISO datetime with timezone offset (e.g. 2024-01-01T00:00:00.000Z), date-only '2024-01-01' is not accepted`);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (typeof it.validFrom === "string" &&
|
|
201
|
+
typeof it.validUntil === "string" &&
|
|
202
|
+
new Date(it.validFrom) > new Date(it.validUntil)) {
|
|
203
|
+
invalid++;
|
|
204
|
+
log.push(`invalid row ${idx}: validFrom (${it.validFrom}) must not be later than validUntil (${it.validUntil})`);
|
|
162
205
|
continue;
|
|
163
206
|
}
|
|
164
207
|
const dup = deduplicate(it.type, it.content);
|
|
@@ -169,13 +212,14 @@ export function importMemoryHandler(args) {
|
|
|
169
212
|
}
|
|
170
213
|
wouldImport++;
|
|
171
214
|
if (args.apply === true) {
|
|
172
|
-
createMemory({
|
|
215
|
+
const newId = createMemory({
|
|
173
216
|
type: it.type,
|
|
174
217
|
content: it.content,
|
|
175
218
|
summary: typeof it.summary === "string" ? it.summary : null,
|
|
176
219
|
source: it.source ?? "imported",
|
|
177
220
|
confidence: typeof it.confidence === "number" ? it.confidence : 0.7,
|
|
178
221
|
importance: typeof it.importance === "number" ? it.importance : 0.5,
|
|
222
|
+
salience: typeof it.salience === "number" ? it.salience : 0.5,
|
|
179
223
|
projectId: typeof it.projectId === "string" ? it.projectId : null,
|
|
180
224
|
sessionId: typeof it.sessionId === "string" ? it.sessionId : null,
|
|
181
225
|
userId: typeof it.userId === "string"
|
|
@@ -187,8 +231,163 @@ export function importMemoryHandler(args) {
|
|
|
187
231
|
validUntil: typeof it.validUntil === "string" ? it.validUntil : null,
|
|
188
232
|
metadata: it.metadata ?? null,
|
|
189
233
|
});
|
|
234
|
+
if (typeof it.id === "number" && Number.isInteger(it.id))
|
|
235
|
+
importedIds.set(it.id, newId);
|
|
236
|
+
if (typeof it.status === "string" && it.status !== "active") {
|
|
237
|
+
db.prepare("UPDATE memories SET status = ? WHERE id = ?").run(it.status, newId);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
let linksImported = 0;
|
|
242
|
+
let linksInvalid = 0;
|
|
243
|
+
if (exportLinks != null) {
|
|
244
|
+
for (const link of exportLinks) {
|
|
245
|
+
const sourceId = typeof link.sourceId === "number" ? importedIds.get(link.sourceId) : undefined;
|
|
246
|
+
const targetId = typeof link.targetId === "number" ? importedIds.get(link.targetId) : undefined;
|
|
247
|
+
if (!sourceId || !targetId || typeof link.relation !== "string" || !LINK_RELATIONS.includes(link.relation) ||
|
|
248
|
+
(link.confidence != null && (typeof link.confidence !== "number" || link.confidence < 0 || link.confidence > 1))) {
|
|
249
|
+
linksInvalid++;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (args.apply === true) {
|
|
253
|
+
db.prepare("INSERT OR IGNORE INTO memory_links (source_memory_id, relation, target_memory_id, confidence, created_at) VALUES (?, ?, ?, ?, ?)").run(sourceId, link.relation, targetId, link.confidence ?? 0.5, nowISO());
|
|
254
|
+
}
|
|
255
|
+
linksImported++;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
// Batch A: restore M003 entities/relations idempotently.
|
|
259
|
+
// - entities: dedupe by UNIQUE(canonical_name); keep oldId->newId map for relations.
|
|
260
|
+
// - relations: `relation` is free-form (e.g. co_occurs) so do NOT check LINK_RELATIONS
|
|
261
|
+
// here (that check stays only for memory_links above). Keep ISO validation as-is;
|
|
262
|
+
// another team will unify validation later. Missing arrays (legacy v2) => skip, still pass.
|
|
263
|
+
let entitiesImported = 0;
|
|
264
|
+
let entitiesInvalid = 0;
|
|
265
|
+
const importedEntityIds = new Map();
|
|
266
|
+
if (exportEntities != null) {
|
|
267
|
+
const selEntity = db.prepare("SELECT id FROM entities WHERE canonical_name = ?");
|
|
268
|
+
const insEntity = db.prepare("INSERT INTO entities (name, canonical_name, type, metadata) VALUES (?, ?, ?, ?)");
|
|
269
|
+
for (const e of exportEntities) {
|
|
270
|
+
const name = typeof e.name === "string" ? e.name : "";
|
|
271
|
+
const canonical = typeof e.canonicalName === "string"
|
|
272
|
+
? e.canonicalName
|
|
273
|
+
: typeof e.canonical_name === "string"
|
|
274
|
+
? e.canonical_name
|
|
275
|
+
: "";
|
|
276
|
+
if (!e || name.length === 0 || name.length > 500 || canonical.length === 0 || canonical.length > 500) {
|
|
277
|
+
entitiesInvalid++;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
entitiesImported++;
|
|
281
|
+
if (args.apply === true) {
|
|
282
|
+
const existing = selEntity.get(canonical);
|
|
283
|
+
let newId;
|
|
284
|
+
if (existing) {
|
|
285
|
+
newId = existing.id;
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
const type = typeof e.type === "string" ? e.type.slice(0, 200) : "concept";
|
|
289
|
+
const meta = e.metadata == null
|
|
290
|
+
? JSON.stringify({})
|
|
291
|
+
: typeof e.metadata === "string"
|
|
292
|
+
? e.metadata
|
|
293
|
+
: JSON.stringify(e.metadata);
|
|
294
|
+
const res = insEntity.run(name.slice(0, 500), canonical, type, meta);
|
|
295
|
+
newId = Number(res.lastInsertRowid);
|
|
296
|
+
}
|
|
297
|
+
if (typeof e.id === "number" && Number.isInteger(e.id))
|
|
298
|
+
importedEntityIds.set(e.id, newId);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
let relationsImported = 0;
|
|
303
|
+
let relationsInvalid = 0;
|
|
304
|
+
if (exportRelations != null) {
|
|
305
|
+
const selEntityById = db.prepare("SELECT id FROM entities WHERE id = ?");
|
|
306
|
+
const selMemById = db.prepare("SELECT id FROM memories WHERE id = ?");
|
|
307
|
+
const selRelation = db.prepare("SELECT id FROM relations WHERE source_entity_id = ? AND relation = ? AND target_entity_id = ? AND COALESCE(source_memory_id, -1) = COALESCE(?, -1)");
|
|
308
|
+
const insRelation = db.prepare("INSERT INTO relations (source_entity_id, relation, target_entity_id, confidence, valid_from, valid_until, source_memory_id, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
|
|
309
|
+
for (const r of exportRelations) {
|
|
310
|
+
const oldSource = typeof r.sourceEntityId === "number"
|
|
311
|
+
? r.sourceEntityId
|
|
312
|
+
: typeof r.source_entity_id === "number"
|
|
313
|
+
? r.source_entity_id
|
|
314
|
+
: null;
|
|
315
|
+
const oldTarget = typeof r.targetEntityId === "number"
|
|
316
|
+
? r.targetEntityId
|
|
317
|
+
: typeof r.target_entity_id === "number"
|
|
318
|
+
? r.target_entity_id
|
|
319
|
+
: null;
|
|
320
|
+
const predicate = typeof r.relation === "string" ? r.relation : "";
|
|
321
|
+
const conf = r.confidence == null ? 0.5 : r.confidence;
|
|
322
|
+
const vf = typeof r.validFrom === "string"
|
|
323
|
+
? r.validFrom
|
|
324
|
+
: typeof r.valid_from === "string"
|
|
325
|
+
? r.valid_from
|
|
326
|
+
: null;
|
|
327
|
+
const vu = typeof r.validUntil === "string"
|
|
328
|
+
? r.validUntil
|
|
329
|
+
: typeof r.valid_until === "string"
|
|
330
|
+
? r.valid_until
|
|
331
|
+
: null;
|
|
332
|
+
const oldMem = typeof r.sourceMemoryId === "number"
|
|
333
|
+
? r.sourceMemoryId
|
|
334
|
+
: typeof r.source_memory_id === "number"
|
|
335
|
+
? r.source_memory_id
|
|
336
|
+
: null;
|
|
337
|
+
if (!r ||
|
|
338
|
+
oldSource == null ||
|
|
339
|
+
oldTarget == null ||
|
|
340
|
+
predicate.length === 0 ||
|
|
341
|
+
predicate.length > 200 ||
|
|
342
|
+
typeof conf !== "number" ||
|
|
343
|
+
conf < 0 ||
|
|
344
|
+
conf > 1 ||
|
|
345
|
+
(vf != null && !isIsoDateString(vf)) ||
|
|
346
|
+
(vu != null && !isIsoDateString(vu)) ||
|
|
347
|
+
(vf != null && vu != null && new Date(vf) > new Date(vu))) {
|
|
348
|
+
relationsInvalid++;
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
// Remap entity ids: prefer import map, else keep original id if row still exists.
|
|
352
|
+
let newSource = null;
|
|
353
|
+
let newTarget = null;
|
|
354
|
+
if (importedEntityIds.has(oldSource))
|
|
355
|
+
newSource = importedEntityIds.get(oldSource);
|
|
356
|
+
else if (selEntityById.get(oldSource))
|
|
357
|
+
newSource = oldSource;
|
|
358
|
+
if (importedEntityIds.has(oldTarget))
|
|
359
|
+
newTarget = importedEntityIds.get(oldTarget);
|
|
360
|
+
else if (selEntityById.get(oldTarget))
|
|
361
|
+
newTarget = oldTarget;
|
|
362
|
+
if (newSource == null || newTarget == null) {
|
|
363
|
+
relationsInvalid++;
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
// Remap memory id: prefer import map, else keep original if still present, else NULL (orphan-safe).
|
|
367
|
+
let newMem = null;
|
|
368
|
+
if (oldMem == null)
|
|
369
|
+
newMem = null;
|
|
370
|
+
else if (importedIds.has(oldMem))
|
|
371
|
+
newMem = importedIds.get(oldMem);
|
|
372
|
+
else if (selMemById.get(oldMem))
|
|
373
|
+
newMem = oldMem;
|
|
374
|
+
else
|
|
375
|
+
newMem = null;
|
|
376
|
+
relationsImported++;
|
|
377
|
+
if (args.apply === true) {
|
|
378
|
+
const dup = selRelation.get(newSource, predicate, newTarget, newMem);
|
|
379
|
+
if (dup)
|
|
380
|
+
continue;
|
|
381
|
+
const meta = r.metadata == null
|
|
382
|
+
? JSON.stringify({})
|
|
383
|
+
: typeof r.metadata === "string"
|
|
384
|
+
? r.metadata
|
|
385
|
+
: JSON.stringify(r.metadata);
|
|
386
|
+
insRelation.run(newSource, predicate, newTarget, conf, vf, vu, newMem, meta);
|
|
387
|
+
}
|
|
190
388
|
}
|
|
191
389
|
}
|
|
390
|
+
const graphSuffix = `users ${usersImported} ok ${usersInvalid} invalid; entities ${entitiesImported} ok ${entitiesInvalid} invalid; relations ${relationsImported} ok ${relationsInvalid} invalid`;
|
|
192
391
|
if (exportPrefs != null || exportLessons != null || exportProfile != null) {
|
|
193
392
|
const prefArr = Array.isArray(exportPrefs) ? exportPrefs : [];
|
|
194
393
|
const lessonArr = Array.isArray(exportLessons) ? exportLessons : [];
|
|
@@ -268,11 +467,11 @@ export function importMemoryHandler(args) {
|
|
|
268
467
|
}
|
|
269
468
|
}
|
|
270
469
|
const mode = args.apply === true ? "applied" : "dry-run";
|
|
271
|
-
const summary = `import ${mode}: ${wouldImport} memories to import, ${skipped} duplicate(s) skipped, ${invalid} invalid memories; preferences ${prefOk} ok ${prefInvalid} invalid; lessons ${lessonOk} ok ${lessonInvalid} invalid; profile ${profileOk} ok`;
|
|
470
|
+
const summary = `import ${mode}: ${wouldImport} memories to import, ${skipped} duplicate(s) skipped, ${invalid} invalid memories; ${linksImported} link(s) imported, ${linksInvalid} invalid link(s); preferences ${prefOk} ok ${prefInvalid} invalid; lessons ${lessonOk} ok ${lessonInvalid} invalid; profile ${profileOk} ok; ${graphSuffix}`;
|
|
272
471
|
return ok(summary);
|
|
273
472
|
}
|
|
274
473
|
const mode = args.apply === true ? "applied" : "dry-run";
|
|
275
|
-
const summary = `import ${mode}: ${wouldImport} to import, ${skipped} duplicate(s) skipped, ${invalid} invalid`;
|
|
474
|
+
const summary = `import ${mode}: ${wouldImport} to import, ${skipped} duplicate(s) skipped, ${invalid} invalid, ${linksImported} link(s) imported, ${linksInvalid} invalid link(s); ${graphSuffix}`;
|
|
276
475
|
return ok(log.length ? `${summary}\n${log.join("\n")}` : summary);
|
|
277
476
|
}
|
|
278
477
|
catch (e) {
|
|
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import { db, nowISO, ok, err, } from "../db/index.js";
|
|
3
3
|
import { getMemoryById, createMemory, syncMemoryIndex, } from "../db/repositories/memories.js";
|
|
4
4
|
import { supersede } from "../core/lifecycle-engine.js";
|
|
5
|
+
import { isoDateTimeSchema, isIsoDateString } from "../lib/iso.js";
|
|
5
6
|
export const updateMemoryInput = {
|
|
6
7
|
id: z.number().int().describe("Memory id to update"),
|
|
7
8
|
content: z
|
|
@@ -12,9 +13,12 @@ export const updateMemoryInput = {
|
|
|
12
13
|
summary: z.string().max(2000).nullable().optional().describe("New summary"),
|
|
13
14
|
importance: z.number().min(0).max(1).optional(),
|
|
14
15
|
confidence: z.number().min(0).max(1).optional(),
|
|
16
|
+
validFrom: z
|
|
17
|
+
.union([isoDateTimeSchema, z.null()])
|
|
18
|
+
.optional()
|
|
19
|
+
.describe("ISO timestamp or null to clear"),
|
|
15
20
|
validUntil: z
|
|
16
|
-
.
|
|
17
|
-
.nullable()
|
|
21
|
+
.union([isoDateTimeSchema, z.null()])
|
|
18
22
|
.optional()
|
|
19
23
|
.describe("ISO timestamp or null to clear"),
|
|
20
24
|
metadata: z.unknown().optional().describe("New metadata object (replaces)"),
|
|
@@ -30,6 +34,24 @@ export function updateMemoryHandler(args) {
|
|
|
30
34
|
return err(`memory ${args.id} not found`);
|
|
31
35
|
if (mem.status === "deleted")
|
|
32
36
|
return err("cannot update a deleted memory");
|
|
37
|
+
if (args.validFrom !== undefined &&
|
|
38
|
+
args.validFrom !== null &&
|
|
39
|
+
!isIsoDateString(args.validFrom)) {
|
|
40
|
+
return err(`validFrom must be a full ISO datetime with timezone offset (e.g. 2024-01-01T00:00:00.000Z), got '${args.validFrom}'`);
|
|
41
|
+
}
|
|
42
|
+
if (args.validUntil !== undefined &&
|
|
43
|
+
args.validUntil !== null &&
|
|
44
|
+
!isIsoDateString(args.validUntil)) {
|
|
45
|
+
return err(`validUntil must be a full ISO datetime with timezone offset (e.g. 2024-01-01T00:00:00.000Z), got '${args.validUntil}'`);
|
|
46
|
+
}
|
|
47
|
+
// Effective range covers new values and values mixed with the existing row.
|
|
48
|
+
const effectiveValidFrom = args.validFrom !== undefined ? args.validFrom : mem.valid_from;
|
|
49
|
+
const effectiveValidUntil = args.validUntil !== undefined ? args.validUntil : mem.valid_until;
|
|
50
|
+
if (effectiveValidFrom != null &&
|
|
51
|
+
effectiveValidUntil != null &&
|
|
52
|
+
new Date(effectiveValidFrom) > new Date(effectiveValidUntil)) {
|
|
53
|
+
return err(`validFrom (${effectiveValidFrom}) must not be later than validUntil (${effectiveValidUntil})`);
|
|
54
|
+
}
|
|
33
55
|
const supersedeContent = args.content !== undefined && (args.supersede ?? true);
|
|
34
56
|
if (supersedeContent) {
|
|
35
57
|
const externalId = mem.user_id
|
|
@@ -48,7 +70,7 @@ export function updateMemoryHandler(args) {
|
|
|
48
70
|
projectId: mem.project_id,
|
|
49
71
|
sessionId: mem.session_id,
|
|
50
72
|
userId: externalId,
|
|
51
|
-
validFrom: mem.valid_from,
|
|
73
|
+
validFrom: args.validFrom !== undefined ? args.validFrom : mem.valid_from,
|
|
52
74
|
validUntil: args.validUntil !== undefined ? args.validUntil : mem.valid_until,
|
|
53
75
|
metadata: args.metadata !== undefined
|
|
54
76
|
? args.metadata
|
|
@@ -73,6 +95,10 @@ export function updateMemoryHandler(args) {
|
|
|
73
95
|
sets.push("confidence = ?");
|
|
74
96
|
vals.push(args.confidence);
|
|
75
97
|
}
|
|
98
|
+
if (args.validFrom !== undefined) {
|
|
99
|
+
sets.push("valid_from = ?");
|
|
100
|
+
vals.push(args.validFrom);
|
|
101
|
+
}
|
|
76
102
|
if (args.validUntil !== undefined) {
|
|
77
103
|
sets.push("valid_until = ?");
|
|
78
104
|
vals.push(args.validUntil);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "th-memory-mcp",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.9",
|
|
4
4
|
"mcpName": "io.github.worakorn-prince/th-memory-mcp",
|
|
5
5
|
"description": "Adaptive Memory MCP server - SQLite-backed memory for OpenCode",
|
|
6
6
|
"author": "worakorn-prince",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"version:check": "node scripts/sync-version.mjs --check",
|
|
32
32
|
"start": "node dist/index.js",
|
|
33
33
|
"inspect": "npx @modelcontextprotocol/inspector node dist/index.js",
|
|
34
|
-
"test": "npm run build && node --test test/*.test.mjs",
|
|
34
|
+
"test": "npm run build && node --test test/*.test.mjs test/smoke.mjs",
|
|
35
35
|
"distill": "node dist/distill.js",
|
|
36
36
|
"quickstart": "npm run build && node scripts/quickstart.mjs",
|
|
37
37
|
"benchmark": "node repro/run.mjs",
|
package/dist/db.js
DELETED
|
@@ -1,111 +0,0 @@
|
|
|
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(" ");
|
|
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
|
-
}
|