@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
package/dist/cli/util.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { MemiError } from "../core/types.js";
|
|
3
|
+
import { EmbedError } from "../embed/types.js";
|
|
4
|
+
/** Prints expected failures as one clean line and exits 1. Anything else is a real bug and is rethrown. */
|
|
5
|
+
export function exitOnKnownError(err) {
|
|
6
|
+
const e = err;
|
|
7
|
+
if (e instanceof MemiError || e instanceof EmbedError || e.name === "ZodError") {
|
|
8
|
+
console.error(`memi: ${e.message}`);
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
11
|
+
throw err;
|
|
12
|
+
}
|
|
13
|
+
/** Wraps a command action so expected failures print one clean line instead of a stack trace. */
|
|
14
|
+
export function action(fn) {
|
|
15
|
+
return async (...args) => {
|
|
16
|
+
try {
|
|
17
|
+
await fn(...args);
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
exitOnKnownError(err);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export async function confirm(question, yes) {
|
|
25
|
+
if (yes)
|
|
26
|
+
return true;
|
|
27
|
+
if (!process.stdin.isTTY)
|
|
28
|
+
throw new MemiError("This needs confirmation and there is no terminal to ask in. Pass --yes to continue.");
|
|
29
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
30
|
+
try {
|
|
31
|
+
return /^y(es)?$/i.test((await rl.question(`${question} [y/N] `)).trim());
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
rl.close();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export const splitTags = (v) => (v ? v.split(",").map((t) => t.trim()).filter(Boolean) : undefined);
|
|
38
|
+
export function toInt(name) {
|
|
39
|
+
return (v) => {
|
|
40
|
+
const n = Number(v);
|
|
41
|
+
if (!Number.isInteger(n))
|
|
42
|
+
throw new MemiError(`${name} must be a whole number.`);
|
|
43
|
+
return n;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export const printJson = (v) => console.log(JSON.stringify(v, null, 2));
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
export const ProviderSchema = z.enum(["ollama", "openai"]);
|
|
6
|
+
const ConfigSchema = z.object({
|
|
7
|
+
embedder: z
|
|
8
|
+
.object({
|
|
9
|
+
provider: ProviderSchema.default("ollama"),
|
|
10
|
+
model: z.string().optional(),
|
|
11
|
+
baseUrl: z.string().optional(),
|
|
12
|
+
})
|
|
13
|
+
.default({ provider: "ollama" }),
|
|
14
|
+
agent: z.string().optional(),
|
|
15
|
+
ui: z.object({ port: z.number().int().default(4747) }).default({ port: 4747 }),
|
|
16
|
+
});
|
|
17
|
+
export function memiHome() {
|
|
18
|
+
return process.env.MEMI_HOME || join(homedir(), ".memi");
|
|
19
|
+
}
|
|
20
|
+
export const dbPath = () => join(memiHome(), "memi.db");
|
|
21
|
+
export const configPath = () => join(memiHome(), "config.json");
|
|
22
|
+
export function loadConfig() {
|
|
23
|
+
let raw = {};
|
|
24
|
+
if (existsSync(configPath())) {
|
|
25
|
+
try {
|
|
26
|
+
raw = JSON.parse(readFileSync(configPath(), "utf8"));
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
throw new Error(`Could not read ${configPath()}: ${err.message}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return ConfigSchema.parse(raw);
|
|
33
|
+
}
|
|
34
|
+
export function saveConfig(config) {
|
|
35
|
+
mkdirSync(memiHome(), { recursive: true });
|
|
36
|
+
writeFileSync(configPath(), JSON.stringify(config, null, 2) + "\n");
|
|
37
|
+
}
|
|
38
|
+
const DEFAULTS = {
|
|
39
|
+
ollama: { model: "nomic-embed-text", baseUrl: "http://localhost:11434" },
|
|
40
|
+
openai: { model: "text-embedding-3-small", baseUrl: "https://api.openai.com/v1" },
|
|
41
|
+
};
|
|
42
|
+
/** Config file first, then env overrides. The OpenAI key only ever comes from the environment. */
|
|
43
|
+
export function resolveEmbedder(config = loadConfig()) {
|
|
44
|
+
const provider = ProviderSchema.parse(process.env.MEMI_EMBED_PROVIDER ?? config.embedder.provider);
|
|
45
|
+
const defaults = DEFAULTS[provider];
|
|
46
|
+
const configured = config.embedder.provider === provider ? config.embedder : undefined;
|
|
47
|
+
const envBase = provider === "ollama" ? process.env.MEMI_OLLAMA_URL : process.env.OPENAI_BASE_URL;
|
|
48
|
+
return {
|
|
49
|
+
provider,
|
|
50
|
+
model: process.env.MEMI_EMBED_MODEL ?? configured?.model ?? defaults.model,
|
|
51
|
+
baseUrl: (envBase ?? configured?.baseUrl ?? defaults.baseUrl).replace(/\/+$/, ""),
|
|
52
|
+
apiKey: provider === "openai" ? process.env.OPENAI_API_KEY : undefined,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export function resolveAgent(config = loadConfig()) {
|
|
56
|
+
return process.env.MEMI_AGENT?.trim() || config.agent?.trim() || null;
|
|
57
|
+
}
|
|
58
|
+
export const CONFIG_KEYS = ["embedder.provider", "embedder.model", "embedder.baseUrl", "agent", "ui.port"];
|
|
59
|
+
/** Returns a copy of the config with one key changed. Switching provider clears the old provider's model and URL. */
|
|
60
|
+
export function setConfigValue(config, key, value) {
|
|
61
|
+
const next = structuredClone(config);
|
|
62
|
+
switch (key) {
|
|
63
|
+
case "embedder.provider": {
|
|
64
|
+
const provider = ProviderSchema.safeParse(value);
|
|
65
|
+
if (!provider.success)
|
|
66
|
+
throw new Error(`embedder.provider must be one of: ${ProviderSchema.options.join(", ")}`);
|
|
67
|
+
next.embedder = { provider: provider.data };
|
|
68
|
+
return next;
|
|
69
|
+
}
|
|
70
|
+
case "embedder.model":
|
|
71
|
+
next.embedder.model = value;
|
|
72
|
+
return next;
|
|
73
|
+
case "embedder.baseUrl":
|
|
74
|
+
next.embedder.baseUrl = value;
|
|
75
|
+
return next;
|
|
76
|
+
case "agent":
|
|
77
|
+
next.agent = value;
|
|
78
|
+
return next;
|
|
79
|
+
case "ui.port": {
|
|
80
|
+
const port = Number(value);
|
|
81
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
82
|
+
throw new Error("ui.port must be a number from 1 to 65535");
|
|
83
|
+
next.ui.port = port;
|
|
84
|
+
return next;
|
|
85
|
+
}
|
|
86
|
+
default:
|
|
87
|
+
throw new Error(`Unknown key "${key}". Keys: ${CONFIG_KEYS.join(", ")}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
package/dist/core/db.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import * as sqliteVec from "sqlite-vec";
|
|
5
|
+
import { dbPath } from "./config.js";
|
|
6
|
+
import { migrate } from "./schema.js";
|
|
7
|
+
import { BUILTIN_CATEGORIES } from "./types.js";
|
|
8
|
+
export function openDb(path = dbPath()) {
|
|
9
|
+
if (path !== ":memory:")
|
|
10
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
11
|
+
const db = new Database(path);
|
|
12
|
+
db.pragma("journal_mode = WAL");
|
|
13
|
+
db.pragma("foreign_keys = ON");
|
|
14
|
+
db.pragma("busy_timeout = 5000");
|
|
15
|
+
sqliteVec.load(db);
|
|
16
|
+
migrate(db);
|
|
17
|
+
const seed = db.prepare("INSERT OR IGNORE INTO categories(name, description, builtin) VALUES (?, ?, 1)");
|
|
18
|
+
for (const [name, description] of Object.entries(BUILTIN_CATEGORIES))
|
|
19
|
+
seed.run(name, description);
|
|
20
|
+
return db;
|
|
21
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function formatMemory(m, score) {
|
|
2
|
+
const where = m.scope === "global" ? "global" : `project ${m.project}`;
|
|
3
|
+
const meta = [
|
|
4
|
+
m.category,
|
|
5
|
+
`importance ${m.importance}`,
|
|
6
|
+
m.pinned ? "pinned" : null,
|
|
7
|
+
where,
|
|
8
|
+
m.tags.length ? `tags ${m.tags.join(", ")}` : null,
|
|
9
|
+
m.updatedAt.slice(0, 10),
|
|
10
|
+
m.agent ? `by ${m.agent}` : null,
|
|
11
|
+
score !== undefined ? `score ${score.toFixed(4)}` : null,
|
|
12
|
+
].filter(Boolean);
|
|
13
|
+
return `#${m.id} | ${meta.join(" | ")}\n${m.content}`;
|
|
14
|
+
}
|
|
15
|
+
export const formatMemories = (list) => list.map((m) => formatMemory(m)).join("\n\n");
|
|
16
|
+
export const formatHits = (hits) => hits.map((h) => formatMemory(h.memory, h.score)).join("\n\n");
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { rowToMemory, whereClause } from "./rows.js";
|
|
2
|
+
import { DEFAULT_CATEGORY, MAX_CONTENT_LENGTH, MemiError, } from "./types.js";
|
|
3
|
+
import { search } from "./search.js";
|
|
4
|
+
import { deleteVector, embedMissing, embedderState, ensureVectors, knn, mismatchMessage, putVector, rebuildVectors, scopeKey } from "./vectors.js";
|
|
5
|
+
/** Cosine distance under which a new memory is reported as similar to an existing one. */
|
|
6
|
+
export const SIMILAR_DISTANCE = 0.15;
|
|
7
|
+
const ORDER = {
|
|
8
|
+
recent: "m.updated_at DESC, m.id DESC",
|
|
9
|
+
oldest: "m.created_at ASC, m.id ASC",
|
|
10
|
+
importance: "m.importance DESC, m.updated_at DESC",
|
|
11
|
+
};
|
|
12
|
+
export function normalizeCategory(raw) {
|
|
13
|
+
const name = raw.trim().toLowerCase().replace(/\s+/g, "-");
|
|
14
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,31}$/.test(name)) {
|
|
15
|
+
throw new MemiError(`Invalid category "${raw}". Use up to 32 letters, numbers, dashes or underscores.`);
|
|
16
|
+
}
|
|
17
|
+
return name;
|
|
18
|
+
}
|
|
19
|
+
const normalizeTags = (tags = []) => [...new Set(tags.map((t) => t.trim().toLowerCase()).filter(Boolean))].slice(0, 10);
|
|
20
|
+
function checkImportance(n) {
|
|
21
|
+
if (!Number.isInteger(n) || n < 1 || n > 5)
|
|
22
|
+
throw new MemiError("Importance must be a whole number from 1 to 5.");
|
|
23
|
+
return n;
|
|
24
|
+
}
|
|
25
|
+
function checkContent(content) {
|
|
26
|
+
const text = content.trim();
|
|
27
|
+
if (!text)
|
|
28
|
+
throw new MemiError("Memory content cannot be empty.");
|
|
29
|
+
if (text.length > MAX_CONTENT_LENGTH)
|
|
30
|
+
throw new MemiError(`Memory content is too long (max ${MAX_CONTENT_LENGTH} characters).`);
|
|
31
|
+
return text;
|
|
32
|
+
}
|
|
33
|
+
export class MemoryStore {
|
|
34
|
+
db;
|
|
35
|
+
embedder;
|
|
36
|
+
constructor(db, embedder = null) {
|
|
37
|
+
this.db = db;
|
|
38
|
+
this.embedder = embedder;
|
|
39
|
+
}
|
|
40
|
+
ensureCategory(name) {
|
|
41
|
+
this.db.prepare("INSERT OR IGNORE INTO categories(name, description, builtin) VALUES (?, '', 0)").run(name);
|
|
42
|
+
}
|
|
43
|
+
resolveScope(scope, project) {
|
|
44
|
+
const s = scope ?? (project ? "project" : "global");
|
|
45
|
+
if (s === "project" && !project)
|
|
46
|
+
throw new MemiError("A project memory needs a project name.");
|
|
47
|
+
return { scope: s, project: s === "project" ? project : null };
|
|
48
|
+
}
|
|
49
|
+
get(id) {
|
|
50
|
+
const row = this.db.prepare("SELECT * FROM memories WHERE id = ?").get(id);
|
|
51
|
+
return row ? rowToMemory(row) : null;
|
|
52
|
+
}
|
|
53
|
+
getMany(ids) {
|
|
54
|
+
if (!ids.length)
|
|
55
|
+
return [];
|
|
56
|
+
const rows = this.db.prepare(`SELECT * FROM memories WHERE id IN (${ids.map(() => "?").join(",")})`).all(...ids);
|
|
57
|
+
return rows.map(rowToMemory);
|
|
58
|
+
}
|
|
59
|
+
/** Embeds text if possible. Never throws: a memory is saved even when the embedder is down. */
|
|
60
|
+
async tryEmbed(text) {
|
|
61
|
+
if (!this.embedder)
|
|
62
|
+
return { vec: null, warning: "No embedder configured, saved for text search only." };
|
|
63
|
+
try {
|
|
64
|
+
const state = await ensureVectors(this.db, this.embedder);
|
|
65
|
+
if (state.status === "mismatch")
|
|
66
|
+
return { vec: null, warning: mismatchMessage(state, this.embedder) };
|
|
67
|
+
const [vec] = await this.embedder.embed([text], "document");
|
|
68
|
+
return { vec: vec ?? null };
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
return { vec: null, warning: `Saved without a vector (${err.message}). Run \`memi reindex --missing\` later to backfill.` };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async add(input) {
|
|
75
|
+
const content = checkContent(input.content);
|
|
76
|
+
const { scope, project } = this.resolveScope(input.scope, input.project);
|
|
77
|
+
const category = normalizeCategory(input.category ?? DEFAULT_CATEGORY);
|
|
78
|
+
const importance = checkImportance(input.importance ?? 3);
|
|
79
|
+
const now = new Date().toISOString();
|
|
80
|
+
const { vec, warning } = await this.tryEmbed(content);
|
|
81
|
+
const key = scopeKey(scope, project);
|
|
82
|
+
const similarHits = vec ? knn(this.db, vec, 5, key).filter((n) => n.distance <= SIMILAR_DISTANCE) : [];
|
|
83
|
+
const id = this.db.transaction(() => {
|
|
84
|
+
this.ensureCategory(category);
|
|
85
|
+
const res = this.db
|
|
86
|
+
.prepare(`INSERT INTO memories(scope, project, agent, category, content, tags, importance, pinned, created_at, updated_at)
|
|
87
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
88
|
+
.run(scope, project, input.agent ?? null, category, content, JSON.stringify(normalizeTags(input.tags)), importance, input.pinned ? 1 : 0, now, now);
|
|
89
|
+
const newId = Number(res.lastInsertRowid);
|
|
90
|
+
if (vec)
|
|
91
|
+
putVector(this.db, newId, key, vec);
|
|
92
|
+
return newId;
|
|
93
|
+
})();
|
|
94
|
+
const byId = new Map(this.getMany(similarHits.map((h) => h.id)).map((m) => [m.id, m]));
|
|
95
|
+
return {
|
|
96
|
+
memory: this.get(id),
|
|
97
|
+
similar: similarHits.flatMap((h) => (byId.has(h.id) ? [{ memory: byId.get(h.id), distance: h.distance }] : [])),
|
|
98
|
+
embedded: vec !== null,
|
|
99
|
+
warning,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
async update(id, patch) {
|
|
103
|
+
const current = this.get(id);
|
|
104
|
+
if (!current)
|
|
105
|
+
return null;
|
|
106
|
+
const content = patch.content !== undefined ? checkContent(patch.content) : current.content;
|
|
107
|
+
const target = this.resolveScope(patch.scope ?? current.scope, patch.project !== undefined ? patch.project : current.project);
|
|
108
|
+
const category = patch.category !== undefined ? normalizeCategory(patch.category) : current.category;
|
|
109
|
+
const importance = patch.importance !== undefined ? checkImportance(patch.importance) : current.importance;
|
|
110
|
+
const tags = patch.tags !== undefined ? normalizeTags(patch.tags) : current.tags;
|
|
111
|
+
const pinned = patch.pinned ?? current.pinned;
|
|
112
|
+
const needsVector = content !== current.content || target.scope !== current.scope || target.project !== current.project;
|
|
113
|
+
const embedded = needsVector ? await this.tryEmbed(content) : { vec: null, warning: undefined };
|
|
114
|
+
this.db.transaction(() => {
|
|
115
|
+
this.ensureCategory(category);
|
|
116
|
+
this.db
|
|
117
|
+
.prepare("UPDATE memories SET scope=?, project=?, category=?, content=?, tags=?, importance=?, pinned=?, updated_at=? WHERE id=?")
|
|
118
|
+
.run(target.scope, target.project, category, content, JSON.stringify(tags), importance, pinned ? 1 : 0, new Date().toISOString(), id);
|
|
119
|
+
if (needsVector) {
|
|
120
|
+
deleteVector(this.db, id);
|
|
121
|
+
if (embedded.vec)
|
|
122
|
+
putVector(this.db, id, scopeKey(target.scope, target.project), embedded.vec);
|
|
123
|
+
}
|
|
124
|
+
})();
|
|
125
|
+
return { memory: this.get(id), warning: embedded.warning };
|
|
126
|
+
}
|
|
127
|
+
remove(id) {
|
|
128
|
+
return this.db.transaction(() => {
|
|
129
|
+
deleteVector(this.db, id);
|
|
130
|
+
return this.db.prepare("DELETE FROM memories WHERE id = ?").run(id).changes > 0;
|
|
131
|
+
})();
|
|
132
|
+
}
|
|
133
|
+
list(opts = {}) {
|
|
134
|
+
const { sql, params } = whereClause(opts);
|
|
135
|
+
const total = this.db.prepare(`SELECT COUNT(*) AS n FROM memories m WHERE ${sql}`).get(params).n;
|
|
136
|
+
const rows = this.db
|
|
137
|
+
.prepare(`SELECT m.* FROM memories m WHERE ${sql} ORDER BY ${ORDER[opts.order ?? "recent"]} LIMIT @limit OFFSET @offset`)
|
|
138
|
+
.all({ ...params, limit: Math.min(opts.limit ?? 50, 200), offset: opts.offset ?? 0 });
|
|
139
|
+
return { items: rows.map(rowToMemory), total };
|
|
140
|
+
}
|
|
141
|
+
search(opts) {
|
|
142
|
+
return search(this.db, this.embedder, opts);
|
|
143
|
+
}
|
|
144
|
+
/** Pinned and high-importance memories: what an agent should load at the start of a session. */
|
|
145
|
+
context(opts = {}) {
|
|
146
|
+
const { sql, params } = whereClause({ scope: "both", project: opts.project });
|
|
147
|
+
const rows = this.db
|
|
148
|
+
.prepare(`SELECT m.* FROM memories m WHERE ${sql} AND (m.pinned = 1 OR m.importance >= 4)
|
|
149
|
+
ORDER BY m.pinned DESC, m.importance DESC, m.updated_at DESC LIMIT @limit`)
|
|
150
|
+
.all({ ...params, limit: opts.limit ?? 20 });
|
|
151
|
+
return rows.map(rowToMemory);
|
|
152
|
+
}
|
|
153
|
+
categories() {
|
|
154
|
+
const rows = this.db
|
|
155
|
+
.prepare(`SELECT c.name, c.description, c.builtin, COUNT(m.id) AS count
|
|
156
|
+
FROM categories c LEFT JOIN memories m ON m.category = c.name
|
|
157
|
+
GROUP BY c.name ORDER BY c.builtin DESC, c.name`)
|
|
158
|
+
.all();
|
|
159
|
+
return rows.map((r) => ({ ...r, builtin: r.builtin === 1 }));
|
|
160
|
+
}
|
|
161
|
+
projects() {
|
|
162
|
+
return this.db
|
|
163
|
+
.prepare("SELECT project, COUNT(*) AS count FROM memories WHERE scope = 'project' GROUP BY project ORDER BY project")
|
|
164
|
+
.all();
|
|
165
|
+
}
|
|
166
|
+
everything() {
|
|
167
|
+
return this.db.prepare("SELECT * FROM memories ORDER BY id").all().map(rowToMemory);
|
|
168
|
+
}
|
|
169
|
+
/** Drops and rebuilds the vector table with the current embedder. Memories are kept. */
|
|
170
|
+
async reindex(onProgress) {
|
|
171
|
+
if (!this.embedder)
|
|
172
|
+
throw new MemiError("No embedder configured.");
|
|
173
|
+
return rebuildVectors(this.db, this.embedder, this.everything(), onProgress);
|
|
174
|
+
}
|
|
175
|
+
async backfill() {
|
|
176
|
+
if (!this.embedder)
|
|
177
|
+
throw new MemiError("No embedder configured.");
|
|
178
|
+
return embedMissing(this.db, this.embedder, this.everything());
|
|
179
|
+
}
|
|
180
|
+
status() {
|
|
181
|
+
const total = this.db.prepare("SELECT COUNT(*) AS n FROM memories").get().n;
|
|
182
|
+
const state = this.embedder ? embedderState(this.db, this.embedder) : { status: "uninitialized" };
|
|
183
|
+
const vectors = state.status === "uninitialized" ? 0 : this.db.prepare("SELECT COUNT(*) AS n FROM memory_vec").get().n;
|
|
184
|
+
return {
|
|
185
|
+
total,
|
|
186
|
+
vectors,
|
|
187
|
+
embedder: this.embedder ? { provider: this.embedder.provider, model: this.embedder.model } : null,
|
|
188
|
+
state,
|
|
189
|
+
warning: this.embedder && state.status === "mismatch" ? mismatchMessage(state, this.embedder) : undefined,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
3
|
+
import { MemiError } from "./types.js";
|
|
4
|
+
export function findGitRoot(start) {
|
|
5
|
+
let dir = resolve(start);
|
|
6
|
+
for (;;) {
|
|
7
|
+
if (existsSync(join(dir, ".git")))
|
|
8
|
+
return dir;
|
|
9
|
+
const parent = dirname(dir);
|
|
10
|
+
if (parent === dir)
|
|
11
|
+
return null;
|
|
12
|
+
dir = parent;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Project id order: explicit argument, MEMI_PROJECT, git root folder name, then the agent name.
|
|
17
|
+
* Returns null when nothing identifies a project, so callers can fall back to global or ask.
|
|
18
|
+
*/
|
|
19
|
+
export function resolveProject(opts) {
|
|
20
|
+
const explicit = opts.project?.trim();
|
|
21
|
+
if (explicit)
|
|
22
|
+
return { project: explicit, source: "explicit" };
|
|
23
|
+
const env = process.env.MEMI_PROJECT?.trim();
|
|
24
|
+
if (env)
|
|
25
|
+
return { project: env, source: "env" };
|
|
26
|
+
const root = findGitRoot(opts.cwd ?? process.cwd());
|
|
27
|
+
if (root)
|
|
28
|
+
return { project: basename(root), source: "git" };
|
|
29
|
+
const agent = opts.agent?.trim();
|
|
30
|
+
if (agent)
|
|
31
|
+
return { project: `agent:${agent}`, source: "agent" };
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
export function requireProject(opts) {
|
|
35
|
+
const info = resolveProject(opts);
|
|
36
|
+
if (!info) {
|
|
37
|
+
throw new MemiError("Could not work out which project this is. There is no git repo here and no agent name set. " +
|
|
38
|
+
"Pass a project name, set MEMI_PROJECT, or set an agent name with MEMI_AGENT.");
|
|
39
|
+
}
|
|
40
|
+
return info;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Where a new memory goes. With no explicit scope it lands in the current project when one can be
|
|
44
|
+
* worked out, and in global otherwise.
|
|
45
|
+
*/
|
|
46
|
+
export function pickScope(opts) {
|
|
47
|
+
if (opts.scope === "global")
|
|
48
|
+
return { scope: "global", project: null };
|
|
49
|
+
if (opts.scope === "project")
|
|
50
|
+
return { scope: "project", project: requireProject(opts).project };
|
|
51
|
+
const info = resolveProject(opts);
|
|
52
|
+
return info ? { scope: "project", project: info.project } : { scope: "global", project: null };
|
|
53
|
+
}
|
|
@@ -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
|
+
}
|