th-memory-mcp 2.2.2 → 2.2.3
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 +3 -2
- package/README.th.md +1 -1
- package/design.md +3 -2
- package/dist/core/consolidation-engine.js +15 -14
- package/dist/core/context-engine.js +41 -2
- package/dist/core/retrieval-engine.js +44 -10
- package/dist/retrieval/fts.js +28 -7
- package/dist/retrieval/vector.js +28 -18
- package/dist/tools/import_memory.js +189 -13
- package/dist/tools/link_memory.js +10 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/th-memory-mcp)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
[](https://nodejs.org)
|
|
7
|
-
|
|
7
|
+
[](https://glama.ai/mcp/servers/worakorn-prince/th-memory-mcp)
|
|
8
8
|
Long-term memory MCP server for OpenCode — stores preferences, lessons, and usage history in a single local SQLite file (100% local, no external API) so the AI can "remember and adapt" to the user through context-based learning.
|
|
9
|
+
[](https://mcpservers.org/servers/worakorn-prince/th-memory-mcp)
|
|
9
10
|
|
|
10
|
-
**Status:** v2.2.
|
|
11
|
+
**Status:** v2.2.3 — 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.
|
|
11
12
|
|
|
12
13
|
## Requirements
|
|
13
14
|
|
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.3 — 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`
|
|
11
11
|
|
|
12
12
|
> English: [README.md](README.md)
|
|
13
13
|
|
package/design.md
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
สเปคฉบับเต็มอยู่ที่ `ARCHITECTURE_v2.md` (canonical spec) ไฟล์นี้สรุปภาพรวมและสถานะปัจจุบันเพื่อความสะดวก
|
|
5
5
|
|
|
6
6
|
## สถานะปัจจุบัน
|
|
7
|
-
- **เวอร์ชัน:** `package.json` = `2.2.
|
|
7
|
+
- **เวอร์ชัน:** `package.json` = `2.2.3`
|
|
8
8
|
- **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`)
|
|
9
|
-
- **ชุดเทสต์:**
|
|
9
|
+
- **ชุดเทสต์:** 25 suites ผ่านหมด (0 fail) — รันผ่าน `npm test` (มี CI บน GitHub Actions)
|
|
10
10
|
|
|
11
11
|
## องค์ประกอบหลัก (src/)
|
|
12
12
|
- `db/` — better-sqlite3 (WAL mode), migrations เชิงเส้น (M001–M007), repositories (`memories`, `users`, `preferences`, `lessons`)
|
|
@@ -57,3 +57,4 @@
|
|
|
57
57
|
- v2.0.0 ปล่อยแล้ว (npm, GitHub Release, Official MCP Registry, Glama)
|
|
58
58
|
- v2.2.0 — ปล่อยครบ: tag + GitHub Release, **npm publish เรียบร้อย**, Official MCP Registry ดึงจาก npm อัตโนมัติ (ไม่ต้องรัน mcp-publisher แยก), Glama Sync เรียบร้อย
|
|
59
59
|
- v2.2.1 — แก้ Glama quality: เพิ่ม `pnpm.onlyBuiltDependencies: ["better-sqlite3"]` ให้ pnpm รันสคริปต์ดาวน์โหลด native binary สำหรับ Node 24, ขยับ better-sqlite3 เป็น `^12.9.0`, เพิ่ม override `ip-address@^10.2.0` (npm+pnpm) อุดช่องโหว่ XSS ผ่าน MCP SDK ไม่มีการเปลี่ยนโค้ด รอ `npm publish` + Glama ทดสอบใหม่
|
|
60
|
+
- v2.2.3 — ชุดแก้ความปลอดภัย + ประสิทธิภาพตาม `report_checkup.md`: บังคับ scope filtering (ไม่คืน foreign USER/SESSION), กรอง graph ไม่ข้าม scope, ห้าม link ข้าม scope, export/import แบบ round-trip, ใช้ `realpath` กัน symlink, ตรวจ import เข้มงวด (enum/0..1/ISO), เลิก N+1 query (vector JOIN, bulk fetch, cap consolidation), benchmark cold/ablation และสวิตช์ `MEMORY_RETRIEVAL_MODE`, เพิ่ม 5 เทสต์ใหม่เป็น 25/25, benchmark รันครบทุก suite
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { db
|
|
1
|
+
import { db } from "../db/index.js";
|
|
2
2
|
import { cosine, deserialize } from "../lib/embed.js";
|
|
3
3
|
import { createMemory } from "../db/repositories/memories.js";
|
|
4
4
|
import { linkMemories } from "./graph-engine.js";
|
|
@@ -6,23 +6,24 @@ import { linkMemories } from "./graph-engine.js";
|
|
|
6
6
|
export function clusterMemories(opts = {}) {
|
|
7
7
|
const threshold = opts.threshold ?? 0.7;
|
|
8
8
|
const minSize = opts.minClusterSize ?? 2;
|
|
9
|
-
const rows =
|
|
9
|
+
const rows = db
|
|
10
|
+
.prepare(`SELECT e.ref_id as ref_id, e.vec as vec, m.status, m.project_id, m.scope, m.session_id, m.user_id
|
|
11
|
+
FROM embeddings e
|
|
12
|
+
JOIN memories m ON m.id = e.ref_id
|
|
13
|
+
WHERE e.ref_table = 'memories'
|
|
14
|
+
AND ( ? OR m.status NOT IN ('deleted','archived','superseded'))`)
|
|
15
|
+
.all(opts.includeArchived ? 1 : 0);
|
|
10
16
|
const valid = rows.filter((r) => {
|
|
11
|
-
|
|
12
|
-
.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
return false;
|
|
16
|
-
if (!opts.includeArchived &&
|
|
17
|
-
(m.status === "deleted" || m.status === "archived"))
|
|
18
|
-
return false;
|
|
19
|
-
if (opts.projectId &&
|
|
20
|
-
!(m.project_id === opts.projectId || m.project_id === null))
|
|
21
|
-
return false;
|
|
17
|
+
if (r.scope === "USER" || r.scope === "SESSION" || r.scope === "PROJECT") {
|
|
18
|
+
if (opts.projectId && r.project_id !== opts.projectId)
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
22
21
|
return true;
|
|
23
22
|
});
|
|
23
|
+
const CAP = 2000;
|
|
24
|
+
const sliced = valid.slice(0, CAP);
|
|
24
25
|
const vecs = new Map();
|
|
25
|
-
for (const r of
|
|
26
|
+
for (const r of sliced)
|
|
26
27
|
vecs.set(r.ref_id, deserialize(r.vec));
|
|
27
28
|
const ids = [...vecs.keys()];
|
|
28
29
|
const parent = new Map();
|
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
import { db } from "../db/index.js";
|
|
2
2
|
import { retrieve } from "./retrieval-engine.js";
|
|
3
3
|
import { traverse } from "./graph-engine.js";
|
|
4
|
+
import { resolveUserId } from "../db/repositories/users.js";
|
|
5
|
+
function isScopeVisible(m, opts, uid) {
|
|
6
|
+
if (m.scope === "USER") {
|
|
7
|
+
if (opts.userId == null)
|
|
8
|
+
return false;
|
|
9
|
+
return m.user_id === uid;
|
|
10
|
+
}
|
|
11
|
+
if (m.scope === "SESSION") {
|
|
12
|
+
if (opts.sessionId == null)
|
|
13
|
+
return false;
|
|
14
|
+
return m.session_id === opts.sessionId;
|
|
15
|
+
}
|
|
16
|
+
if (m.scope === "PROJECT") {
|
|
17
|
+
if (opts.projectId == null)
|
|
18
|
+
return false;
|
|
19
|
+
return m.project_id === opts.projectId;
|
|
20
|
+
}
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
4
23
|
function isCurrentlyValid(m, now) {
|
|
5
24
|
if (m.valid_from && new Date(m.valid_from) > now)
|
|
6
25
|
return false;
|
|
@@ -26,15 +45,35 @@ export function getContext(opts = {}) {
|
|
|
26
45
|
seedScores.set(s.id, s.final_score);
|
|
27
46
|
const ids = new Set(seeds.map((s) => s.id));
|
|
28
47
|
if (opts.includeGraph) {
|
|
48
|
+
const uid = opts.userId ? resolveUserId(opts.userId) : null;
|
|
29
49
|
for (const s of seeds) {
|
|
30
|
-
for (const n of traverse(s.id, { maxDepth: 1 }))
|
|
50
|
+
for (const n of traverse(s.id, { maxDepth: 1 })) {
|
|
51
|
+
const m = db
|
|
52
|
+
.prepare("SELECT * FROM memories WHERE id = ?")
|
|
53
|
+
.get(n.memoryId);
|
|
54
|
+
if (!m)
|
|
55
|
+
continue;
|
|
56
|
+
if (!opts.includeHistory &&
|
|
57
|
+
(m.status === "deleted" ||
|
|
58
|
+
m.status === "archived" ||
|
|
59
|
+
m.status === "superseded"))
|
|
60
|
+
continue;
|
|
61
|
+
if (!isScopeVisible(m, opts, uid))
|
|
62
|
+
continue;
|
|
63
|
+
if (!opts.includeHistory && !isCurrentlyValid(m, now))
|
|
64
|
+
continue;
|
|
31
65
|
ids.add(n.memoryId);
|
|
66
|
+
}
|
|
32
67
|
}
|
|
33
68
|
}
|
|
69
|
+
const resolvedUid = opts.userId ? resolveUserId(opts.userId) : null;
|
|
34
70
|
const all = [...ids]
|
|
35
71
|
.map((id) => db.prepare("SELECT * FROM memories WHERE id = ?").get(id))
|
|
36
72
|
.filter((m) => !!m)
|
|
37
|
-
.filter((m) => opts.includeHistory || isCurrentlyValid(m, now))
|
|
73
|
+
.filter((m) => opts.includeHistory || isCurrentlyValid(m, now))
|
|
74
|
+
.filter((m) => isScopeVisible(m, opts, resolvedUid))
|
|
75
|
+
.filter((m) => opts.includeHistory ||
|
|
76
|
+
!(m.status === "deleted" || m.status === "archived" || m.status === "superseded"));
|
|
38
77
|
all.sort((a, b) => (seedScores.get(b.id) ?? 0) - (seedScores.get(a.id) ?? 0));
|
|
39
78
|
let used = 0;
|
|
40
79
|
const out = [];
|
|
@@ -8,23 +8,57 @@ import { resolveUserId } from "../db/repositories/users.js";
|
|
|
8
8
|
// Hybrid retrieval pipeline (spec §13): FTS + vector -> RRF fusion -> scoring/rerank -> filter -> topK
|
|
9
9
|
export function retrieve(query, opts = {}) {
|
|
10
10
|
const limit = Math.min(Math.max(opts.limit ?? 10, 1), 50);
|
|
11
|
+
const mode = (process.env.MEMORY_RETRIEVAL_MODE || "rrf").toLowerCase();
|
|
11
12
|
const fts = ftsSearch(query, opts);
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
rank: i + 1
|
|
16
|
-
}
|
|
17
|
-
|
|
13
|
+
let lists = [fts];
|
|
14
|
+
if (mode === "vector-only") {
|
|
15
|
+
const vecRaw = vectorSearch(query, opts);
|
|
16
|
+
lists = [vecRaw.map((r, i) => ({ id: r.id, rank: i + 1 }))];
|
|
17
|
+
}
|
|
18
|
+
else if (mode !== "fts-only") {
|
|
19
|
+
const vecRaw = vectorSearch(query, opts);
|
|
20
|
+
lists = [fts, vecRaw.map((r, i) => ({ id: r.id, rank: i + 1 }))];
|
|
21
|
+
}
|
|
22
|
+
const fused = rrfFuse(lists);
|
|
18
23
|
const now = new Date();
|
|
24
|
+
const resolvedUid = opts.userId ? resolveUserId(opts.userId) : null;
|
|
25
|
+
const visible = (m) => {
|
|
26
|
+
if (m.scope === "USER") {
|
|
27
|
+
if (opts.userId == null)
|
|
28
|
+
return false;
|
|
29
|
+
return m.user_id === resolvedUid;
|
|
30
|
+
}
|
|
31
|
+
if (m.scope === "SESSION") {
|
|
32
|
+
if (opts.sessionId == null)
|
|
33
|
+
return false;
|
|
34
|
+
return m.session_id === opts.sessionId;
|
|
35
|
+
}
|
|
36
|
+
if (m.scope === "PROJECT") {
|
|
37
|
+
if (opts.projectId == null)
|
|
38
|
+
return false;
|
|
39
|
+
return m.project_id === opts.projectId;
|
|
40
|
+
}
|
|
41
|
+
return true;
|
|
42
|
+
};
|
|
43
|
+
const ids = [...fused.keys()];
|
|
44
|
+
if (ids.length === 0)
|
|
45
|
+
return [];
|
|
46
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
47
|
+
const rows = db
|
|
48
|
+
.prepare(`SELECT * FROM memories WHERE id IN (${placeholders})`)
|
|
49
|
+
.all(...ids);
|
|
50
|
+
const byId = new Map(rows.map((r) => [r.id, r]));
|
|
19
51
|
const out = [];
|
|
20
52
|
for (const [id, rrf] of fused.entries()) {
|
|
21
|
-
const mem =
|
|
22
|
-
.prepare("SELECT * FROM memories WHERE id = ?")
|
|
23
|
-
.get(id);
|
|
53
|
+
const mem = byId.get(id);
|
|
24
54
|
if (!mem)
|
|
25
55
|
continue;
|
|
26
56
|
if (!opts.includeArchived &&
|
|
27
|
-
(mem.status === "deleted" ||
|
|
57
|
+
(mem.status === "deleted" ||
|
|
58
|
+
mem.status === "archived" ||
|
|
59
|
+
mem.status === "superseded"))
|
|
60
|
+
continue;
|
|
61
|
+
if (!visible(mem))
|
|
28
62
|
continue;
|
|
29
63
|
const recency = recencyFactorFor(mem.type, mem.updated_at, now);
|
|
30
64
|
const scope = scopeFactorFor(mem, {
|
package/dist/retrieval/fts.js
CHANGED
|
@@ -1,22 +1,43 @@
|
|
|
1
1
|
import { db, buildFtsMatch } from "../db/index.js";
|
|
2
|
+
import { resolveUserId } from "../db/repositories/users.js";
|
|
2
3
|
export function ftsSearch(query, opts = {}) {
|
|
3
4
|
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
|
|
4
5
|
const statusClause = opts.includeArchived
|
|
5
6
|
? ""
|
|
6
|
-
: "AND m.status NOT IN ('deleted','archived')";
|
|
7
|
-
const projectClause = opts.projectId
|
|
8
|
-
? "AND (m.project_id = @projectId OR m.project_id IS NULL)"
|
|
9
|
-
: "";
|
|
7
|
+
: "AND m.status NOT IN ('deleted','archived','superseded')";
|
|
10
8
|
const rows = db
|
|
11
9
|
.prepare(`SELECT m.id FROM memories m
|
|
12
10
|
JOIN search_index ON search_index.ref_table = 'memories' AND search_index.ref_id = m.id
|
|
13
|
-
WHERE search_index MATCH @match ${statusClause}
|
|
11
|
+
WHERE search_index MATCH @match ${statusClause}
|
|
14
12
|
ORDER BY rank
|
|
15
13
|
LIMIT @limit`)
|
|
16
14
|
.all({
|
|
17
15
|
match: buildFtsMatch(query),
|
|
18
16
|
limit,
|
|
19
|
-
projectId: opts.projectId,
|
|
20
17
|
});
|
|
21
|
-
|
|
18
|
+
const filtered = rows.filter(({ id }) => {
|
|
19
|
+
const m = db
|
|
20
|
+
.prepare("SELECT scope, project_id, session_id, user_id FROM memories WHERE id = ?")
|
|
21
|
+
.get(id);
|
|
22
|
+
if (!m)
|
|
23
|
+
return false;
|
|
24
|
+
if (m.scope === "USER") {
|
|
25
|
+
if (opts.userId == null)
|
|
26
|
+
return false;
|
|
27
|
+
const uid = resolveUserId(opts.userId);
|
|
28
|
+
return m.user_id === uid;
|
|
29
|
+
}
|
|
30
|
+
if (m.scope === "SESSION") {
|
|
31
|
+
if (opts.sessionId == null)
|
|
32
|
+
return false;
|
|
33
|
+
return m.session_id === opts.sessionId;
|
|
34
|
+
}
|
|
35
|
+
if (m.scope === "PROJECT") {
|
|
36
|
+
if (opts.projectId == null)
|
|
37
|
+
return false;
|
|
38
|
+
return m.project_id === opts.projectId;
|
|
39
|
+
}
|
|
40
|
+
return true;
|
|
41
|
+
});
|
|
42
|
+
return filtered.map((r, i) => ({ id: r.id, rank: i + 1 }));
|
|
22
43
|
}
|
package/dist/retrieval/vector.js
CHANGED
|
@@ -1,29 +1,39 @@
|
|
|
1
1
|
import { db } from "../db/index.js";
|
|
2
2
|
import { embed, cosine, deserialize } from "../lib/embed.js";
|
|
3
|
+
import { resolveUserId } from "../db/repositories/users.js";
|
|
3
4
|
export function vectorSearch(query, opts = {}) {
|
|
4
5
|
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
|
|
5
6
|
const floor = opts.floor ?? 0.15;
|
|
6
7
|
const topicVec = embed(query);
|
|
7
8
|
const rows = db
|
|
8
|
-
.prepare(
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
return false;
|
|
16
|
-
if (!opts.includeArchived &&
|
|
17
|
-
(m.status === "deleted" || m.status === "archived"))
|
|
18
|
-
return false;
|
|
19
|
-
if (opts.projectId &&
|
|
20
|
-
!(m.project_id === opts.projectId || m.project_id === null))
|
|
21
|
-
return false;
|
|
22
|
-
return true;
|
|
23
|
-
};
|
|
9
|
+
.prepare(`SELECT e.ref_id as id, e.vec as vec, m.status, m.scope, m.project_id, m.session_id, m.user_id
|
|
10
|
+
FROM embeddings e
|
|
11
|
+
JOIN memories m ON m.id = e.ref_id
|
|
12
|
+
WHERE e.ref_table = 'memories'
|
|
13
|
+
AND ( ? OR m.status NOT IN ('deleted','archived','superseded'))`)
|
|
14
|
+
.all(opts.includeArchived ? 1 : 0);
|
|
15
|
+
const uid = opts.userId ? resolveUserId(opts.userId) : null;
|
|
24
16
|
return rows
|
|
25
|
-
.
|
|
26
|
-
|
|
17
|
+
.filter((r) => {
|
|
18
|
+
if (r.scope === "USER") {
|
|
19
|
+
if (opts.userId == null)
|
|
20
|
+
return false;
|
|
21
|
+
return r.user_id === uid;
|
|
22
|
+
}
|
|
23
|
+
if (r.scope === "SESSION") {
|
|
24
|
+
if (opts.sessionId == null)
|
|
25
|
+
return false;
|
|
26
|
+
return r.session_id === opts.sessionId;
|
|
27
|
+
}
|
|
28
|
+
if (r.scope === "PROJECT") {
|
|
29
|
+
if (opts.projectId == null)
|
|
30
|
+
return false;
|
|
31
|
+
return r.project_id === opts.projectId;
|
|
32
|
+
}
|
|
33
|
+
return true;
|
|
34
|
+
})
|
|
35
|
+
.map((r) => ({ id: r.id, score: cosine(topicVec, deserialize(r.vec)) }))
|
|
36
|
+
.filter((r) => r.score >= floor)
|
|
27
37
|
.sort((a, b) => b.score - a.score)
|
|
28
38
|
.slice(0, limit);
|
|
29
39
|
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
2
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
3
3
|
import { join, dirname, resolve, sep } from "node:path";
|
|
4
|
-
import { DB_PATH, ok, err } from "../db/index.js";
|
|
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 } from "../memory/types.js";
|
|
7
|
+
import { MEMORY_TYPES, SOURCE_TYPES } from "../memory/types.js";
|
|
8
8
|
import { EXPORTS_DIRNAME } from "../lib/config.js";
|
|
9
|
+
import { embed } from "../lib/embed.js";
|
|
9
10
|
export const importMemoryInput = {
|
|
10
11
|
file: z
|
|
11
12
|
.string()
|
|
@@ -25,6 +26,23 @@ export const importMemoryInput = {
|
|
|
25
26
|
.optional()
|
|
26
27
|
.describe("Scope imported memories to a user (USER scope)"),
|
|
27
28
|
};
|
|
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
|
+
function isValidSource(s) {
|
|
44
|
+
return SOURCE_TYPES.includes(s);
|
|
45
|
+
}
|
|
28
46
|
export function importMemoryHandler(args) {
|
|
29
47
|
try {
|
|
30
48
|
let raw;
|
|
@@ -34,12 +52,25 @@ export function importMemoryHandler(args) {
|
|
|
34
52
|
else if (args.file) {
|
|
35
53
|
const exportDir = join(dirname(DB_PATH), EXPORTS_DIRNAME);
|
|
36
54
|
const full = resolve(args.file);
|
|
37
|
-
|
|
38
|
-
|
|
55
|
+
let realExportDir;
|
|
56
|
+
try {
|
|
57
|
+
realExportDir = realpathSync(exportDir);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
realExportDir = resolve(exportDir);
|
|
61
|
+
}
|
|
62
|
+
let realFull;
|
|
63
|
+
try {
|
|
64
|
+
realFull = realpathSync(full);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return err(`file not found: ${full}`);
|
|
68
|
+
}
|
|
69
|
+
if (!realFull.startsWith(realExportDir + sep) && realFull !== realExportDir)
|
|
39
70
|
return err(`file must be inside ${exportDir}`);
|
|
40
|
-
if (!
|
|
71
|
+
if (!realFull.endsWith(".json"))
|
|
41
72
|
return err("file must end with .json");
|
|
42
|
-
raw = readFileSync(
|
|
73
|
+
raw = readFileSync(realFull, "utf8");
|
|
43
74
|
}
|
|
44
75
|
else {
|
|
45
76
|
return err("provide either file or json");
|
|
@@ -51,22 +82,85 @@ export function importMemoryHandler(args) {
|
|
|
51
82
|
catch {
|
|
52
83
|
return err("invalid JSON");
|
|
53
84
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
85
|
+
let memoryItems = [];
|
|
86
|
+
let exportPrefs = null;
|
|
87
|
+
let exportLessons = null;
|
|
88
|
+
let exportProfile = null;
|
|
89
|
+
if (Array.isArray(parsed)) {
|
|
90
|
+
memoryItems = parsed;
|
|
91
|
+
}
|
|
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)) {
|
|
102
|
+
const p = parsed;
|
|
103
|
+
if (Array.isArray(p.preferences))
|
|
104
|
+
exportPrefs = p.preferences;
|
|
105
|
+
if (Array.isArray(p.lessons))
|
|
106
|
+
exportLessons = p.lessons;
|
|
107
|
+
if (Array.isArray(p.profile))
|
|
108
|
+
exportProfile = p.profile;
|
|
109
|
+
if (Array.isArray(p.memories))
|
|
110
|
+
memoryItems = p.memories;
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
memoryItems = [];
|
|
114
|
+
}
|
|
59
115
|
let wouldImport = 0;
|
|
60
116
|
let skipped = 0;
|
|
61
117
|
let invalid = 0;
|
|
62
118
|
const log = [];
|
|
63
|
-
for (const it of
|
|
119
|
+
for (const it of memoryItems) {
|
|
64
120
|
if (!it ||
|
|
65
121
|
typeof it.content !== "string" ||
|
|
122
|
+
it.content.length === 0 ||
|
|
123
|
+
it.content.length > 2000 ||
|
|
66
124
|
!MEMORY_TYPES.includes(it.type)) {
|
|
67
125
|
invalid++;
|
|
68
126
|
continue;
|
|
69
127
|
}
|
|
128
|
+
if (it.summary != null && (typeof it.summary !== "string" || it.summary.length > 2000)) {
|
|
129
|
+
invalid++;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (it.source != null && typeof it.source === "string" && !isValidSource(it.source)) {
|
|
133
|
+
invalid++;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (it.confidence != null && (typeof it.confidence !== "number" || it.confidence < 0 || it.confidence > 1)) {
|
|
137
|
+
invalid++;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (it.importance != null && (typeof it.importance !== "number" || it.importance < 0 || it.importance > 1)) {
|
|
141
|
+
invalid++;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (it.projectId != null && (typeof it.projectId !== "string" || it.projectId.length === 0 || it.projectId.length > 200)) {
|
|
145
|
+
invalid++;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (it.sessionId != null && (typeof it.sessionId !== "string" || it.sessionId.length === 0 || it.sessionId.length > 200)) {
|
|
149
|
+
invalid++;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (it.userId != null && (typeof it.userId !== "string" || it.userId.length === 0 || it.userId.length > 200)) {
|
|
153
|
+
invalid++;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (it.validFrom != null && (typeof it.validFrom !== "string" || !isIsoDateString(it.validFrom))) {
|
|
157
|
+
invalid++;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (it.validUntil != null && (typeof it.validUntil !== "string" || !isIsoDateString(it.validUntil))) {
|
|
161
|
+
invalid++;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
70
164
|
const dup = deduplicate(it.type, it.content);
|
|
71
165
|
if (dup.verdict === "duplicate") {
|
|
72
166
|
skipped++;
|
|
@@ -95,6 +189,88 @@ export function importMemoryHandler(args) {
|
|
|
95
189
|
});
|
|
96
190
|
}
|
|
97
191
|
}
|
|
192
|
+
if (exportPrefs != null || exportLessons != null || exportProfile != null) {
|
|
193
|
+
const prefArr = Array.isArray(exportPrefs) ? exportPrefs : [];
|
|
194
|
+
const lessonArr = Array.isArray(exportLessons) ? exportLessons : [];
|
|
195
|
+
const profileArr = Array.isArray(exportProfile) ? exportProfile : [];
|
|
196
|
+
const validCat = new Set(["work_style", "coding_pref", "language", "domain", "other"]);
|
|
197
|
+
let prefOk = 0;
|
|
198
|
+
let prefInvalid = 0;
|
|
199
|
+
let lessonOk = 0;
|
|
200
|
+
let lessonInvalid = 0;
|
|
201
|
+
let profileOk = 0;
|
|
202
|
+
const selectPref = db.prepare("SELECT id FROM preferences WHERE category = ? AND key = ?");
|
|
203
|
+
const insertPref = db.prepare("INSERT INTO preferences (category, key, value, confidence, source, updated_at) VALUES (?, ?, ?, ?, ?, ?)");
|
|
204
|
+
const updatePref = db.prepare("UPDATE preferences SET value = ?, confidence = ?, source = ?, updated_at = ? WHERE category = ? AND key = ?");
|
|
205
|
+
const insertLesson = db.prepare("INSERT INTO lessons (situation, mistake, correction, created_at) VALUES (?, ?, ?, ?)");
|
|
206
|
+
const upsertProfile = db.prepare("INSERT INTO profile (section, content, updated_at) VALUES (?, ?, ?) ON CONFLICT(section) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at");
|
|
207
|
+
for (const p of prefArr) {
|
|
208
|
+
if (!p ||
|
|
209
|
+
typeof p.category !== "string" ||
|
|
210
|
+
!validCat.has(p.category) ||
|
|
211
|
+
typeof p.key !== "string" ||
|
|
212
|
+
p.key.length === 0 ||
|
|
213
|
+
p.key.length > 200 ||
|
|
214
|
+
typeof p.value !== "string" ||
|
|
215
|
+
p.value.length === 0 ||
|
|
216
|
+
p.value.length > 2000 ||
|
|
217
|
+
(p.confidence != null && (typeof p.confidence !== "number" || p.confidence < 0 || p.confidence > 1))) {
|
|
218
|
+
prefInvalid++;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
prefOk++;
|
|
222
|
+
if (args.apply === true) {
|
|
223
|
+
const existing = selectPref.get(p.category, p.key);
|
|
224
|
+
const conf = typeof p.confidence === "number" ? p.confidence : 0.5;
|
|
225
|
+
const src = typeof p.source === "string" ? p.source : "imported";
|
|
226
|
+
const ts = typeof p.updated_at === "string" && isIsoDateString(p.updated_at) ? p.updated_at : nowISO();
|
|
227
|
+
if (existing)
|
|
228
|
+
updatePref.run(p.value, conf, src, ts, p.category, p.key);
|
|
229
|
+
else
|
|
230
|
+
insertPref.run(p.category, p.key, p.value, conf, src, ts);
|
|
231
|
+
const row = db.prepare("SELECT id FROM preferences WHERE category = ? AND key = ?").get(p.category, p.key);
|
|
232
|
+
if (row) {
|
|
233
|
+
syncSearchIndex("preferences", row.id, `${p.category}/${p.key}`, `${p.key}: ${p.value}`);
|
|
234
|
+
upsertEmbedding("preferences", row.id, embed(`${p.category} ${p.key} ${p.value}`));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
for (const l of lessonArr) {
|
|
239
|
+
if (!l ||
|
|
240
|
+
typeof l.situation !== "string" ||
|
|
241
|
+
l.situation.length === 0 ||
|
|
242
|
+
l.situation.length > 1000 ||
|
|
243
|
+
typeof l.mistake !== "string" ||
|
|
244
|
+
l.mistake.length === 0 ||
|
|
245
|
+
l.mistake.length > 1000 ||
|
|
246
|
+
typeof l.correction !== "string" ||
|
|
247
|
+
l.correction.length === 0 ||
|
|
248
|
+
l.correction.length > 1000) {
|
|
249
|
+
lessonInvalid++;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
lessonOk++;
|
|
253
|
+
if (args.apply === true) {
|
|
254
|
+
const ts = typeof l.created_at === "string" && isIsoDateString(l.created_at) ? l.created_at : nowISO();
|
|
255
|
+
const res = insertLesson.run(l.situation, l.mistake, l.correction, ts);
|
|
256
|
+
const id = Number(res.lastInsertRowid);
|
|
257
|
+
syncSearchIndex("lessons", id, l.situation.slice(0, 80), `${l.situation} | mistake: ${l.mistake} -> correction: ${l.correction}`);
|
|
258
|
+
upsertEmbedding("lessons", id, embed(`${l.situation} ${l.mistake} ${l.correction}`));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
for (const pr of profileArr) {
|
|
262
|
+
if (!pr || typeof pr.section !== "string" || pr.section.length === 0 || typeof pr.content !== "string" || pr.content.length === 0)
|
|
263
|
+
continue;
|
|
264
|
+
profileOk++;
|
|
265
|
+
if (args.apply === true) {
|
|
266
|
+
const ts = typeof pr.updated_at === "string" && isIsoDateString(pr.updated_at) ? pr.updated_at : nowISO();
|
|
267
|
+
upsertProfile.run(pr.section, pr.content, ts);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
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`;
|
|
272
|
+
return ok(summary);
|
|
273
|
+
}
|
|
98
274
|
const mode = args.apply === true ? "applied" : "dry-run";
|
|
99
275
|
const summary = `import ${mode}: ${wouldImport} to import, ${skipped} duplicate(s) skipped, ${invalid} invalid`;
|
|
100
276
|
return ok(log.length ? `${summary}\n${log.join("\n")}` : summary);
|
|
@@ -22,6 +22,16 @@ export function linkMemoryHandler(args) {
|
|
|
22
22
|
return err("cannot link deleted memories");
|
|
23
23
|
if (src.id === tgt.id)
|
|
24
24
|
return err("cannot link a memory to itself");
|
|
25
|
+
if ((src.scope === "USER" || tgt.scope === "USER") &&
|
|
26
|
+
src.user_id !== tgt.user_id)
|
|
27
|
+
return err("cannot link memories across different users");
|
|
28
|
+
if ((src.scope === "SESSION" || tgt.scope === "SESSION") &&
|
|
29
|
+
src.session_id !== tgt.session_id)
|
|
30
|
+
return err("cannot link memories across different sessions");
|
|
31
|
+
if (src.scope === "PROJECT" &&
|
|
32
|
+
tgt.scope === "PROJECT" &&
|
|
33
|
+
src.project_id !== tgt.project_id)
|
|
34
|
+
return err("cannot link memories across different projects");
|
|
25
35
|
linkMemories(args.sourceId, args.targetId, args.relation);
|
|
26
36
|
return ok(`linked memory ${args.sourceId} -[${args.relation}]-> ${args.targetId}`);
|
|
27
37
|
}
|
package/package.json
CHANGED