src / store.ts
import "./suppressExperimentalWarnings";
import { DatabaseSync } from "node:sqlite";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
export interface MemoryRow {
key: string;
content: string;
tags: string;
created_at: string;
updated_at: string;
}
// Shares storage with memory-mcp-server by default, so anything saved through either
// path (MCP tool calls or this plugin) shows up in both. Override with MEMORY_DB_PATH
// if you want Alexiel's LM Studio memory kept separate from the MCP store.
const DEFAULT_DB_DIR = path.join(os.homedir(), ".memory-mcp-server");
const DB_PATH = process.env.MEMORY_DB_PATH ?? path.join(DEFAULT_DB_DIR, "memory.db");
let dbInstance: DatabaseSync | null = null;
let ftsAvailable = true;
export function getDb(): DatabaseSync {
if (dbInstance) return dbInstance;
const dir = path.dirname(DB_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const db = new DatabaseSync(DB_PATH);
// WAL mode allows this plugin and memory-mcp-server to safely read/write the same
// file concurrently (SQLite serializes writes; readers don't block each other).
db.exec("PRAGMA journal_mode = WAL;");
db.exec(`
CREATE TABLE IF NOT EXISTS memories (
key TEXT PRIMARY KEY,
content TEXT NOT NULL,
tags TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
`);
try {
db.exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
key,
content,
tags,
content='memories',
content_rowid='rowid'
);
CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
INSERT INTO memories_fts(rowid, key, content, tags)
VALUES (new.rowid, new.key, new.content, new.tags);
END;
CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid, key, content, tags)
VALUES ('delete', old.rowid, old.key, old.content, old.tags);
END;
CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid, key, content, tags)
VALUES ('delete', old.rowid, old.key, old.content, old.tags);
INSERT INTO memories_fts(rowid, key, content, tags)
VALUES (new.rowid, new.key, new.content, new.tags);
END;
`);
} catch (error) {
// Falls back to a plain LIKE search below if this Node's bundled SQLite wasn't
// compiled with FTS5. Everything else (save/list/get/delete) still works fine.
ftsAvailable = false;
console.error("FTS5 unavailable, falling back to LIKE-based search:", error);
}
dbInstance = db;
return db;
}
/** Turn free text into a safe FTS5 MATCH query: quote each token, OR them together, prefix-match. */
function toFtsQuery(raw: string): string {
const tokens = raw
.split(/\s+/)
.map((t) => t.replace(/["]/g, "").trim())
.filter((t) => t.length > 1); // skip single-char tokens, too noisy for prefix match
if (tokens.length === 0) return '""';
return tokens.map((t) => `"${t}"*`).join(" OR ");
}
export function searchMemories(query: string, limit: number): MemoryRow[] {
const db = getDb();
if (ftsAvailable) {
try {
const ftsQuery = toFtsQuery(query);
return db
.prepare(
`SELECT m.key, m.content, m.tags, m.created_at, m.updated_at
FROM memories_fts f
JOIN memories m ON m.rowid = f.rowid
WHERE memories_fts MATCH ?
ORDER BY rank
LIMIT ?`
)
.all(ftsQuery, limit) as unknown as MemoryRow[];
} catch {
// fall through to LIKE search below
}
}
// Fallback: plain substring search across content/key/tags, most-recent first.
const likeTerm = `%${query.trim()}%`;
return db
.prepare(
`SELECT key, content, tags, created_at, updated_at FROM memories
WHERE content LIKE ? OR key LIKE ? OR tags LIKE ?
ORDER BY updated_at DESC
LIMIT ?`
)
.all(likeTerm, likeTerm, likeTerm, limit) as unknown as MemoryRow[];
}
export function saveMemory(key: string, content: string, tags: string): "created" | "updated" {
const db = getDb();
const existed = db.prepare(`SELECT key FROM memories WHERE key = ?`).get(key);
const now = new Date().toISOString();
db.prepare(
`INSERT INTO memories (key, content, tags, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(key) DO UPDATE SET content = excluded.content, tags = excluded.tags, updated_at = excluded.updated_at`
).run(key, content, tags, now, now);
return existed ? "updated" : "created";
}
export function deleteMemory(key: string): boolean {
const db = getDb();
const existed = db.prepare(`SELECT key FROM memories WHERE key = ?`).get(key);
if (!existed) return false;
db.prepare(`DELETE FROM memories WHERE key = ?`).run(key);
return true;
}
export function listMemories(prefix: string | undefined, limit: number): MemoryRow[] {
const db = getDb();
if (prefix) {
return db
.prepare(
`SELECT key, content, tags, created_at, updated_at FROM memories WHERE key LIKE ? ORDER BY updated_at DESC LIMIT ?`
)
.all(`${prefix}%`, limit) as unknown as MemoryRow[];
}
return db
.prepare(
`SELECT key, content, tags, created_at, updated_at FROM memories ORDER BY updated_at DESC LIMIT ?`
)
.all(limit) as unknown as MemoryRow[];
}
export function getDbPath(): string {
return DB_PATH;
}