@pinkpixel/memi 0.1.0 → 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/README.md +299 -0
- 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 +8 -1
- package/skills/memi-setup/SKILL.md +28 -13
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { MemiError } from "../core/types.js";
|
|
4
|
+
import { EmbedError } from "../embed/types.js";
|
|
5
|
+
const Scope = z.enum(["both", "project", "global", "all"]);
|
|
6
|
+
const Tags = z.array(z.string()).max(10);
|
|
7
|
+
const ListQuery = z.object({
|
|
8
|
+
q: z.string().trim().optional(),
|
|
9
|
+
scope: Scope.default("all"),
|
|
10
|
+
project: z.string().optional(),
|
|
11
|
+
category: z.string().optional(),
|
|
12
|
+
minImportance: z.coerce.number().int().min(1).max(5).optional(),
|
|
13
|
+
pinned: z.enum(["true", "false"]).optional(),
|
|
14
|
+
order: z.enum(["recent", "oldest", "importance"]).default("recent"),
|
|
15
|
+
mode: z.enum(["hybrid", "semantic", "text"]).default("hybrid"),
|
|
16
|
+
limit: z.coerce.number().int().min(1).max(200).default(50),
|
|
17
|
+
offset: z.coerce.number().int().min(0).default(0),
|
|
18
|
+
});
|
|
19
|
+
const NewBody = z.object({
|
|
20
|
+
content: z.string(),
|
|
21
|
+
scope: z.enum(["project", "global"]).optional(),
|
|
22
|
+
project: z.string().nullable().optional(),
|
|
23
|
+
category: z.string().optional(),
|
|
24
|
+
importance: z.number().int().optional(),
|
|
25
|
+
tags: Tags.optional(),
|
|
26
|
+
pinned: z.boolean().optional(),
|
|
27
|
+
});
|
|
28
|
+
const PatchBody = z.object({
|
|
29
|
+
content: z.string().optional(),
|
|
30
|
+
scope: z.enum(["project", "global"]).optional(),
|
|
31
|
+
project: z.string().nullable().optional(),
|
|
32
|
+
category: z.string().optional(),
|
|
33
|
+
importance: z.number().int().optional(),
|
|
34
|
+
tags: Tags.optional(),
|
|
35
|
+
pinned: z.boolean().optional(),
|
|
36
|
+
});
|
|
37
|
+
const Id = z.coerce.number().int().positive();
|
|
38
|
+
export function createApi({ store, currentProject }) {
|
|
39
|
+
const api = new Hono();
|
|
40
|
+
api.onError((err, c) => {
|
|
41
|
+
if (err instanceof z.ZodError)
|
|
42
|
+
return c.json({ error: err.issues.map((i) => `${i.path.join(".") || "request"}: ${i.message}`).join("; ") }, 400);
|
|
43
|
+
if (err instanceof SyntaxError)
|
|
44
|
+
return c.json({ error: "Body is not valid JSON." }, 400);
|
|
45
|
+
if (err instanceof MemiError || err instanceof EmbedError)
|
|
46
|
+
return c.json({ error: err.message }, 400);
|
|
47
|
+
console.error(err);
|
|
48
|
+
return c.json({ error: "Something went wrong. Check the terminal running memi ui." }, 500);
|
|
49
|
+
});
|
|
50
|
+
api.get("/status", (c) => c.json({
|
|
51
|
+
...store.status(),
|
|
52
|
+
currentProject,
|
|
53
|
+
projects: store.projects(),
|
|
54
|
+
categories: store.categories(),
|
|
55
|
+
}));
|
|
56
|
+
api.get("/memories", async (c) => {
|
|
57
|
+
const q = ListQuery.parse(c.req.query());
|
|
58
|
+
const filters = {
|
|
59
|
+
scope: q.scope,
|
|
60
|
+
project: q.project ?? null,
|
|
61
|
+
category: q.category,
|
|
62
|
+
minImportance: q.minImportance,
|
|
63
|
+
pinned: q.pinned === undefined ? undefined : q.pinned === "true",
|
|
64
|
+
};
|
|
65
|
+
if (q.q) {
|
|
66
|
+
const r = await store.search({ ...filters, query: q.q, mode: q.mode, limit: Math.min(q.limit, 50) });
|
|
67
|
+
return c.json({
|
|
68
|
+
items: r.hits.map((h) => h.memory),
|
|
69
|
+
total: r.hits.length,
|
|
70
|
+
scores: Object.fromEntries(r.hits.map((h) => [h.memory.id, h.score])),
|
|
71
|
+
modeUsed: r.modeUsed,
|
|
72
|
+
note: r.note ?? null,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return c.json({ ...store.list({ ...filters, order: q.order, limit: q.limit, offset: q.offset }), scores: null, modeUsed: null, note: null });
|
|
76
|
+
});
|
|
77
|
+
api.post("/memories", async (c) => {
|
|
78
|
+
const body = NewBody.parse(await c.req.json());
|
|
79
|
+
return c.json(await store.add({ ...body, agent: "memi-ui" }), 201);
|
|
80
|
+
});
|
|
81
|
+
api.patch("/memories/:id", async (c) => {
|
|
82
|
+
const id = Id.parse(c.req.param("id"));
|
|
83
|
+
const result = await store.update(id, PatchBody.parse(await c.req.json()));
|
|
84
|
+
return result ? c.json(result) : c.json({ error: `No memory with id ${id}.` }, 404);
|
|
85
|
+
});
|
|
86
|
+
api.delete("/memories/:id", (c) => {
|
|
87
|
+
const id = Id.parse(c.req.param("id"));
|
|
88
|
+
return store.remove(id) ? c.body(null, 204) : c.json({ error: `No memory with id ${id}.` }, 404);
|
|
89
|
+
});
|
|
90
|
+
return api;
|
|
91
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
2
|
+
// Memory text is untrusted (an agent can save anything), so the page may only load its own files.
|
|
3
|
+
const CSP = [
|
|
4
|
+
"default-src 'none'",
|
|
5
|
+
"script-src 'self'",
|
|
6
|
+
"style-src 'self'",
|
|
7
|
+
"font-src 'self'",
|
|
8
|
+
"img-src 'self' data:",
|
|
9
|
+
"connect-src 'self'",
|
|
10
|
+
"base-uri 'none'",
|
|
11
|
+
"form-action 'none'",
|
|
12
|
+
"frame-ancestors 'none'",
|
|
13
|
+
].join("; ");
|
|
14
|
+
const forbidden = (message, status) => Response.json({ error: message }, { status });
|
|
15
|
+
/**
|
|
16
|
+
* The UI can delete memories, so a web page you happen to have open must not be able to drive it.
|
|
17
|
+
* - Host check: stops DNS rebinding, where evil.example resolves to 127.0.0.1.
|
|
18
|
+
* - Origin check and JSON-only bodies: stops cross-site writes. A cross-origin JSON request needs a
|
|
19
|
+
* CORS preflight, and this server never answers one.
|
|
20
|
+
*/
|
|
21
|
+
export const guard = async (c, next) => {
|
|
22
|
+
const url = new URL(c.req.url);
|
|
23
|
+
if (!LOCAL_HOSTS.has(url.hostname))
|
|
24
|
+
return forbidden("Forbidden host.", 403);
|
|
25
|
+
if (c.req.method !== "GET" && c.req.method !== "HEAD") {
|
|
26
|
+
const origin = c.req.header("origin");
|
|
27
|
+
if (origin) {
|
|
28
|
+
let originHost = null;
|
|
29
|
+
try {
|
|
30
|
+
originHost = new URL(origin).host;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// "null" and other unparseable origins are rejected below.
|
|
34
|
+
}
|
|
35
|
+
if (originHost !== url.host)
|
|
36
|
+
return forbidden("Forbidden origin.", 403);
|
|
37
|
+
}
|
|
38
|
+
if (c.req.method !== "DELETE" && !c.req.header("content-type")?.toLowerCase().startsWith("application/json")) {
|
|
39
|
+
return forbidden("Send JSON.", 415);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
await next();
|
|
43
|
+
c.header("Content-Security-Policy", CSP);
|
|
44
|
+
c.header("X-Content-Type-Options", "nosniff");
|
|
45
|
+
c.header("Referrer-Policy", "no-referrer");
|
|
46
|
+
c.header("X-Frame-Options", "DENY");
|
|
47
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { serve } from "@hono/node-server";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { resolveProject } from "../core/project.js";
|
|
4
|
+
import { MemiError } from "../core/types.js";
|
|
5
|
+
import { readAsset } from "./assets.js";
|
|
6
|
+
import { createApi } from "./routes.js";
|
|
7
|
+
import { guard } from "./security.js";
|
|
8
|
+
export function createApp(rt, cwd = process.cwd()) {
|
|
9
|
+
const app = new Hono();
|
|
10
|
+
app.use("*", guard);
|
|
11
|
+
app.route("/api", createApi({ store: rt.store, currentProject: resolveProject({ agent: rt.agent, cwd })?.project ?? null }));
|
|
12
|
+
app.get("*", async (c) => {
|
|
13
|
+
const asset = await readAsset(new URL(c.req.url).pathname);
|
|
14
|
+
if (!asset)
|
|
15
|
+
return c.text("Not found", 404);
|
|
16
|
+
return c.body(new Uint8Array(asset.body), 200, { "Content-Type": asset.type, "Cache-Control": asset.cache });
|
|
17
|
+
});
|
|
18
|
+
return app;
|
|
19
|
+
}
|
|
20
|
+
/** Starts the UI on 127.0.0.1 only. Pass port 0 to let the system pick one. */
|
|
21
|
+
export function startUi(rt, opts) {
|
|
22
|
+
const app = createApp(rt, opts.cwd);
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
// Without a createServer option, serve() returns a plain HTTP/1 server.
|
|
25
|
+
const server = serve({ fetch: app.fetch, port: opts.port, hostname: "127.0.0.1" }, (info) => {
|
|
26
|
+
resolve({
|
|
27
|
+
url: `http://localhost:${info.port}`,
|
|
28
|
+
close: () => new Promise((done) => (server.close(() => done()), server.closeAllConnections())),
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
server.once("error", (err) => reject(err.code === "EADDRINUSE" ? new MemiError(`Port ${opts.port} is already in use. Pick another with --port.`) : err));
|
|
32
|
+
});
|
|
33
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pinkpixel/memi",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Local-first memory for AI agents. SQLite, hybrid search, MCP server, and a small local UI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"scripts": {
|
|
27
27
|
"build": "tsc -p tsconfig.build.json",
|
|
28
|
+
"prepublishOnly": "npm run typecheck && npm run build",
|
|
28
29
|
"dev": "tsx src/cli/index.ts",
|
|
29
30
|
"typecheck": "tsc --noEmit",
|
|
30
31
|
"test": "vitest run"
|
|
@@ -54,5 +55,11 @@
|
|
|
54
55
|
"tsx": "^4.23.15",
|
|
55
56
|
"typescript": "^7.0.2",
|
|
56
57
|
"vitest": "^5.0.3"
|
|
58
|
+
},
|
|
59
|
+
"publishConfig": {
|
|
60
|
+
"access": "public"
|
|
61
|
+
},
|
|
62
|
+
"bugs": {
|
|
63
|
+
"url": "https://github.com/pinkpixel-dev/memi/issues"
|
|
57
64
|
}
|
|
58
65
|
}
|