th-memory-mcp 1.2.2 → 2.1.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 +1582 -0
- package/README.md +214 -187
- package/README.th.md +28 -7
- package/dist/core/consolidation-engine.js +87 -0
- package/dist/core/context-engine.js +50 -0
- package/dist/core/graph-engine.js +67 -0
- package/dist/core/lifecycle-engine.js +76 -0
- package/dist/core/retrieval-engine.js +40 -0
- package/dist/core/temporal-engine.js +73 -0
- package/dist/db/index.js +111 -0
- package/dist/db/migrations.js +160 -0
- package/dist/db/repositories/memories.js +52 -0
- package/dist/db.js +3 -0
- package/dist/index.js +42 -0
- package/dist/lib/embed.js +8 -5
- package/dist/memory/conflict-resolver.js +125 -0
- package/dist/memory/decay.js +30 -0
- package/dist/memory/deduplicator.js +51 -0
- package/dist/memory/scorer.js +44 -0
- package/dist/memory/source-weights.js +13 -0
- package/dist/memory/types.js +41 -0
- package/dist/retrieval/fts.js +22 -0
- package/dist/retrieval/fusion.js +11 -0
- package/dist/retrieval/scorer.js +19 -0
- package/dist/retrieval/vector.js +29 -0
- package/dist/tools/consolidate.js +63 -0
- package/dist/tools/context.js +55 -0
- package/dist/tools/export_memory.js +1 -1
- package/dist/tools/extract_memories.js +90 -0
- package/dist/tools/forget.js +1 -1
- package/dist/tools/history.js +1 -1
- package/dist/tools/import_memory.js +95 -0
- package/dist/tools/lesson.js +1 -1
- package/dist/tools/link_memory.js +31 -0
- package/dist/tools/memory_stats.js +1 -1
- package/dist/tools/merge_memory.js +49 -0
- package/dist/tools/profile.js +1 -1
- package/dist/tools/recall.js +1 -1
- package/dist/tools/recent_interactions.js +1 -1
- package/dist/tools/remember.js +1 -1
- package/dist/tools/update_memory.js +98 -0
- package/package.json +46 -46
- package/design.md +0 -308
package/dist/index.js
CHANGED
|
@@ -10,6 +10,13 @@ import { forgetInput, forgetHandler } from "./tools/forget.js";
|
|
|
10
10
|
import { memoryStatsHandler } from "./tools/memory_stats.js";
|
|
11
11
|
import { recentInteractionsInput, getRecentInteractionsHandler, } from "./tools/recent_interactions.js";
|
|
12
12
|
import { exportMemoryInput, exportMemoryHandler, } from "./tools/export_memory.js";
|
|
13
|
+
import { contextInput, contextHandler } from "./tools/context.js";
|
|
14
|
+
import { consolidateInput, consolidateHandler } from "./tools/consolidate.js";
|
|
15
|
+
import { linkMemoryInput, linkMemoryHandler } from "./tools/link_memory.js";
|
|
16
|
+
import { mergeMemoryInput, mergeMemoryHandler } from "./tools/merge_memory.js";
|
|
17
|
+
import { updateMemoryInput, updateMemoryHandler } from "./tools/update_memory.js";
|
|
18
|
+
import { importMemoryInput, importMemoryHandler } from "./tools/import_memory.js";
|
|
19
|
+
import { extractMemoriesInput, extractMemoriesHandler } from "./tools/extract_memories.js";
|
|
13
20
|
import { VERSION } from "./lib/config.js";
|
|
14
21
|
const server = new McpServer({
|
|
15
22
|
name: "th-memory-mcp",
|
|
@@ -60,6 +67,41 @@ server.registerTool("export_memory", {
|
|
|
60
67
|
description: "Export preferences, lessons, profile (and optionally raw interactions) to a JSON file under data/exports/. Only writes inside that directory. Returns the file path, size in bytes and a JSON preview.",
|
|
61
68
|
inputSchema: exportMemoryInput,
|
|
62
69
|
}, (args) => exportMemoryHandler(args));
|
|
70
|
+
server.registerTool("get_context", {
|
|
71
|
+
title: "Get assembled context",
|
|
72
|
+
description: "Assemble relevant memories for the current task via hybrid retrieval (+ optional memory-graph expansion), with token budgeting. Use to load memory into context before a task.",
|
|
73
|
+
inputSchema: contextInput,
|
|
74
|
+
}, (args) => contextHandler(args));
|
|
75
|
+
server.registerTool("consolidate", {
|
|
76
|
+
title: "Consolidate memories",
|
|
77
|
+
description: "Cluster similar memories via embedding similarity and optionally create derived/consolidated memories linked via 'derived_from'. Use during periodic consolidation.",
|
|
78
|
+
inputSchema: consolidateInput,
|
|
79
|
+
}, (args) => consolidateHandler(args));
|
|
80
|
+
server.registerTool("link_memory", {
|
|
81
|
+
title: "Link two memories",
|
|
82
|
+
description: "Create a typed relationship between two memories in the graph (supports/contradicts/supersedes/derived_from/related_to/caused_by/depends_on).",
|
|
83
|
+
inputSchema: linkMemoryInput,
|
|
84
|
+
}, (args) => linkMemoryHandler(args));
|
|
85
|
+
server.registerTool("merge_memory", {
|
|
86
|
+
title: "Merge memories",
|
|
87
|
+
description: "Merge a duplicate/near-duplicate memory into a canonical one. The source becomes superseded and provenance is recorded in metadata.merged_from.",
|
|
88
|
+
inputSchema: mergeMemoryInput,
|
|
89
|
+
}, (args) => mergeMemoryHandler(args));
|
|
90
|
+
server.registerTool("update_memory", {
|
|
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).",
|
|
93
|
+
inputSchema: updateMemoryInput,
|
|
94
|
+
}, (args) => updateMemoryHandler(args));
|
|
95
|
+
server.registerTool("import_memory", {
|
|
96
|
+
title: "Import memories",
|
|
97
|
+
description: "Import memories from a JSON array or a .json file inside data/exports/. Validates type, dedupes against existing memories, and never overwrites active memory blindly. Dry-run by default; pass apply=true to insert.",
|
|
98
|
+
inputSchema: importMemoryInput,
|
|
99
|
+
}, (args) => importMemoryHandler(args));
|
|
100
|
+
server.registerTool("extract_memories", {
|
|
101
|
+
title: "Extract memories from interactions",
|
|
102
|
+
description: "Scan recent captured interactions for memory-intent phrases and propose memory candidates (deterministic, no LLM). Dry-run by default; pass apply=true to create them (source=captured).",
|
|
103
|
+
inputSchema: extractMemoriesInput,
|
|
104
|
+
}, (args) => extractMemoriesHandler(args));
|
|
63
105
|
async function main() {
|
|
64
106
|
const transport = new StdioServerTransport();
|
|
65
107
|
await server.connect(transport);
|
package/dist/lib/embed.js
CHANGED
|
@@ -53,13 +53,16 @@ export function cosine(a, b) {
|
|
|
53
53
|
return dot;
|
|
54
54
|
}
|
|
55
55
|
export function serialize(vec) {
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
const buf = Buffer.alloc(EMBED_DIM * 4);
|
|
57
|
+
const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
58
|
+
for (let i = 0; i < EMBED_DIM; i++)
|
|
59
|
+
dv.setFloat32(i * 4, vec[i] ?? 0, true);
|
|
60
|
+
return buf;
|
|
59
61
|
}
|
|
60
62
|
export function deserialize(buf) {
|
|
61
63
|
const out = new Float32Array(EMBED_DIM);
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
+
const view = new DataView(buf.buffer, buf.byteOffset, Math.min(EMBED_DIM * 4, buf.byteLength));
|
|
65
|
+
for (let i = 0; i < EMBED_DIM; i++)
|
|
66
|
+
out[i] = view.getFloat32(i * 4, true);
|
|
64
67
|
return out;
|
|
65
68
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { db, nowISO } from "../db/index.js";
|
|
2
|
+
import { getMemoryById } from "../db/repositories/memories.js";
|
|
3
|
+
import { supersede, reinforce, softDelete } from "../core/lifecycle-engine.js";
|
|
4
|
+
import { embed, cosine, deserialize } from "../lib/embed.js";
|
|
5
|
+
const SIM_DUP = 0.9;
|
|
6
|
+
const SIM_UPDATE = 0.45;
|
|
7
|
+
const SIM_CONTRA = 0.35;
|
|
8
|
+
const RELATED_THRESHOLD = 0.35;
|
|
9
|
+
const NEGATION = [
|
|
10
|
+
/\bnot\b/i,
|
|
11
|
+
/\bnever\b/i,
|
|
12
|
+
/\bno longer\b/i,
|
|
13
|
+
/\bstop\b/i,
|
|
14
|
+
/\bavoid\b/i,
|
|
15
|
+
/\bdon'?t\b/i,
|
|
16
|
+
/\binstead\b/i,
|
|
17
|
+
/\bbut\b/i,
|
|
18
|
+
/\bhowever\b/i,
|
|
19
|
+
/ไม่/i,
|
|
20
|
+
/ห้าม/i,
|
|
21
|
+
/เลิก/i,
|
|
22
|
+
/อย่า/i,
|
|
23
|
+
];
|
|
24
|
+
export function isContradiction(a, b) {
|
|
25
|
+
const na = NEGATION.some((re) => re.test(a));
|
|
26
|
+
const nb = NEGATION.some((re) => re.test(b));
|
|
27
|
+
if (na === nb)
|
|
28
|
+
return false; // both or neither negated -> not a simple contradiction
|
|
29
|
+
const ta = tokenSet(a);
|
|
30
|
+
const tb = tokenSet(b);
|
|
31
|
+
let overlap = 0;
|
|
32
|
+
for (const t of ta)
|
|
33
|
+
if (tb.has(t))
|
|
34
|
+
overlap++;
|
|
35
|
+
return overlap >= 2;
|
|
36
|
+
}
|
|
37
|
+
function tokenSet(s) {
|
|
38
|
+
return new Set(s.toLowerCase().match(/[a-z0-9ก-์]+/gi) ?? []);
|
|
39
|
+
}
|
|
40
|
+
export function classifyRelationship(candidate, relatedId) {
|
|
41
|
+
const rel = getMemoryById(relatedId);
|
|
42
|
+
if (!rel)
|
|
43
|
+
return "unrelated";
|
|
44
|
+
const relVecRow = db
|
|
45
|
+
.prepare("SELECT vec FROM embeddings WHERE ref_table='memories' AND ref_id=?")
|
|
46
|
+
.get(relatedId);
|
|
47
|
+
if (!relVecRow)
|
|
48
|
+
return "unrelated";
|
|
49
|
+
const score = cosine(embed(candidate.content), deserialize(relVecRow.vec));
|
|
50
|
+
if (score >= SIM_DUP)
|
|
51
|
+
return "duplicate";
|
|
52
|
+
if (isContradiction(candidate.content, rel.content) && score >= SIM_CONTRA) {
|
|
53
|
+
return "contradiction";
|
|
54
|
+
}
|
|
55
|
+
if (score >= SIM_UPDATE)
|
|
56
|
+
return "update";
|
|
57
|
+
return "unrelated";
|
|
58
|
+
}
|
|
59
|
+
// Find best related active/non-archived memory of the same type for a candidate.
|
|
60
|
+
export function findRelated(candidate, threshold = RELATED_THRESHOLD) {
|
|
61
|
+
const vec = embed(candidate.content);
|
|
62
|
+
const rows = db
|
|
63
|
+
.prepare("SELECT ref_id, vec FROM embeddings WHERE ref_table = 'memories'")
|
|
64
|
+
.all();
|
|
65
|
+
let best;
|
|
66
|
+
for (const r of rows) {
|
|
67
|
+
if (candidate.id && r.ref_id === candidate.id)
|
|
68
|
+
continue;
|
|
69
|
+
const m = getMemoryById(r.ref_id);
|
|
70
|
+
if (!m ||
|
|
71
|
+
m.type !== candidate.type ||
|
|
72
|
+
m.status === "deleted" ||
|
|
73
|
+
m.status === "archived")
|
|
74
|
+
continue;
|
|
75
|
+
const score = cosine(vec, deserialize(r.vec));
|
|
76
|
+
if (score >= threshold && (!best || score > best.score)) {
|
|
77
|
+
best = { id: r.ref_id, score };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return best;
|
|
81
|
+
}
|
|
82
|
+
// Resolve a candidate against existing memories. If candidate.id is set (memory
|
|
83
|
+
// already created), it will be reconciled (superseded / linked / merged).
|
|
84
|
+
export function resolveConflict(candidate) {
|
|
85
|
+
const related = findRelated(candidate);
|
|
86
|
+
if (!related)
|
|
87
|
+
return { relation: "unrelated", score: 0, action: "none" };
|
|
88
|
+
const relation = classifyRelationship(candidate, related.id);
|
|
89
|
+
if (relation === "duplicate" && candidate.id !== undefined) {
|
|
90
|
+
reinforce(related.id);
|
|
91
|
+
softDelete(candidate.id);
|
|
92
|
+
return {
|
|
93
|
+
relation,
|
|
94
|
+
relatedId: related.id,
|
|
95
|
+
score: related.score,
|
|
96
|
+
action: "merged",
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
if (relation === "update" && candidate.id !== undefined) {
|
|
100
|
+
supersede(related.id, candidate.id);
|
|
101
|
+
return {
|
|
102
|
+
relation,
|
|
103
|
+
relatedId: related.id,
|
|
104
|
+
score: related.score,
|
|
105
|
+
action: "superseded",
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
if (relation === "contradiction" && candidate.id !== undefined) {
|
|
109
|
+
db.prepare(`INSERT INTO memory_links (source_memory_id, relation, target_memory_id, confidence, created_at)
|
|
110
|
+
VALUES (?, 'contradicts', ?, 0.8, ?)
|
|
111
|
+
ON CONFLICT(source_memory_id, relation, target_memory_id) DO UPDATE SET confidence = 0.8`).run(candidate.id, related.id, nowISO());
|
|
112
|
+
return {
|
|
113
|
+
relation,
|
|
114
|
+
relatedId: related.id,
|
|
115
|
+
score: related.score,
|
|
116
|
+
action: "linked_contradiction",
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
relation,
|
|
121
|
+
relatedId: related.id,
|
|
122
|
+
score: related.score,
|
|
123
|
+
action: "none",
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Per-type decay rate (lambda) for recency = exp(-lambda * age_days).
|
|
2
|
+
// Lower lambda => slower decay (more durable). Policy classes, not fixed constants.
|
|
3
|
+
export const DECAY_LAMBDA_BY_TYPE = {
|
|
4
|
+
CONSTRAINT: 0.0005,
|
|
5
|
+
DECISION: 0.001,
|
|
6
|
+
LESSON: 0.001,
|
|
7
|
+
PREFERENCE: 0.001,
|
|
8
|
+
FACT: 0.005,
|
|
9
|
+
GOAL: 0.01,
|
|
10
|
+
PROCEDURE: 0.005,
|
|
11
|
+
EPISODE: 0.01,
|
|
12
|
+
RELATION: 0.005,
|
|
13
|
+
PROFILE: 0.0,
|
|
14
|
+
DERIVED: 0.002,
|
|
15
|
+
};
|
|
16
|
+
export function decayLambdaFor(type) {
|
|
17
|
+
return DECAY_LAMBDA_BY_TYPE[type] ?? 0.005;
|
|
18
|
+
}
|
|
19
|
+
export function ageInDays(isoTs, now = new Date()) {
|
|
20
|
+
const t = new Date(isoTs).getTime();
|
|
21
|
+
if (Number.isNaN(t))
|
|
22
|
+
return 0;
|
|
23
|
+
return Math.max(0, (now.getTime() - t) / 86_400_000);
|
|
24
|
+
}
|
|
25
|
+
export function recencyFactor(isoTs, lambda, now = new Date()) {
|
|
26
|
+
return Math.exp(-lambda * ageInDays(isoTs, now));
|
|
27
|
+
}
|
|
28
|
+
export function recencyFactorFor(type, isoTs, now = new Date()) {
|
|
29
|
+
return recencyFactor(isoTs, decayLambdaFor(type), now);
|
|
30
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { embed, cosine, deserialize } from "../lib/embed.js";
|
|
2
|
+
import { db } from "../db/index.js";
|
|
3
|
+
export function normalizeText(text) {
|
|
4
|
+
return text
|
|
5
|
+
.toLowerCase()
|
|
6
|
+
.normalize("NFKC")
|
|
7
|
+
.replace(/\s+/g, " ")
|
|
8
|
+
.replace(/[^\p{L}\p{N}\s]/gu, "")
|
|
9
|
+
.trim();
|
|
10
|
+
}
|
|
11
|
+
// Exact / normalized match on content within the same type.
|
|
12
|
+
export function findExactMatch(type, content) {
|
|
13
|
+
const norm = normalizeText(content);
|
|
14
|
+
const rows = db
|
|
15
|
+
.prepare("SELECT id, content FROM memories WHERE type = ? AND status != 'deleted'")
|
|
16
|
+
.all(type);
|
|
17
|
+
for (const r of rows) {
|
|
18
|
+
if (normalizeText(r.content) === norm)
|
|
19
|
+
return r.id;
|
|
20
|
+
}
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
// Semantic similarity against existing memories of the same type.
|
|
24
|
+
export function findSimilar(type, content, threshold = 0.82) {
|
|
25
|
+
const vec = embed(content);
|
|
26
|
+
const rows = db
|
|
27
|
+
.prepare("SELECT ref_id, vec FROM embeddings WHERE ref_table = 'memories'")
|
|
28
|
+
.all();
|
|
29
|
+
let best;
|
|
30
|
+
for (const r of rows) {
|
|
31
|
+
const m = db
|
|
32
|
+
.prepare("SELECT type, status FROM memories WHERE id = ?")
|
|
33
|
+
.get(r.ref_id);
|
|
34
|
+
if (!m || m.type !== type || m.status === "deleted")
|
|
35
|
+
continue;
|
|
36
|
+
const score = cosine(vec, deserialize(r.vec));
|
|
37
|
+
if (score >= threshold && (!best || score > best.score)) {
|
|
38
|
+
best = { id: r.ref_id, score };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return best;
|
|
42
|
+
}
|
|
43
|
+
export function deduplicate(type, content) {
|
|
44
|
+
const exact = findExactMatch(type, content);
|
|
45
|
+
if (exact !== undefined)
|
|
46
|
+
return { verdict: "duplicate", existingId: exact, score: 1 };
|
|
47
|
+
const sim = findSimilar(type, content);
|
|
48
|
+
if (sim)
|
|
49
|
+
return { verdict: "duplicate", existingId: sim.id, score: sim.score };
|
|
50
|
+
return { verdict: "distinct", score: 0 };
|
|
51
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { sourceWeight } from "./source-weights.js";
|
|
2
|
+
import { recencyFactorFor } from "./decay.js";
|
|
3
|
+
export const DEFAULT_SALIENCE_WEIGHTS = {
|
|
4
|
+
semanticRelevance: 0.3,
|
|
5
|
+
importance: 0.2,
|
|
6
|
+
confidence: 0.15,
|
|
7
|
+
recency: 0.15,
|
|
8
|
+
accessFrequency: 0.1,
|
|
9
|
+
projectRelevance: 0.1,
|
|
10
|
+
};
|
|
11
|
+
export function clamp01(x) {
|
|
12
|
+
if (Number.isNaN(x))
|
|
13
|
+
return 0;
|
|
14
|
+
return Math.min(1, Math.max(0, x));
|
|
15
|
+
}
|
|
16
|
+
export function computeSalience(input) {
|
|
17
|
+
const w = { ...DEFAULT_SALIENCE_WEIGHTS, ...(input.weights ?? {}) };
|
|
18
|
+
const raw = w.semanticRelevance * clamp01(input.relevance) +
|
|
19
|
+
w.importance * clamp01(input.importance) +
|
|
20
|
+
w.confidence * clamp01(input.confidence) +
|
|
21
|
+
w.recency * clamp01(input.recency) +
|
|
22
|
+
w.accessFrequency * clamp01(input.accessFrequency) +
|
|
23
|
+
w.projectRelevance * clamp01(input.projectRelevance);
|
|
24
|
+
return clamp01(raw);
|
|
25
|
+
}
|
|
26
|
+
export function computeConfidence(input) {
|
|
27
|
+
const w = sourceWeight(input.source);
|
|
28
|
+
const n = Math.max(0, input.confirmations);
|
|
29
|
+
const next = w + (1 - w) * (1 - 1 / (1 + n));
|
|
30
|
+
return clamp01(next);
|
|
31
|
+
}
|
|
32
|
+
// Convenience: build salience from a memory record + query relevance.
|
|
33
|
+
export function salienceForMemory(params) {
|
|
34
|
+
const recency = recencyFactorFor(params.type, params.updatedAt);
|
|
35
|
+
const accessFrequency = clamp01(params.accessCount / (params.maxAccess ?? 10));
|
|
36
|
+
return computeSalience({
|
|
37
|
+
relevance: params.relevance,
|
|
38
|
+
importance: params.importance,
|
|
39
|
+
confidence: params.confidence,
|
|
40
|
+
recency,
|
|
41
|
+
accessFrequency,
|
|
42
|
+
projectRelevance: params.projectRelevance ?? 0.5,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Recommended source weights for confidence (spec §8). Defaults, not immutable constants.
|
|
2
|
+
export const SOURCE_WEIGHTS = {
|
|
3
|
+
explicit: 1.0,
|
|
4
|
+
corrected: 0.95,
|
|
5
|
+
system: 0.8,
|
|
6
|
+
consolidated: 0.75,
|
|
7
|
+
imported: 0.7,
|
|
8
|
+
inferred: 0.5,
|
|
9
|
+
captured: 0.3,
|
|
10
|
+
};
|
|
11
|
+
export function sourceWeight(source) {
|
|
12
|
+
return SOURCE_WEIGHTS[source] ?? 0.5;
|
|
13
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export const MEMORY_TYPES = [
|
|
2
|
+
"FACT",
|
|
3
|
+
"PREFERENCE",
|
|
4
|
+
"GOAL",
|
|
5
|
+
"DECISION",
|
|
6
|
+
"CONSTRAINT",
|
|
7
|
+
"LESSON",
|
|
8
|
+
"PROCEDURE",
|
|
9
|
+
"EPISODE",
|
|
10
|
+
"RELATION",
|
|
11
|
+
"PROFILE",
|
|
12
|
+
"DERIVED",
|
|
13
|
+
];
|
|
14
|
+
export const SOURCE_TYPES = [
|
|
15
|
+
"explicit",
|
|
16
|
+
"corrected",
|
|
17
|
+
"inferred",
|
|
18
|
+
"captured",
|
|
19
|
+
"consolidated",
|
|
20
|
+
"imported",
|
|
21
|
+
"system",
|
|
22
|
+
];
|
|
23
|
+
export const LIFECYCLE_STATES = [
|
|
24
|
+
"new",
|
|
25
|
+
"active",
|
|
26
|
+
"reinforced",
|
|
27
|
+
"stale",
|
|
28
|
+
"superseded",
|
|
29
|
+
"archived",
|
|
30
|
+
"deleted",
|
|
31
|
+
];
|
|
32
|
+
export const SCOPES = ["GLOBAL", "USER", "PROJECT", "SESSION"];
|
|
33
|
+
export const LINK_RELATIONS = [
|
|
34
|
+
"supports",
|
|
35
|
+
"contradicts",
|
|
36
|
+
"supersedes",
|
|
37
|
+
"derived_from",
|
|
38
|
+
"related_to",
|
|
39
|
+
"caused_by",
|
|
40
|
+
"depends_on",
|
|
41
|
+
];
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { db, buildFtsMatch } from "../db/index.js";
|
|
2
|
+
export function ftsSearch(query, opts = {}) {
|
|
3
|
+
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
|
|
4
|
+
const statusClause = opts.includeArchived
|
|
5
|
+
? ""
|
|
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
|
+
: "";
|
|
10
|
+
const rows = db
|
|
11
|
+
.prepare(`SELECT m.id FROM memories m
|
|
12
|
+
JOIN search_index ON search_index.ref_table = 'memories' AND search_index.ref_id = m.id
|
|
13
|
+
WHERE search_index MATCH @match ${statusClause} ${projectClause}
|
|
14
|
+
ORDER BY rank
|
|
15
|
+
LIMIT @limit`)
|
|
16
|
+
.all({
|
|
17
|
+
match: buildFtsMatch(query),
|
|
18
|
+
limit,
|
|
19
|
+
projectId: opts.projectId,
|
|
20
|
+
});
|
|
21
|
+
return rows.map((r, i) => ({ id: r.id, rank: i + 1 }));
|
|
22
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Reciprocal Rank Fusion (spec §13.1): RRF(m) = Σ 1 / (k + rank_i(m))
|
|
2
|
+
export function rrfFuse(lists, k = 60) {
|
|
3
|
+
const scores = new Map();
|
|
4
|
+
for (const list of lists) {
|
|
5
|
+
for (const item of list) {
|
|
6
|
+
const rrf = 1 / (k + item.rank);
|
|
7
|
+
scores.set(item.id, (scores.get(item.id) ?? 0) + rrf);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
return scores;
|
|
11
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { clamp01 } from "../memory/scorer.js";
|
|
2
|
+
// spec §13.1: final_score = RRF * confidence * importance_factor * recency_factor * scope_factor
|
|
3
|
+
export function finalScore(input) {
|
|
4
|
+
const importanceFactor = 0.5 + 0.5 * clamp01(input.importance); // 0.5..1.0
|
|
5
|
+
return (input.rrf *
|
|
6
|
+
clamp01(input.confidence) *
|
|
7
|
+
importanceFactor *
|
|
8
|
+
clamp01(input.recency) *
|
|
9
|
+
clamp01(input.scopeFactor));
|
|
10
|
+
}
|
|
11
|
+
export function scopeFactorFor(mem, projectId) {
|
|
12
|
+
if (!projectId)
|
|
13
|
+
return 0.8; // global query: project-scoped memories slightly favored
|
|
14
|
+
if (mem.project_id === projectId)
|
|
15
|
+
return 1.0;
|
|
16
|
+
if (mem.project_id === null)
|
|
17
|
+
return 0.7;
|
|
18
|
+
return 0.3; // different project
|
|
19
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { db } from "../db/index.js";
|
|
2
|
+
import { embed, cosine, deserialize } from "../lib/embed.js";
|
|
3
|
+
export function vectorSearch(query, opts = {}) {
|
|
4
|
+
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
|
|
5
|
+
const floor = opts.floor ?? 0.15;
|
|
6
|
+
const topicVec = embed(query);
|
|
7
|
+
const rows = db
|
|
8
|
+
.prepare("SELECT ref_id, vec FROM embeddings WHERE ref_table = 'memories'")
|
|
9
|
+
.all();
|
|
10
|
+
const statusOk = (id) => {
|
|
11
|
+
const m = db
|
|
12
|
+
.prepare("SELECT status, project_id FROM memories WHERE id = ?")
|
|
13
|
+
.get(id);
|
|
14
|
+
if (!m)
|
|
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
|
+
};
|
|
24
|
+
return rows
|
|
25
|
+
.map((r) => ({ id: r.ref_id, score: cosine(topicVec, deserialize(r.vec)) }))
|
|
26
|
+
.filter((r) => r.score >= floor && statusOk(r.id))
|
|
27
|
+
.sort((a, b) => b.score - a.score)
|
|
28
|
+
.slice(0, limit);
|
|
29
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { clusterMemories, createDerivedMemory, } from "../core/consolidation-engine.js";
|
|
3
|
+
import { db, ok } from "../db/index.js";
|
|
4
|
+
export const consolidateInput = {
|
|
5
|
+
threshold: z
|
|
6
|
+
.number()
|
|
7
|
+
.min(0)
|
|
8
|
+
.max(1)
|
|
9
|
+
.optional()
|
|
10
|
+
.describe("Cosine similarity threshold for clustering (default 0.7)"),
|
|
11
|
+
projectId: z.string().nullable().optional().describe("Scope to a project"),
|
|
12
|
+
minClusterSize: z
|
|
13
|
+
.number()
|
|
14
|
+
.int()
|
|
15
|
+
.min(2)
|
|
16
|
+
.max(20)
|
|
17
|
+
.optional()
|
|
18
|
+
.describe("Minimum members to report a cluster (default 2)"),
|
|
19
|
+
derive: z
|
|
20
|
+
.boolean()
|
|
21
|
+
.optional()
|
|
22
|
+
.describe("Create a derived memory for each cluster"),
|
|
23
|
+
};
|
|
24
|
+
export function consolidateHandler(args) {
|
|
25
|
+
const clusters = clusterMemories({
|
|
26
|
+
threshold: typeof args.threshold === "number" ? args.threshold : undefined,
|
|
27
|
+
projectId: typeof args.projectId === "string" ? args.projectId : null,
|
|
28
|
+
minClusterSize: typeof args.minClusterSize === "number"
|
|
29
|
+
? args.minClusterSize
|
|
30
|
+
: undefined,
|
|
31
|
+
});
|
|
32
|
+
const lines = [];
|
|
33
|
+
const derivedIds = [];
|
|
34
|
+
for (const c of clusters) {
|
|
35
|
+
const contents = c.map((id) => {
|
|
36
|
+
const m = db
|
|
37
|
+
.prepare("SELECT content FROM memories WHERE id = ?")
|
|
38
|
+
.get(id);
|
|
39
|
+
return ` - [${id}] ${m?.content ?? "?"}`;
|
|
40
|
+
});
|
|
41
|
+
lines.push(`Cluster (${c.length}):\n${contents.join("\n")}`);
|
|
42
|
+
if (args.derive === true) {
|
|
43
|
+
const summary = c
|
|
44
|
+
.map((id) => {
|
|
45
|
+
const m = db
|
|
46
|
+
.prepare("SELECT content FROM memories WHERE id = ?")
|
|
47
|
+
.get(id);
|
|
48
|
+
return m?.content ?? "";
|
|
49
|
+
})
|
|
50
|
+
.join(" | ");
|
|
51
|
+
const did = createDerivedMemory({
|
|
52
|
+
content: `Consolidated: ${summary}`,
|
|
53
|
+
sourceIds: c,
|
|
54
|
+
});
|
|
55
|
+
derivedIds.push(did);
|
|
56
|
+
lines.push(` => derived memory id=${did}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const header = `Found ${clusters.length} cluster(s)${args.derive === true
|
|
60
|
+
? `, created ${derivedIds.length} derived memories`
|
|
61
|
+
: ""}`;
|
|
62
|
+
return ok(header + "\n\n" + (lines.join("\n\n") || "(no clusters)"));
|
|
63
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getContext } from "../core/context-engine.js";
|
|
3
|
+
import { ok } from "../db/index.js";
|
|
4
|
+
export const contextInput = {
|
|
5
|
+
query: z
|
|
6
|
+
.string()
|
|
7
|
+
.optional()
|
|
8
|
+
.describe("Optional focus query to seed hybrid retrieval"),
|
|
9
|
+
projectId: z
|
|
10
|
+
.string()
|
|
11
|
+
.nullable()
|
|
12
|
+
.optional()
|
|
13
|
+
.describe("Scope context to a project"),
|
|
14
|
+
sessionId: z.string().nullable().optional(),
|
|
15
|
+
limit: z
|
|
16
|
+
.number()
|
|
17
|
+
.int()
|
|
18
|
+
.min(1)
|
|
19
|
+
.max(50)
|
|
20
|
+
.optional()
|
|
21
|
+
.describe("Number of seed memories (default 10)"),
|
|
22
|
+
maxTokens: z
|
|
23
|
+
.number()
|
|
24
|
+
.int()
|
|
25
|
+
.min(100)
|
|
26
|
+
.max(8000)
|
|
27
|
+
.optional()
|
|
28
|
+
.describe("Token budget for assembled context (default 2000)"),
|
|
29
|
+
includeHistory: z
|
|
30
|
+
.boolean()
|
|
31
|
+
.optional()
|
|
32
|
+
.describe("Include superseded/archived memories"),
|
|
33
|
+
includeGraph: z
|
|
34
|
+
.boolean()
|
|
35
|
+
.optional()
|
|
36
|
+
.describe("Expand seeds with memory-graph neighbors"),
|
|
37
|
+
};
|
|
38
|
+
export function contextHandler(args) {
|
|
39
|
+
const res = getContext({
|
|
40
|
+
query: typeof args.query === "string" ? args.query : "",
|
|
41
|
+
projectId: typeof args.projectId === "string" ? args.projectId : null,
|
|
42
|
+
sessionId: typeof args.sessionId === "string" ? args.sessionId : null,
|
|
43
|
+
limit: typeof args.limit === "number" ? args.limit : undefined,
|
|
44
|
+
maxTokens: typeof args.maxTokens === "number" ? args.maxTokens : undefined,
|
|
45
|
+
includeHistory: args.includeHistory === true,
|
|
46
|
+
includeGraph: args.includeGraph === true,
|
|
47
|
+
});
|
|
48
|
+
const lines = res.memories.map((m) => {
|
|
49
|
+
const meta = m.metadata ? ` meta=${m.metadata}` : "";
|
|
50
|
+
const tag = m.viaGraph ? " [graph]" : "";
|
|
51
|
+
return `[${m.id}] (${m.type}/${m.status})${tag} ${m.content}${meta}`;
|
|
52
|
+
});
|
|
53
|
+
const header = `Context for "${res.query || "<no query>"}" — ${res.memories.length} memories, ~${res.tokenEstimate} tokens${res.truncated ? " (truncated to budget)" : ""}`;
|
|
54
|
+
return ok(header + "\n\n" + (lines.join("\n") || "(no memories)"));
|
|
55
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mkdirSync, writeFileSync, statSync } from "node:fs";
|
|
2
2
|
import { join, dirname } from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
import { db, DB_PATH, nowISO, truncate, ok, err, } from "../db.js";
|
|
4
|
+
import { db, DB_PATH, nowISO, truncate, ok, err, } from "../db/index.js";
|
|
5
5
|
import { VERSION, EXPORTS_DIRNAME } from "../lib/config.js";
|
|
6
6
|
export const exportMemoryInput = {
|
|
7
7
|
includeInteractions: z
|