@sksoftofficial/mindroot 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 ADDED
@@ -0,0 +1,94 @@
1
+ # Mindroot
2
+
3
+ Persistent two-layer memory system for AI agents. Embedding-searched **memories** over human-editable markdown **notes**, exposed through a local HTTP service with an MCP interface.
4
+
5
+ - **Notes** (Layer 2): human-editable markdown files under `~/.mindroot/notes/<project>/`, parsed into heading-addressable sections. The unit of storage.
6
+ - **Memories** (Layer 1): short, retrieval-optimized texts linked (or standalone) that point into notes. Embedded and full-text indexed. The unit of retrieval.
7
+ - **Search**: hybrid semantic (embeddinggemma-300m ONNX) + keyword (SQLite FTS5/bm25), strictly project-scoped. Fuzzy project-name matching included (`search_projects`).
8
+
9
+ - Website: https://skbilisim.com/en/projects/mindroot
10
+ - npm: https://www.npmjs.com/package/@sksoftofficial/mindroot
11
+
12
+ ## Install
13
+
14
+ Requires Node.js >= 22.
15
+
16
+ ```sh
17
+ npm install -g mindroot # or: npm link from a clone
18
+ mindroot init # creates store, caches the embedding model (~316MB)
19
+ mindroot start # daemonizes and waits until healthy
20
+ ```
21
+
22
+ ## Quick start (MCP)
23
+
24
+ Point your MCP client at the service:
25
+
26
+ ```json
27
+ {
28
+ "mcpServers": {
29
+ "mindroot": {
30
+ "type": "remote",
31
+ "url": "http://127.0.0.1:7620/mcp",
32
+ "headers": { "Authorization": "Bearer <apiKey>" }
33
+ }
34
+ }
35
+ }
36
+ ```
37
+
38
+ The API key is generated by `mindroot init` and stored in `~/.mindroot/config.json` (never printed). Dashboard lives at `http://127.0.0.1:7620/`.
39
+
40
+ For how agents should actually *use* this memory day-to-day, see [INSTRUCTIONS.md](./INSTRUCTIONS.md) — a ready-made memory policy you can drop into your agent's configuration.
41
+
42
+ ## CLI
43
+
44
+ | Command | Description |
45
+ |---|---|
46
+ | `mindroot init` | Initialize store + model cache (idempotent) |
47
+ | `mindroot start [--foreground]` | Daemonize the service (or run foreground for debugging) |
48
+ | `mindroot stop` | Stop the running service |
49
+ | `mindroot status` | Show whether the service is running |
50
+ | `mindroot projects` | List projects with counts |
51
+ | `mindroot notes --project <slug> [path]` | List notes, or print one |
52
+ | `mindroot memories --project <slug>` | List memories |
53
+ | `mindroot save-memory "<text>" --project <slug> [--link "note.md::Heading"]` | Save a memory |
54
+ | `mindroot search "<query>" --project <slug> [--limit N]` | Search memories + notes |
55
+
56
+ ## MCP tools
57
+
58
+ All tools are prefixed `mindroot_`.
59
+
60
+ | Tool | Params | Purpose |
61
+ |---|---|---|
62
+ | `list_projects` | — | Discover slugs |
63
+ | `search_projects` | `query` | Fuzzy-find a project by name |
64
+ | `search_memories` | `project*`, `query`, `limit?` | Hybrid search over memories (hits carry ids) |
65
+ | `search_notes` | `project*`, `query`, `limit?` | Search note sections |
66
+ | `save_memory` | `project*`, `text`, `target_path?` | Save a memory, optionally linked to a note/section |
67
+ | `delete_memory` | `project*`, `id` | Delete a memory by id |
68
+ | `list_notes` | `project*` | List notes with section paths |
69
+ | `read_note` | `project*`, `path` | Full note content (hash-verified) |
70
+ | `save_note` | `project*`, `path`, `content` | Create/overwrite a note |
71
+ | `update_section` | `project*`, `path`, `heading_path`, `content` | Replace one section body |
72
+ | `delete_note` | `project*`, `path` | Delete a note |
73
+
74
+ ## Configuration
75
+
76
+ Everything lives under `~/.mindroot/`:
77
+
78
+ - `config.json` — `port` (default 7620) and `apiKey`
79
+ - `notes/<project>/` — your markdown notes
80
+ - `models/` — cached ONNX embedding model
81
+ - `mindroot.db` — SQLite index (WAL mode)
82
+ - `mindroot.pid` / `mindroot.log` — written by `start`/`stop`
83
+
84
+ Set `MINDROOT_DIR` to relocate the whole store.
85
+
86
+ ## Development
87
+
88
+ ```sh
89
+ node --test # runs the test suite in test/
90
+ ```
91
+
92
+ ## License
93
+
94
+ MIT
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { init } from "../src/commands/init.js";
4
+ import { start, stop, status } from "../src/commands/start.js";
5
+ import { projects, searchCmd, saveMemoryCmd, memoriesCmd, notesCmd } from "../src/commands/human.js";
6
+
7
+ const [command, ...rest] = process.argv.slice(2);
8
+
9
+ const usage = `mindroot — persistent memory for AI agents
10
+
11
+ Usage:
12
+ mindroot init Initialize store and download model
13
+ mindroot start [--foreground] Start the service in the background (or foreground)
14
+ mindroot stop Stop the running service
15
+ mindroot status Show whether the service is running
16
+ mindroot projects List projects
17
+ mindroot notes --project <slug> [path] List notes (or show one)
18
+ mindroot memories --project <slug> List memories
19
+ mindroot save-memory <text> --project <slug> [--link "note.md::Heading"]
20
+ mindroot search "query" --project <slug> [--limit N]
21
+ `;
22
+
23
+ async function main() {
24
+ switch (command) {
25
+ case undefined:
26
+ case "--help":
27
+ case "-h":
28
+ case "help":
29
+ return console.log(usage);
30
+ case "init":
31
+ return init();
32
+ case "start":
33
+ return start(rest);
34
+ case "stop":
35
+ return stop();
36
+ case "status":
37
+ return status();
38
+ case "projects":
39
+ return projects();
40
+ case "notes":
41
+ return notesCmd(rest);
42
+ case "memories":
43
+ return memoriesCmd(rest);
44
+ case "save-memory":
45
+ return saveMemoryCmd(rest);
46
+ case "search":
47
+ return searchCmd(rest);
48
+ default:
49
+ console.log(`unknown command: ${command}\n\n${usage}`);
50
+ process.exit(1);
51
+ }
52
+ }
53
+
54
+ main().catch((err) => {
55
+ console.error(`error: ${err.message ?? err}`);
56
+ process.exit(1);
57
+ });
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@sksoftofficial/mindroot",
3
+ "version": "1.0.0",
4
+ "description": "Persistent two-layer memory for AI agents: embedding-searched memories over human-editable markdown facts. Runs as a local background service.",
5
+ "homepage": "https://skbilisim.com/en/projects/mindroot",
6
+ "type": "module",
7
+ "bin": {
8
+ "mindroot": "./bin/mindroot.js"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "src/",
13
+ "test/"
14
+ ],
15
+ "scripts": {
16
+ "test": "node --test"
17
+ },
18
+ "keywords": [
19
+ "ai",
20
+ "agent",
21
+ "memory",
22
+ "embeddings",
23
+ "mcp",
24
+ "markdown"
25
+ ],
26
+ "author": "sksoftofficial",
27
+ "license": "ISC",
28
+ "dependencies": {
29
+ "@huggingface/transformers": "^3.7.5"
30
+ },
31
+ "engines": {
32
+ "node": ">=22"
33
+ }
34
+ }
@@ -0,0 +1,42 @@
1
+ import { loadConfig } from "../paths.js";
2
+
3
+ function base() {
4
+ const cfg = loadConfig();
5
+ return `http://127.0.0.1:${cfg.port ?? 7620}`;
6
+ }
7
+
8
+ export async function api(method, pathname, body) {
9
+ let res;
10
+ try {
11
+ res = await fetch(base() + pathname, {
12
+ method,
13
+ headers: {
14
+ authorization: `Bearer ${loadConfig().apiKey ?? ""}`,
15
+ "content-type": "application/json",
16
+ },
17
+ body: body === undefined ? undefined : JSON.stringify(body),
18
+ });
19
+ } catch (err) {
20
+ if (err.cause?.code === "ECONNREFUSED") {
21
+ throw new Error("mindroot service is not running — start it with: mindroot service");
22
+ }
23
+ throw err;
24
+ }
25
+ const data = await res.json().catch(() => ({}));
26
+ if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
27
+ return data;
28
+ }
29
+
30
+ export function parseArgs(argv) {
31
+ const positional = [];
32
+ const flags = {};
33
+ for (let i = 0; i < argv.length; i++) {
34
+ if (argv[i].startsWith("--")) {
35
+ flags[argv[i].slice(2)] = argv[i + 1];
36
+ i++;
37
+ } else {
38
+ positional.push(argv[i]);
39
+ }
40
+ }
41
+ return { positional, flags };
42
+ }
@@ -0,0 +1,79 @@
1
+ import { api, parseArgs } from "./cli-api.js";
2
+
3
+ function fmtDate(ts) {
4
+ return new Date(ts).toISOString().replace("T", " ").slice(0, 16);
5
+ }
6
+
7
+ function projectFlag(flags) {
8
+ const p = flags.project;
9
+ if (!p) throw new Error("--project <slug> is required");
10
+ return p;
11
+ }
12
+
13
+ export async function projects() {
14
+ const rows = await api("GET", "/api/projects");
15
+ if (!rows.length) return console.log("no projects yet");
16
+ for (const r of rows) {
17
+ console.log(`${r.slug} (${r.docs} notes, ${r.memories} memories)`);
18
+ }
19
+ }
20
+
21
+ export async function searchCmd(argv) {
22
+ const { positional, flags } = parseArgs(argv);
23
+ const query = positional.join(" ");
24
+ if (!query) throw new Error('usage: mindroot search "query" --project <slug>');
25
+ const hits = await api("POST", "/api/search", {
26
+ project: projectFlag(flags),
27
+ query,
28
+ limit: Number(flags.limit ?? 8),
29
+ });
30
+ if (!hits.length) return console.log("no results");
31
+ for (const [i, h] of hits.entries()) {
32
+ const target = h.target ? `\n -> ${h.target}` : "";
33
+ console.log(`${i + 1}. [${h.score.toFixed(3)}] ${h.kind}${h.doc_title ? ` in "${h.doc_title}"` : ""}${target}`);
34
+ console.log(` ${h.text.replace(/\s+/g, " ").slice(0, 200)}`);
35
+ console.log(` updated ${fmtDate(h.updated_at)}`);
36
+ }
37
+ }
38
+
39
+ export async function saveMemoryCmd(argv) {
40
+ const { positional, flags } = parseArgs(argv);
41
+ const text = positional.join(" ");
42
+ if (!text) throw new Error('usage: mindroot save-memory "text" --project <slug> [--link "note.md::Heading"]');
43
+ const out = await api("POST", `/api/projects/${flags.project}/memories`, {
44
+ text,
45
+ target_path: flags.link,
46
+ });
47
+ console.log(`saved memory #${out.id} (${out.target_type})`);
48
+ }
49
+
50
+ export async function memoriesCmd(argv) {
51
+ const { flags } = parseArgs(argv);
52
+ const rows = await api("GET", `/api/projects/${projectFlag(flags)}/memories`);
53
+ if (!rows.length) return console.log("no memories");
54
+ for (const r of rows) {
55
+ console.log(`#${r.id} ${r.text}`);
56
+ if (r.target_path) console.log(` -> ${r.target_path}`);
57
+ }
58
+ }
59
+
60
+ export async function notesCmd(argv) {
61
+ const { flags, positional } = parseArgs(argv);
62
+ const slug = projectFlag(flags);
63
+ if (positional[0]) {
64
+ const doc = await api("GET", `/api/projects/${slug}/doc?path=${encodeURIComponent(positional[0])}`);
65
+ console.log(`# ${doc.title}\n`);
66
+ for (const s of doc.sections) {
67
+ console.log(s.path ? `[${s.path}]` : "[preamble]");
68
+ }
69
+ console.log("\n" + "-".repeat(40));
70
+ process.stdout.write(doc.content);
71
+ return;
72
+ }
73
+ const docs = await api("GET", `/api/projects/${slug}/docs`);
74
+ if (!docs.length) return console.log("no notes");
75
+ for (const d of docs) {
76
+ console.log(`${d.path} "${d.title}" (${d.sections.length} sections)`);
77
+ for (const s of d.sections) console.log(` - ${s || "(preamble)"}`);
78
+ }
79
+ }
@@ -0,0 +1,25 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { MINDROOT_DIR, MODELS_DIR, NOTES_DIR, ensureConfig } from "../paths.js";
3
+ import { open } from "../store/db.js";
4
+ import { getEmbedder } from "../embedder.js";
5
+
6
+ export async function init() {
7
+ await mkdir(MODELS_DIR, { recursive: true });
8
+ await mkdir(NOTES_DIR, { recursive: true });
9
+
10
+ const db = open();
11
+ db.close();
12
+
13
+ const { cfg, created } = ensureConfig();
14
+
15
+ console.log(`initializing memory store at ${MINDROOT_DIR}`);
16
+ console.log("fetching embedding model (embeddinggemma-300m ONNX q8)...");
17
+ await getEmbedder();
18
+ console.log("model cached");
19
+
20
+ if (created) {
21
+ console.log(`\nAPI key generated and stored in ${MINDROOT_DIR}/config.json\n`);
22
+ } else {
23
+ console.log(`memory store ready at ${MINDROOT_DIR}`);
24
+ }
25
+ }
@@ -0,0 +1,162 @@
1
+ import fsSync from "node:fs";
2
+ import path from "node:path";
3
+ import { spawn } from "node:child_process";
4
+ import { fileURLToPath } from "node:url";
5
+ import { DEFAULT_PORT, MINDROOT_DIR, ensureConfig } from "../paths.js";
6
+ import { open } from "../store/db.js";
7
+ import { createServer } from "../server.js";
8
+
9
+ const BIN_PATH = path.join(
10
+ path.dirname(fileURLToPath(import.meta.url)),
11
+ "..",
12
+ "..",
13
+ "bin",
14
+ "mindroot.js",
15
+ );
16
+ const PID_FILE = path.join(MINDROOT_DIR, "mindroot.pid");
17
+ const LOG_FILE = path.join(MINDROOT_DIR, "mindroot.log");
18
+
19
+ function readPid() {
20
+ try {
21
+ return Number(fsSync.readFileSync(PID_FILE, "utf8").trim());
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ function pidAlive(pid) {
28
+ try {
29
+ process.kill(pid, 0);
30
+ return true;
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ async function healthy(port) {
37
+ try {
38
+ const res = await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(1000) });
39
+ return res.ok;
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ async function waitFor(port, ms) {
46
+ const deadline = Date.now() + ms;
47
+ while (Date.now() < deadline) {
48
+ if (await healthy(port)) return true;
49
+ await new Promise((r) => setTimeout(r, 200));
50
+ }
51
+ return false;
52
+ }
53
+
54
+ export function isRunning(cfg) {
55
+ const pid = readPid();
56
+ return pid !== null && pidAlive(pid) ? pid : null;
57
+ }
58
+
59
+ export async function start(argv = []) {
60
+ const { cfg } = ensureConfig();
61
+
62
+ if (argv.includes("--foreground")) {
63
+ return runForeground(cfg);
64
+ }
65
+
66
+ const existing = isRunning(cfg);
67
+ if (existing) {
68
+ console.log(`mindroot already running (pid ${existing})`);
69
+ return;
70
+ }
71
+
72
+ fsSync.mkdirSync(MINDROOT_DIR, { recursive: true });
73
+ const out = fsSync.openSync(LOG_FILE, "a");
74
+ const child = spawn(process.execPath, [BIN_PATH, "start", "--foreground"], {
75
+ detached: true,
76
+ stdio: ["ignore", out, out],
77
+ });
78
+ child.unref();
79
+ fsSync.writeFileSync(PID_FILE, String(child.pid));
80
+
81
+ console.log(`starting mindroot on http://127.0.0.1:${cfg.port} ...`);
82
+ if (await waitFor(cfg.port, 15000)) {
83
+ console.log(`started (pid ${child.pid})`);
84
+ console.log(`dashboard: http://127.0.0.1:${cfg.port}/`);
85
+ console.log(`logs: ${LOG_FILE}`);
86
+ console.log(`stop: mindroot stop`);
87
+ } else {
88
+ console.error(`failed to start — last log lines:`);
89
+ try {
90
+ const log = fsSync.readFileSync(LOG_FILE, "utf8").trimEnd();
91
+ console.error(log.split("\n").slice(-10).join("\n"));
92
+ } catch {}
93
+ try { fsSync.unlinkSync(PID_FILE); } catch {}
94
+ process.exitCode = 1;
95
+ }
96
+ }
97
+
98
+ function runForeground(cfg) {
99
+ fsSync.writeFileSync(PID_FILE, String(process.pid));
100
+ const db = open();
101
+ const server = createServer(db, cfg);
102
+ server.on("error", (err) => {
103
+ if (err.code === "EADDRINUSE") {
104
+ console.error(`port ${cfg.port} is already in use — is mindroot already running? (port config: ${MINDROOT_DIR}/config.json)`);
105
+ process.exit(1);
106
+ }
107
+ throw err;
108
+ });
109
+ server.listen(cfg.port, "127.0.0.1", () => {
110
+ console.log(`mindroot listening on http://127.0.0.1:${cfg.port}`);
111
+ console.log(`press Ctrl+C to stop`);
112
+ });
113
+ const shutdown = () => {
114
+ server.close(() => process.exit(0));
115
+ try { fsSync.unlinkSync(PID_FILE); } catch {}
116
+ setTimeout(() => process.exit(0), 1500).unref();
117
+ };
118
+ process.on("SIGINT", shutdown);
119
+ process.on("SIGTERM", shutdown);
120
+ }
121
+
122
+ export async function stop() {
123
+ const pid = readPid();
124
+ if (pid === null || !pidAlive(pid)) {
125
+ try { fsSync.unlinkSync(PID_FILE); } catch {}
126
+ console.log("mindroot is not running");
127
+ return;
128
+ }
129
+ process.kill(pid, "SIGTERM");
130
+ for (let i = 0; i < 30 && pidAlive(pid); i++) {
131
+ await new Promise((r) => setTimeout(r, 100));
132
+ }
133
+ if (pidAlive(pid)) {
134
+ process.kill(pid, "SIGKILL");
135
+ }
136
+ try { fsSync.unlinkSync(PID_FILE); } catch {}
137
+ console.log(`stopped (pid ${pid})`);
138
+ }
139
+
140
+ export async function status() {
141
+ const { cfg } = ensureConfig();
142
+ const pid = isRunning(cfg);
143
+ if (pid === null) {
144
+ console.log("mindroot is not running");
145
+ return;
146
+ }
147
+ const port = cfg.port ?? DEFAULT_PORT;
148
+ const ok = await healthy(port);
149
+ let auth = "unknown";
150
+ if (ok) {
151
+ try {
152
+ const res = await fetch(`http://127.0.0.1:${port}/api/projects`, {
153
+ headers: { authorization: `Bearer ${cfg.apiKey}` },
154
+ signal: AbortSignal.timeout(2000),
155
+ });
156
+ auth = res.ok ? "key ok" : "KEY MISMATCH — update your MCP client's Bearer key from ~/.mindroot/config.json";
157
+ } catch {
158
+ auth = "check failed";
159
+ }
160
+ }
161
+ console.log(`mindroot is running (pid ${pid}, http://127.0.0.1:${port}, health: ${ok ? "ok" : "unreachable"}, auth: ${auth})`);
162
+ }
package/src/core.js ADDED
@@ -0,0 +1,176 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import {
4
+ projectDir,
5
+ safeJoin,
6
+ validSlug,
7
+ ensureProject,
8
+ getProjectRow,
9
+ verifyAndLoadDoc,
10
+ reindexDoc,
11
+ httpError,
12
+ } from "./indexer.js";
13
+ import { parseMarkdown, replaceSectionBody } from "./markdown.js";
14
+ import { embedDoc } from "./embedder.js";
15
+ import { ONNX_MODEL_ID } from "./paths.js";
16
+ import { searchMemories as hybridSearchMemories, searchNotes as hybridSearchNotes, searchProjects } from "./search.js";
17
+
18
+ export { searchProjects };
19
+
20
+ export async function listProjects(db) {
21
+ return db
22
+ .prepare(
23
+ `SELECT p.slug, p.created_at,
24
+ (SELECT COUNT(*) FROM docs d WHERE d.project_id = p.id) AS docs,
25
+ (SELECT COUNT(*) FROM memories m WHERE m.project_id = p.id) AS memories
26
+ FROM projects p ORDER BY p.slug`,
27
+ )
28
+ .all();
29
+ }
30
+
31
+ export function getProject(db, slug) {
32
+ return getProjectRow(db, slug);
33
+ }
34
+
35
+ export async function deleteProject(db, slug) {
36
+ const project = getProjectRow(db, slug);
37
+ await fs.rm(projectDir(slug), { recursive: true, force: true });
38
+ db.prepare("DELETE FROM projects WHERE id = ?").run(project.id);
39
+ return { deleted: slug };
40
+ }
41
+
42
+ export async function listDocs(db, slug) {
43
+ const project = getProjectRow(db, slug);
44
+ const docs = db
45
+ .prepare("SELECT id, rel_path, title, indexed_at FROM docs WHERE project_id = ? ORDER BY rel_path")
46
+ .all(project.id);
47
+ const result = [];
48
+ for (const doc of docs) {
49
+ const sections = db
50
+ .prepare("SELECT heading_path FROM sections WHERE doc_id = ? ORDER BY id")
51
+ .all(doc.id)
52
+ .map((r) => r.heading_path);
53
+ result.push({ path: doc.rel_path, title: doc.title, sections });
54
+ }
55
+ return result;
56
+ }
57
+
58
+ export async function readNote(db, slug, relPath) {
59
+ await verifyAndLoadDoc(db, slug, relPath);
60
+ const project = getProjectRow(db, slug);
61
+ const doc = db
62
+ .prepare("SELECT * FROM docs WHERE project_id = ? AND rel_path = ?")
63
+ .get(project.id, relPath);
64
+ const content = await fs.readFile(safeJoin(slug, relPath), "utf8");
65
+ const parsed = parseMarkdown(content);
66
+ return {
67
+ path: relPath,
68
+ title: doc.title,
69
+ updated_at: doc.indexed_at,
70
+ sections: parsed.sections.map((s) => ({ path: s.path, level: s.level })),
71
+ content,
72
+ };
73
+ }
74
+
75
+ export async function saveNote(db, slug, relPath, content) {
76
+ const full = safeJoin(slug, relPath);
77
+ await fs.mkdir(path.dirname(full), { recursive: true });
78
+ await fs.writeFile(full, content, "utf8");
79
+ const stat = await fs.stat(full);
80
+ await reindexDoc(db, slug, relPath, content, Math.floor(stat.mtimeMs));
81
+ return readNote(db, slug, relPath);
82
+ }
83
+
84
+ export async function deleteNote(db, slug, relPath) {
85
+ const full = safeJoin(slug, relPath);
86
+ const project = getProjectRow(db, slug);
87
+ try {
88
+ await fs.unlink(full);
89
+ } catch (e) {
90
+ if (e.code !== "ENOENT") throw e;
91
+ }
92
+ const doc = db
93
+ .prepare("SELECT id FROM docs WHERE project_id = ? AND rel_path = ?")
94
+ .get(project.id, relPath);
95
+ if (doc) {
96
+ const ids = db.prepare("SELECT id FROM sections WHERE doc_id = ?").all(doc.id).map((r) => r.id);
97
+ for (const sid of ids) {
98
+ db.prepare("DELETE FROM embeddings WHERE owner_type = 'section' AND owner_id = ?").run(sid);
99
+ }
100
+ db.prepare("DELETE FROM docs WHERE id = ?").run(doc.id);
101
+ }
102
+ db.prepare(
103
+ "UPDATE memories SET target_path = NULL, updated_at = ? WHERE project_id = ? AND (target_path = ? OR target_path LIKE ?)",
104
+ ).run(Date.now(), project.id, relPath, relPath + "::%");
105
+ return { deleted: relPath };
106
+ }
107
+
108
+ export async function updateSection(db, slug, relPath, headingPath, newContent) {
109
+ const { content } = await verifyAndLoadDoc(db, slug, relPath);
110
+ const parsed = parseMarkdown(content);
111
+ const sec = parsed.sections.find((s) => s.path === headingPath);
112
+ if (!sec) throw httpError(404, `section not found: ${headingPath}`);
113
+ const updated = replaceSectionBody(content, sec, newContent);
114
+ return saveNote(db, slug, relPath, updated);
115
+ }
116
+
117
+ export async function listMemories(db, slug) {
118
+ const project = getProjectRow(db, slug);
119
+ return db
120
+ .prepare(
121
+ "SELECT id, text, target_type, target_path, created_at, updated_at FROM memories WHERE project_id = ? ORDER BY id",
122
+ )
123
+ .all(project.id);
124
+ }
125
+
126
+ export async function addMemory(db, slug, text, targetPath) {
127
+ if (!text?.trim()) throw httpError(400, "memory text is required");
128
+ let targetType = "standalone";
129
+ if (targetPath) {
130
+ if (!targetPath.includes(".md")) throw httpError(400, "target_path must reference a note (.md or .md::Heading)");
131
+ targetType = targetPath.includes("::") ? "section" : "doc";
132
+ }
133
+ const project = getProjectRow(db, slug) ?? ensureProject(db, slug);
134
+ const now = Date.now();
135
+ const res = db
136
+ .prepare(
137
+ "INSERT INTO memories (project_id, text, target_type, target_path, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
138
+ )
139
+ .run(project.id, text.trim(), targetType, targetPath ?? null, now, now);
140
+ const id = Number(res.lastInsertRowid);
141
+ const vec = await embedDoc(text.trim());
142
+ db.prepare(
143
+ "INSERT INTO embeddings (owner_type, owner_id, dim, model, vec) VALUES ('memory', ?, ?, ?, ?)",
144
+ ).run(id, vec.length, ONNX_MODEL_ID, Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength));
145
+ return { id, target_type: targetType };
146
+ }
147
+
148
+ export async function deleteMemory(db, slug, id) {
149
+ const project = getProjectRow(db, slug);
150
+ const row = db
151
+ .prepare("SELECT id FROM memories WHERE project_id = ? AND id = ?")
152
+ .get(project.id, id);
153
+ if (!row) throw httpError(404, `memory not found: ${id}`);
154
+ db.prepare("DELETE FROM embeddings WHERE owner_type = 'memory' AND owner_id = ?").run(id);
155
+ db.prepare("DELETE FROM memories WHERE id = ?").run(id);
156
+ return { deleted: id };
157
+ }
158
+
159
+ function scopedProject(db, slug) {
160
+ if (!slug) throw httpError(400, "project is required");
161
+ return getProjectRow(db, slug).id;
162
+ }
163
+
164
+ function cleanLimit(limit) {
165
+ const n = Number(limit);
166
+ if (!Number.isFinite(n)) return 8;
167
+ return Math.min(Math.max(Math.floor(n), 1), 100);
168
+ }
169
+
170
+ export async function searchMemories(db, slug, query, limit) {
171
+ return hybridSearchMemories(db, scopedProject(db, slug), query, cleanLimit(limit));
172
+ }
173
+
174
+ export async function searchNotes(db, slug, query, limit) {
175
+ return hybridSearchNotes(db, scopedProject(db, slug), query, cleanLimit(limit));
176
+ }