@sksoftofficial/mindroot 1.0.0 → 1.0.2
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 +40 -4
- package/bin/mindroot.js +4 -1
- package/package.json +1 -1
- package/src/commands/start.js +11 -1
- package/src/core.js +17 -0
- package/src/dashboard/favicon.png +0 -0
- package/src/dashboard/index.html +130 -45
- package/src/dashboard/logo.png +0 -0
- package/src/mcp.js +15 -0
- package/src/server.js +17 -1
- package/test/mcp.test.js +37 -0
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
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
4
|
|
|
5
|
+

|
|
6
|
+
|
|
5
7
|
- **Notes** (Layer 2): human-editable markdown files under `~/.mindroot/notes/<project>/`, parsed into heading-addressable sections. The unit of storage.
|
|
6
8
|
- **Memories** (Layer 1): short, retrieval-optimized texts linked (or standalone) that point into notes. Embedded and full-text indexed. The unit of retrieval.
|
|
7
9
|
- **Search**: hybrid semantic (embeddinggemma-300m ONNX) + keyword (SQLite FTS5/bm25), strictly project-scoped. Fuzzy project-name matching included (`search_projects`).
|
|
@@ -14,9 +16,9 @@ Persistent two-layer memory system for AI agents. Embedding-searched **memories*
|
|
|
14
16
|
Requires Node.js >= 22.
|
|
15
17
|
|
|
16
18
|
```sh
|
|
17
|
-
npm install -g mindroot # or: npm link from a clone
|
|
18
|
-
mindroot init
|
|
19
|
-
mindroot start
|
|
19
|
+
npm install -g @sksoftofficial/mindroot # or: npm link from a clone
|
|
20
|
+
mindroot init # creates store, caches the embedding model (~316MB)
|
|
21
|
+
mindroot start # daemonizes and waits until healthy
|
|
20
22
|
```
|
|
21
23
|
|
|
22
24
|
## Quick start (MCP)
|
|
@@ -37,7 +39,41 @@ Point your MCP client at the service:
|
|
|
37
39
|
|
|
38
40
|
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
41
|
|
|
40
|
-
|
|
42
|
+
## Agent memory policy
|
|
43
|
+
|
|
44
|
+
Add this policy to your agent's instructions:
|
|
45
|
+
|
|
46
|
+
```markdown
|
|
47
|
+
<!-- mindroot:start -->
|
|
48
|
+
# Agent memory policy (mindroot)
|
|
49
|
+
|
|
50
|
+
Use mindroot MCP tools as persistent memory for durable project facts. It's a project index, not a scratchpad.
|
|
51
|
+
|
|
52
|
+
- Once per session, before relying on memory or exploring source: `list_projects` (or `search_projects` for fuzzy name matching), then `search_memories` / `read_note` on relevant subjects. Source files are ground truth; fall back to them when memory lacks the detail.
|
|
53
|
+
- Every content tool needs a project slug. A project is created automatically on first write.
|
|
54
|
+
|
|
55
|
+
## Notes vs memories
|
|
56
|
+
|
|
57
|
+
Notes are storage; memories are the retrieval index into them. They work as a pair:
|
|
58
|
+
|
|
59
|
+
- **Memories** (`save_memory`, `delete_memory`) — short, standalone atomic facts ("where email delivery lives", "never migrate during business hours"), optionally linked via `target_path` ("note.md::Heading"). No listing tool: `search_memories` is how you find them; hits carry the ids for `delete_memory`. When in doubt, save a memory.
|
|
60
|
+
- **Notes** (`save_note`, `update_section`, `read_note`, `delete_note`) — structured markdown docs parsed by headings. `update_section` takes full heading paths (`Audit logging::API`); run `read_note` first and copy exact paths.
|
|
61
|
+
|
|
62
|
+
After writing or updating a notable note section, also save 1–3 memories pointing at it (`target_path`) so future searches surface it — one per key fact a future agent would search for. If a memory stands alone (no note worth writing), that's fine too. Dedupe by searching first, then deleting stale hits — never stack near-copies.
|
|
63
|
+
|
|
64
|
+
Notes read like compact index cards: one sentence on what the feature is, then the files, entry points, and data flow needed to work on it later — enough to answer "where is this implemented?" without reading source. Keep one feature per note under keyword-rich headings (`## Model`, `## API`, `## Gotchas`, …), plus standard cross-cutting notes: `overview.md`, `conventions.md` (patterns shared across features), `commands.md`, `gotchas.md`.
|
|
65
|
+
|
|
66
|
+
## What to save
|
|
67
|
+
|
|
68
|
+
Facts that improve future navigation, implementation, debugging, or verification: structure and key files, config values, frameworks/services, build/test/deploy commands, conventions, recurring bugs and gotchas.
|
|
69
|
+
|
|
70
|
+
Never save: task history, reasoning trails, rejected alternatives, dated recaps, secrets, logs, or speculation.
|
|
71
|
+
|
|
72
|
+
Write bullets as standalone present-tense facts with file anchors (`models/AuditLog.jsx` defines model `AuditLog` in collection `auditlog`). Not "we decided X today".
|
|
73
|
+
|
|
74
|
+
After work that changes a durable fact, update the smallest relevant note section — plus linked memories for its key facts — automatically before your final response, no confirmation needed. Notes stay compact: merge overlapping bullets, drop stale ones.
|
|
75
|
+
<!-- mindroot:end -->
|
|
76
|
+
```
|
|
41
77
|
|
|
42
78
|
## CLI
|
|
43
79
|
|
package/bin/mindroot.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { init } from "../src/commands/init.js";
|
|
4
|
-
import { start, stop, status } from "../src/commands/start.js";
|
|
4
|
+
import { start, stop, restart, status } from "../src/commands/start.js";
|
|
5
5
|
import { projects, searchCmd, saveMemoryCmd, memoriesCmd, notesCmd } from "../src/commands/human.js";
|
|
6
6
|
|
|
7
7
|
const [command, ...rest] = process.argv.slice(2);
|
|
@@ -12,6 +12,7 @@ Usage:
|
|
|
12
12
|
mindroot init Initialize store and download model
|
|
13
13
|
mindroot start [--foreground] Start the service in the background (or foreground)
|
|
14
14
|
mindroot stop Stop the running service
|
|
15
|
+
mindroot restart Restart the service (stop + start)
|
|
15
16
|
mindroot status Show whether the service is running
|
|
16
17
|
mindroot projects List projects
|
|
17
18
|
mindroot notes --project <slug> [path] List notes (or show one)
|
|
@@ -33,6 +34,8 @@ async function main() {
|
|
|
33
34
|
return start(rest);
|
|
34
35
|
case "stop":
|
|
35
36
|
return stop();
|
|
37
|
+
case "restart":
|
|
38
|
+
return restart();
|
|
36
39
|
case "status":
|
|
37
40
|
return status();
|
|
38
41
|
case "projects":
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sksoftofficial/mindroot",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "Persistent two-layer memory for AI agents: embedding-searched memories over human-editable markdown facts. Runs as a local background service.",
|
|
5
5
|
"homepage": "https://skbilisim.com/en/projects/mindroot",
|
|
6
6
|
"type": "module",
|
package/src/commands/start.js
CHANGED
|
@@ -79,7 +79,7 @@ export async function start(argv = []) {
|
|
|
79
79
|
fsSync.writeFileSync(PID_FILE, String(child.pid));
|
|
80
80
|
|
|
81
81
|
console.log(`starting mindroot on http://127.0.0.1:${cfg.port} ...`);
|
|
82
|
-
if (await waitFor(cfg.port, 15000)) {
|
|
82
|
+
if (await waitFor(cfg.port, 15000) && pidAlive(child.pid)) {
|
|
83
83
|
console.log(`started (pid ${child.pid})`);
|
|
84
84
|
console.log(`dashboard: http://127.0.0.1:${cfg.port}/`);
|
|
85
85
|
console.log(`logs: ${LOG_FILE}`);
|
|
@@ -119,6 +119,16 @@ function runForeground(cfg) {
|
|
|
119
119
|
process.on("SIGTERM", shutdown);
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
export async function restart() {
|
|
123
|
+
const pid = readPid();
|
|
124
|
+
if (pid !== null && pidAlive(pid)) {
|
|
125
|
+
await stop();
|
|
126
|
+
} else {
|
|
127
|
+
try { fsSync.unlinkSync(PID_FILE); } catch {}
|
|
128
|
+
}
|
|
129
|
+
return start();
|
|
130
|
+
}
|
|
131
|
+
|
|
122
132
|
export async function stop() {
|
|
123
133
|
const pid = readPid();
|
|
124
134
|
if (pid === null || !pidAlive(pid)) {
|
package/src/core.js
CHANGED
|
@@ -39,6 +39,23 @@ export async function deleteProject(db, slug) {
|
|
|
39
39
|
return { deleted: slug };
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
export async function renameProject(db, from, to) {
|
|
43
|
+
const project = getProjectRow(db, from);
|
|
44
|
+
if (to === from) throw httpError(400, `project is already named: ${from}`);
|
|
45
|
+
if (!validSlug(to)) throw httpError(400, `invalid project slug: ${to}`);
|
|
46
|
+
if (db.prepare("SELECT id FROM projects WHERE slug = ?").get(to)) {
|
|
47
|
+
throw httpError(409, `project already exists: ${to}`);
|
|
48
|
+
}
|
|
49
|
+
const targetDir = projectDir(to);
|
|
50
|
+
if (await fs.stat(targetDir).then(() => true, () => false)) {
|
|
51
|
+
throw httpError(409, `directory already exists: ${to}`);
|
|
52
|
+
}
|
|
53
|
+
await fs.rename(projectDir(from), targetDir);
|
|
54
|
+
db.prepare("UPDATE projects SET slug = ? WHERE id = ?").run(to, project.id);
|
|
55
|
+
db.prepare("DELETE FROM project_embeddings WHERE project_id = ?").run(project.id);
|
|
56
|
+
return { renamed: { from, to } };
|
|
57
|
+
}
|
|
58
|
+
|
|
42
59
|
export async function listDocs(db, slug) {
|
|
43
60
|
const project = getProjectRow(db, slug);
|
|
44
61
|
const docs = db
|
|
Binary file
|
package/src/dashboard/index.html
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
-
<
|
|
7
|
-
<
|
|
6
|
+
<meta name="theme-color" content="#161616">
|
|
7
|
+
<link rel="icon" type="image/png" sizes="64x64" href="/favicon.png">
|
|
8
|
+
<title>Mindroot</title>
|
|
8
9
|
<style>
|
|
9
|
-
:root { --bg:#1e1e1e; --bg2:#161616; --bg3:#2b2b2b; --hover:#303030; --fg:#dcddde; --dim:#999; --accent:#e9973f; --accent-strong:#d97721; --accent-soft:#442f20; --danger:#ff6b6b; --ok:#53c59f; --border
|
|
10
|
+
:root { --bg:#1e1e1e; --bg2:#161616; --bg3:#2b2b2b; --hover:#303030; --fg:#dcddde; --dim:#999; --accent:#e9973f; --accent-strong:#d97721; --accent-soft:#442f20; --danger:#ff6b6b; --ok:#53c59f; --border:rgba(255,255,255,.055); --border-soft:rgba(255,255,255,.03); --border-hover:rgba(255,255,255,.11); --border-accent:rgba(233,151,63,.22); }
|
|
10
11
|
* { box-sizing:border-box; margin:0; padding:0; }
|
|
11
12
|
* { scrollbar-width:thin; scrollbar-color:#505050 transparent }
|
|
12
13
|
*::-webkit-scrollbar { width:10px; height:10px }
|
|
@@ -14,23 +15,36 @@
|
|
|
14
15
|
*::-webkit-scrollbar-thumb { background:#484848; border:2px solid transparent; border-radius:999px; background-clip:padding-box }
|
|
15
16
|
*::-webkit-scrollbar-thumb:hover { background:#606060; border:2px solid transparent; background-clip:padding-box }
|
|
16
17
|
*::-webkit-scrollbar-corner { background:transparent }
|
|
17
|
-
body { font:14px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:var(--bg); color:var(--fg); height:100vh; display:flex; flex-direction:column; }
|
|
18
|
-
header { display:flex; align-items:center; gap:
|
|
19
|
-
|
|
18
|
+
body { font:14px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:var(--bg); color:var(--fg); height:100vh; height:100dvh; display:flex; flex-direction:column; }
|
|
19
|
+
header { position:relative; z-index:2; height:74px; flex:none; display:flex; align-items:center; gap:18px; padding:18px 14px; background:var(--bg2); border-bottom:1px solid var(--border-soft); box-shadow:0 8px 20px rgba(0,0,0,.14); }
|
|
20
|
+
.brand { display:flex; align-items:center; gap:10px; min-width:0 }
|
|
21
|
+
.brand-mark { width:36px; height:36px; flex:none; display:grid; place-items:center; background:#211c19; border:1px solid rgba(233,151,63,.14); border-radius:8px; box-shadow:0 1px 2px rgba(0,0,0,.3), inset 0 1px rgba(255,255,255,.025) }
|
|
22
|
+
.brand-logo { width:26px; height:26px; object-fit:contain; flex:none }
|
|
23
|
+
.brand-copy { min-width:0; line-height:1.15 }
|
|
24
|
+
header h1 { font-size:15px; font-weight:680; letter-spacing:-.015em; color:var(--fg); }
|
|
25
|
+
.brand-subtitle { display:block; margin-top:3px; color:#aaa; font-size:11px; white-space:nowrap }
|
|
26
|
+
.header-context { min-width:0; display:flex; align-items:center; gap:8px; color:var(--dim); font-size:12px }
|
|
27
|
+
.header-context[hidden] { display:none }
|
|
28
|
+
.header-divider { width:1px; height:24px; flex:none; background:var(--border-soft) }
|
|
29
|
+
.header-context svg { width:15px; height:15px; flex:none; stroke:currentColor }
|
|
30
|
+
#headerProject { max-width:280px; overflow:hidden; color:#b9b9b9; text-overflow:ellipsis; white-space:nowrap }
|
|
20
31
|
header .spacer { flex:1 }
|
|
21
|
-
.header-lock {
|
|
32
|
+
.header-lock { height:34px; padding:0 10px; display:flex; align-items:center; gap:7px; color:#aaa; background:transparent; transition:background-color 120ms ease-out, border-color 120ms ease-out, color 120ms ease-out }
|
|
33
|
+
.header-lock:hover { color:var(--fg); background:var(--hover) }
|
|
34
|
+
.header-lock:focus-visible { outline:2px solid var(--accent); outline-offset:2px }
|
|
22
35
|
.header-lock svg { width:16px; height:16px; stroke:currentColor }
|
|
36
|
+
.header-lock-label { font-size:12px }
|
|
23
37
|
button { background:var(--bg3); color:var(--fg); border:1px solid var(--border); border-radius:5px; padding:5px 10px; cursor:pointer; font:inherit; }
|
|
24
|
-
button:hover { background:var(--hover); border-color
|
|
38
|
+
button:hover { background:var(--hover); border-color:var(--border-hover) }
|
|
25
39
|
button.primary { background:var(--accent-strong); color:#fff; border-color:var(--accent-strong); font-weight:600 }
|
|
26
40
|
button.primary:hover { background:#e58732; border-color:#e58732 }
|
|
27
41
|
button.danger { color:var(--danger) }
|
|
28
42
|
input, textarea { background:#242424; color:var(--fg); border:1px solid var(--border); border-radius:5px; padding:6px 9px; font:inherit }
|
|
29
|
-
input:focus, textarea:focus { outline:none; border-color:
|
|
43
|
+
input:focus, textarea:focus { outline:none; border-color:rgba(233,151,63,.68); box-shadow:0 0 0 2px rgba(233,151,63,.12) }
|
|
30
44
|
#gate { margin:auto; text-align:center; display:flex; flex-direction:column; gap:14px; width:min(420px, calc(100% - 32px)); padding:28px; background:var(--bg2); border:1px solid var(--border); border-radius:10px; box-shadow:0 12px 40px rgba(0,0,0,.28) }
|
|
31
45
|
#gate input { width:100% }
|
|
32
46
|
main { display:flex; flex:1; overflow:hidden }
|
|
33
|
-
aside { width:230px; background:var(--bg2); border-right:1px solid var(--border); overflow-y:auto; padding:10px 8px }
|
|
47
|
+
aside { position:relative; z-index:1; width:230px; background:var(--bg2); border-right:1px solid var(--border-soft); overflow-y:auto; padding:10px 8px; box-shadow:8px 0 20px rgba(0,0,0,.12) }
|
|
34
48
|
.proj { padding:7px 10px; border-radius:5px; cursor:pointer; color:var(--dim) }
|
|
35
49
|
.proj:hover { background:var(--hover); color:var(--fg) }
|
|
36
50
|
.proj.active { background:var(--accent-soft); color:#f2b46f }
|
|
@@ -39,22 +53,23 @@
|
|
|
39
53
|
.tabs { display:flex; gap:4px; align-items:center }
|
|
40
54
|
.tab { padding:5px 12px; border-radius:5px; cursor:pointer; color:var(--dim); border:1px solid transparent }
|
|
41
55
|
.tab:hover { color:var(--fg); background:var(--hover) }
|
|
42
|
-
.tab.active { color:#f2b46f; border-color
|
|
56
|
+
.tab.active { color:#f2b46f; border-color:var(--border-accent); background:var(--accent-soft) }
|
|
43
57
|
.project-actions { margin-left:auto; display:flex; align-items:center; gap:10px }
|
|
44
58
|
.delete-project { white-space:nowrap }
|
|
45
59
|
.view { flex:1; min-height:0; overflow-y:auto; width:100% }
|
|
46
60
|
.docrow { padding:11px 12px; border:1px solid var(--border); border-radius:7px; margin-bottom:8px; cursor:pointer; background:#242424 }
|
|
47
|
-
.docrow:hover { background:#282828; border-color
|
|
61
|
+
.docrow:hover { background:#282828; border-color:var(--border-hover) }
|
|
48
62
|
.doc-head { display:flex; align-items:baseline; justify-content:space-between; gap:12px; margin-bottom:5px }
|
|
49
63
|
.docrow .t { color:var(--fg); font-weight:600 }
|
|
50
64
|
.docrow .p { color:var(--dim); font-size:12px }
|
|
65
|
+
.docrow .p, .rem .meta, .hit .kind { font-variant-numeric:tabular-nums }
|
|
51
66
|
.sec-chip { display:inline-block; background:var(--bg3); border-radius:4px; padding:0 6px; margin:3px 4px 0 0; font-size:12px; color:#aaa }
|
|
52
67
|
textarea.editor { width:100%; min-height:420px; resize:vertical; white-space:pre; line-height:1.5; font-family:ui-monospace, SFMono-Regular, Menlo, monospace }
|
|
53
68
|
.rem { border:1px solid var(--border); border-radius:7px; padding:10px 12px; margin-bottom:8px; display:flex; gap:10px; align-items:flex-start; background:#242424 }
|
|
54
69
|
.rem .txt { flex:1 }
|
|
55
70
|
.rem a { color:var(--accent); text-decoration:none; font-size:12px }
|
|
56
71
|
.rem .meta { color:var(--dim); font-size:11px }
|
|
57
|
-
.hit { border
|
|
72
|
+
.hit { border:1px solid var(--border); padding:8px 12px; margin-bottom:10px; background:#242424; border-radius:7px }
|
|
58
73
|
.hit .kind { color:var(--accent); font-size:11px; text-transform:uppercase; letter-spacing:.08em }
|
|
59
74
|
.hit .tgt { color:var(--dim); font-size:12px }
|
|
60
75
|
.muted { color:var(--dim) }
|
|
@@ -97,22 +112,46 @@
|
|
|
97
112
|
.md code { background:var(--bg3); border-radius:4px; padding:1px 5px; font-family:ui-monospace, Menlo, monospace; font-size:13px }
|
|
98
113
|
.md pre { background:#191919; border:1px solid var(--border); border-radius:6px; padding:12px; overflow-x:auto; margin:10px 0 }
|
|
99
114
|
.md pre code { background:none; padding:0 }
|
|
100
|
-
.md blockquote { border-left:
|
|
115
|
+
.md blockquote { border-left:2px solid rgba(233,151,63,.34); margin:10px 0; padding:2px 14px; color:var(--dim) }
|
|
101
116
|
.md table { border-collapse:collapse; margin:10px 0 }
|
|
102
117
|
.md th, .md td { border:1px solid var(--border); padding:5px 10px; text-align:left }
|
|
103
118
|
.md hr { border:none; border-top:1px solid var(--border); margin:16px 0 }
|
|
104
119
|
.md img { max-width:100% }
|
|
120
|
+
.md a img { -webkit-user-drag:none; user-select:none }
|
|
121
|
+
#toast { position:fixed; right:20px; bottom:20px; z-index:10; display:flex; align-items:center; gap:8px; padding:8px 14px; color:var(--fg); background:var(--bg3); border:1px solid var(--border); border-radius:8px; box-shadow:0 10px 28px rgba(0,0,0,.3), 0 1px 2px rgba(0,0,0,.35) }
|
|
122
|
+
#toast .toast-icon { width:16px; height:16px; flex:none }
|
|
123
|
+
#toast .toast-icon svg { display:block; width:100%; height:100%; stroke:currentColor }
|
|
124
|
+
#toast.success .toast-icon { color:var(--ok) }
|
|
125
|
+
#toast.error .toast-icon { color:var(--danger) }
|
|
105
126
|
::selection { background:rgba(233,151,63,.3) }
|
|
127
|
+
@media (pointer:coarse) {
|
|
128
|
+
.header-lock { min-width:44px; height:44px; justify-content:center }
|
|
129
|
+
}
|
|
106
130
|
@media (max-width:700px) {
|
|
131
|
+
header { gap:10px; padding:14px 10px }
|
|
132
|
+
.brand { gap:8px }
|
|
133
|
+
.brand-mark { width:34px; height:34px }
|
|
134
|
+
.brand-logo { width:24px; height:24px }
|
|
135
|
+
.brand-subtitle { display:none }
|
|
136
|
+
.header-context { flex:1 }
|
|
137
|
+
.header-divider { height:20px }
|
|
138
|
+
#headerProject { max-width:none }
|
|
139
|
+
header .spacer { display:none }
|
|
140
|
+
.header-lock { justify-content:center; padding:0 }
|
|
141
|
+
.header-lock-label { display:none }
|
|
107
142
|
main { flex-direction:column }
|
|
108
|
-
aside { width:100%; max-height:112px; border-right:0; border-bottom:1px solid var(--border); padding:7px 8px }
|
|
143
|
+
aside { width:100%; max-height:112px; border-right:0; border-bottom:1px solid var(--border-soft); padding:7px 8px; box-shadow:0 8px 20px rgba(0,0,0,.12) }
|
|
109
144
|
#projList { display:flex; gap:4px; overflow-x:auto }
|
|
110
145
|
.proj { flex:none; white-space:nowrap }
|
|
111
146
|
#content { padding:14px }
|
|
112
|
-
.workspace-head { align-items:flex-start; gap:8px; margin-bottom:12px; padding-bottom:10px }
|
|
147
|
+
.workspace-head { align-items:flex-start; flex-wrap:wrap; gap:8px; margin-bottom:12px; padding-bottom:10px }
|
|
148
|
+
button, input { min-height:44px; touch-action:manipulation }
|
|
149
|
+
.header-lock, .back-button { width:44px; height:44px }
|
|
150
|
+
.proj, .tab { min-height:44px; display:flex; align-items:center }
|
|
113
151
|
.tab { padding:5px 8px }
|
|
114
|
-
.delete-project { padding:5px
|
|
152
|
+
.delete-project { padding:5px 9px }
|
|
115
153
|
form.row, form.row.single { grid-template-columns:1fr }
|
|
154
|
+
form.row input, form.row button { height:44px }
|
|
116
155
|
.editor-view { overflow-y:auto }
|
|
117
156
|
.editorbar { grid-template-columns:minmax(0,1fr) auto; gap:8px }
|
|
118
157
|
.editor-title { max-width:180px }
|
|
@@ -128,13 +167,29 @@
|
|
|
128
167
|
</head>
|
|
129
168
|
<body>
|
|
130
169
|
<header>
|
|
131
|
-
<
|
|
170
|
+
<div class="brand">
|
|
171
|
+
<span class="brand-mark" aria-hidden="true">
|
|
172
|
+
<img class="brand-logo" src="/logo.png" alt="" draggable="false">
|
|
173
|
+
</span>
|
|
174
|
+
<div class="brand-copy">
|
|
175
|
+
<h1>mindroot</h1>
|
|
176
|
+
<span class="brand-subtitle">Persistent memory</span>
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
<div class="header-context" hidden>
|
|
180
|
+
<span class="header-divider" aria-hidden="true"></span>
|
|
181
|
+
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
182
|
+
<path d="M4 7.5h6l2 2h8v9H4z" stroke-width="1.7" stroke-linejoin="round"/>
|
|
183
|
+
</svg>
|
|
184
|
+
<span id="headerProject" aria-live="polite"></span>
|
|
185
|
+
</div>
|
|
132
186
|
<div class="spacer"></div>
|
|
133
|
-
<button class="header-lock" onclick="logout()" title="Lock dashboard" aria-label="Lock dashboard">
|
|
187
|
+
<button class="header-lock" type="button" onclick="logout()" title="Lock dashboard" aria-label="Lock dashboard">
|
|
134
188
|
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
135
189
|
<rect x="5" y="10" width="14" height="10" rx="2" stroke-width="1.8"/>
|
|
136
190
|
<path d="M8 10V7a4 4 0 0 1 8 0v3" stroke-width="1.8" stroke-linecap="round"/>
|
|
137
191
|
</svg>
|
|
192
|
+
<span class="header-lock-label">Lock</span>
|
|
138
193
|
</button>
|
|
139
194
|
</header>
|
|
140
195
|
|
|
@@ -190,8 +245,10 @@ async function boot() {
|
|
|
190
245
|
await api("GET", "/api/projects");
|
|
191
246
|
$("#gate").style.display = "none";
|
|
192
247
|
$("#app").style.display = "flex";
|
|
193
|
-
await renderProjects();
|
|
248
|
+
const projects = await renderProjects();
|
|
194
249
|
if (currentProject) selectProject(currentProject);
|
|
250
|
+
else if (projects.length) selectProject(projects[0].slug);
|
|
251
|
+
else $("#content").innerHTML = '<div class="empty">No projects yet</div>';
|
|
195
252
|
} catch (e) {
|
|
196
253
|
if (e.message !== "unauthorized") showGate(String(e));
|
|
197
254
|
}
|
|
@@ -215,6 +272,11 @@ async function renderProjects() {
|
|
|
215
272
|
function selectProject(slug) {
|
|
216
273
|
currentProject = slug;
|
|
217
274
|
currentDoc = null;
|
|
275
|
+
$(".header-context").hidden = false;
|
|
276
|
+
const projectLabel = $("#headerProject");
|
|
277
|
+
projectLabel.textContent = slug;
|
|
278
|
+
projectLabel.title = slug;
|
|
279
|
+
projectLabel.setAttribute("aria-label", "Current project: " + slug);
|
|
218
280
|
document.querySelectorAll(".proj").forEach((n) => n.classList.toggle("active", n.textContent === slug));
|
|
219
281
|
renderTabs();
|
|
220
282
|
}
|
|
@@ -222,23 +284,25 @@ function selectProject(slug) {
|
|
|
222
284
|
function renderTabs() {
|
|
223
285
|
const c = $("#content");
|
|
224
286
|
c.innerHTML = "";
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
287
|
+
if (!(currentTab === "notes" && currentDoc !== null)) {
|
|
288
|
+
const head = document.createElement("div");
|
|
289
|
+
head.className = "workspace-head";
|
|
290
|
+
const tabs = document.createElement("div");
|
|
291
|
+
tabs.className = "tabs";
|
|
292
|
+
for (const [name, label] of [["notes","Notes"],["memories","Memories"],["search","Search"]]) {
|
|
293
|
+
const t = document.createElement("div");
|
|
294
|
+
t.className = "tab" + (currentTab === name ? " active" : "");
|
|
295
|
+
t.textContent = label;
|
|
296
|
+
t.onclick = () => { currentTab = name; currentDoc = null; renderTabs(); };
|
|
297
|
+
tabs.appendChild(t);
|
|
298
|
+
}
|
|
299
|
+
const actions = document.createElement("div");
|
|
300
|
+
actions.className = "project-actions";
|
|
301
|
+
actions.innerHTML = '<button class="danger delete-project">Delete project</button>';
|
|
302
|
+
actions.querySelector("button").onclick = deleteCurrentProject;
|
|
303
|
+
head.append(tabs, actions);
|
|
304
|
+
c.appendChild(head);
|
|
235
305
|
}
|
|
236
|
-
const actions = document.createElement("div");
|
|
237
|
-
actions.className = "project-actions";
|
|
238
|
-
actions.innerHTML = '<button class="danger delete-project">Delete project</button>';
|
|
239
|
-
actions.querySelector("button").onclick = deleteCurrentProject;
|
|
240
|
-
head.append(tabs, actions);
|
|
241
|
-
c.appendChild(head);
|
|
242
306
|
const view = document.createElement("div");
|
|
243
307
|
view.className = "view";
|
|
244
308
|
c.appendChild(view);
|
|
@@ -254,7 +318,14 @@ async function deleteCurrentProject() {
|
|
|
254
318
|
currentDoc = null;
|
|
255
319
|
const rows = await renderProjects();
|
|
256
320
|
if (rows.length) selectProject(rows[0].slug);
|
|
257
|
-
else
|
|
321
|
+
else {
|
|
322
|
+
const projectLabel = $("#headerProject");
|
|
323
|
+
$(".header-context").hidden = true;
|
|
324
|
+
projectLabel.textContent = "";
|
|
325
|
+
projectLabel.removeAttribute("title");
|
|
326
|
+
projectLabel.removeAttribute("aria-label");
|
|
327
|
+
$("#content").innerHTML = '<div class="empty">No projects yet</div>';
|
|
328
|
+
}
|
|
258
329
|
toast('project "' + slug + '" deleted');
|
|
259
330
|
} catch (e) {
|
|
260
331
|
toast("delete failed: " + e.message, true);
|
|
@@ -277,7 +348,7 @@ async function renderNotes(view) {
|
|
|
277
348
|
if (!docs.length) view.innerHTML = '<div class="empty">No notes yet. Agents create them with <b>mindroot_save_note</b>.</div>';
|
|
278
349
|
}
|
|
279
350
|
|
|
280
|
-
let mdMode = localStorage.getItem("mindroot_mdmode") || "
|
|
351
|
+
let mdMode = localStorage.getItem("mindroot_mdmode") || "preview";
|
|
281
352
|
|
|
282
353
|
function renderMd(text) {
|
|
283
354
|
if (window.marked && window.DOMPurify) {
|
|
@@ -320,7 +391,11 @@ async function renderEditor(view, docs) {
|
|
|
320
391
|
prev = document.createElement("div");
|
|
321
392
|
prev.className = "md";
|
|
322
393
|
}
|
|
323
|
-
const paint = () => {
|
|
394
|
+
const paint = () => {
|
|
395
|
+
if (!prev) return;
|
|
396
|
+
prev.innerHTML = renderMd(ta.value);
|
|
397
|
+
prev.querySelectorAll("a img").forEach((img) => { img.draggable = false; });
|
|
398
|
+
};
|
|
324
399
|
let t;
|
|
325
400
|
ta.oninput = () => { clearTimeout(t); t = setTimeout(paint, 150); };
|
|
326
401
|
if (prev) {
|
|
@@ -404,12 +479,22 @@ async function renderSearch(view) {
|
|
|
404
479
|
|
|
405
480
|
function toast(msg, isErr) {
|
|
406
481
|
let t = $("#toast");
|
|
407
|
-
if (!t) {
|
|
408
|
-
t
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
482
|
+
if (!t) {
|
|
483
|
+
t = document.createElement("div");
|
|
484
|
+
t.id = "toast";
|
|
485
|
+
document.body.appendChild(t);
|
|
486
|
+
}
|
|
487
|
+
clearTimeout(t.dismissTimer);
|
|
488
|
+
t.className = isErr ? "error" : "success";
|
|
489
|
+
t.setAttribute("role", isErr ? "alert" : "status");
|
|
490
|
+
t.setAttribute("aria-live", isErr ? "assertive" : "polite");
|
|
491
|
+
t.innerHTML = isErr
|
|
492
|
+
? '<span class="toast-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none"><path d="m7 7 10 10M17 7 7 17" stroke-width="2" stroke-linecap="round"/></svg></span>'
|
|
493
|
+
: '<span class="toast-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none"><path d="m5 12 4 4L19 6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></span>';
|
|
494
|
+
const text = document.createElement("span");
|
|
495
|
+
text.textContent = msg;
|
|
496
|
+
t.appendChild(text);
|
|
497
|
+
t.dismissTimer = setTimeout(() => t.remove(), 2500);
|
|
413
498
|
}
|
|
414
499
|
|
|
415
500
|
function esc(s) { return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/"/g,"""); }
|
|
Binary file
|
package/src/mcp.js
CHANGED
|
@@ -18,6 +18,19 @@ export function tools() {
|
|
|
18
18
|
required: ["query"],
|
|
19
19
|
},
|
|
20
20
|
},
|
|
21
|
+
{
|
|
22
|
+
name: "mindroot_rename_project",
|
|
23
|
+
description:
|
|
24
|
+
"Rename a project by slug. Moves its notes directory and updates the slug; notes, memories, and search indexes are preserved under the internal project id. The fuzzy-search vector for the new slug is rebuilt automatically on the next project search.",
|
|
25
|
+
inputSchema: {
|
|
26
|
+
type: "object",
|
|
27
|
+
properties: {
|
|
28
|
+
project: { type: "string", description: "Current project slug" },
|
|
29
|
+
new_slug: { type: "string", description: "New project slug" },
|
|
30
|
+
},
|
|
31
|
+
required: ["project", "new_slug"],
|
|
32
|
+
},
|
|
33
|
+
},
|
|
21
34
|
{
|
|
22
35
|
name: "mindroot_search_memories",
|
|
23
36
|
description:
|
|
@@ -136,6 +149,8 @@ async function callTool(db, name, args) {
|
|
|
136
149
|
return core.listProjects(db);
|
|
137
150
|
case "mindroot_search_projects":
|
|
138
151
|
return core.searchProjects(db, args.query);
|
|
152
|
+
case "mindroot_rename_project":
|
|
153
|
+
return core.renameProject(db, args.project, args.new_slug);
|
|
139
154
|
case "mindroot_search_memories":
|
|
140
155
|
return core.searchMemories(db, args.project, args.query, args.limit ?? 8);
|
|
141
156
|
case "mindroot_search_notes":
|
package/src/server.js
CHANGED
|
@@ -7,7 +7,12 @@ import { handleRpc } from "./mcp.js";
|
|
|
7
7
|
import { validSlug, ensureProject, getProjectRow } from "./indexer.js";
|
|
8
8
|
import * as core from "./core.js";
|
|
9
9
|
|
|
10
|
-
const
|
|
10
|
+
const DASHBOARD_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "dashboard");
|
|
11
|
+
const DASHBOARD = path.join(DASHBOARD_DIR, "index.html");
|
|
12
|
+
const DASHBOARD_ASSETS = new Map([
|
|
13
|
+
["/favicon.png", ["favicon.png", "image/png"]],
|
|
14
|
+
["/logo.png", ["logo.png", "image/png"]],
|
|
15
|
+
]);
|
|
11
16
|
|
|
12
17
|
export function createServer(db, cfg) {
|
|
13
18
|
return http.createServer(async (req, res) => {
|
|
@@ -22,6 +27,17 @@ export function createServer(db, cfg) {
|
|
|
22
27
|
return;
|
|
23
28
|
}
|
|
24
29
|
|
|
30
|
+
const asset = DASHBOARD_ASSETS.get(url.pathname);
|
|
31
|
+
if (req.method === "GET" && asset) {
|
|
32
|
+
const body = await fs.readFile(path.join(DASHBOARD_DIR, asset[0]));
|
|
33
|
+
res.writeHead(200, {
|
|
34
|
+
"content-type": asset[1],
|
|
35
|
+
"cache-control": "public, max-age=86400",
|
|
36
|
+
});
|
|
37
|
+
res.end(body);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
25
41
|
if (url.pathname === "/health") {
|
|
26
42
|
sendJson(res, 200, { ok: true });
|
|
27
43
|
return;
|
package/test/mcp.test.js
CHANGED
|
@@ -12,6 +12,7 @@ let open;
|
|
|
12
12
|
const EXPECTED_TOOLS = [
|
|
13
13
|
"mindroot_list_projects",
|
|
14
14
|
"mindroot_search_projects",
|
|
15
|
+
"mindroot_rename_project",
|
|
15
16
|
"mindroot_search_memories",
|
|
16
17
|
"mindroot_search_notes",
|
|
17
18
|
"mindroot_save_memory",
|
|
@@ -72,3 +73,39 @@ test("unknown project is a clean error", async () => {
|
|
|
72
73
|
const res = await call("mindroot_search_memories", { project: "ghost", query: "x" });
|
|
73
74
|
assert.match(res.body.result.content[0].text, /unknown project: ghost/);
|
|
74
75
|
});
|
|
76
|
+
|
|
77
|
+
test("rename_project moves slug, dir, and embeddings", async () => {
|
|
78
|
+
const { NOTES_DIR } = await import("../src/paths.js");
|
|
79
|
+
db.prepare("INSERT INTO projects (slug, created_at) VALUES ('old-name', 1)").run();
|
|
80
|
+
const pid = db.prepare("SELECT id FROM projects WHERE slug='old-name'").get().id;
|
|
81
|
+
db.prepare(
|
|
82
|
+
"INSERT INTO memories (project_id, text, target_type, created_at, updated_at) VALUES (?, 'durable fact', 'standalone', 1, 1)",
|
|
83
|
+
).run(pid);
|
|
84
|
+
const f = new Float32Array([1, 0]);
|
|
85
|
+
db.prepare("INSERT INTO project_embeddings (project_id, dim, model, vec) VALUES (?, 2, 'm', ?)").run(
|
|
86
|
+
pid,
|
|
87
|
+
Buffer.from(f.buffer, f.byteOffset, f.byteLength),
|
|
88
|
+
);
|
|
89
|
+
fs.mkdirSync(path.join(NOTES_DIR, "old-name"), { recursive: true });
|
|
90
|
+
fs.writeFileSync(path.join(NOTES_DIR, "old-name", "overview.md"), "# Old\n");
|
|
91
|
+
|
|
92
|
+
const res = await call("mindroot_rename_project", { project: "old-name", new_slug: "new-name" });
|
|
93
|
+
const data = JSON.parse(res.body.result.content[0].text);
|
|
94
|
+
assert.deepEqual(data.renamed, { from: "old-name", to: "new-name" });
|
|
95
|
+
assert.equal(db.prepare("SELECT slug FROM projects WHERE id = ?").get(pid).slug, "new-name");
|
|
96
|
+
assert.ok(fs.existsSync(path.join(NOTES_DIR, "new-name", "overview.md")));
|
|
97
|
+
assert.ok(!fs.existsSync(path.join(NOTES_DIR, "old-name")));
|
|
98
|
+
assert.equal(db.prepare("SELECT COUNT(*) n FROM project_embeddings WHERE project_id = ?").get(pid).n, 0);
|
|
99
|
+
const listed = JSON.parse((await call("mindroot_list_projects", {})).body.result.content[0].text);
|
|
100
|
+
const row = listed.find((p) => p.slug === "new-name");
|
|
101
|
+
assert.equal(row.memories, 1);
|
|
102
|
+
assert.ok(!listed.some((p) => p.slug === "old-name"));
|
|
103
|
+
|
|
104
|
+
const dup = await call("mindroot_rename_project", { project: "new-name", new_slug: "new-name" });
|
|
105
|
+
assert.equal(dup.body.result.isError, true);
|
|
106
|
+
assert.match(dup.body.result.content[0].text, /already named/);
|
|
107
|
+
|
|
108
|
+
const bad = await call("mindroot_rename_project", { project: "new-name", new_slug: "../evil" });
|
|
109
|
+
assert.equal(bad.body.result.isError, true);
|
|
110
|
+
assert.match(bad.body.result.content[0].text, /invalid project slug/);
|
|
111
|
+
});
|