th-memory-mcp 1.1.0 → 1.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/README.md CHANGED
@@ -1,8 +1,13 @@
1
1
  # th-memory-mcp
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/th-memory-mcp.svg)](https://www.npmjs.com/package/th-memory-mcp)
4
+ [![npm downloads](https://img.shields.io/npm/dm/th-memory-mcp.svg)](https://www.npmjs.com/package/th-memory-mcp)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
6
+ [![Node](https://img.shields.io/badge/node-%3E%3D20-brightgreen.svg)](https://nodejs.org)
7
+
3
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.
4
9
 
5
- **Status:** v1.1.0 — all 4 phases implemented, tests passing 70/70 assertions (smoke 53 + capture 8 + distill 9)
10
+ **Status:** v1.2.0 — all 4 phases implemented, tests passing 70/70 assertions (smoke 53 + capture 8 + distill 9). New: local semantic/vector search blended into `recall`, and full auto-capture on Claude Code (hooks) plus ready configs for Codex & Cursor.
6
11
 
7
12
  ## Requirements
8
13
 
@@ -75,6 +80,48 @@ LLMs don't remember you between sessions — every new chat starts blank. th-mem
75
80
  - **Resilient** — every tool degrades gracefully; if the DB is unavailable the AI keeps working instead of crashing.
76
81
  - **Open & extensible** — MIT licensed, 9 documented tools, a rule-based distill, and an auto-capture plugin you can adapt.
77
82
 
83
+ ## Works with other harnesses
84
+
85
+ th-memory-mcp is a standard MCP server, so the 9 tools run anywhere MCP-over-stdio
86
+ is supported. Full **auto-capture** (background prompt/tool/error capture + profile
87
+ injection) needs a hook runtime — OpenCode has it built in; Claude Code gets it via
88
+ our hooks bridge; Codex and Cursor use the tools manually (no hook runtime yet).
89
+
90
+ | Feature | OpenCode | Claude Code | Codex | Cursor |
91
+ |---|---|---|---|---|
92
+ | 9 MCP tools | ✅ | ✅ | ✅ | ✅ |
93
+ | Auto-capture (background) | ✅ plugin | ✅ [hooks](CLAUDE_CODE_HOOKS.md) | ❌ manual | ❌ Rules |
94
+ | Profile injection | ✅ compaction | ✅ UserPromptSubmit | ❌ `get_profile` | ❌ `get_profile` |
95
+ | Local semantic search | ✅ (v1.2) | ✅ (v1.2) | ✅ (v1.2) | ✅ (v1.2) |
96
+
97
+ - **Claude Code:** see [CLAUDE_CODE_HOOKS.md](CLAUDE_CODE_HOOKS.md) — drop-in hooks replicate the OpenCode plugin (capture + profile injection on `UserPromptSubmit`/`PreCompact`, rule-based distill on `SessionEnd`).
98
+ - **Codex:** see [CODEX_SETUP.md](CODEX_SETUP.md)
99
+ - **Cursor:** see [CURSOR_SETUP.md](CURSOR_SETUP.md)
100
+
101
+ All harnesses share one SQLite file via `MEMORY_DB_PATH`, so memory captured
102
+ anywhere is readable everywhere.
103
+
104
+ ## Comparison with rekal
105
+
106
+ [rekal](https://github.com/janbjorge/rekal) is the closest alternative (Python,
107
+ single SQLite file, hybrid search). How th-memory-mcp differs:
108
+
109
+ | | th-memory-mcp | rekal |
110
+ |---|---|---|
111
+ | Runtime | Node ≥20 (`better-sqlite3`) | Python 3.11+ |
112
+ | Search | FTS5 keyword + **local vector blend** (v1.2, no model download) | BM25 + vector (384-dim local) + recency |
113
+ | Memory model | `preference` (confidence) / `lesson` (situation→mistake→correction) / `profile` / `interactions` | flat `fact` |
114
+ | Auto-capture | OpenCode plugin + Claude hooks (capture + `PreCompact` + `SessionEnd` distill) | Claude plugin (SessionStart/UserPromptSubmit/`PreCompact`/`SessionEnd`) |
115
+ | Harness coverage | OpenCode, Claude Code, Codex, Cursor | Claude Code, Codex, OpenCode, Cursor |
116
+ | Thai / i18n | ✅ Thai tokenization in distill | English-centric |
117
+ | Secret filter | ✅ built-in | not specified |
118
+ | Weight | lightweight, zero native extras | needs embedding model (local) |
119
+
120
+ th-memory-mcp's edge: structured **lessons** for corrections, confidence scoring,
121
+ first-class **Thai** support, secret filtering, and a dependency-free local vector
122
+ search that stays 100% offline. rekal's edge: transformer-grade semantic embeddings
123
+ and a one-command plugin marketplace install.
124
+
78
125
  ## Scripts
79
126
 
80
127
  | Command | Description |
package/README.th.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # th-memory-mcp
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/th-memory-mcp.svg)](https://www.npmjs.com/package/th-memory-mcp)
4
+ [![npm downloads](https://img.shields.io/npm/dm/th-memory-mcp.svg)](https://www.npmjs.com/package/th-memory-mcp)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
6
+ [![Node](https://img.shields.io/badge/node-%3E%3D20-brightgreen.svg)](https://nodejs.org)
7
+
3
8
  MCP server ความจำระยะยาวสำหรับ OpenCode — เก็บ preferences, lessons, ประวัติการใช้งาน ลง SQLite ไฟล์เดียว (local 100%, ไม่มี external API) เพื่อให้ AI "จำและปรับตัว" กับผู้ใช้ผ่าน context-based learning
4
9
 
5
10
  **สถานะ:** v1.1.0 — อิมพลีเมนต์ครบ 4 Phase, tests ผ่าน 70/70 assertions (smoke 53 + capture 8 + distill 9)
@@ -19,6 +24,8 @@ MCP server ความจำระยะยาวสำหรับ OpenCode
19
24
 
20
25
  **ทางที่เร็วที่สุด:** หลัง clone ให้รัน `npm run quickstart` — มันจะ build, ต่อไฟล์ `opencode.json`, วาง plugin และตั้ง `MEMORY_DB_PATH` ให้ในคำสั่งเดียว ขั้นตอนด้านล่างคือสิ่งที่สคริปต์ทำ (ใช้ได้หากอยากควบคุมเองทีละขั้น)
21
26
 
27
+ **ติดตั้งผ่าน npm (อีกทางเลือก):** ติดตั้ง server แบบ global ด้วย `npm install -g th-memory-mcp` (หรือรันทันทีด้วย `npx th-memory-mcp`) แล้วชี้ `command` ของ `mcp` ใน `opencode.json` ไปที่ `th-memory-mcp` แทน `dist/index.js` ที่ build แล้ว plugin auto-capture ยังคงมาจาก repo นี้ (คัดลอก `src/plugin/learning-capture.ts` ตามขั้นตอน 4 ด้านล่าง)
28
+
22
29
  ```bash
23
30
  # 1. Clone และ build
24
31
  git clone https://github.com/worakorn-prince/th-memory-mcp.git
package/dist/db.js CHANGED
@@ -2,6 +2,7 @@ import Database from "better-sqlite3";
2
2
  import { mkdirSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
4
  import { truncate } from "./lib/capture-core.js";
5
+ import { serialize } from "./lib/embed.js";
5
6
  import { DEFAULT_DB_PATH } from "./lib/config.js";
6
7
  export const DB_PATH = process.env.MEMORY_DB_PATH ?? DEFAULT_DB_PATH;
7
8
  function initDb() {
@@ -56,6 +57,13 @@ CREATE TABLE IF NOT EXISTS profile (
56
57
  CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
57
58
  ref_table, ref_id, title, body
58
59
  );
60
+
61
+ CREATE TABLE IF NOT EXISTS embeddings (
62
+ ref_table TEXT NOT NULL,
63
+ ref_id INTEGER NOT NULL,
64
+ vec BLOB NOT NULL,
65
+ PRIMARY KEY (ref_table, ref_id)
66
+ );
59
67
  `);
60
68
  export function nowISO() {
61
69
  return new Date().toISOString();
@@ -78,6 +86,20 @@ export function syncSearchIndex(refTable, refId, title, body) {
78
86
  export function removeSearchIndex(refTable, refId) {
79
87
  deleteSearchIndex.run(refTable, refId);
80
88
  }
89
+ // --- vector embeddings (lightweight local semantic search) ---
90
+ const upsertEmbed = db.prepare(`INSERT INTO embeddings (ref_table, ref_id, vec) VALUES (?, ?, ?)
91
+ ON CONFLICT(ref_table, ref_id) DO UPDATE SET vec = excluded.vec`);
92
+ const deleteEmbed = db.prepare("DELETE FROM embeddings WHERE ref_table = ? AND ref_id = ?");
93
+ const allEmbeds = db.prepare("SELECT ref_table, ref_id, vec FROM embeddings");
94
+ export function upsertEmbedding(refTable, refId, vec) {
95
+ upsertEmbed.run(refTable, refId, serialize(vec));
96
+ }
97
+ export function removeEmbedding(refTable, refId) {
98
+ deleteEmbed.run(refTable, refId);
99
+ }
100
+ export function getAllEmbeddings() {
101
+ return allEmbeds.all();
102
+ }
81
103
  export function ok(text) {
82
104
  return { content: [{ type: "text", text }] };
83
105
  }
@@ -0,0 +1,69 @@
1
+ export const EMBED_DIM = 512;
2
+ const THAI = /[ก-์]/;
3
+ function fnv1a(str) {
4
+ let h = 0x811c9dc5;
5
+ for (let i = 0; i < str.length; i++) {
6
+ h ^= str.charCodeAt(i);
7
+ h = Math.imul(h, 0x01000193);
8
+ }
9
+ return h >>> 0;
10
+ }
11
+ function hashToken(token) {
12
+ return fnv1a(token) % EMBED_DIM;
13
+ }
14
+ function tokenize(text) {
15
+ const lower = text.toLowerCase();
16
+ const tokens = [];
17
+ const matches = lower.match(/[a-z0-9ก-์]+/gi);
18
+ if (matches) {
19
+ for (const m of matches)
20
+ tokens.push(m);
21
+ }
22
+ const chars = Array.from(lower.replace(/\s+/g, ""));
23
+ if (chars.length >= 3) {
24
+ for (let i = 0; i < chars.length - 2; i++) {
25
+ tokens.push("g:" + chars.slice(i, i + 3).join(""));
26
+ }
27
+ }
28
+ else if (chars.length > 0) {
29
+ tokens.push("g:" + chars.join(""));
30
+ }
31
+ return tokens;
32
+ }
33
+ export function embed(text) {
34
+ const vec = new Float32Array(EMBED_DIM);
35
+ const tokens = tokenize(text || "");
36
+ if (tokens.length === 0)
37
+ return vec;
38
+ for (const t of tokens) {
39
+ const idx = hashToken(t);
40
+ vec[idx] = (vec[idx] ?? 0) + 1;
41
+ }
42
+ let norm = 0;
43
+ for (let i = 0; i < EMBED_DIM; i++)
44
+ norm += (vec[i] ?? 0) * (vec[i] ?? 0);
45
+ norm = Math.sqrt(norm) || 1;
46
+ for (let i = 0; i < EMBED_DIM; i++)
47
+ vec[i] = (vec[i] ?? 0) / norm;
48
+ return vec;
49
+ }
50
+ export function cosine(a, b) {
51
+ let dot = 0;
52
+ for (let i = 0; i < EMBED_DIM; i++)
53
+ dot += (a[i] ?? 0) * (b[i] ?? 0);
54
+ return dot;
55
+ }
56
+ export function serialize(vec) {
57
+ const copy = new Float32Array(EMBED_DIM);
58
+ copy.set(vec);
59
+ return Buffer.from(copy.buffer);
60
+ }
61
+ export function deserialize(buf) {
62
+ const out = new Float32Array(EMBED_DIM);
63
+ const ab = Buffer.from(buf).buffer.slice(0, EMBED_DIM * 4);
64
+ out.set(new Float32Array(ab));
65
+ return out;
66
+ }
67
+ export function hasThai(text) {
68
+ return THAI.test(text);
69
+ }
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { db, removeSearchIndex, ok, err } from "../db.js";
2
+ import { db, removeSearchIndex, removeEmbedding, ok, err, } from "../db.js";
3
3
  export const forgetInput = {
4
4
  target_id: z
5
5
  .number()
@@ -61,11 +61,13 @@ export async function forgetHandler(args) {
61
61
  if (kind === "preferences") {
62
62
  delPreference.run(id);
63
63
  removeSearchIndex("preferences", id);
64
+ removeEmbedding("preferences", id);
64
65
  removed.push(`preference #${id}`);
65
66
  }
66
67
  else if (kind === "lessons") {
67
68
  delLesson.run(id);
68
69
  removeSearchIndex("lessons", id);
70
+ removeEmbedding("lessons", id);
69
71
  removed.push(`lesson #${id}`);
70
72
  }
71
73
  else {
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
- import { db, nowISO, syncSearchIndex, ok, err, } from "../db.js";
2
+ import { db, nowISO, syncSearchIndex, upsertEmbedding, ok, err, } from "../db.js";
3
+ import { embed } from "../lib/embed.js";
3
4
  export const saveLessonInput = {
4
5
  situation: z.string().min(1).max(1000).describe("The original situation/context"),
5
6
  mistake: z.string().min(1).max(1000).describe("What was done wrong"),
@@ -12,6 +13,7 @@ export async function saveLessonHandler(args) {
12
13
  const res = insertLesson.run(args.situation, args.mistake, args.correction, nowISO());
13
14
  const id = Number(res.lastInsertRowid);
14
15
  syncSearchIndex("lessons", id, args.situation.slice(0, LESSON_TITLE_MAX), `${args.situation} | mistake: ${args.mistake} -> correction: ${args.correction}`);
16
+ upsertEmbedding("lessons", id, embed(`${args.situation} ${args.mistake} ${args.correction}`));
15
17
  return ok(`lesson saved (lesson id=${id})`);
16
18
  }
17
19
  catch (e) {
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
- import { db, buildFtsMatch, escapeLike, truncate, ok, err, } from "../db.js";
2
+ import { db, buildFtsMatch, escapeLike, truncate, getAllEmbeddings, ok, err, } from "../db.js";
3
+ import { embed, cosine, deserialize } from "../lib/embed.js";
3
4
  export const recallInput = {
4
5
  topic: z.string().min(1).max(500).describe("Topic to recall from memory"),
5
6
  limit: z
@@ -12,22 +13,46 @@ export const recallInput = {
12
13
  };
13
14
  const RECALL_BUDGET = 2000;
14
15
  const RECENT_INTERACTIONS_LIMIT = 20;
16
+ const SEMANTIC_FLOOR = 0.15;
15
17
  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
18
  const recentInteractions = db.prepare(`SELECT ts, kind, content FROM interactions WHERE content LIKE ? ESCAPE '\\' ORDER BY ts DESC LIMIT ${RECENT_INTERACTIONS_LIMIT}`);
19
+ const prefById = db.prepare("SELECT category, key, value FROM preferences WHERE id = ?");
20
+ const lessonById = db.prepare("SELECT situation, mistake, correction FROM lessons WHERE id = ?");
21
+ function prefLine(id) {
22
+ const r = prefById.get(id);
23
+ if (!r)
24
+ return null;
25
+ return `- ${truncate(`${r.category}/${r.key}`, 120)} | ${truncate(`${r.key}: ${r.value}`, 200)}`;
26
+ }
27
+ function lessonLine(id) {
28
+ const r = lessonById.get(id);
29
+ if (!r)
30
+ return null;
31
+ return `- ${truncate(r.situation, 120)} | ${truncate(`mistake: ${r.mistake} -> correction: ${r.correction}`, 300)}`;
32
+ }
17
33
  export async function recallHandler(args) {
18
34
  try {
19
35
  const limit = args.limit;
20
36
  const parts = [];
37
+ const seen = new Set();
21
38
  let prefLines = "";
22
39
  let lessonLines = "";
23
40
  try {
24
41
  const rows = searchIndexed.all(buildFtsMatch(args.topic), limit);
25
42
  for (const r of rows) {
26
43
  if (r.ref_table === "preferences") {
27
- prefLines += `- ${truncate(r.title, 120)} | ${truncate(r.body, 200)}\n`;
44
+ const line = prefLine(r.ref_id);
45
+ if (line) {
46
+ prefLines += line + "\n";
47
+ seen.add(`p:${r.ref_id}`);
48
+ }
28
49
  }
29
50
  else if (r.ref_table === "lessons") {
30
- lessonLines += `- ${truncate(r.title, 120)} | ${truncate(r.body, 300)}\n`;
51
+ const line = lessonLine(r.ref_id);
52
+ if (line) {
53
+ lessonLines += line + "\n";
54
+ seen.add(`l:${r.ref_id}`);
55
+ }
31
56
  }
32
57
  }
33
58
  }
@@ -37,6 +62,43 @@ export async function recallHandler(args) {
37
62
  prefLines = "";
38
63
  lessonLines = "";
39
64
  }
65
+ // Semantic blend: surface vector neighbors missed by keyword search.
66
+ try {
67
+ const topicVec = embed(args.topic);
68
+ const all = getAllEmbeddings();
69
+ const scored = all
70
+ .map((row) => ({
71
+ table: row.ref_table,
72
+ id: row.ref_id,
73
+ score: cosine(topicVec, deserialize(row.vec)),
74
+ }))
75
+ .filter((x) => x.score >= SEMANTIC_FLOOR)
76
+ .sort((a, b) => b.score - a.score)
77
+ .slice(0, limit);
78
+ for (const x of scored) {
79
+ const key = `${x.table[0]}:${x.id}`;
80
+ if (seen.has(key))
81
+ continue;
82
+ if (x.table === "preferences") {
83
+ const line = prefLine(x.id);
84
+ if (line) {
85
+ prefLines += line + "\n";
86
+ seen.add(key);
87
+ }
88
+ }
89
+ else if (x.table === "lessons") {
90
+ const line = lessonLine(x.id);
91
+ if (line) {
92
+ lessonLines += line + "\n";
93
+ seen.add(key);
94
+ }
95
+ }
96
+ }
97
+ }
98
+ catch (e) {
99
+ const msg = e instanceof Error ? e.message : String(e);
100
+ console.error("[recall] semantic search failed:", msg);
101
+ }
40
102
  let interactionLines = "";
41
103
  try {
42
104
  const like = `%${escapeLike(args.topic)}%`;
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
- import { db, nowISO, syncSearchIndex, ok, err, } from "../db.js";
2
+ import { db, nowISO, syncSearchIndex, upsertEmbedding, ok, err, } from "../db.js";
3
+ import { embed } from "../lib/embed.js";
3
4
  export const rememberInput = {
4
5
  category: z
5
6
  .enum(["work_style", "coding_pref", "language", "domain", "other"])
@@ -29,6 +30,7 @@ export async function rememberHandler(args) {
29
30
  confidence = CONFIDENCE_INITIAL;
30
31
  }
31
32
  syncSearchIndex("preferences", id, `${args.category}/${args.key}`, `${args.key}: ${args.value}`);
33
+ upsertEmbedding("preferences", id, embed(`${args.category} ${args.key} ${args.value}`));
32
34
  return ok(`remembered [${args.category}] ${args.key} = ${args.value} (preference id=${id}, confidence=${confidence.toFixed(2)})`);
33
35
  }
34
36
  catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "th-memory-mcp",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Adaptive Memory MCP server - SQLite-backed memory for OpenCode",
5
5
  "author": "worakorn-prince",
6
6
  "repository": {