th-memory-mcp 2.0.0 → 2.2.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/ARCHITECTURE_v2.md +1594 -0
- package/README.md +13 -8
- package/README.th.md +10 -5
- package/design.md +58 -98
- package/dist/core/context-engine.js +2 -0
- package/dist/core/entity-extractor.js +83 -0
- package/dist/core/retrieval-engine.js +6 -1
- package/dist/db/migrations.js +25 -0
- package/dist/db/repositories/memories.js +16 -5
- package/dist/db/repositories/users.js +23 -0
- package/dist/index.js +30 -0
- package/dist/memory/conflict-resolver.js +37 -1
- package/dist/retrieval/scorer.js +36 -7
- package/dist/tools/consolidate.js +11 -0
- package/dist/tools/context.js +6 -0
- package/dist/tools/extract_memories.js +96 -0
- package/dist/tools/history.js +1 -1
- package/dist/tools/import_memory.js +105 -0
- package/dist/tools/link_memory.js +31 -0
- package/dist/tools/merge_memory.js +49 -0
- package/dist/tools/profile.js +10 -0
- package/dist/tools/recall.js +3 -3
- package/dist/tools/recent_interactions.js +1 -1
- package/dist/tools/update_memory.js +98 -0
- package/package.json +3 -2
|
@@ -21,6 +21,14 @@ const NEGATION = [
|
|
|
21
21
|
/เลิก/i,
|
|
22
22
|
/อย่า/i,
|
|
23
23
|
];
|
|
24
|
+
// Opposite-value lexicon for ambiguous preference conflicts (preserve, don't
|
|
25
|
+
// silently supersede). A pair is contradictory when each side names a different
|
|
26
|
+
// antonym from this set.
|
|
27
|
+
const ANTONYMS = new Set([
|
|
28
|
+
"tabs", "spaces", "vim", "emacs", "light", "dark",
|
|
29
|
+
"mysql", "postgres", "windows", "mac", "linux",
|
|
30
|
+
"react", "vue", "ios", "android",
|
|
31
|
+
]);
|
|
24
32
|
export function isContradiction(a, b) {
|
|
25
33
|
const na = NEGATION.some((re) => re.test(a));
|
|
26
34
|
const nb = NEGATION.some((re) => re.test(b));
|
|
@@ -34,6 +42,22 @@ export function isContradiction(a, b) {
|
|
|
34
42
|
overlap++;
|
|
35
43
|
return overlap >= 2;
|
|
36
44
|
}
|
|
45
|
+
// True when the two texts each name a *different* antonym from ANTONYMS
|
|
46
|
+
// (e.g. "Prefer tabs" vs "Prefer spaces"). Used to preserve ambiguous
|
|
47
|
+
// conflicts as contradictions instead of destructively superseding them.
|
|
48
|
+
export function hasAntonymPair(a, b) {
|
|
49
|
+
const ta = tokenSet(a);
|
|
50
|
+
const tb = tokenSet(b);
|
|
51
|
+
let aAnt;
|
|
52
|
+
let bAnt;
|
|
53
|
+
for (const t of ta)
|
|
54
|
+
if (ANTONYMS.has(t))
|
|
55
|
+
aAnt = t;
|
|
56
|
+
for (const t of tb)
|
|
57
|
+
if (ANTONYMS.has(t))
|
|
58
|
+
bAnt = t;
|
|
59
|
+
return !!aAnt && !!bAnt && aAnt !== bAnt;
|
|
60
|
+
}
|
|
37
61
|
function tokenSet(s) {
|
|
38
62
|
return new Set(s.toLowerCase().match(/[a-z0-9ก-์]+/gi) ?? []);
|
|
39
63
|
}
|
|
@@ -47,12 +71,24 @@ export function classifyRelationship(candidate, relatedId) {
|
|
|
47
71
|
if (!relVecRow)
|
|
48
72
|
return "unrelated";
|
|
49
73
|
const score = cosine(embed(candidate.content), deserialize(relVecRow.vec));
|
|
74
|
+
const ta = tokenSet(candidate.content);
|
|
75
|
+
const tb = tokenSet(rel.content);
|
|
76
|
+
let overlap = 0;
|
|
77
|
+
for (const t of ta)
|
|
78
|
+
if (tb.has(t))
|
|
79
|
+
overlap++;
|
|
80
|
+
const jac = overlap / (ta.size + tb.size - overlap || 1);
|
|
50
81
|
if (score >= SIM_DUP)
|
|
51
82
|
return "duplicate";
|
|
52
83
|
if (isContradiction(candidate.content, rel.content) && score >= SIM_CONTRA) {
|
|
53
84
|
return "contradiction";
|
|
54
85
|
}
|
|
55
|
-
if (
|
|
86
|
+
if (hasAntonymPair(candidate.content, rel.content)) {
|
|
87
|
+
return "contradiction";
|
|
88
|
+
}
|
|
89
|
+
if (score >= 0.6 && jac >= 0.5)
|
|
90
|
+
return "duplicate";
|
|
91
|
+
if (score >= SIM_UPDATE && jac >= 0.25)
|
|
56
92
|
return "update";
|
|
57
93
|
return "unrelated";
|
|
58
94
|
}
|
package/dist/retrieval/scorer.js
CHANGED
|
@@ -8,12 +8,41 @@ export function finalScore(input) {
|
|
|
8
8
|
clamp01(input.recency) *
|
|
9
9
|
clamp01(input.scopeFactor));
|
|
10
10
|
}
|
|
11
|
-
export function scopeFactorFor(mem,
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
if (
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
export function scopeFactorFor(mem, opts = {}) {
|
|
12
|
+
const { projectId, sessionId, userId } = opts;
|
|
13
|
+
const memScope = mem.scope ?? "GLOBAL";
|
|
14
|
+
if (sessionId) {
|
|
15
|
+
if (memScope === "SESSION" && mem.session_id === sessionId)
|
|
16
|
+
return 1.0;
|
|
17
|
+
if (memScope === "PROJECT" && mem.project_id === projectId)
|
|
18
|
+
return 0.8;
|
|
19
|
+
if (memScope === "USER" && mem.user_id === userId)
|
|
20
|
+
return 0.8;
|
|
21
|
+
if (memScope === "GLOBAL")
|
|
22
|
+
return 0.6;
|
|
23
|
+
return 0.2;
|
|
24
|
+
}
|
|
25
|
+
if (projectId) {
|
|
26
|
+
if (memScope === "PROJECT" && mem.project_id === projectId)
|
|
27
|
+
return 1.0;
|
|
28
|
+
if (memScope === "USER" && mem.user_id === userId)
|
|
29
|
+
return 0.8;
|
|
30
|
+
if (memScope === "GLOBAL")
|
|
31
|
+
return 0.7;
|
|
32
|
+
return 0.3;
|
|
33
|
+
}
|
|
34
|
+
if (userId) {
|
|
35
|
+
if (memScope === "USER" && mem.user_id === userId)
|
|
36
|
+
return 1.0;
|
|
37
|
+
if (memScope === "GLOBAL")
|
|
38
|
+
return 0.7;
|
|
39
|
+
return 0.3;
|
|
40
|
+
}
|
|
41
|
+
if (memScope === "GLOBAL")
|
|
42
|
+
return 0.8;
|
|
43
|
+
if (memScope === "PROJECT")
|
|
17
44
|
return 0.7;
|
|
18
|
-
|
|
45
|
+
if (memScope === "USER")
|
|
46
|
+
return 0.6;
|
|
47
|
+
return 0.4;
|
|
19
48
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { clusterMemories, createDerivedMemory, } from "../core/consolidation-engine.js";
|
|
3
|
+
import { linkEntitiesForMemory, linkMemoriesBySharedEntities, } from "../core/entity-extractor.js";
|
|
3
4
|
import { db, ok } from "../db/index.js";
|
|
4
5
|
export const consolidateInput = {
|
|
5
6
|
threshold: z
|
|
@@ -38,6 +39,16 @@ export function consolidateHandler(args) {
|
|
|
38
39
|
.get(id);
|
|
39
40
|
return ` - [${id}] ${m?.content ?? "?"}`;
|
|
40
41
|
});
|
|
42
|
+
// Auto entity extraction (item 5): persist entities + co-occurrence, then
|
|
43
|
+
// link memories in the cluster that share an entity.
|
|
44
|
+
for (const id of c) {
|
|
45
|
+
const m = db
|
|
46
|
+
.prepare("SELECT content FROM memories WHERE id = ?")
|
|
47
|
+
.get(id);
|
|
48
|
+
if (m)
|
|
49
|
+
linkEntitiesForMemory(id, m.content);
|
|
50
|
+
}
|
|
51
|
+
linkMemoriesBySharedEntities(c);
|
|
41
52
|
lines.push(`Cluster (${c.length}):\n${contents.join("\n")}`);
|
|
42
53
|
if (args.derive === true) {
|
|
43
54
|
const summary = c
|
package/dist/tools/context.js
CHANGED
|
@@ -12,6 +12,11 @@ export const contextInput = {
|
|
|
12
12
|
.optional()
|
|
13
13
|
.describe("Scope context to a project"),
|
|
14
14
|
sessionId: z.string().nullable().optional(),
|
|
15
|
+
userId: z
|
|
16
|
+
.string()
|
|
17
|
+
.nullable()
|
|
18
|
+
.optional()
|
|
19
|
+
.describe("Scope context to a user (USER scope)"),
|
|
15
20
|
limit: z
|
|
16
21
|
.number()
|
|
17
22
|
.int()
|
|
@@ -40,6 +45,7 @@ export function contextHandler(args) {
|
|
|
40
45
|
query: typeof args.query === "string" ? args.query : "",
|
|
41
46
|
projectId: typeof args.projectId === "string" ? args.projectId : null,
|
|
42
47
|
sessionId: typeof args.sessionId === "string" ? args.sessionId : null,
|
|
48
|
+
userId: typeof args.userId === "string" ? args.userId : null,
|
|
43
49
|
limit: typeof args.limit === "number" ? args.limit : undefined,
|
|
44
50
|
maxTokens: typeof args.maxTokens === "number" ? args.maxTokens : undefined,
|
|
45
51
|
includeHistory: args.includeHistory === true,
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { db, ok, err } from "../db/index.js";
|
|
3
|
+
import { createMemory } from "../db/repositories/memories.js";
|
|
4
|
+
import { deduplicate } from "../memory/deduplicator.js";
|
|
5
|
+
// Deterministic, LLM-free intent heuristics (spec §19 "optional extraction").
|
|
6
|
+
// Scans captured interactions and proposes memory candidates. Safe by default:
|
|
7
|
+
// dry-run proposes; pass apply=true to actually create memories (source=captured).
|
|
8
|
+
const INTENT_PATTERNS = [
|
|
9
|
+
{ re: /(?:remember|จำไว้ว่า|บันทึกว่า)\s+(?:that\s+)?(.+)/i, type: "FACT" },
|
|
10
|
+
{
|
|
11
|
+
re: /(?:i prefer|my preference is|ผมชอบ|ฉันชอบ|เราชอบ)\s+(.+)/i,
|
|
12
|
+
type: "PREFERENCE",
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
re: /(?:i use|เราใช้|ฉันใช้)\s+([^\s,]+)\s+(?:for|ในการ|เพื่อ)\s+(.+)/i,
|
|
16
|
+
type: "PREFERENCE",
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
re: /(?:don'?t use|ห้ามใช้|อย่าใช้)\s+(.+?)(?:,\s*(?:use|ใช้)\s+(.+))?/i,
|
|
20
|
+
type: "LESSON",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
re: /(?:instead|use)\s+(.+?)\s+(?:rather than|แทน)\s+(.+)/i,
|
|
24
|
+
type: "LESSON",
|
|
25
|
+
},
|
|
26
|
+
];
|
|
27
|
+
export const extractMemoriesInput = {
|
|
28
|
+
limit: z
|
|
29
|
+
.number()
|
|
30
|
+
.int()
|
|
31
|
+
.min(1)
|
|
32
|
+
.max(200)
|
|
33
|
+
.optional()
|
|
34
|
+
.describe("Max recent interactions to scan (default 50)"),
|
|
35
|
+
kind: z
|
|
36
|
+
.enum(["prompt", "tool_call", "error"])
|
|
37
|
+
.optional()
|
|
38
|
+
.describe("Interaction kind to scan (default prompt)"),
|
|
39
|
+
apply: z
|
|
40
|
+
.boolean()
|
|
41
|
+
.optional()
|
|
42
|
+
.describe("Create the proposed memories (default false = propose only)"),
|
|
43
|
+
userId: z
|
|
44
|
+
.string()
|
|
45
|
+
.nullable()
|
|
46
|
+
.optional()
|
|
47
|
+
.describe("Scope extracted memories to a user (USER scope)"),
|
|
48
|
+
};
|
|
49
|
+
export function extractMemoriesHandler(args) {
|
|
50
|
+
try {
|
|
51
|
+
const limit = args.limit ?? 50;
|
|
52
|
+
const kind = args.kind ?? "prompt";
|
|
53
|
+
const rows = db
|
|
54
|
+
.prepare("SELECT id, content FROM interactions WHERE kind = ? ORDER BY id DESC LIMIT ?")
|
|
55
|
+
.all(kind, limit);
|
|
56
|
+
const candidates = [];
|
|
57
|
+
for (const r of rows) {
|
|
58
|
+
for (const p of INTENT_PATTERNS) {
|
|
59
|
+
const m = r.content.match(p.re);
|
|
60
|
+
if (m) {
|
|
61
|
+
const clause = (m[1] || "").trim();
|
|
62
|
+
if (clause.length >= 3) {
|
|
63
|
+
candidates.push({
|
|
64
|
+
interactionId: r.id,
|
|
65
|
+
type: p.type,
|
|
66
|
+
content: clause,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const distinct = candidates.filter((c) => deduplicate(c.type, c.content).verdict === "distinct");
|
|
74
|
+
if (args.apply === true) {
|
|
75
|
+
let n = 0;
|
|
76
|
+
for (const c of distinct) {
|
|
77
|
+
createMemory({
|
|
78
|
+
type: c.type,
|
|
79
|
+
content: c.content,
|
|
80
|
+
source: "captured",
|
|
81
|
+
userId: typeof args.userId === "string" ? args.userId : null,
|
|
82
|
+
});
|
|
83
|
+
n++;
|
|
84
|
+
}
|
|
85
|
+
return ok(`extracted and created ${n} memories from interactions (${candidates.length} candidates, ${candidates.length - distinct.length} duplicates skipped)`);
|
|
86
|
+
}
|
|
87
|
+
const preview = distinct
|
|
88
|
+
.slice(0, 20)
|
|
89
|
+
.map((c) => `[${c.type}] ${c.content}`)
|
|
90
|
+
.join("\n") || "(no candidates)";
|
|
91
|
+
return ok(`proposed ${distinct.length} memory candidate(s) from ${rows.length} ${kind} interactions (dry-run; pass apply=true to create):\n${preview}`);
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
95
|
+
}
|
|
96
|
+
}
|
package/dist/tools/history.js
CHANGED
|
@@ -14,7 +14,7 @@ const ITEM_BUDGET = 200;
|
|
|
14
14
|
const searchPrompts = db.prepare("SELECT ts, content FROM interactions WHERE kind = 'prompt' AND content LIKE ? ESCAPE '\\' ORDER BY ts DESC LIMIT ?");
|
|
15
15
|
export async function searchHistoryHandler(args) {
|
|
16
16
|
try {
|
|
17
|
-
const limit = args.limit;
|
|
17
|
+
const limit = args.limit ?? 10;
|
|
18
18
|
const like = `%${escapeLike(args.query)}%`;
|
|
19
19
|
const rows = searchPrompts.all(like, limit);
|
|
20
20
|
if (rows.length === 0) {
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join, dirname, resolve, sep } from "node:path";
|
|
4
|
+
import { DB_PATH, ok, err } from "../db/index.js";
|
|
5
|
+
import { createMemory } from "../db/repositories/memories.js";
|
|
6
|
+
import { deduplicate } from "../memory/deduplicator.js";
|
|
7
|
+
import { MEMORY_TYPES } from "../memory/types.js";
|
|
8
|
+
import { EXPORTS_DIRNAME } from "../lib/config.js";
|
|
9
|
+
export const importMemoryInput = {
|
|
10
|
+
file: z
|
|
11
|
+
.string()
|
|
12
|
+
.optional()
|
|
13
|
+
.describe("Path to a .json export file (must be inside data/exports/)"),
|
|
14
|
+
json: z
|
|
15
|
+
.string()
|
|
16
|
+
.optional()
|
|
17
|
+
.describe("Inline JSON: an array of memory objects, or { memories: [...] }"),
|
|
18
|
+
apply: z
|
|
19
|
+
.boolean()
|
|
20
|
+
.optional()
|
|
21
|
+
.describe("Actually insert memories (default false = dry run, just report)"),
|
|
22
|
+
userId: z
|
|
23
|
+
.string()
|
|
24
|
+
.nullable()
|
|
25
|
+
.optional()
|
|
26
|
+
.describe("Scope imported memories to a user (USER scope)"),
|
|
27
|
+
};
|
|
28
|
+
export function importMemoryHandler(args) {
|
|
29
|
+
try {
|
|
30
|
+
let raw;
|
|
31
|
+
if (args.json) {
|
|
32
|
+
raw = args.json;
|
|
33
|
+
}
|
|
34
|
+
else if (args.file) {
|
|
35
|
+
const exportDir = join(dirname(DB_PATH), EXPORTS_DIRNAME);
|
|
36
|
+
const full = resolve(args.file);
|
|
37
|
+
const allowed = resolve(exportDir);
|
|
38
|
+
if (!full.startsWith(allowed + sep))
|
|
39
|
+
return err(`file must be inside ${exportDir}`);
|
|
40
|
+
if (!full.endsWith(".json"))
|
|
41
|
+
return err("file must end with .json");
|
|
42
|
+
raw = readFileSync(full, "utf8");
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
return err("provide either file or json");
|
|
46
|
+
}
|
|
47
|
+
let parsed;
|
|
48
|
+
try {
|
|
49
|
+
parsed = JSON.parse(raw);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return err("invalid JSON");
|
|
53
|
+
}
|
|
54
|
+
const items = Array.isArray(parsed)
|
|
55
|
+
? parsed
|
|
56
|
+
: Array.isArray(parsed.memories)
|
|
57
|
+
? (parsed.memories)
|
|
58
|
+
: [];
|
|
59
|
+
let wouldImport = 0;
|
|
60
|
+
let skipped = 0;
|
|
61
|
+
let invalid = 0;
|
|
62
|
+
const log = [];
|
|
63
|
+
for (const it of items) {
|
|
64
|
+
if (!it ||
|
|
65
|
+
typeof it.content !== "string" ||
|
|
66
|
+
!MEMORY_TYPES.includes(it.type)) {
|
|
67
|
+
invalid++;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const dup = deduplicate(it.type, it.content);
|
|
71
|
+
if (dup.verdict === "duplicate") {
|
|
72
|
+
skipped++;
|
|
73
|
+
log.push(`skip duplicate -> existing ${dup.existingId}`);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
wouldImport++;
|
|
77
|
+
if (args.apply === true) {
|
|
78
|
+
createMemory({
|
|
79
|
+
type: it.type,
|
|
80
|
+
content: it.content,
|
|
81
|
+
summary: typeof it.summary === "string" ? it.summary : null,
|
|
82
|
+
source: it.source ?? "imported",
|
|
83
|
+
confidence: typeof it.confidence === "number" ? it.confidence : 0.7,
|
|
84
|
+
importance: typeof it.importance === "number" ? it.importance : 0.5,
|
|
85
|
+
projectId: typeof it.projectId === "string" ? it.projectId : null,
|
|
86
|
+
sessionId: typeof it.sessionId === "string" ? it.sessionId : null,
|
|
87
|
+
userId: typeof it.userId === "string"
|
|
88
|
+
? it.userId
|
|
89
|
+
: typeof args.userId === "string"
|
|
90
|
+
? args.userId
|
|
91
|
+
: null,
|
|
92
|
+
validFrom: typeof it.validFrom === "string" ? it.validFrom : null,
|
|
93
|
+
validUntil: typeof it.validUntil === "string" ? it.validUntil : null,
|
|
94
|
+
metadata: it.metadata ?? null,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const mode = args.apply === true ? "applied" : "dry-run";
|
|
99
|
+
const summary = `import ${mode}: ${wouldImport} to import, ${skipped} duplicate(s) skipped, ${invalid} invalid`;
|
|
100
|
+
return ok(log.length ? `${summary}\n${log.join("\n")}` : summary);
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ok, err } from "../db/index.js";
|
|
3
|
+
import { linkMemories } from "../core/graph-engine.js";
|
|
4
|
+
import { getMemoryById } from "../db/repositories/memories.js";
|
|
5
|
+
import { LINK_RELATIONS } from "../memory/types.js";
|
|
6
|
+
export const linkMemoryInput = {
|
|
7
|
+
sourceId: z.number().int().describe("Source memory id"),
|
|
8
|
+
targetId: z.number().int().describe("Target memory id"),
|
|
9
|
+
relation: z
|
|
10
|
+
.enum(LINK_RELATIONS)
|
|
11
|
+
.describe("Link relation (supports/contradicts/supersedes/derived_from/related_to/caused_by/depends_on)"),
|
|
12
|
+
};
|
|
13
|
+
export function linkMemoryHandler(args) {
|
|
14
|
+
try {
|
|
15
|
+
const src = getMemoryById(args.sourceId);
|
|
16
|
+
const tgt = getMemoryById(args.targetId);
|
|
17
|
+
if (!src)
|
|
18
|
+
return err(`source memory ${args.sourceId} not found`);
|
|
19
|
+
if (!tgt)
|
|
20
|
+
return err(`target memory ${args.targetId} not found`);
|
|
21
|
+
if (src.status === "deleted" || tgt.status === "deleted")
|
|
22
|
+
return err("cannot link deleted memories");
|
|
23
|
+
if (src.id === tgt.id)
|
|
24
|
+
return err("cannot link a memory to itself");
|
|
25
|
+
linkMemories(args.sourceId, args.targetId, args.relation);
|
|
26
|
+
return ok(`linked memory ${args.sourceId} -[${args.relation}]-> ${args.targetId}`);
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { db, nowISO, ok, err } from "../db/index.js";
|
|
3
|
+
import { getMemoryById } from "../db/repositories/memories.js";
|
|
4
|
+
import { supersede } from "../core/lifecycle-engine.js";
|
|
5
|
+
export const mergeMemoryInput = {
|
|
6
|
+
sourceId: z
|
|
7
|
+
.number()
|
|
8
|
+
.int()
|
|
9
|
+
.describe("Memory to merge away (becomes superseded)"),
|
|
10
|
+
targetId: z
|
|
11
|
+
.number()
|
|
12
|
+
.int()
|
|
13
|
+
.describe("Canonical memory to keep (becomes active)"),
|
|
14
|
+
};
|
|
15
|
+
function mergeMetadata(existing, mergedId) {
|
|
16
|
+
let obj = {};
|
|
17
|
+
if (existing) {
|
|
18
|
+
try {
|
|
19
|
+
obj = JSON.parse(existing);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
obj = {};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const from = Array.isArray(obj.merged_from) ? obj.merged_from : [];
|
|
26
|
+
from.push(mergedId);
|
|
27
|
+
obj.merged_from = from;
|
|
28
|
+
return JSON.stringify(obj);
|
|
29
|
+
}
|
|
30
|
+
export function mergeMemoryHandler(args) {
|
|
31
|
+
try {
|
|
32
|
+
const src = getMemoryById(args.sourceId);
|
|
33
|
+
const tgt = getMemoryById(args.targetId);
|
|
34
|
+
if (!src)
|
|
35
|
+
return err(`source memory ${args.sourceId} not found`);
|
|
36
|
+
if (!tgt)
|
|
37
|
+
return err(`target memory ${args.targetId} not found`);
|
|
38
|
+
if (src.id === tgt.id)
|
|
39
|
+
return err("cannot merge a memory into itself");
|
|
40
|
+
if (src.status === "deleted" || tgt.status === "deleted")
|
|
41
|
+
return err("cannot merge deleted memories");
|
|
42
|
+
db.prepare("UPDATE memories SET metadata = ?, updated_at = ? WHERE id = ?").run(mergeMetadata(tgt.metadata, src.id), nowISO(), tgt.id);
|
|
43
|
+
supersede(src.id, tgt.id);
|
|
44
|
+
return ok(`merged memory ${src.id} into ${tgt.id} (source superseded, provenance recorded in metadata.merged_from)`);
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
48
|
+
}
|
|
49
|
+
}
|
package/dist/tools/profile.js
CHANGED
|
@@ -9,6 +9,7 @@ const LESSON_CORRECTION_MAX = 150;
|
|
|
9
9
|
const profileRows = db.prepare("SELECT section, content FROM profile");
|
|
10
10
|
const topPrefs = db.prepare("SELECT category, key, value, confidence FROM preferences ORDER BY confidence DESC, updated_at DESC LIMIT 15");
|
|
11
11
|
const recentLessons = db.prepare("SELECT situation, mistake, correction FROM lessons ORDER BY created_at DESC, id DESC LIMIT 5");
|
|
12
|
+
const topMemories = db.prepare("SELECT type, content, importance, confidence FROM memories WHERE status = 'active' ORDER BY importance * confidence DESC, updated_at DESC LIMIT 15");
|
|
12
13
|
export function buildProfileText() {
|
|
13
14
|
const parts = [];
|
|
14
15
|
const prof = profileRows.all();
|
|
@@ -34,6 +35,15 @@ export function buildProfileText() {
|
|
|
34
35
|
}
|
|
35
36
|
parts.push(block);
|
|
36
37
|
}
|
|
38
|
+
// Auto-projection: surface top memories from the unified store (spec §7 / item 7).
|
|
39
|
+
const mems = topMemories.all();
|
|
40
|
+
if (mems.length > 0) {
|
|
41
|
+
let block = "[memories]";
|
|
42
|
+
for (const m of mems) {
|
|
43
|
+
block += `\n- (${m.type} c${m.confidence.toFixed(2)}) ${truncate(m.content, 200)}`;
|
|
44
|
+
}
|
|
45
|
+
parts.push(block);
|
|
46
|
+
}
|
|
37
47
|
return truncate(parts.join("\n\n"), PROFILE_BUDGET);
|
|
38
48
|
}
|
|
39
49
|
export async function getProfileHandler() {
|
package/dist/tools/recall.js
CHANGED
|
@@ -32,7 +32,7 @@ function lessonLine(id) {
|
|
|
32
32
|
}
|
|
33
33
|
export async function recallHandler(args) {
|
|
34
34
|
try {
|
|
35
|
-
const limit = args.limit;
|
|
35
|
+
const limit = args.limit ?? 8;
|
|
36
36
|
const parts = [];
|
|
37
37
|
const seen = new Set();
|
|
38
38
|
let prefLines = "";
|
|
@@ -41,14 +41,14 @@ export async function recallHandler(args) {
|
|
|
41
41
|
const rows = searchIndexed.all(buildFtsMatch(args.topic), limit);
|
|
42
42
|
for (const r of rows) {
|
|
43
43
|
if (r.ref_table === "preferences") {
|
|
44
|
-
const line = prefLine(r.ref_id);
|
|
44
|
+
const line = prefLine(Number(r.ref_id));
|
|
45
45
|
if (line) {
|
|
46
46
|
prefLines += line + "\n";
|
|
47
47
|
seen.add(`p:${r.ref_id}`);
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
else if (r.ref_table === "lessons") {
|
|
51
|
-
const line = lessonLine(r.ref_id);
|
|
51
|
+
const line = lessonLine(Number(r.ref_id));
|
|
52
52
|
if (line) {
|
|
53
53
|
lessonLines += line + "\n";
|
|
54
54
|
seen.add(`l:${r.ref_id}`);
|
|
@@ -20,7 +20,7 @@ const selectAny = db.prepare("SELECT id, ts, kind, content FROM interactions ORD
|
|
|
20
20
|
const selectByKind = db.prepare("SELECT id, ts, kind, content FROM interactions WHERE kind = ? ORDER BY id DESC LIMIT ?");
|
|
21
21
|
export async function getRecentInteractionsHandler(args) {
|
|
22
22
|
try {
|
|
23
|
-
const limit = args.limit;
|
|
23
|
+
const limit = args.limit ?? 20;
|
|
24
24
|
const rows = (args.kind
|
|
25
25
|
? selectByKind.all(args.kind, limit)
|
|
26
26
|
: selectAny.all(limit));
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { db, nowISO, ok, err, } from "../db/index.js";
|
|
3
|
+
import { getMemoryById, createMemory, syncMemoryIndex, } from "../db/repositories/memories.js";
|
|
4
|
+
import { supersede } from "../core/lifecycle-engine.js";
|
|
5
|
+
export const updateMemoryInput = {
|
|
6
|
+
id: z.number().int().describe("Memory id to update"),
|
|
7
|
+
content: z
|
|
8
|
+
.string()
|
|
9
|
+
.max(2000)
|
|
10
|
+
.optional()
|
|
11
|
+
.describe("New content. When provided, a superseding memory is created (supersede=true) unless supersede=false."),
|
|
12
|
+
summary: z.string().max(2000).nullable().optional().describe("New summary"),
|
|
13
|
+
importance: z.number().min(0).max(1).optional(),
|
|
14
|
+
confidence: z.number().min(0).max(1).optional(),
|
|
15
|
+
validUntil: z
|
|
16
|
+
.string()
|
|
17
|
+
.nullable()
|
|
18
|
+
.optional()
|
|
19
|
+
.describe("ISO timestamp or null to clear"),
|
|
20
|
+
metadata: z.unknown().optional().describe("New metadata object (replaces)"),
|
|
21
|
+
supersede: z
|
|
22
|
+
.boolean()
|
|
23
|
+
.optional()
|
|
24
|
+
.describe("If content changes, create a superseding memory instead of editing in place (default true)"),
|
|
25
|
+
};
|
|
26
|
+
export function updateMemoryHandler(args) {
|
|
27
|
+
try {
|
|
28
|
+
const mem = getMemoryById(args.id);
|
|
29
|
+
if (!mem)
|
|
30
|
+
return err(`memory ${args.id} not found`);
|
|
31
|
+
if (mem.status === "deleted")
|
|
32
|
+
return err("cannot update a deleted memory");
|
|
33
|
+
const supersedeContent = args.content !== undefined && (args.supersede ?? true);
|
|
34
|
+
if (supersedeContent) {
|
|
35
|
+
const newId = createMemory({
|
|
36
|
+
type: mem.type,
|
|
37
|
+
content: args.content,
|
|
38
|
+
summary: args.summary ?? mem.summary,
|
|
39
|
+
source: mem.source,
|
|
40
|
+
confidence: args.confidence ?? mem.confidence,
|
|
41
|
+
importance: args.importance ?? mem.importance,
|
|
42
|
+
salience: mem.salience,
|
|
43
|
+
projectId: mem.project_id,
|
|
44
|
+
sessionId: mem.session_id,
|
|
45
|
+
validFrom: mem.valid_from,
|
|
46
|
+
validUntil: args.validUntil !== undefined ? args.validUntil : mem.valid_until,
|
|
47
|
+
metadata: args.metadata !== undefined
|
|
48
|
+
? args.metadata
|
|
49
|
+
: mem.metadata
|
|
50
|
+
? JSON.parse(mem.metadata)
|
|
51
|
+
: null,
|
|
52
|
+
});
|
|
53
|
+
supersede(mem.id, newId);
|
|
54
|
+
return ok(`created superseding memory id=${newId} for old id=${mem.id} (old now superseded)`);
|
|
55
|
+
}
|
|
56
|
+
const sets = [];
|
|
57
|
+
const vals = [];
|
|
58
|
+
if (args.summary !== undefined) {
|
|
59
|
+
sets.push("summary = ?");
|
|
60
|
+
vals.push(args.summary);
|
|
61
|
+
}
|
|
62
|
+
if (args.importance !== undefined) {
|
|
63
|
+
sets.push("importance = ?");
|
|
64
|
+
vals.push(args.importance);
|
|
65
|
+
}
|
|
66
|
+
if (args.confidence !== undefined) {
|
|
67
|
+
sets.push("confidence = ?");
|
|
68
|
+
vals.push(args.confidence);
|
|
69
|
+
}
|
|
70
|
+
if (args.validUntil !== undefined) {
|
|
71
|
+
sets.push("valid_until = ?");
|
|
72
|
+
vals.push(args.validUntil);
|
|
73
|
+
}
|
|
74
|
+
if (args.metadata !== undefined) {
|
|
75
|
+
sets.push("metadata = ?");
|
|
76
|
+
vals.push(JSON.stringify(args.metadata));
|
|
77
|
+
}
|
|
78
|
+
if (args.content !== undefined) {
|
|
79
|
+
sets.push("content = ?");
|
|
80
|
+
vals.push(args.content);
|
|
81
|
+
}
|
|
82
|
+
if (sets.length === 0)
|
|
83
|
+
return ok(`no mutable fields provided; memory ${mem.id} unchanged`);
|
|
84
|
+
sets.push("updated_at = ?");
|
|
85
|
+
vals.push(nowISO());
|
|
86
|
+
vals.push(mem.id);
|
|
87
|
+
db.prepare(`UPDATE memories SET ${sets.join(", ")} WHERE id = ?`).run(...vals);
|
|
88
|
+
if (args.content !== undefined) {
|
|
89
|
+
const m = getMemoryById(mem.id);
|
|
90
|
+
if (m)
|
|
91
|
+
syncMemoryIndex(m.id, m.type, m.content);
|
|
92
|
+
}
|
|
93
|
+
return ok(`updated memory ${mem.id} in place`);
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
97
|
+
}
|
|
98
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "th-memory-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
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",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"dist",
|
|
18
18
|
"README.md",
|
|
19
19
|
"LICENSE",
|
|
20
|
+
"ARCHITECTURE_v2.md",
|
|
20
21
|
"design.md",
|
|
21
22
|
"opencode.example.json",
|
|
22
23
|
"AGENTS.memory.example.md"
|
|
@@ -26,7 +27,7 @@
|
|
|
26
27
|
"prepublishOnly": "npm run build",
|
|
27
28
|
"start": "node dist/index.js",
|
|
28
29
|
"inspect": "npx @modelcontextprotocol/inspector node dist/index.js",
|
|
29
|
-
"test": "npm run build && node test
|
|
30
|
+
"test": "npm run build && node --test test/*.test.mjs",
|
|
30
31
|
"distill": "node dist/distill.js",
|
|
31
32
|
"quickstart": "npm run build && node scripts/quickstart.mjs"
|
|
32
33
|
},
|