@sksoftofficial/mindroot 1.0.1 → 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/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.1",
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
+ });