@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 +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/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
|
+
});
|