@pinkpixel/memi 0.0.0-stage → 1.0.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/LICENSE +202 -0
- package/README.md +298 -2
- package/dist/cli/commands/config.js +38 -0
- package/dist/cli/commands/doctor.js +53 -0
- package/dist/cli/commands/memories.js +146 -0
- package/dist/cli/commands/reindex.js +34 -0
- package/dist/cli/commands/serve.js +9 -0
- package/dist/cli/commands/ui.js +26 -0
- package/dist/cli/index.js +20 -0
- package/dist/cli/util.js +46 -0
- package/dist/core/config.js +89 -0
- package/dist/core/db.js +21 -0
- package/dist/core/format.js +16 -0
- package/dist/core/memories.js +192 -0
- package/dist/core/project.js +53 -0
- package/dist/core/rows.js +38 -0
- package/dist/core/runtime.js +10 -0
- package/dist/core/schema.js +60 -0
- package/dist/core/search.js +86 -0
- package/dist/core/types.js +12 -0
- package/dist/core/vectors.js +102 -0
- package/dist/embed/http.js +20 -0
- package/dist/embed/index.js +9 -0
- package/dist/embed/ollama.js +24 -0
- package/dist/embed/openai.js +22 -0
- package/dist/embed/types.js +2 -0
- package/dist/lib/app-info.js +5 -0
- package/dist/mcp/server.js +24 -0
- package/dist/mcp/tools.js +152 -0
- package/dist/ui/assets.js +38 -0
- package/dist/ui/routes.js +91 -0
- package/dist/ui/security.js +47 -0
- package/dist/ui/server.js +33 -0
- package/package.json +63 -4
- package/skills/memi-curate/SKILL.md +89 -0
- package/skills/memi-memory/SKILL.md +141 -0
- package/skills/memi-memory/references/examples.md +50 -0
- package/skills/memi-memory/references/rubrics.md +44 -0
- package/skills/memi-setup/SKILL.md +159 -0
- package/ui/css/controls.css +274 -0
- package/ui/css/dialogs.css +145 -0
- package/ui/css/layout.css +237 -0
- package/ui/css/memories.css +168 -0
- package/ui/css/tokens.css +125 -0
- package/ui/favicon.svg +1 -0
- package/ui/fonts/OFL.txt +192 -0
- package/ui/fonts/geist-latin-wght-normal.woff2 +0 -0
- package/ui/fonts/geist-mono-latin-wght-normal.woff2 +0 -0
- package/ui/index.html +75 -0
- package/ui/js/api.js +30 -0
- package/ui/js/confirm.js +29 -0
- package/ui/js/dom.js +49 -0
- package/ui/js/editor.js +133 -0
- package/ui/js/icons.js +29 -0
- package/ui/js/list.js +66 -0
- package/ui/js/main.js +244 -0
- package/ui/js/sidebar.js +85 -0
- package/ui/js/state.js +61 -0
- package/ui/js/toast.js +10 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export const rowToMemory = (r) => ({
|
|
2
|
+
id: r.id,
|
|
3
|
+
scope: r.scope,
|
|
4
|
+
project: r.project,
|
|
5
|
+
agent: r.agent,
|
|
6
|
+
category: r.category,
|
|
7
|
+
content: r.content,
|
|
8
|
+
tags: JSON.parse(r.tags),
|
|
9
|
+
importance: r.importance,
|
|
10
|
+
pinned: r.pinned === 1,
|
|
11
|
+
createdAt: r.created_at,
|
|
12
|
+
updatedAt: r.updated_at,
|
|
13
|
+
});
|
|
14
|
+
/** Builds a WHERE fragment for the memories table, aliased as `alias`. */
|
|
15
|
+
export function whereClause(f, alias = "m") {
|
|
16
|
+
const a = alias;
|
|
17
|
+
const parts = [];
|
|
18
|
+
const params = {};
|
|
19
|
+
const scope = f.scope ?? "both";
|
|
20
|
+
const project = f.project ?? null;
|
|
21
|
+
if (scope === "global")
|
|
22
|
+
parts.push(`${a}.scope = 'global'`);
|
|
23
|
+
else if (scope === "project")
|
|
24
|
+
parts.push(project ? `${a}.scope = 'project' AND ${a}.project = @project` : "0");
|
|
25
|
+
else if (scope === "both")
|
|
26
|
+
parts.push(project ? `(${a}.scope = 'global' OR (${a}.scope = 'project' AND ${a}.project = @project))` : `${a}.scope = 'global'`);
|
|
27
|
+
if (project && scope !== "global" && scope !== "all")
|
|
28
|
+
params.project = project;
|
|
29
|
+
if (f.category)
|
|
30
|
+
(parts.push(`${a}.category = @category`), (params.category = f.category));
|
|
31
|
+
if (f.minImportance)
|
|
32
|
+
(parts.push(`${a}.importance >= @minImportance`), (params.minImportance = f.minImportance));
|
|
33
|
+
if (f.pinned !== undefined)
|
|
34
|
+
parts.push(`${a}.pinned = ${f.pinned ? 1 : 0}`);
|
|
35
|
+
if (f.agent)
|
|
36
|
+
(parts.push(`${a}.agent = @agent`), (params.agent = f.agent));
|
|
37
|
+
return { sql: parts.length ? parts.join(" AND ") : "1", params };
|
|
38
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { loadConfig, resolveAgent, resolveEmbedder } from "./config.js";
|
|
2
|
+
import { openDb } from "./db.js";
|
|
3
|
+
import { MemoryStore } from "./memories.js";
|
|
4
|
+
import { createEmbedder } from "../embed/index.js";
|
|
5
|
+
/** Opens the database at its default location with whichever embedder the config and environment select. */
|
|
6
|
+
export function openRuntime() {
|
|
7
|
+
const config = loadConfig();
|
|
8
|
+
const store = new MemoryStore(openDb(), createEmbedder(resolveEmbedder(config)));
|
|
9
|
+
return { store, config, agent: resolveAgent(config) };
|
|
10
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
const V1 = `
|
|
2
|
+
CREATE TABLE memories (
|
|
3
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
4
|
+
scope TEXT NOT NULL CHECK (scope IN ('global','project')),
|
|
5
|
+
project TEXT,
|
|
6
|
+
agent TEXT,
|
|
7
|
+
category TEXT NOT NULL,
|
|
8
|
+
content TEXT NOT NULL,
|
|
9
|
+
tags TEXT NOT NULL DEFAULT '[]',
|
|
10
|
+
importance INTEGER NOT NULL DEFAULT 3 CHECK (importance BETWEEN 1 AND 5),
|
|
11
|
+
pinned INTEGER NOT NULL DEFAULT 0,
|
|
12
|
+
created_at TEXT NOT NULL,
|
|
13
|
+
updated_at TEXT NOT NULL,
|
|
14
|
+
CHECK ((scope = 'global' AND project IS NULL) OR (scope = 'project' AND project IS NOT NULL))
|
|
15
|
+
);
|
|
16
|
+
CREATE INDEX idx_memories_scope ON memories(scope, project);
|
|
17
|
+
CREATE INDEX idx_memories_category ON memories(category);
|
|
18
|
+
|
|
19
|
+
CREATE TABLE categories (
|
|
20
|
+
name TEXT PRIMARY KEY,
|
|
21
|
+
description TEXT NOT NULL DEFAULT '',
|
|
22
|
+
builtin INTEGER NOT NULL DEFAULT 0
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
CREATE TABLE meta (
|
|
26
|
+
key TEXT PRIMARY KEY,
|
|
27
|
+
value TEXT NOT NULL
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
CREATE VIRTUAL TABLE memories_fts USING fts5(
|
|
31
|
+
content, tags, category,
|
|
32
|
+
content='memories', content_rowid='id',
|
|
33
|
+
tokenize='porter unicode61'
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
CREATE TRIGGER memories_ai AFTER INSERT ON memories BEGIN
|
|
37
|
+
INSERT INTO memories_fts(rowid, content, tags, category)
|
|
38
|
+
VALUES (new.id, new.content, new.tags, new.category);
|
|
39
|
+
END;
|
|
40
|
+
CREATE TRIGGER memories_ad AFTER DELETE ON memories BEGIN
|
|
41
|
+
INSERT INTO memories_fts(memories_fts, rowid, content, tags, category)
|
|
42
|
+
VALUES ('delete', old.id, old.content, old.tags, old.category);
|
|
43
|
+
END;
|
|
44
|
+
CREATE TRIGGER memories_au AFTER UPDATE ON memories BEGIN
|
|
45
|
+
INSERT INTO memories_fts(memories_fts, rowid, content, tags, category)
|
|
46
|
+
VALUES ('delete', old.id, old.content, old.tags, old.category);
|
|
47
|
+
INSERT INTO memories_fts(rowid, content, tags, category)
|
|
48
|
+
VALUES (new.id, new.content, new.tags, new.category);
|
|
49
|
+
END;
|
|
50
|
+
`;
|
|
51
|
+
const MIGRATIONS = [V1];
|
|
52
|
+
export function migrate(db) {
|
|
53
|
+
const current = db.pragma("user_version", { simple: true });
|
|
54
|
+
for (let v = current; v < MIGRATIONS.length; v++) {
|
|
55
|
+
db.transaction(() => {
|
|
56
|
+
db.exec(MIGRATIONS[v]);
|
|
57
|
+
db.pragma(`user_version = ${v + 1}`);
|
|
58
|
+
})();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { rowToMemory, whereClause } from "./rows.js";
|
|
2
|
+
import { embedderState, knn, mismatchMessage, scopeKey } from "./vectors.js";
|
|
3
|
+
const RRF_K = 60;
|
|
4
|
+
/**
|
|
5
|
+
* How strongly importance and recency nudge the fused relevance score. Top RRF scores are only about
|
|
6
|
+
* 1.6% apart per rank, so these have to stay small or they outrank relevance.
|
|
7
|
+
*/
|
|
8
|
+
export const RANKING = { importance: 0.025, recency: 0.025, recencyHalfLifeDays: 60 };
|
|
9
|
+
function boost(m) {
|
|
10
|
+
const ageDays = Math.max((Date.now() - Date.parse(m.updatedAt)) / 86_400_000, 0);
|
|
11
|
+
return (1 + RANKING.importance * (m.importance - 3)) * (1 + RANKING.recency * Math.pow(0.5, ageDays / RANKING.recencyHalfLifeDays));
|
|
12
|
+
}
|
|
13
|
+
/** Turns free text into a safe FTS5 query: quoted tokens joined with OR. */
|
|
14
|
+
export function toFtsQuery(text) {
|
|
15
|
+
const tokens = text.match(/[\p{L}\p{N}]+/gu);
|
|
16
|
+
return tokens?.length ? tokens.map((t) => `"${t}"`).join(" OR ") : null;
|
|
17
|
+
}
|
|
18
|
+
function textSearch(db, query, f, n) {
|
|
19
|
+
const fts = toFtsQuery(query);
|
|
20
|
+
if (!fts)
|
|
21
|
+
return [];
|
|
22
|
+
const { sql, params } = whereClause(f);
|
|
23
|
+
const rows = db
|
|
24
|
+
.prepare(`SELECT m.* FROM memories_fts JOIN memories m ON m.id = memories_fts.rowid
|
|
25
|
+
WHERE memories_fts MATCH @fts AND ${sql} ORDER BY bm25(memories_fts) LIMIT @n`)
|
|
26
|
+
.all({ ...params, fts, n });
|
|
27
|
+
return rows.map(rowToMemory);
|
|
28
|
+
}
|
|
29
|
+
async function semanticSearch(db, embedder, query, f, n) {
|
|
30
|
+
const [vec] = await embedder.embed([query], "query");
|
|
31
|
+
if (!vec)
|
|
32
|
+
return [];
|
|
33
|
+
const scope = f.scope ?? "both";
|
|
34
|
+
const keys = scope === "all" ? [null] : scope === "global" ? ["global"] : scope === "project" ? (f.project ? [scopeKey("project", f.project)] : []) : f.project ? ["global", scopeKey("project", f.project)] : ["global"];
|
|
35
|
+
const ids = keys.flatMap((k) => knn(db, vec, n, k)).sort((a, b) => a.distance - b.distance).map((r) => Number(r.id));
|
|
36
|
+
if (!ids.length)
|
|
37
|
+
return [];
|
|
38
|
+
const { sql, params } = whereClause({ ...f, scope: "all" });
|
|
39
|
+
const rows = db
|
|
40
|
+
.prepare(`SELECT m.* FROM memories m WHERE m.id IN (SELECT value FROM json_each(@ids)) AND ${sql}`)
|
|
41
|
+
.all({ ...params, ids: JSON.stringify(ids) });
|
|
42
|
+
const byId = new Map(rows.map((r) => [r.id, rowToMemory(r)]));
|
|
43
|
+
return ids.flatMap((id) => (byId.has(id) ? [byId.get(id)] : []));
|
|
44
|
+
}
|
|
45
|
+
export async function search(db, embedder, opts) {
|
|
46
|
+
const limit = Math.min(opts.limit ?? 10, 50);
|
|
47
|
+
const pool = Math.max(limit * 5, 50);
|
|
48
|
+
const wanted = opts.mode ?? "hybrid";
|
|
49
|
+
let mode = wanted;
|
|
50
|
+
let note;
|
|
51
|
+
if (wanted !== "text") {
|
|
52
|
+
const state = embedder ? embedderState(db, embedder) : null;
|
|
53
|
+
if (!embedder)
|
|
54
|
+
(mode = "text"), (note = "No embedder configured, used text search.");
|
|
55
|
+
else if (state?.status === "uninitialized")
|
|
56
|
+
(mode = "text"), (note = "No vectors yet, used text search.");
|
|
57
|
+
else if (state?.status === "mismatch")
|
|
58
|
+
(mode = "text"), (note = mismatchMessage(state, embedder));
|
|
59
|
+
}
|
|
60
|
+
let text = [];
|
|
61
|
+
let semantic = [];
|
|
62
|
+
if (mode !== "semantic")
|
|
63
|
+
text = textSearch(db, opts.query, opts, pool);
|
|
64
|
+
if (mode !== "text" && embedder) {
|
|
65
|
+
try {
|
|
66
|
+
semantic = await semanticSearch(db, embedder, opts.query, opts, pool);
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
if (mode === "semantic")
|
|
70
|
+
throw err;
|
|
71
|
+
mode = "text";
|
|
72
|
+
note = `Semantic search failed (${err.message}), used text search.`;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const fused = new Map();
|
|
76
|
+
const add = (list, source) => list.forEach((memory, rank) => {
|
|
77
|
+
const hit = fused.get(memory.id) ?? { memory, score: 0, sources: [] };
|
|
78
|
+
hit.score += 1 / (RRF_K + rank + 1);
|
|
79
|
+
hit.sources.push(source);
|
|
80
|
+
fused.set(memory.id, hit);
|
|
81
|
+
});
|
|
82
|
+
add(text, "text");
|
|
83
|
+
add(semantic, "semantic");
|
|
84
|
+
const hits = [...fused.values()].map((h) => ({ ...h, score: h.score * boost(h.memory) })).sort((a, b) => b.score - a.score).slice(0, limit);
|
|
85
|
+
return { hits, modeUsed: mode, note };
|
|
86
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const BUILTIN_CATEGORIES = {
|
|
2
|
+
decision: "A choice that was made, with the reasoning",
|
|
3
|
+
preference: "How the user likes things done",
|
|
4
|
+
fact: "A durable fact about the project, system, or user",
|
|
5
|
+
error: "Something that failed and what to avoid next time",
|
|
6
|
+
todo: "Follow-up work that should not be forgotten",
|
|
7
|
+
context: "Background that helps future sessions",
|
|
8
|
+
};
|
|
9
|
+
export const DEFAULT_CATEGORY = "context";
|
|
10
|
+
export const MAX_CONTENT_LENGTH = 8000;
|
|
11
|
+
export class MemiError extends Error {
|
|
12
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
export const scopeKey = (scope, project) => (scope === "global" ? "global" : `p:${project}`);
|
|
2
|
+
const asBlob = (v) => Buffer.from(new Float32Array(v).buffer);
|
|
3
|
+
export function getMeta(db, key) {
|
|
4
|
+
const row = db.prepare("SELECT value FROM meta WHERE key = ?").get(key);
|
|
5
|
+
return row?.value ?? null;
|
|
6
|
+
}
|
|
7
|
+
const setMeta = (db, key, value) => db.prepare("INSERT INTO meta(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, value);
|
|
8
|
+
export function embedderState(db, embedder) {
|
|
9
|
+
const provider = getMeta(db, "embedder_provider");
|
|
10
|
+
const model = getMeta(db, "embedder_model");
|
|
11
|
+
const dim = Number(getMeta(db, "embedder_dimensions"));
|
|
12
|
+
if (!provider || !model || !dim)
|
|
13
|
+
return { status: "uninitialized" };
|
|
14
|
+
const stored = { provider, model, dimensions: dim };
|
|
15
|
+
return provider === embedder.provider && model === embedder.model ? { status: "ok", stored } : { status: "mismatch", stored };
|
|
16
|
+
}
|
|
17
|
+
export const mismatchMessage = (state, embedder) => `The embedder changed from ${state.stored.provider}/${state.stored.model} (${state.stored.dimensions} dims) to ${embedder.provider}/${embedder.model}. ` +
|
|
18
|
+
`Vectors from different models are not compatible and can have different dimensions, so semantic search is off until you run \`memi reindex\`. ` +
|
|
19
|
+
`Reindexing drops the vector table, rebuilds it for the new model, and re-embeds your saved memories. The memories themselves are kept.`;
|
|
20
|
+
function createVecTable(db, dimensions) {
|
|
21
|
+
db.exec(`CREATE VIRTUAL TABLE memory_vec USING vec0(
|
|
22
|
+
memory_id INTEGER PRIMARY KEY,
|
|
23
|
+
scope_key TEXT PARTITION KEY,
|
|
24
|
+
embedding FLOAT[${Math.trunc(dimensions)}] distance_metric=cosine
|
|
25
|
+
)`);
|
|
26
|
+
}
|
|
27
|
+
async function probeDimensions(embedder) {
|
|
28
|
+
const [vec] = await embedder.embed(["memi dimension probe"], "document");
|
|
29
|
+
if (!vec?.length)
|
|
30
|
+
throw new Error("The embedder returned an empty vector.");
|
|
31
|
+
return vec.length;
|
|
32
|
+
}
|
|
33
|
+
/** Creates the vector table on first use. Returns false when the stored embedder does not match. */
|
|
34
|
+
export async function ensureVectors(db, embedder) {
|
|
35
|
+
const state = embedderState(db, embedder);
|
|
36
|
+
if (state.status !== "uninitialized")
|
|
37
|
+
return state;
|
|
38
|
+
const dimensions = await probeDimensions(embedder);
|
|
39
|
+
db.transaction(() => {
|
|
40
|
+
db.exec("DROP TABLE IF EXISTS memory_vec");
|
|
41
|
+
createVecTable(db, dimensions);
|
|
42
|
+
setMeta(db, "embedder_provider", embedder.provider);
|
|
43
|
+
setMeta(db, "embedder_model", embedder.model);
|
|
44
|
+
setMeta(db, "embedder_dimensions", String(dimensions));
|
|
45
|
+
})();
|
|
46
|
+
return embedderState(db, embedder);
|
|
47
|
+
}
|
|
48
|
+
export function putVector(db, id, key, vec) {
|
|
49
|
+
db.prepare("DELETE FROM memory_vec WHERE memory_id = ?").run(BigInt(id));
|
|
50
|
+
db.prepare("INSERT INTO memory_vec(memory_id, scope_key, embedding) VALUES (?, ?, ?)").run(BigInt(id), key, asBlob(vec));
|
|
51
|
+
}
|
|
52
|
+
export function deleteVector(db, id) {
|
|
53
|
+
const exists = db.prepare("SELECT 1 FROM sqlite_master WHERE name = 'memory_vec'").get();
|
|
54
|
+
if (exists)
|
|
55
|
+
db.prepare("DELETE FROM memory_vec WHERE memory_id = ?").run(BigInt(id));
|
|
56
|
+
}
|
|
57
|
+
/** Cosine distance KNN. Pass a scope key to search one partition, or null to search all of them. */
|
|
58
|
+
export function knn(db, query, k, key) {
|
|
59
|
+
const sql = key
|
|
60
|
+
? "SELECT memory_id AS id, distance FROM memory_vec WHERE embedding MATCH ? AND k = ? AND scope_key = ? ORDER BY distance"
|
|
61
|
+
: "SELECT memory_id AS id, distance FROM memory_vec WHERE embedding MATCH ? AND k = ? ORDER BY distance";
|
|
62
|
+
const args = key ? [asBlob(query), k, key] : [asBlob(query), k];
|
|
63
|
+
return db.prepare(sql).all(...args);
|
|
64
|
+
}
|
|
65
|
+
const BATCH = 32;
|
|
66
|
+
async function embedAll(embedder, memories, onProgress) {
|
|
67
|
+
const out = [];
|
|
68
|
+
for (let i = 0; i < memories.length; i += BATCH) {
|
|
69
|
+
const slice = memories.slice(i, i + BATCH);
|
|
70
|
+
out.push(...(await embedder.embed(slice.map((m) => m.content), "document")));
|
|
71
|
+
onProgress?.(Math.min(i + BATCH, memories.length), memories.length);
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Drops the vector table, re-embeds every saved memory with the current embedder, and records the new
|
|
77
|
+
* provider, model and dimensions. All embedding happens first, so a failed run changes nothing.
|
|
78
|
+
*/
|
|
79
|
+
export async function rebuildVectors(db, embedder, all, onProgress) {
|
|
80
|
+
const dimensions = await probeDimensions(embedder);
|
|
81
|
+
const vectors = await embedAll(embedder, all, onProgress);
|
|
82
|
+
db.transaction(() => {
|
|
83
|
+
db.exec("DROP TABLE IF EXISTS memory_vec");
|
|
84
|
+
createVecTable(db, dimensions);
|
|
85
|
+
all.forEach((m, i) => putVector(db, m.id, scopeKey(m.scope, m.project), vectors[i]));
|
|
86
|
+
setMeta(db, "embedder_provider", embedder.provider);
|
|
87
|
+
setMeta(db, "embedder_model", embedder.model);
|
|
88
|
+
setMeta(db, "embedder_dimensions", String(dimensions));
|
|
89
|
+
})();
|
|
90
|
+
return { count: all.length, dimensions };
|
|
91
|
+
}
|
|
92
|
+
/** Embeds memories that were saved while the embedder was unavailable. */
|
|
93
|
+
export async function embedMissing(db, embedder, all) {
|
|
94
|
+
const state = await ensureVectors(db, embedder);
|
|
95
|
+
if (state.status !== "ok")
|
|
96
|
+
return 0;
|
|
97
|
+
const have = new Set(db.prepare("SELECT memory_id AS id FROM memory_vec").all().map((r) => Number(r.id)));
|
|
98
|
+
const missing = all.filter((m) => !have.has(m.id));
|
|
99
|
+
const vectors = await embedAll(embedder, missing);
|
|
100
|
+
db.transaction(() => missing.forEach((m, i) => putVector(db, m.id, scopeKey(m.scope, m.project), vectors[i])))();
|
|
101
|
+
return missing.length;
|
|
102
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { EmbedError } from "./types.js";
|
|
2
|
+
export async function postJson(url, body, headers, hint) {
|
|
3
|
+
let res;
|
|
4
|
+
try {
|
|
5
|
+
res = await fetch(url, {
|
|
6
|
+
method: "POST",
|
|
7
|
+
headers: { "content-type": "application/json", ...headers },
|
|
8
|
+
body: JSON.stringify(body),
|
|
9
|
+
signal: AbortSignal.timeout(120_000),
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
catch (err) {
|
|
13
|
+
throw new EmbedError(`Could not reach ${url}: ${err.message}. ${hint}`);
|
|
14
|
+
}
|
|
15
|
+
if (!res.ok) {
|
|
16
|
+
const text = (await res.text().catch(() => "")).slice(0, 300);
|
|
17
|
+
throw new EmbedError(`${url} returned ${res.status}: ${text}. ${hint}`);
|
|
18
|
+
}
|
|
19
|
+
return (await res.json());
|
|
20
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { resolveEmbedder } from "../core/config.js";
|
|
2
|
+
import { OllamaEmbedder } from "./ollama.js";
|
|
3
|
+
import { OpenAIEmbedder } from "./openai.js";
|
|
4
|
+
export { EmbedError } from "./types.js";
|
|
5
|
+
export function createEmbedder(resolved = resolveEmbedder()) {
|
|
6
|
+
return resolved.provider === "openai"
|
|
7
|
+
? new OpenAIEmbedder(resolved.model, resolved.baseUrl, resolved.apiKey)
|
|
8
|
+
: new OllamaEmbedder(resolved.model, resolved.baseUrl);
|
|
9
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { postJson } from "./http.js";
|
|
2
|
+
import { EmbedError } from "./types.js";
|
|
3
|
+
// nomic-embed-text expects a task prefix on its inputs.
|
|
4
|
+
const PREFIXES = {
|
|
5
|
+
"nomic-embed-text": { document: "search_document: ", query: "search_query: " },
|
|
6
|
+
};
|
|
7
|
+
export class OllamaEmbedder {
|
|
8
|
+
model;
|
|
9
|
+
baseUrl;
|
|
10
|
+
provider = "ollama";
|
|
11
|
+
constructor(model, baseUrl) {
|
|
12
|
+
this.model = model;
|
|
13
|
+
this.baseUrl = baseUrl;
|
|
14
|
+
}
|
|
15
|
+
async embed(texts, kind) {
|
|
16
|
+
const base = this.model.replace(/:latest$/, "");
|
|
17
|
+
const prefix = PREFIXES[base]?.[kind] ?? "";
|
|
18
|
+
const data = await postJson(`${this.baseUrl}/api/embed`, { model: this.model, input: texts.map((t) => prefix + t) }, {}, `Is Ollama running? You may need: ollama pull ${this.model}`);
|
|
19
|
+
if (!data.embeddings || data.embeddings.length !== texts.length) {
|
|
20
|
+
throw new EmbedError(`Ollama returned an unexpected response for model ${this.model}.`);
|
|
21
|
+
}
|
|
22
|
+
return data.embeddings;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { postJson } from "./http.js";
|
|
2
|
+
import { EmbedError } from "./types.js";
|
|
3
|
+
export class OpenAIEmbedder {
|
|
4
|
+
model;
|
|
5
|
+
baseUrl;
|
|
6
|
+
apiKey;
|
|
7
|
+
provider = "openai";
|
|
8
|
+
constructor(model, baseUrl, apiKey) {
|
|
9
|
+
this.model = model;
|
|
10
|
+
this.baseUrl = baseUrl;
|
|
11
|
+
this.apiKey = apiKey;
|
|
12
|
+
}
|
|
13
|
+
async embed(texts) {
|
|
14
|
+
if (!this.apiKey)
|
|
15
|
+
throw new EmbedError("OPENAI_API_KEY is not set. Export it in the environment that runs memi.");
|
|
16
|
+
const data = await postJson(`${this.baseUrl}/embeddings`, { model: this.model, input: texts, encoding_format: "float" }, { authorization: `Bearer ${this.apiKey}` }, "Check OPENAI_API_KEY and the model name.");
|
|
17
|
+
if (!data.data || data.data.length !== texts.length) {
|
|
18
|
+
throw new EmbedError(`OpenAI returned an unexpected response for model ${this.model}.`);
|
|
19
|
+
}
|
|
20
|
+
return [...data.data].sort((a, b) => a.index - b.index).map((d) => d.embedding);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
// package.json is the single source of truth for the version. This path works from both src/ and dist/.
|
|
3
|
+
const pkg = createRequire(import.meta.url)("../../package.json");
|
|
4
|
+
export const APP_NAME = pkg.name;
|
|
5
|
+
export const APP_VERSION = pkg.version;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { APP_VERSION } from "../lib/app-info.js";
|
|
4
|
+
import { registerTools } from "./tools.js";
|
|
5
|
+
const INSTRUCTIONS = "memi is long-term memory that persists between sessions. " +
|
|
6
|
+
"Call get_context at the start of a session. Call recall before answering questions that past decisions or preferences might affect. " +
|
|
7
|
+
"Call remember for durable decisions, preferences, facts, and mistakes. Prefer update_memory over saving a near-duplicate. " +
|
|
8
|
+
"Never store secrets.";
|
|
9
|
+
export function createServer(rt, cwd = process.cwd()) {
|
|
10
|
+
const server = new McpServer({ name: "memi", version: APP_VERSION }, { instructions: INSTRUCTIONS });
|
|
11
|
+
registerTools(server, {
|
|
12
|
+
store: rt.store,
|
|
13
|
+
agent: rt.agent,
|
|
14
|
+
clientName: () => server.server.getClientVersion()?.name ?? null,
|
|
15
|
+
cwd,
|
|
16
|
+
});
|
|
17
|
+
return server;
|
|
18
|
+
}
|
|
19
|
+
/** Serves MCP over stdio. stdout belongs to the protocol, so anything human-readable goes to stderr. */
|
|
20
|
+
export async function serveStdio(rt) {
|
|
21
|
+
const server = createServer(rt);
|
|
22
|
+
await server.connect(new StdioServerTransport());
|
|
23
|
+
console.error(`memi MCP server running (${rt.store.status().embedder?.provider ?? "no"} embedder)`);
|
|
24
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { formatHits, formatMemories, formatMemory } from "../core/format.js";
|
|
3
|
+
import { pickScope, resolveProject } from "../core/project.js";
|
|
4
|
+
import { MemiError } from "../core/types.js";
|
|
5
|
+
import { EmbedError } from "../embed/types.js";
|
|
6
|
+
const text = (t) => ({ content: [{ type: "text", text: t }] });
|
|
7
|
+
/** Turns expected failures into tool errors the model can read, and lets real bugs through. */
|
|
8
|
+
function safe(fn) {
|
|
9
|
+
return async (args) => {
|
|
10
|
+
try {
|
|
11
|
+
return await fn(args);
|
|
12
|
+
}
|
|
13
|
+
catch (err) {
|
|
14
|
+
if (err instanceof MemiError || err instanceof EmbedError) {
|
|
15
|
+
return { ...text(err.message), isError: true };
|
|
16
|
+
}
|
|
17
|
+
throw err;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
const category = z.string().describe("Short label such as decision, preference, fact, error, todo, context. New names are created on the fly.");
|
|
22
|
+
const importance = z.number().int().min(1).max(5).describe("1 (minor) to 5 (critical). Default 3.");
|
|
23
|
+
const project = z.string().describe("Project name. Normally left out: it is worked out from the git repo or agent name.");
|
|
24
|
+
const readScope = z.enum(["both", "project", "global", "all"]).describe("both = global plus this project (default), project, global, or all projects.");
|
|
25
|
+
export function registerTools(server, ctx) {
|
|
26
|
+
const { store } = ctx;
|
|
27
|
+
const agent = () => ctx.agent ?? ctx.clientName();
|
|
28
|
+
const current = (p) => resolveProject({ project: p, agent: ctx.agent, cwd: ctx.cwd })?.project ?? null;
|
|
29
|
+
server.registerTool("remember", {
|
|
30
|
+
title: "Save a memory",
|
|
31
|
+
description: "Save something worth remembering in future sessions: a decision and why, a user preference, a durable fact, a mistake to avoid, or follow-up work. " +
|
|
32
|
+
"Write it so it makes sense on its own, without this conversation. Do not save secrets or things that are obvious from the code. " +
|
|
33
|
+
"The result lists similar existing memories; if one already covers this, use update_memory instead of saving a duplicate.",
|
|
34
|
+
inputSchema: {
|
|
35
|
+
content: z.string().describe("The memory itself. Short and self-contained."),
|
|
36
|
+
category: category.optional(),
|
|
37
|
+
importance: importance.optional(),
|
|
38
|
+
tags: z.array(z.string()).optional().describe("Optional keywords to help find it later."),
|
|
39
|
+
pinned: z.boolean().optional().describe("Pinned memories are loaded at the start of every session. Use for the few things that must never be missed."),
|
|
40
|
+
scope: z.enum(["project", "global"]).optional().describe("project = only this project. global = applies everywhere, such as user preferences. Default: project when one is known."),
|
|
41
|
+
project: project.optional(),
|
|
42
|
+
},
|
|
43
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
44
|
+
}, safe(async (a) => {
|
|
45
|
+
const target = pickScope({ scope: a.scope, project: a.project, agent: ctx.agent, cwd: ctx.cwd });
|
|
46
|
+
const r = await store.add({ ...a, ...target, agent: agent() });
|
|
47
|
+
const lines = [`Saved memory #${r.memory.id} (${r.memory.scope === "global" ? "global" : `project ${r.memory.project}`}, ${r.memory.category}, importance ${r.memory.importance}).`];
|
|
48
|
+
if (r.similar.length) {
|
|
49
|
+
lines.push("", "Similar memories already exist. If one covers this, call update_memory on it and forget #" + r.memory.id + ":");
|
|
50
|
+
lines.push(...r.similar.map((s) => `${formatMemory(s.memory)}`));
|
|
51
|
+
}
|
|
52
|
+
if (r.warning)
|
|
53
|
+
lines.push("", `Note: ${r.warning}`);
|
|
54
|
+
return text(lines.join("\n"));
|
|
55
|
+
}));
|
|
56
|
+
server.registerTool("recall", {
|
|
57
|
+
title: "Search memories",
|
|
58
|
+
description: "Search saved memories by meaning and by keyword. Use before answering anything that earlier decisions, preferences, or mistakes might affect, " +
|
|
59
|
+
"and when the user refers to something from a past session. Describe what you are looking for in plain language.",
|
|
60
|
+
inputSchema: {
|
|
61
|
+
query: z.string().describe("What to look for."),
|
|
62
|
+
scope: readScope.optional(),
|
|
63
|
+
project: project.optional(),
|
|
64
|
+
category: category.optional(),
|
|
65
|
+
min_importance: importance.optional(),
|
|
66
|
+
mode: z.enum(["hybrid", "semantic", "text"]).optional().describe("hybrid (default) combines meaning and keywords."),
|
|
67
|
+
limit: z.number().int().min(1).max(50).optional().describe("Default 10."),
|
|
68
|
+
},
|
|
69
|
+
annotations: { readOnlyHint: true },
|
|
70
|
+
}, safe(async (a) => {
|
|
71
|
+
const r = await store.search({
|
|
72
|
+
query: a.query,
|
|
73
|
+
scope: a.scope,
|
|
74
|
+
project: current(a.project),
|
|
75
|
+
category: a.category,
|
|
76
|
+
minImportance: a.min_importance,
|
|
77
|
+
mode: a.mode,
|
|
78
|
+
limit: a.limit,
|
|
79
|
+
});
|
|
80
|
+
const out = r.hits.length ? formatHits(r.hits) : "No matching memories.";
|
|
81
|
+
return text(r.note ? `${out}\n\nNote: ${r.note}` : out);
|
|
82
|
+
}));
|
|
83
|
+
server.registerTool("get_context", {
|
|
84
|
+
title: "Load session context",
|
|
85
|
+
description: "Load the pinned and high-importance memories (importance 4 or 5) for this project plus global ones. Call this once at the start of a session.",
|
|
86
|
+
inputSchema: { project: project.optional(), limit: z.number().int().min(1).max(50).optional().describe("Default 20.") },
|
|
87
|
+
annotations: { readOnlyHint: true },
|
|
88
|
+
}, safe(async (a) => {
|
|
89
|
+
const list = store.context({ project: current(a.project), limit: a.limit });
|
|
90
|
+
return text(list.length ? formatMemories(list) : "No pinned or high-importance memories yet.");
|
|
91
|
+
}));
|
|
92
|
+
server.registerTool("list_memories", {
|
|
93
|
+
title: "List memories",
|
|
94
|
+
description: "Browse memories without a search query, newest first by default. Use recall when you know what you are looking for.",
|
|
95
|
+
inputSchema: {
|
|
96
|
+
scope: readScope.optional(),
|
|
97
|
+
project: project.optional(),
|
|
98
|
+
category: category.optional(),
|
|
99
|
+
min_importance: importance.optional(),
|
|
100
|
+
pinned: z.boolean().optional(),
|
|
101
|
+
order: z.enum(["recent", "oldest", "importance"]).optional(),
|
|
102
|
+
limit: z.number().int().min(1).max(200).optional().describe("Default 50."),
|
|
103
|
+
offset: z.number().int().min(0).optional(),
|
|
104
|
+
},
|
|
105
|
+
annotations: { readOnlyHint: true },
|
|
106
|
+
}, safe(async (a) => {
|
|
107
|
+
const { items, total } = store.list({
|
|
108
|
+
scope: a.scope,
|
|
109
|
+
project: current(a.project),
|
|
110
|
+
category: a.category,
|
|
111
|
+
minImportance: a.min_importance,
|
|
112
|
+
pinned: a.pinned,
|
|
113
|
+
order: a.order,
|
|
114
|
+
limit: a.limit,
|
|
115
|
+
offset: a.offset,
|
|
116
|
+
});
|
|
117
|
+
return text(items.length ? `${items.length} of ${total}\n\n${formatMemories(items)}` : "No memories found.");
|
|
118
|
+
}));
|
|
119
|
+
server.registerTool("update_memory", {
|
|
120
|
+
title: "Update a memory",
|
|
121
|
+
description: "Change a memory by id: reword it, recategorize it, change importance, tags or pinning, or move it between global and a project. Only the fields you pass change.",
|
|
122
|
+
inputSchema: {
|
|
123
|
+
id: z.number().int().describe("Memory id, shown as #id in results."),
|
|
124
|
+
content: z.string().optional(),
|
|
125
|
+
category: category.optional(),
|
|
126
|
+
importance: importance.optional(),
|
|
127
|
+
tags: z.array(z.string()).optional().describe("Replaces the existing tags."),
|
|
128
|
+
pinned: z.boolean().optional(),
|
|
129
|
+
scope: z.enum(["project", "global"]).optional(),
|
|
130
|
+
project: project.optional(),
|
|
131
|
+
},
|
|
132
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
133
|
+
}, safe(async ({ id, ...patch }) => {
|
|
134
|
+
const moving = patch.scope === "project" && !patch.project;
|
|
135
|
+
const r = await store.update(id, moving ? { ...patch, project: pickScope({ scope: "project", agent: ctx.agent, cwd: ctx.cwd }).project } : patch);
|
|
136
|
+
if (!r)
|
|
137
|
+
return { ...text(`No memory with id ${id}.`), isError: true };
|
|
138
|
+
return text(`Updated.\n\n${formatMemory(r.memory)}${r.warning ? `\n\nNote: ${r.warning}` : ""}`);
|
|
139
|
+
}));
|
|
140
|
+
server.registerTool("forget", {
|
|
141
|
+
title: "Delete a memory",
|
|
142
|
+
description: "Permanently delete a memory by id. Use when a memory is wrong, outdated, or a duplicate. This cannot be undone.",
|
|
143
|
+
inputSchema: { id: z.number().int().describe("Memory id, shown as #id in results.") },
|
|
144
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
|
|
145
|
+
}, safe(async ({ id }) => (store.remove(id) ? text(`Deleted memory #${id}.`) : { ...text(`No memory with id ${id}.`), isError: true })));
|
|
146
|
+
server.registerTool("list_categories", {
|
|
147
|
+
title: "List categories",
|
|
148
|
+
description: "Show every category with how many memories it holds. Check this before inventing a new category name.",
|
|
149
|
+
inputSchema: {},
|
|
150
|
+
annotations: { readOnlyHint: true },
|
|
151
|
+
}, safe(async () => text(store.categories().map((c) => `${c.name} (${c.count})${c.description ? `: ${c.description}` : ""}`).join("\n"))));
|
|
152
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { extname, join, resolve, sep } from "node:path";
|
|
3
|
+
// The frontend files live in ui/ at the package root. This path is the same from src/ui and dist/ui.
|
|
4
|
+
const ROOT = resolve(import.meta.dirname, "../../ui");
|
|
5
|
+
const TYPES = {
|
|
6
|
+
".html": "text/html; charset=utf-8",
|
|
7
|
+
".css": "text/css; charset=utf-8",
|
|
8
|
+
".js": "text/javascript; charset=utf-8",
|
|
9
|
+
".woff2": "font/woff2",
|
|
10
|
+
".svg": "image/svg+xml",
|
|
11
|
+
".txt": "text/plain; charset=utf-8",
|
|
12
|
+
};
|
|
13
|
+
/** Returns a file from ui/, or null. Only known file types, and never anything outside ui/. */
|
|
14
|
+
export async function readAsset(urlPath) {
|
|
15
|
+
let rel;
|
|
16
|
+
try {
|
|
17
|
+
rel = decodeURIComponent(urlPath);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
if (rel === "/" || rel === "")
|
|
23
|
+
rel = "/index.html";
|
|
24
|
+
if (rel.includes("\0") || rel.includes("\\"))
|
|
25
|
+
return null;
|
|
26
|
+
const file = resolve(join(ROOT, rel));
|
|
27
|
+
const type = TYPES[extname(file)];
|
|
28
|
+
if (!type || !file.startsWith(ROOT + sep))
|
|
29
|
+
return null;
|
|
30
|
+
try {
|
|
31
|
+
if (!(await stat(file)).isFile())
|
|
32
|
+
return null;
|
|
33
|
+
return { body: await readFile(file), type, cache: type.startsWith("font/") || type === "image/svg+xml" ? "public, max-age=86400" : "no-cache" };
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|