@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 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
+ ![Mindroot persistent AI agent memory system](https://skbilisim.com/assets/projects/mindroot.webp)
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 # creates store, caches the embedding model (~316MB)
19
- mindroot start # daemonizes and waits until healthy
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
- 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.
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.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",
@@ -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
@@ -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
- <link rel="icon" href="data:,">
7
- <title>mindroot</title>
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:#363636; }
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:12px; padding:9px 14px; background:var(--bg2); border-bottom:1px solid var(--border); }
19
- header h1 { font-size:15px; font-weight:650; letter-spacing:-.01em; color:var(--fg); }
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 { width:32px; height:32px; padding:0; display:grid; place-items:center }
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:#4a4a4a }
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:var(--accent); box-shadow:0 0 0 2px rgba(233,151,63,.12) }
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:#6a4930; background:var(--accent-soft) }
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:#555 }
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-left:3px solid var(--accent); padding:8px 12px; margin-bottom:10px; background:#242424; border-radius:0 7px 7px 0 }
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:3px solid var(--accent); margin:10px 0; padding:2px 14px; color:var(--dim) }
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 7px }
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
- <h1>mindroot</h1>
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
- const head = document.createElement("div");
226
- head.className = "workspace-head";
227
- const tabs = document.createElement("div");
228
- tabs.className = "tabs";
229
- for (const [name, label] of [["notes","Notes"],["memories","Memories"],["search","Search"]]) {
230
- const t = document.createElement("div");
231
- t.className = "tab" + (currentTab === name ? " active" : "");
232
- t.textContent = label;
233
- t.onclick = () => { currentTab = name; currentDoc = null; renderTabs(); };
234
- tabs.appendChild(t);
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 $("#content").innerHTML = '<div class="empty">No projects yet</div>';
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") || "split";
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 = () => { if (prev) prev.innerHTML = renderMd(ta.value); };
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) { t = document.createElement("div"); t.id = "toast";
408
- t.style.cssText = "position:fixed;bottom:20px;right:20px;background:var(--bg3);border:1px solid var(--border);padding:8px 14px;border-radius:8px";
409
- document.body.appendChild(t); }
410
- t.textContent = msg;
411
- t.style.color = isErr ? "var(--danger)" : "var(--ok)";
412
- setTimeout(() => t.remove(), 2500);
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,"&amp;").replace(/</g,"&lt;").replace(/"/g,"&quot;"); }
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 DASHBOARD = path.join(path.dirname(fileURLToPath(import.meta.url)), "dashboard", "index.html");
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
+ });