th-memory-mcp 1.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/AGENTS.memory.example.md +41 -0
- package/LICENSE +21 -0
- package/README.md +143 -0
- package/README.th.md +143 -0
- package/design.md +308 -0
- package/dist/db.js +86 -0
- package/dist/distill.js +68 -0
- package/dist/index.js +71 -0
- package/dist/lib/capture-core.js +46 -0
- package/dist/lib/config.js +6 -0
- package/dist/lib/distill-core.js +137 -0
- package/dist/tools/export_memory.js +90 -0
- package/dist/tools/forget.js +86 -0
- package/dist/tools/history.js +29 -0
- package/dist/tools/lesson.js +20 -0
- package/dist/tools/memory_stats.js +50 -0
- package/dist/tools/profile.js +46 -0
- package/dist/tools/recall.js +67 -0
- package/dist/tools/recent_interactions.js +36 -0
- package/dist/tools/remember.js +37 -0
- package/opencode.example.json +13 -0
- package/package.json +45 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// distill-core: pure rule-based summarization helpers for the Learning Loop.
|
|
2
|
+
// NO DB imports — must compile with the main tsc config and stay testable.
|
|
3
|
+
// --- stopwords (basic EN + TH) ---
|
|
4
|
+
export const STOPWORDS = new Set([
|
|
5
|
+
// english
|
|
6
|
+
"the", "a", "an", "is", "are", "to", "of", "and", "or", "in", "on", "for",
|
|
7
|
+
"with", "this", "that", "it", "i", "you", "me", "my", "we", "be", "was",
|
|
8
|
+
"were", "been", "am", "do", "does", "did", "have", "has", "had", "not",
|
|
9
|
+
"no", "but", "so", "if", "then", "than", "as", "at", "by", "from", "into",
|
|
10
|
+
"about", "can", "could", "will", "would", "should", "shall", "may",
|
|
11
|
+
"might", "must", "just", "very", "there", "here", "what", "when", "where",
|
|
12
|
+
"which", "who", "how", "why", "all", "any", "some", "such", "own", "same",
|
|
13
|
+
"too", "its", "our", "your", "they", "them", "their", "he", "she", "his",
|
|
14
|
+
"her", "him", "us", "s", "t",
|
|
15
|
+
// thai
|
|
16
|
+
"และ", "หรือ", "ที่", "ให้", "ของ", "การ", "ไม่", "ใน", "มี", "ผม", "ฉัน",
|
|
17
|
+
"คือ", "ไป", "มา", "แล้ว", "ด้วย", "อะไร", "ทำ", "ใส่", "เป็น", "อยู่",
|
|
18
|
+
"จาก", "กับ", "ว่า", "นี้", "นั้น", "โดย", "ครับ", "ค่ะ", "ต้อง", "ยัง",
|
|
19
|
+
"เพื่อ", "แบบ", "หน่อย", "นะ", "ล่ะ", "เลย", "ก็", "แต่", "ถ้า", "ช่วย",
|
|
20
|
+
]);
|
|
21
|
+
let cachedSegmenter;
|
|
22
|
+
function getSegmenter() {
|
|
23
|
+
if (cachedSegmenter !== undefined)
|
|
24
|
+
return cachedSegmenter;
|
|
25
|
+
try {
|
|
26
|
+
const intl = Intl;
|
|
27
|
+
if (typeof intl.Segmenter === "function") {
|
|
28
|
+
const segmenter = new intl.Segmenter("th", { granularity: "word" });
|
|
29
|
+
cachedSegmenter = (text) => {
|
|
30
|
+
const out = [];
|
|
31
|
+
for (const s of segmenter.segment(text)) {
|
|
32
|
+
if (typeof s.segment === "string")
|
|
33
|
+
out.push(s.segment);
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
cachedSegmenter = null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
cachedSegmenter = null;
|
|
44
|
+
}
|
|
45
|
+
return cachedSegmenter;
|
|
46
|
+
}
|
|
47
|
+
const PURE_DIGITS = /^\d+$/;
|
|
48
|
+
export function tokenize(text) {
|
|
49
|
+
const input = String(text ?? "");
|
|
50
|
+
const seg = getSegmenter();
|
|
51
|
+
const raw = seg ? seg(input) : input.split(/\s+/);
|
|
52
|
+
const out = [];
|
|
53
|
+
for (const r of raw) {
|
|
54
|
+
const tok = r.toLowerCase().trim();
|
|
55
|
+
if (tok.length < 2)
|
|
56
|
+
continue;
|
|
57
|
+
if (PURE_DIGITS.test(tok))
|
|
58
|
+
continue;
|
|
59
|
+
if (STOPWORDS.has(tok))
|
|
60
|
+
continue;
|
|
61
|
+
out.push(tok);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
function topN(map, n) {
|
|
66
|
+
return [...map.entries()]
|
|
67
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
68
|
+
.slice(0, n);
|
|
69
|
+
}
|
|
70
|
+
function parseMetaObject(meta) {
|
|
71
|
+
if (typeof meta !== "string" || !meta.trim())
|
|
72
|
+
return null;
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(meta);
|
|
75
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
76
|
+
return parsed;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch { }
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
function bump(map, key) {
|
|
83
|
+
map.set(key, (map.get(key) ?? 0) + 1);
|
|
84
|
+
}
|
|
85
|
+
export function computeStats(rows) {
|
|
86
|
+
let totalPrompts = 0;
|
|
87
|
+
const days = new Set();
|
|
88
|
+
const toolCounts = new Map();
|
|
89
|
+
const keywordCounts = new Map();
|
|
90
|
+
const dirCounts = new Map();
|
|
91
|
+
for (const row of rows ?? []) {
|
|
92
|
+
if (!row || typeof row !== "object")
|
|
93
|
+
continue;
|
|
94
|
+
if (row.kind === "prompt") {
|
|
95
|
+
totalPrompts += 1;
|
|
96
|
+
days.add(String(row.ts ?? "").slice(0, 10));
|
|
97
|
+
for (const tok of tokenize(String(row.content ?? ""))) {
|
|
98
|
+
bump(keywordCounts, tok);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const metaObj = parseMetaObject(row.meta);
|
|
102
|
+
if (metaObj) {
|
|
103
|
+
const tool = metaObj.tool;
|
|
104
|
+
if (typeof tool === "string" && tool.trim()) {
|
|
105
|
+
bump(toolCounts, tool.trim());
|
|
106
|
+
}
|
|
107
|
+
const directory = metaObj.directory;
|
|
108
|
+
if (typeof directory === "string" && directory.trim()) {
|
|
109
|
+
bump(dirCounts, directory.trim());
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
totalPrompts,
|
|
115
|
+
promptDays: days.size,
|
|
116
|
+
topTools: topN(toolCounts, 10),
|
|
117
|
+
topKeywords: topN(keywordCounts, 15),
|
|
118
|
+
topDirs: topN(dirCounts, 5),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function pairList(pairs) {
|
|
122
|
+
return pairs.map(([name, count]) => `${name}(${count})`).join(", ");
|
|
123
|
+
}
|
|
124
|
+
export function formatProfileSections(stats) {
|
|
125
|
+
const lines = [];
|
|
126
|
+
lines.push(`prompts: ${stats.totalPrompts} across ${stats.promptDays} ${stats.promptDays === 1 ? "day" : "days"}`);
|
|
127
|
+
if (stats.topTools.length > 0) {
|
|
128
|
+
lines.push(`top tools: ${pairList(stats.topTools)}`);
|
|
129
|
+
}
|
|
130
|
+
if (stats.topDirs.length > 0) {
|
|
131
|
+
lines.push(`top dirs: ${pairList(stats.topDirs)}`);
|
|
132
|
+
}
|
|
133
|
+
const topics = stats.topKeywords.length > 0
|
|
134
|
+
? `frequent topics: ${stats.topKeywords.map(([w]) => w).join(", ")}`
|
|
135
|
+
: "frequent topics: (none)";
|
|
136
|
+
return { usage_stats: lines.join("\n"), topics };
|
|
137
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join, dirname } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { db, DB_PATH, nowISO, truncate, ok, err, } from "../db.js";
|
|
5
|
+
import { VERSION, EXPORTS_DIRNAME } from "../lib/config.js";
|
|
6
|
+
export const exportMemoryInput = {
|
|
7
|
+
includeInteractions: z
|
|
8
|
+
.boolean()
|
|
9
|
+
.default(false)
|
|
10
|
+
.describe("Include raw interaction rows in the export (file gets bigger)"),
|
|
11
|
+
filename: z
|
|
12
|
+
.string()
|
|
13
|
+
.min(1)
|
|
14
|
+
.max(200)
|
|
15
|
+
.optional()
|
|
16
|
+
.describe("Output file name inside data/exports/ (only A-Z a-z 0-9 . _ - allowed, must end with .json). Defaults to memory-export-YYYYMMDD-HHmmss.json"),
|
|
17
|
+
};
|
|
18
|
+
const PREVIEW_BUDGET = 500;
|
|
19
|
+
const EXPORT_DIR = join(dirname(DB_PATH), EXPORTS_DIRNAME);
|
|
20
|
+
const selectPrefs = db.prepare("SELECT id, category, key, value, confidence, source, updated_at FROM preferences ORDER BY id");
|
|
21
|
+
const selectLessons = db.prepare("SELECT id, situation, mistake, correction, created_at FROM lessons ORDER BY id");
|
|
22
|
+
const selectProfile = db.prepare("SELECT section, content, updated_at FROM profile ORDER BY section");
|
|
23
|
+
const selectInteractions = db.prepare("SELECT id, ts, session_id, kind, content, meta FROM interactions ORDER BY id");
|
|
24
|
+
function timestamp(d = new Date()) {
|
|
25
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
26
|
+
return (`${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}` +
|
|
27
|
+
`-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`);
|
|
28
|
+
}
|
|
29
|
+
const WINDOWS_RESERVED = new Set([
|
|
30
|
+
"CON",
|
|
31
|
+
"PRN",
|
|
32
|
+
"AUX",
|
|
33
|
+
"NUL",
|
|
34
|
+
...Array.from({ length: 9 }, (_, i) => `COM${i + 1}`),
|
|
35
|
+
...Array.from({ length: 9 }, (_, i) => `LPT${i + 1}`),
|
|
36
|
+
]);
|
|
37
|
+
function sanitizeFilename(name) {
|
|
38
|
+
if (name.includes(".."))
|
|
39
|
+
return null;
|
|
40
|
+
if (!/^[A-Za-z0-9._-]+$/.test(name))
|
|
41
|
+
return null;
|
|
42
|
+
if (!name.endsWith(".json"))
|
|
43
|
+
return null;
|
|
44
|
+
const stem = name.slice(0, -".json".length);
|
|
45
|
+
if (stem.length === 0)
|
|
46
|
+
return null;
|
|
47
|
+
if (WINDOWS_RESERVED.has(stem.toUpperCase()))
|
|
48
|
+
return null;
|
|
49
|
+
return name;
|
|
50
|
+
}
|
|
51
|
+
export async function exportMemoryHandler(args) {
|
|
52
|
+
try {
|
|
53
|
+
const includeInteractions = args.includeInteractions ?? false;
|
|
54
|
+
let filename;
|
|
55
|
+
if (args.filename !== undefined) {
|
|
56
|
+
const clean = sanitizeFilename(args.filename);
|
|
57
|
+
if (!clean) {
|
|
58
|
+
return err(`invalid filename "${truncate(args.filename, 100)}": only [A-Za-z0-9._-] allowed, no "..", must end with .json`);
|
|
59
|
+
}
|
|
60
|
+
filename = clean;
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
filename = `memory-export-${timestamp()}.json`;
|
|
64
|
+
}
|
|
65
|
+
const interactionsIncluded = includeInteractions
|
|
66
|
+
? selectInteractions.all()
|
|
67
|
+
: undefined;
|
|
68
|
+
const payload = {
|
|
69
|
+
exported_at: nowISO(),
|
|
70
|
+
version: VERSION,
|
|
71
|
+
preferences: selectPrefs.all(),
|
|
72
|
+
lessons: selectLessons.all(),
|
|
73
|
+
profile: selectProfile.all(),
|
|
74
|
+
interactions: {
|
|
75
|
+
included: includeInteractions,
|
|
76
|
+
count: interactionsIncluded ? interactionsIncluded.length : 0,
|
|
77
|
+
...(interactionsIncluded ? { rows: interactionsIncluded } : {}),
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
const json = JSON.stringify(payload, null, 2);
|
|
81
|
+
mkdirSync(EXPORT_DIR, { recursive: true });
|
|
82
|
+
const filePath = join(EXPORT_DIR, filename);
|
|
83
|
+
writeFileSync(filePath, json, "utf8");
|
|
84
|
+
const size = statSync(filePath).size;
|
|
85
|
+
return ok(`exported: ${filePath}\nsize: ${size} bytes\npreview: ${truncate(json, PREVIEW_BUDGET)}`);
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { db, removeSearchIndex, ok, err } from "../db.js";
|
|
3
|
+
export const forgetInput = {
|
|
4
|
+
target_id: z
|
|
5
|
+
.number()
|
|
6
|
+
.int()
|
|
7
|
+
.positive()
|
|
8
|
+
.describe("Row id to delete (id returned by remember/save_lesson)"),
|
|
9
|
+
type: z
|
|
10
|
+
.enum(["preference", "lesson", "interaction"])
|
|
11
|
+
.optional()
|
|
12
|
+
.describe("Which table the id belongs to. Recommended whenever known, because numeric ids can coincide across tables."),
|
|
13
|
+
};
|
|
14
|
+
const indexTables = db.prepare("SELECT DISTINCT ref_table FROM search_index WHERE ref_id = ?");
|
|
15
|
+
const existsPreference = db.prepare("SELECT id FROM preferences WHERE id = ?");
|
|
16
|
+
const existsLesson = db.prepare("SELECT id FROM lessons WHERE id = ?");
|
|
17
|
+
const existsInteraction = db.prepare("SELECT id FROM interactions WHERE id = ?");
|
|
18
|
+
const delPreference = db.prepare("DELETE FROM preferences WHERE id = ?");
|
|
19
|
+
const delLesson = db.prepare("DELETE FROM lessons WHERE id = ?");
|
|
20
|
+
const delInteraction = db.prepare("DELETE FROM interactions WHERE id = ?");
|
|
21
|
+
function existsIn(kind, id) {
|
|
22
|
+
if (kind === "preferences") {
|
|
23
|
+
return !!existsPreference.get(id);
|
|
24
|
+
}
|
|
25
|
+
if (kind === "lessons") {
|
|
26
|
+
return !!existsLesson.get(id);
|
|
27
|
+
}
|
|
28
|
+
return !!existsInteraction.get(id);
|
|
29
|
+
}
|
|
30
|
+
export async function forgetHandler(args) {
|
|
31
|
+
try {
|
|
32
|
+
const id = args.target_id;
|
|
33
|
+
const kindOf = {
|
|
34
|
+
preference: "preferences",
|
|
35
|
+
lesson: "lessons",
|
|
36
|
+
interaction: "interactions",
|
|
37
|
+
};
|
|
38
|
+
let targets = [];
|
|
39
|
+
const typedKind = args.type ? kindOf[args.type] : undefined;
|
|
40
|
+
if (typedKind) {
|
|
41
|
+
targets = [typedKind];
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
const indexed = indexTables.all(id);
|
|
45
|
+
const evidence = indexed
|
|
46
|
+
.map((r) => r.ref_table)
|
|
47
|
+
.filter((t) => t === "preferences" || t === "lessons" || t === "interactions");
|
|
48
|
+
const candidates = evidence.length > 0
|
|
49
|
+
? evidence
|
|
50
|
+
: ["preferences", "lessons", "interactions"];
|
|
51
|
+
targets = candidates.filter((k) => existsIn(k, id));
|
|
52
|
+
}
|
|
53
|
+
if (targets.length === 0) {
|
|
54
|
+
return ok(`nothing found with id=${id}`);
|
|
55
|
+
}
|
|
56
|
+
const removed = [];
|
|
57
|
+
db.transaction(() => {
|
|
58
|
+
for (const kind of targets) {
|
|
59
|
+
if (!existsIn(kind, id))
|
|
60
|
+
continue;
|
|
61
|
+
if (kind === "preferences") {
|
|
62
|
+
delPreference.run(id);
|
|
63
|
+
removeSearchIndex("preferences", id);
|
|
64
|
+
removed.push(`preference #${id}`);
|
|
65
|
+
}
|
|
66
|
+
else if (kind === "lessons") {
|
|
67
|
+
delLesson.run(id);
|
|
68
|
+
removeSearchIndex("lessons", id);
|
|
69
|
+
removed.push(`lesson #${id}`);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
delInteraction.run(id);
|
|
73
|
+
removeSearchIndex("interactions", id);
|
|
74
|
+
removed.push(`interaction #${id}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
})();
|
|
78
|
+
if (removed.length === 0) {
|
|
79
|
+
return ok(`nothing found with id=${id}${args.type ? ` (${args.type})` : ""}`);
|
|
80
|
+
}
|
|
81
|
+
return ok(`forgot ${removed.join(", ")}`);
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { db, escapeLike, truncate, ok, err, } from "../db.js";
|
|
3
|
+
export const searchHistoryInput = {
|
|
4
|
+
query: z.string().min(1).max(500).describe("Text to search in past prompts"),
|
|
5
|
+
limit: z
|
|
6
|
+
.number()
|
|
7
|
+
.int()
|
|
8
|
+
.min(1)
|
|
9
|
+
.max(50)
|
|
10
|
+
.default(10)
|
|
11
|
+
.describe("Max results (default 10)"),
|
|
12
|
+
};
|
|
13
|
+
const ITEM_BUDGET = 200;
|
|
14
|
+
const searchPrompts = db.prepare("SELECT ts, content FROM interactions WHERE kind = 'prompt' AND content LIKE ? ESCAPE '\\' ORDER BY ts DESC LIMIT ?");
|
|
15
|
+
export async function searchHistoryHandler(args) {
|
|
16
|
+
try {
|
|
17
|
+
const limit = args.limit;
|
|
18
|
+
const like = `%${escapeLike(args.query)}%`;
|
|
19
|
+
const rows = searchPrompts.all(like, limit);
|
|
20
|
+
if (rows.length === 0) {
|
|
21
|
+
return ok(`no prompts found matching "${truncate(args.query, 100)}"`);
|
|
22
|
+
}
|
|
23
|
+
const lines = rows.map((r) => `- [${r.ts}] ${truncate(r.content, ITEM_BUDGET)}`);
|
|
24
|
+
return ok(lines.join("\n"));
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { db, nowISO, syncSearchIndex, ok, err, } from "../db.js";
|
|
3
|
+
export const saveLessonInput = {
|
|
4
|
+
situation: z.string().min(1).max(1000).describe("The original situation/context"),
|
|
5
|
+
mistake: z.string().min(1).max(1000).describe("What was done wrong"),
|
|
6
|
+
correction: z.string().min(1).max(1000).describe("The correct approach"),
|
|
7
|
+
};
|
|
8
|
+
const LESSON_TITLE_MAX = 80;
|
|
9
|
+
const insertLesson = db.prepare("INSERT INTO lessons (situation, mistake, correction, created_at) VALUES (?, ?, ?, ?)");
|
|
10
|
+
export async function saveLessonHandler(args) {
|
|
11
|
+
try {
|
|
12
|
+
const res = insertLesson.run(args.situation, args.mistake, args.correction, nowISO());
|
|
13
|
+
const id = Number(res.lastInsertRowid);
|
|
14
|
+
syncSearchIndex("lessons", id, args.situation.slice(0, LESSON_TITLE_MAX), `${args.situation} | mistake: ${args.mistake} -> correction: ${args.correction}`);
|
|
15
|
+
return ok(`lesson saved (lesson id=${id})`);
|
|
16
|
+
}
|
|
17
|
+
catch (e) {
|
|
18
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
import { db, DB_PATH, truncate, ok, err, } from "../db.js";
|
|
3
|
+
import { CAPTURE_KINDS } from "../lib/capture-core.js";
|
|
4
|
+
export const STATS_BUDGET = 1500;
|
|
5
|
+
const kindCounts = db.prepare("SELECT kind, COUNT(*) AS n FROM interactions GROUP BY kind");
|
|
6
|
+
const prefCount = db.prepare("SELECT COUNT(*) AS n FROM preferences");
|
|
7
|
+
const lessonCount = db.prepare("SELECT COUNT(*) AS n FROM lessons");
|
|
8
|
+
const interactionRange = db.prepare("SELECT MIN(ts) AS oldest, MAX(ts) AS newest FROM interactions");
|
|
9
|
+
const profileSections = db.prepare("SELECT section, updated_at FROM profile ORDER BY section");
|
|
10
|
+
export async function memoryStatsHandler() {
|
|
11
|
+
try {
|
|
12
|
+
const lines = [];
|
|
13
|
+
const kinds = kindCounts.all();
|
|
14
|
+
const byKind = new Map(kinds.map((k) => [k.kind, k.n]));
|
|
15
|
+
const total = kinds.reduce((s, k) => s + k.n, 0);
|
|
16
|
+
const known = [...CAPTURE_KINDS];
|
|
17
|
+
const kindText = known.map((k) => `${k}=${byKind.get(k) ?? 0}`).join(", ") +
|
|
18
|
+
kinds
|
|
19
|
+
.filter((k) => !known.includes(k.kind))
|
|
20
|
+
.map((k) => `, ${k.kind}=${k.n}`)
|
|
21
|
+
.join("");
|
|
22
|
+
lines.push(`interactions: ${total} total (${kindText})`);
|
|
23
|
+
let dbSize = -1;
|
|
24
|
+
try {
|
|
25
|
+
dbSize = statSync(DB_PATH).size;
|
|
26
|
+
}
|
|
27
|
+
catch { }
|
|
28
|
+
lines.push(`db file: ${DB_PATH}${dbSize >= 0 ? ` (${dbSize} bytes)` : " (not found)"}`);
|
|
29
|
+
const range = interactionRange.get();
|
|
30
|
+
if (range.oldest && range.newest) {
|
|
31
|
+
lines.push(`oldest interaction: ${range.oldest}`);
|
|
32
|
+
lines.push(`newest interaction: ${range.newest}`);
|
|
33
|
+
}
|
|
34
|
+
lines.push(`preferences: ${prefCount.get().n}`);
|
|
35
|
+
lines.push(`lessons: ${lessonCount.get().n}`);
|
|
36
|
+
const prof = profileSections.all();
|
|
37
|
+
if (prof.length > 0) {
|
|
38
|
+
const latest = prof.reduce((a, b) => a.updated_at > b.updated_at ? a : b);
|
|
39
|
+
lines.push(`profile sections: ${prof.map((p) => p.section).join(", ")}`);
|
|
40
|
+
lines.push(`profile latest update: ${latest.updated_at}`);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
lines.push("profile sections: (none)");
|
|
44
|
+
}
|
|
45
|
+
return ok(truncate(lines.join("\n"), STATS_BUDGET));
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { db, truncate, ok, err, } from "../db.js";
|
|
2
|
+
export const PROFILE_BUDGET = 3000;
|
|
3
|
+
const PROFILE_SECTION_MAX = 400;
|
|
4
|
+
const PREF_CATEGORY_MAX = 30;
|
|
5
|
+
const PREF_VALUE_MAX = 120;
|
|
6
|
+
const LESSON_SITUATION_MAX = 100;
|
|
7
|
+
const LESSON_MISTAKE_MAX = 120;
|
|
8
|
+
const LESSON_CORRECTION_MAX = 150;
|
|
9
|
+
const profileRows = db.prepare("SELECT section, content FROM profile");
|
|
10
|
+
const topPrefs = db.prepare("SELECT category, key, value, confidence FROM preferences ORDER BY confidence DESC, updated_at DESC LIMIT 15");
|
|
11
|
+
const recentLessons = db.prepare("SELECT situation, mistake, correction FROM lessons ORDER BY created_at DESC, id DESC LIMIT 5");
|
|
12
|
+
export function buildProfileText() {
|
|
13
|
+
const parts = [];
|
|
14
|
+
const prof = profileRows.all();
|
|
15
|
+
for (const p of prof) {
|
|
16
|
+
parts.push(`[${p.section}]\n${truncate(p.content, PROFILE_SECTION_MAX)}`);
|
|
17
|
+
}
|
|
18
|
+
const prefs = topPrefs.all();
|
|
19
|
+
if (prefs.length > 0) {
|
|
20
|
+
let block = "[preferences]";
|
|
21
|
+
for (const p of prefs) {
|
|
22
|
+
block += `\n- (${p.confidence.toFixed(2)}) ${truncate(p.category, PREF_CATEGORY_MAX)}/${p.key}: ${truncate(p.value, PREF_VALUE_MAX)}`;
|
|
23
|
+
}
|
|
24
|
+
parts.push(block);
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
parts.push("[preferences]\n(none yet)");
|
|
28
|
+
}
|
|
29
|
+
const lessons = recentLessons.all();
|
|
30
|
+
if (lessons.length > 0) {
|
|
31
|
+
let block = "[lessons]";
|
|
32
|
+
for (const l of lessons) {
|
|
33
|
+
block += `\n- ${truncate(l.situation, LESSON_SITUATION_MAX)} | mistake: ${truncate(l.mistake, LESSON_MISTAKE_MAX)} -> ${truncate(l.correction, LESSON_CORRECTION_MAX)}`;
|
|
34
|
+
}
|
|
35
|
+
parts.push(block);
|
|
36
|
+
}
|
|
37
|
+
return truncate(parts.join("\n\n"), PROFILE_BUDGET);
|
|
38
|
+
}
|
|
39
|
+
export async function getProfileHandler() {
|
|
40
|
+
try {
|
|
41
|
+
return ok(buildProfileText());
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { db, buildFtsMatch, escapeLike, truncate, ok, err, } from "../db.js";
|
|
3
|
+
export const recallInput = {
|
|
4
|
+
topic: z.string().min(1).max(500).describe("Topic to recall from memory"),
|
|
5
|
+
limit: z
|
|
6
|
+
.number()
|
|
7
|
+
.int()
|
|
8
|
+
.min(1)
|
|
9
|
+
.max(50)
|
|
10
|
+
.default(8)
|
|
11
|
+
.describe("Max preference/lesson matches (default 8)"),
|
|
12
|
+
};
|
|
13
|
+
const RECALL_BUDGET = 2000;
|
|
14
|
+
const RECENT_INTERACTIONS_LIMIT = 20;
|
|
15
|
+
const searchIndexed = db.prepare("SELECT ref_table, ref_id, title, body FROM search_index WHERE search_index MATCH ? AND ref_table IN ('preferences','lessons') LIMIT ?");
|
|
16
|
+
const recentInteractions = db.prepare(`SELECT ts, kind, content FROM interactions WHERE content LIKE ? ESCAPE '\\' ORDER BY ts DESC LIMIT ${RECENT_INTERACTIONS_LIMIT}`);
|
|
17
|
+
export async function recallHandler(args) {
|
|
18
|
+
try {
|
|
19
|
+
const limit = args.limit;
|
|
20
|
+
const parts = [];
|
|
21
|
+
let prefLines = "";
|
|
22
|
+
let lessonLines = "";
|
|
23
|
+
try {
|
|
24
|
+
const rows = searchIndexed.all(buildFtsMatch(args.topic), limit);
|
|
25
|
+
for (const r of rows) {
|
|
26
|
+
if (r.ref_table === "preferences") {
|
|
27
|
+
prefLines += `- ${truncate(r.title, 120)} | ${truncate(r.body, 200)}\n`;
|
|
28
|
+
}
|
|
29
|
+
else if (r.ref_table === "lessons") {
|
|
30
|
+
lessonLines += `- ${truncate(r.title, 120)} | ${truncate(r.body, 300)}\n`;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
36
|
+
console.error("[recall] preference/lesson search failed:", msg);
|
|
37
|
+
prefLines = "";
|
|
38
|
+
lessonLines = "";
|
|
39
|
+
}
|
|
40
|
+
let interactionLines = "";
|
|
41
|
+
try {
|
|
42
|
+
const like = `%${escapeLike(args.topic)}%`;
|
|
43
|
+
const rows = recentInteractions.all(like);
|
|
44
|
+
for (const r of rows) {
|
|
45
|
+
interactionLines += `- [${r.ts}] (${r.kind}) ${truncate(r.content, 150)}\n`;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
50
|
+
console.error("[recall] interaction search failed:", msg);
|
|
51
|
+
interactionLines = "";
|
|
52
|
+
}
|
|
53
|
+
if (prefLines)
|
|
54
|
+
parts.push(`[preferences]\n${prefLines.trimEnd()}`);
|
|
55
|
+
if (lessonLines)
|
|
56
|
+
parts.push(`[lessons]\n${lessonLines.trimEnd()}`);
|
|
57
|
+
if (interactionLines)
|
|
58
|
+
parts.push(`[recent interactions matching "${truncate(args.topic, 80)}"]\n${interactionLines.trimEnd()}`);
|
|
59
|
+
if (parts.length === 0) {
|
|
60
|
+
return ok(`no memory found for "${truncate(args.topic, 100)}"`);
|
|
61
|
+
}
|
|
62
|
+
return ok(truncate(parts.join("\n\n"), RECALL_BUDGET));
|
|
63
|
+
}
|
|
64
|
+
catch (e) {
|
|
65
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { db, truncate, ok, err } from "../db.js";
|
|
3
|
+
import { CAPTURE_KINDS, } from "../lib/capture-core.js";
|
|
4
|
+
export const recentInteractionsInput = {
|
|
5
|
+
limit: z
|
|
6
|
+
.number()
|
|
7
|
+
.int()
|
|
8
|
+
.min(1)
|
|
9
|
+
.max(100)
|
|
10
|
+
.default(20)
|
|
11
|
+
.describe("Max rows (default 20, max 100)"),
|
|
12
|
+
kind: z
|
|
13
|
+
.enum(CAPTURE_KINDS)
|
|
14
|
+
.optional()
|
|
15
|
+
.describe("Filter by kind (prompt / tool_call / error)"),
|
|
16
|
+
};
|
|
17
|
+
const RECENT_BUDGET = 4000;
|
|
18
|
+
const ITEM_BUDGET = 300;
|
|
19
|
+
const selectAny = db.prepare("SELECT id, ts, kind, content FROM interactions ORDER BY id DESC LIMIT ?");
|
|
20
|
+
const selectByKind = db.prepare("SELECT id, ts, kind, content FROM interactions WHERE kind = ? ORDER BY id DESC LIMIT ?");
|
|
21
|
+
export async function getRecentInteractionsHandler(args) {
|
|
22
|
+
try {
|
|
23
|
+
const limit = args.limit;
|
|
24
|
+
const rows = (args.kind
|
|
25
|
+
? selectByKind.all(args.kind, limit)
|
|
26
|
+
: selectAny.all(limit));
|
|
27
|
+
if (rows.length === 0) {
|
|
28
|
+
return ok(`no interactions found${args.kind ? ` with kind '${args.kind}'` : ""}`);
|
|
29
|
+
}
|
|
30
|
+
const lines = rows.map((r) => `[${r.id}] ${r.ts} [${r.kind}] ${truncate(r.content, ITEM_BUDGET)}`);
|
|
31
|
+
return ok(truncate(lines.join("\n"), RECENT_BUDGET));
|
|
32
|
+
}
|
|
33
|
+
catch (e) {
|
|
34
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { db, nowISO, syncSearchIndex, ok, err, } from "../db.js";
|
|
3
|
+
export const rememberInput = {
|
|
4
|
+
category: z
|
|
5
|
+
.enum(["work_style", "coding_pref", "language", "domain", "other"])
|
|
6
|
+
.describe("Preference category"),
|
|
7
|
+
key: z.string().min(1).max(200).describe("Short stable key, e.g. package_manager"),
|
|
8
|
+
value: z.string().min(1).max(2000).describe("The preference value"),
|
|
9
|
+
};
|
|
10
|
+
const CONFIDENCE_INITIAL = 0.5;
|
|
11
|
+
const CONFIDENCE_STEP = 0.1;
|
|
12
|
+
const CONFIDENCE_MAX = 1.0;
|
|
13
|
+
const selectPref = db.prepare("SELECT id, confidence FROM preferences WHERE category = @category AND key = @key");
|
|
14
|
+
const insertPref = db.prepare(`INSERT INTO preferences (category, key, value, confidence, source, updated_at) VALUES (?, ?, ?, ${CONFIDENCE_INITIAL}, 'explicit', ?)`);
|
|
15
|
+
const updatePref = db.prepare(`UPDATE preferences SET value = ?, confidence = MIN(confidence + ${CONFIDENCE_STEP}, ${CONFIDENCE_MAX}), updated_at = ? WHERE id = ?`);
|
|
16
|
+
export async function rememberHandler(args) {
|
|
17
|
+
try {
|
|
18
|
+
const existing = selectPref.get({ category: args.category, key: args.key });
|
|
19
|
+
let id;
|
|
20
|
+
let confidence;
|
|
21
|
+
if (existing) {
|
|
22
|
+
updatePref.run(args.value, nowISO(), existing.id);
|
|
23
|
+
id = existing.id;
|
|
24
|
+
confidence = Math.min(existing.confidence + CONFIDENCE_STEP, CONFIDENCE_MAX);
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
const res = insertPref.run(args.category, args.key, args.value, nowISO());
|
|
28
|
+
id = Number(res.lastInsertRowid);
|
|
29
|
+
confidence = CONFIDENCE_INITIAL;
|
|
30
|
+
}
|
|
31
|
+
syncSearchIndex("preferences", id, `${args.category}/${args.key}`, `${args.key}: ${args.value}`);
|
|
32
|
+
return ok(`remembered [${args.category}] ${args.key} = ${args.value} (preference id=${id}, confidence=${confidence.toFixed(2)})`);
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://opencode.ai/config.json",
|
|
3
|
+
"mcp": {
|
|
4
|
+
"memory": {
|
|
5
|
+
"type": "local",
|
|
6
|
+
"command": ["node", "<ABSOLUTE_PATH>/th-memory-mcp/dist/index.js"],
|
|
7
|
+
"enabled": true,
|
|
8
|
+
"environment": {
|
|
9
|
+
"MEMORY_DB_PATH": "<ABSOLUTE_PATH>/th-memory-mcp/data/memory.db"
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "th-memory-mcp",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Adaptive Memory MCP server - SQLite-backed memory for OpenCode",
|
|
5
|
+
"author": "worakorn-prince",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/worakorn-prince/th-memory-mcp.git"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"main": "dist/index.js",
|
|
12
|
+
"bin": {
|
|
13
|
+
"th-memory-mcp": "dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE",
|
|
19
|
+
"design.md",
|
|
20
|
+
"opencode.example.json",
|
|
21
|
+
"AGENTS.memory.example.md"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsc",
|
|
25
|
+
"prepublishOnly": "npm run build",
|
|
26
|
+
"start": "node dist/index.js",
|
|
27
|
+
"inspect": "npx @modelcontextprotocol/inspector node dist/index.js",
|
|
28
|
+
"test": "npm run build && node test/capture.test.mjs && node test/distill.test.mjs && node test/smoke.mjs",
|
|
29
|
+
"distill": "node dist/distill.js",
|
|
30
|
+
"quickstart": "npm run build && node scripts/quickstart.mjs"
|
|
31
|
+
},
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=20"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
37
|
+
"better-sqlite3": "^12.0.0",
|
|
38
|
+
"zod": "^3.25.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/better-sqlite3": "^7.6.0",
|
|
42
|
+
"@types/node": "^22.0.0",
|
|
43
|
+
"typescript": "^5.6.0"
|
|
44
|
+
}
|
|
45
|
+
}
|