@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 +94 -0
- package/bin/mindroot.js +57 -0
- package/package.json +34 -0
- package/src/commands/cli-api.js +42 -0
- package/src/commands/human.js +79 -0
- package/src/commands/init.js +25 -0
- package/src/commands/start.js +162 -0
- package/src/core.js +176 -0
- package/src/dashboard/index.html +420 -0
- package/src/embedder.js +32 -0
- package/src/indexer.js +175 -0
- package/src/markdown.js +72 -0
- package/src/mcp.js +216 -0
- package/src/paths.js +38 -0
- package/src/search.js +196 -0
- package/src/server.js +192 -0
- package/src/store/db.js +107 -0
- package/test/markdown.test.js +32 -0
- package/test/mcp.test.js +74 -0
- package/test/search.test.js +87 -0
- package/test/store.test.js +34 -0
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
|
package/bin/mindroot.js
ADDED
|
@@ -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
|
+
}
|