@sksoftofficial/mindroot 1.0.6 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/INSTRUCTIONS.md +41 -0
- package/README.md +80 -13
- package/bin/mindroot.js +4 -0
- package/package.json +14 -3
- package/src/commands/install.js +210 -0
- package/src/core.js +9 -2
- package/src/dashboard/graph.js +175 -0
- package/src/dashboard/index.html +246 -80
- package/src/embedder.js +2 -0
- package/src/mcp.js +62 -1
- package/src/paths.js +15 -3
- package/src/search.js +28 -0
- package/src/server.js +25 -4
- package/src/skills.js +133 -0
- package/src/store/db.js +50 -0
- package/test/install-clients.test.js +187 -0
- package/test/install.test.js +127 -0
- package/test/mcp.test.js +4 -0
- package/test/paths.test.js +53 -0
- package/test/skills.test.js +253 -0
package/src/dashboard/index.html
CHANGED
|
@@ -47,46 +47,75 @@
|
|
|
47
47
|
aside { position:relative; z-index:1; width:230px; background:var(--bg2); border-right:1px solid var(--border-soft); overflow-y:auto; padding:10px 14px; box-shadow:8px 0 20px rgba(0,0,0,.12); display:flex; flex-direction:column; gap:8px; transition:width 160ms ease-out }
|
|
48
48
|
.sidebar-toggle { flex:none; height:34px; padding:0; display:flex; align-items:center; justify-content:center }
|
|
49
49
|
.sidebar-toggle svg { width:17px; height:17px; stroke:currentColor; transition:transform 160ms ease-out }
|
|
50
|
+
.sidebar-item { width:100%; display:flex; align-items:center; gap:9px; padding:8px 10px; text-align:left; color:var(--dim); background:transparent; border-color:transparent; white-space:nowrap }
|
|
51
|
+
.sidebar-item svg { width:17px; height:17px; flex:none; stroke:currentColor }
|
|
52
|
+
.sidebar-item.active { background:var(--accent-soft); color:#f2b46f }
|
|
53
|
+
.sidebar-item:focus-visible, .proj:focus-visible { outline:2px solid var(--accent); outline-offset:-2px }
|
|
54
|
+
.sidebar-item .menu-chevron { margin-left:auto; width:13px; transition:transform 160ms ease-out }
|
|
55
|
+
.sidebar-item[aria-expanded="true"] .menu-chevron { transform:rotate(90deg) }
|
|
56
|
+
#projectMenu { min-width:0 }
|
|
57
|
+
#projList[hidden] { display:none }
|
|
50
58
|
@media (min-width:701px) {
|
|
51
59
|
aside.collapsed { width:56px; padding:10px 12px }
|
|
52
60
|
aside.collapsed .sidebar-toggle svg { transform:rotate(180deg) }
|
|
61
|
+
aside.collapsed .sidebar-item { padding:8px 0; justify-content:center }
|
|
62
|
+
aside.collapsed .sidebar-label, aside.collapsed .menu-chevron { display:none }
|
|
63
|
+
aside.collapsed #projList { margin-left:0; padding-left:0; border-left:0 }
|
|
53
64
|
aside.collapsed .proj { padding:7px 0; text-align:center; font-size:0 }
|
|
54
65
|
aside.collapsed .proj::before { content:attr(data-initial); font-size:12px; line-height:inherit }
|
|
55
66
|
}
|
|
56
|
-
#projList { display:flex; flex-direction:column; gap:2px }
|
|
57
|
-
.proj { padding:7px 10px; border-radius:5px; cursor:pointer; color:var(--dim) }
|
|
67
|
+
#projList { display:flex; flex-direction:column; gap:2px; margin:4px 0 0 18px; padding-left:8px; border-left:1px solid var(--border) }
|
|
68
|
+
.proj { padding:7px 10px; border-radius:5px; cursor:pointer; color:var(--dim); background:transparent; border:0; text-align:left; overflow-wrap:anywhere }
|
|
58
69
|
.proj:hover { background:var(--hover); color:var(--fg) }
|
|
59
70
|
.proj.active { background:var(--accent-soft); color:#f2b46f }
|
|
60
71
|
#content { flex:1; min-width:0; overflow:hidden; padding:18px 22px; display:flex; flex-direction:column }
|
|
61
72
|
.workspace-head { flex:none; display:flex; align-items:center; gap:12px; min-height:36px; margin-bottom:16px; padding-bottom:12px; border-bottom:1px solid var(--border) }
|
|
62
73
|
.tabs { display:flex; gap:4px; align-items:center }
|
|
63
74
|
.tab { padding:5px 12px; border-radius:5px; cursor:pointer; color:var(--dim); border:1px solid transparent }
|
|
75
|
+
button.tab { background:transparent }
|
|
64
76
|
.tab:hover { color:var(--fg); background:var(--hover) }
|
|
65
77
|
.tab.active { color:#f2b46f; border-color:var(--border-accent); background:var(--accent-soft) }
|
|
66
78
|
.project-actions { margin-left:auto; display:flex; align-items:center; gap:10px }
|
|
67
79
|
.delete-project { white-space:nowrap }
|
|
68
80
|
.view { flex:1; min-height:0; overflow-y:auto; width:100% }
|
|
81
|
+
.graph-view { display:flex; flex-direction:column; gap:12px; overflow:hidden }
|
|
82
|
+
.graph-workspace { display:flex; flex:1; min-height:0; border:1px solid var(--border); border-radius:8px; overflow:hidden; background:var(--bg2) }
|
|
83
|
+
.graph-stage { position:relative; flex:1; min-width:0; overflow:hidden }
|
|
84
|
+
.graph-canvas { position:absolute; inset:0; touch-action:none }
|
|
85
|
+
.graph-legend { position:absolute; left:16px; top:14px; display:flex; gap:16px; color:var(--dim); font-size:12px; pointer-events:none }
|
|
86
|
+
.graph-legend span { display:flex; align-items:center; gap:6px }
|
|
87
|
+
.graph-legend i { width:8px; height:8px; background:var(--accent); border-radius:50% }
|
|
88
|
+
.graph-legend .memory-dot { width:6px; height:6px; background:var(--ok) }
|
|
89
|
+
.graph-controls { position:absolute; bottom:14px; left:14px; display:flex; gap:5px }
|
|
90
|
+
.graph-controls button { min-width:34px }
|
|
91
|
+
.graph-hint { color:var(--dim); font-size:11px; flex:none }
|
|
92
|
+
.graph-view button:focus-visible, .tab:focus-visible { outline:2px solid var(--accent); outline-offset:2px }
|
|
69
93
|
.docrow { padding:11px 12px; border:1px solid var(--border); border-radius:7px; margin-bottom:8px; cursor:pointer; background:#242424 }
|
|
70
94
|
.docrow:hover { background:#282828; border-color:var(--border-hover) }
|
|
71
95
|
.doc-head { display:flex; align-items:baseline; justify-content:space-between; gap:12px; margin-bottom:5px }
|
|
72
96
|
.docrow .t { color:var(--fg); font-weight:600 }
|
|
73
97
|
.docrow .p { color:var(--dim); font-size:12px }
|
|
74
|
-
.docrow .p, .rem .meta
|
|
98
|
+
.docrow .p, .rem .meta { font-variant-numeric:tabular-nums }
|
|
99
|
+
.skillrow { display:block; width:100%; text-align:left; font:inherit; color:var(--fg) }
|
|
100
|
+
.skillrow .t, .skill-description { overflow-wrap:anywhere }
|
|
101
|
+
.skillrow .p { flex:none }
|
|
102
|
+
.skill-summary { color:var(--dim); font-size:12px; overflow-wrap:anywhere }
|
|
103
|
+
.skill-fields { display:grid; gap:8px; margin-bottom:12px }
|
|
104
|
+
.skill-fields label { display:grid; gap:5px; color:var(--dim); font-size:12px }
|
|
105
|
+
.skill-fields input { width:100%; min-width:0 }
|
|
106
|
+
.skill-history { display:flex; align-items:center; flex-wrap:wrap; gap:8px }
|
|
107
|
+
.skill-history select { background:var(--bg3); color:var(--fg); border:1px solid var(--border); border-radius:5px; padding:5px 8px; font:inherit }
|
|
108
|
+
.skill-status { color:var(--dim); font-size:12px; font-variant-numeric:tabular-nums }
|
|
109
|
+
button:disabled { opacity:.5; cursor:default }
|
|
110
|
+
.skill-fields [hidden], .editor-head .viewmode[hidden] { display:none }
|
|
75
111
|
.sec-chip { display:inline-block; background:var(--bg3); border-radius:4px; padding:0 6px; margin:3px 4px 0 0; font-size:12px; color:#aaa }
|
|
76
112
|
textarea.editor { width:100%; min-height:420px; resize:vertical; white-space:pre; line-height:1.5; font-family:ui-monospace, SFMono-Regular, Menlo, monospace }
|
|
77
113
|
.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 }
|
|
78
114
|
.rem .txt { flex:1 }
|
|
79
115
|
.rem a { color:var(--accent); text-decoration:none; font-size:12px }
|
|
80
116
|
.rem .meta { color:var(--dim); font-size:11px }
|
|
81
|
-
.hit { border:1px solid var(--border); padding:8px 12px; margin-bottom:10px; background:#242424; border-radius:7px }
|
|
82
|
-
.hit .kind { color:var(--accent); font-size:11px; text-transform:uppercase; letter-spacing:.08em }
|
|
83
|
-
.hit .tgt { color:var(--dim); font-size:12px }
|
|
84
117
|
.muted { color:var(--dim) }
|
|
85
118
|
.err { color:var(--danger) }
|
|
86
|
-
form.row { display:grid; grid-template-columns:minmax(0,1fr) 280px auto; gap:8px; margin:0 0 18px; align-items:center }
|
|
87
|
-
form.row.single { grid-template-columns:minmax(0,1fr) auto }
|
|
88
|
-
form.row input { min-width:0; height:36px }
|
|
89
|
-
form.row button { height:36px }
|
|
90
119
|
.empty { color:var(--dim); border:1px dashed var(--border); border-radius:7px; padding:28px 20px; text-align:center }
|
|
91
120
|
h2 { font-size:13px; text-transform:uppercase; letter-spacing:.1em; color:var(--dim); margin:18px 0 10px }
|
|
92
121
|
.editor-view { display:flex; flex-direction:column; overflow:hidden; padding-right:0 }
|
|
@@ -149,19 +178,19 @@
|
|
|
149
178
|
.header-lock { justify-content:center; padding:0 }
|
|
150
179
|
.header-lock-label { display:none }
|
|
151
180
|
main { flex-direction:column }
|
|
152
|
-
aside { width:100%; max-height:
|
|
153
|
-
#projList { flex-direction:row; gap:4px; overflow-x:auto }
|
|
181
|
+
aside { width:100%; max-height:190px; flex:none; border-right:0; border-bottom:1px solid var(--border-soft); padding:7px 8px; box-shadow:0 8px 20px rgba(0,0,0,.12) }
|
|
182
|
+
#projList { flex-direction:row; gap:4px; overflow-x:auto; margin-left:18px }
|
|
154
183
|
.proj { flex:none; white-space:nowrap }
|
|
155
184
|
#content { padding:14px }
|
|
156
185
|
.workspace-head { align-items:flex-start; flex-wrap:wrap; gap:8px; margin-bottom:12px; padding-bottom:10px }
|
|
157
|
-
button, input { min-height:44px; touch-action:manipulation }
|
|
186
|
+
button, input, select { min-height:44px; touch-action:manipulation }
|
|
158
187
|
.header-lock, .back-button { width:44px; height:44px }
|
|
159
188
|
.sidebar-toggle { display:none }
|
|
160
189
|
.proj, .tab { min-height:44px; display:flex; align-items:center }
|
|
161
190
|
.tab { padding:5px 8px }
|
|
162
191
|
.delete-project { padding:5px 9px }
|
|
163
|
-
|
|
164
|
-
|
|
192
|
+
.graph-view { overflow-y:auto }
|
|
193
|
+
.graph-workspace { min-height:300px }
|
|
165
194
|
.editor-view { overflow-y:auto }
|
|
166
195
|
.editorbar { grid-template-columns:minmax(0,1fr) auto; gap:8px }
|
|
167
196
|
.editor-title { max-width:180px }
|
|
@@ -218,7 +247,14 @@
|
|
|
218
247
|
<path d="m14 7-5 5 5 5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
219
248
|
</svg>
|
|
220
249
|
</button>
|
|
221
|
-
<div id="
|
|
250
|
+
<div id="projectMenu">
|
|
251
|
+
<button class="sidebar-item" id="projectMenuToggle" type="button" title="Project Memories" aria-label="Project Memories" aria-expanded="true" aria-controls="projList" onclick="toggleProjectMenu()">
|
|
252
|
+
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M3 7h7l2 2h9v11H3zM3 7V4h7l2 3" stroke-width="1.7" stroke-linejoin="round"/></svg>
|
|
253
|
+
<span class="sidebar-label">Project Memories</span>
|
|
254
|
+
<svg class="menu-chevron" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="m9 6 6 6-6 6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
255
|
+
</button>
|
|
256
|
+
<div id="projList"></div>
|
|
257
|
+
</div>
|
|
222
258
|
</aside>
|
|
223
259
|
<section id="content"></section>
|
|
224
260
|
</main>
|
|
@@ -229,6 +265,8 @@ let KEY = localStorage.getItem("mindroot_key") || "";
|
|
|
229
265
|
let currentProject = null;
|
|
230
266
|
let currentTab = "notes";
|
|
231
267
|
let currentDoc = null;
|
|
268
|
+
let activeHash = "";
|
|
269
|
+
let disposeGraph = null;
|
|
232
270
|
|
|
233
271
|
async function api(method, url, body) {
|
|
234
272
|
const res = await fetch(url, {
|
|
@@ -238,11 +276,13 @@ async function api(method, url, body) {
|
|
|
238
276
|
});
|
|
239
277
|
if (res.status === 401) { showGate("wrong key"); throw new Error("unauthorized"); }
|
|
240
278
|
const data = await res.json().catch(() => ({}));
|
|
241
|
-
if (!res.ok) throw new Error(data.error || res.status);
|
|
279
|
+
if (!res.ok) throw Object.assign(new Error(data.error || res.status), { status: res.status });
|
|
242
280
|
return data;
|
|
243
281
|
}
|
|
244
282
|
|
|
245
283
|
function showGate(msg) {
|
|
284
|
+
disposeGraph?.();
|
|
285
|
+
disposeGraph = null;
|
|
246
286
|
$("#app").style.display = "none";
|
|
247
287
|
$("#gate").style.display = "flex";
|
|
248
288
|
if (msg) $("#gateErr").textContent = msg;
|
|
@@ -265,11 +305,8 @@ async function boot() {
|
|
|
265
305
|
const projects = await renderProjects();
|
|
266
306
|
const r = routeFromHash();
|
|
267
307
|
if (projects.some((p) => p.slug === r.slug)) applyRoute(r);
|
|
268
|
-
else if (projects.length) navTo(
|
|
269
|
-
else
|
|
270
|
-
$(".header-context").hidden = true;
|
|
271
|
-
$("#content").innerHTML = '<div class="empty">No projects yet</div>';
|
|
272
|
-
}
|
|
308
|
+
else if (projects.length) navTo("#/" + encodeURIComponent(projects[0].slug) + "/notes");
|
|
309
|
+
else applyRoute(r);
|
|
273
310
|
} catch (e) {
|
|
274
311
|
if (e.message !== "unauthorized") showGate(String(e));
|
|
275
312
|
}
|
|
@@ -281,9 +318,10 @@ function routeUrl(slug, tab, doc) {
|
|
|
281
318
|
|
|
282
319
|
function routeFromHash() {
|
|
283
320
|
const [rawSlug, rawTab, rawDoc] = location.hash.replace(/^#\/?/, "").split("/");
|
|
321
|
+
if (!rawSlug) return { slug: null };
|
|
284
322
|
return {
|
|
285
|
-
slug:
|
|
286
|
-
tab: ["notes", "memories", "
|
|
323
|
+
slug: decodeURIComponent(rawSlug),
|
|
324
|
+
tab: ["notes", "memories", "skills", "graph"].includes(rawTab) ? rawTab : "notes",
|
|
287
325
|
doc: rawDoc ? decodeURIComponent(rawDoc) : null,
|
|
288
326
|
};
|
|
289
327
|
}
|
|
@@ -294,17 +332,28 @@ function navTo(hash) {
|
|
|
294
332
|
}
|
|
295
333
|
|
|
296
334
|
function applyRoute({ slug = null, tab = "notes", doc = null } = {}) {
|
|
335
|
+
disposeGraph?.();
|
|
336
|
+
disposeGraph = null;
|
|
337
|
+
activeHash = location.hash || "#/";
|
|
297
338
|
currentProject = slug;
|
|
298
339
|
currentTab = tab;
|
|
299
340
|
currentDoc = doc;
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
341
|
+
document.querySelectorAll(".proj").forEach((n) => {
|
|
342
|
+
n.classList.toggle("active", n.dataset.slug === slug);
|
|
343
|
+
if (n.dataset.slug === slug) n.setAttribute("aria-current", "page");
|
|
344
|
+
else n.removeAttribute("aria-current");
|
|
345
|
+
});
|
|
346
|
+
if (slug) setProjectMenu(true);
|
|
347
|
+
if (!slug) {
|
|
348
|
+
const c = $("#content");
|
|
349
|
+
c.innerHTML = '<div class="view"><div class="empty">No projects yet. Agents create them automatically on first write.</div></div>';
|
|
350
|
+
return;
|
|
306
351
|
}
|
|
307
|
-
|
|
352
|
+
$(".header-context").hidden = false;
|
|
353
|
+
const projectLabel = $("#headerProject");
|
|
354
|
+
projectLabel.textContent = slug;
|
|
355
|
+
projectLabel.title = projectLabel.textContent;
|
|
356
|
+
projectLabel.setAttribute("aria-label", "Current project: " + slug);
|
|
308
357
|
renderTabs();
|
|
309
358
|
}
|
|
310
359
|
|
|
@@ -315,14 +364,17 @@ async function renderProjects() {
|
|
|
315
364
|
const el = $("#projList");
|
|
316
365
|
el.innerHTML = "";
|
|
317
366
|
for (const r of rows) {
|
|
318
|
-
const div = document.createElement("
|
|
367
|
+
const div = document.createElement("button");
|
|
368
|
+
div.type = "button";
|
|
319
369
|
div.className = "proj" + (r.slug === currentProject ? " active" : "");
|
|
370
|
+
div.dataset.slug = r.slug;
|
|
320
371
|
div.dataset.initial = r.slug.slice(0, 2).toUpperCase();
|
|
321
372
|
div.textContent = r.slug;
|
|
322
|
-
div.title = r.docs + " notes, " + r.memories + " memories";
|
|
373
|
+
div.title = r.docs + " notes, " + r.memories + " memories, " + (r.skills || 0) + " skills";
|
|
323
374
|
div.onclick = () => selectProject(r.slug);
|
|
324
375
|
el.appendChild(div);
|
|
325
376
|
}
|
|
377
|
+
if (!rows.length) el.innerHTML = '<p class="muted">No projects yet</p>';
|
|
326
378
|
return rows;
|
|
327
379
|
}
|
|
328
380
|
|
|
@@ -331,15 +383,19 @@ function selectProject(slug) {
|
|
|
331
383
|
}
|
|
332
384
|
|
|
333
385
|
function renderTabs() {
|
|
386
|
+
disposeGraph?.();
|
|
387
|
+
disposeGraph = null;
|
|
334
388
|
const c = $("#content");
|
|
335
389
|
c.innerHTML = "";
|
|
336
|
-
if (!(
|
|
390
|
+
if (!(["notes", "skills"].includes(currentTab) && currentDoc !== null)) {
|
|
337
391
|
const head = document.createElement("div");
|
|
338
392
|
head.className = "workspace-head";
|
|
339
393
|
const tabs = document.createElement("div");
|
|
340
394
|
tabs.className = "tabs";
|
|
341
|
-
for (const [name, label] of [["notes","Notes"],["memories","Memories"],["
|
|
342
|
-
const t = document.createElement("
|
|
395
|
+
for (const [name, label] of [["notes","Notes"],["memories","Memories"],["skills","Skills"],["graph","Graph"]]) {
|
|
396
|
+
const t = document.createElement("button");
|
|
397
|
+
t.type = "button";
|
|
398
|
+
if (currentTab === name) t.setAttribute("aria-current", "page");
|
|
343
399
|
t.className = "tab" + (currentTab === name ? " active" : "");
|
|
344
400
|
t.textContent = label;
|
|
345
401
|
t.onclick = () => navTo(routeUrl(currentProject, name));
|
|
@@ -355,12 +411,29 @@ function renderTabs() {
|
|
|
355
411
|
const view = document.createElement("div");
|
|
356
412
|
view.className = "view";
|
|
357
413
|
c.appendChild(view);
|
|
358
|
-
({notes:renderNotes, memories:renderMemories,
|
|
414
|
+
({notes:renderNotes, memories:renderMemories, skills:renderSkills, graph:renderGraph}[currentTab])(view).catch((e) => {
|
|
415
|
+
if (!view.isConnected) return;
|
|
416
|
+
view.innerHTML = '<div class="empty err"></div>';
|
|
417
|
+
view.firstChild.textContent = "Unable to load this view: " + e.message;
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function renderGraph(view) {
|
|
422
|
+
const slug = currentProject;
|
|
423
|
+
view.innerHTML = '<div class="empty" role="status">Loading project graph...</div>';
|
|
424
|
+
const [data, { mountGraph }] = await Promise.all([
|
|
425
|
+
api("GET", "/api/projects/" + encodeURIComponent(slug)),
|
|
426
|
+
import("/graph.js"),
|
|
427
|
+
]);
|
|
428
|
+
if (!view.isConnected || currentTab !== "graph") return;
|
|
429
|
+
const dispose = await mountGraph(view, data);
|
|
430
|
+
if (!view.isConnected || currentTab !== "graph") dispose?.();
|
|
431
|
+
else disposeGraph = dispose;
|
|
359
432
|
}
|
|
360
433
|
|
|
361
434
|
async function deleteCurrentProject() {
|
|
362
435
|
const slug = currentProject;
|
|
363
|
-
if (!slug || !confirm('Delete project "' + slug + '"?\n\nAll notes, memories, and
|
|
436
|
+
if (!slug || !confirm('Delete project "' + slug + '"?\n\nAll notes, memories, skills, and revision history for this project will be permanently removed.')) return;
|
|
364
437
|
try {
|
|
365
438
|
await api("DELETE", "/api/projects/" + encodeURIComponent(slug));
|
|
366
439
|
currentProject = null;
|
|
@@ -368,14 +441,7 @@ async function deleteCurrentProject() {
|
|
|
368
441
|
history.replaceState(null, "", location.pathname + location.search);
|
|
369
442
|
const rows = await renderProjects();
|
|
370
443
|
if (rows.length) navTo(routeUrl(rows[0].slug, "notes"));
|
|
371
|
-
else
|
|
372
|
-
const projectLabel = $("#headerProject");
|
|
373
|
-
$(".header-context").hidden = true;
|
|
374
|
-
projectLabel.textContent = "";
|
|
375
|
-
projectLabel.removeAttribute("title");
|
|
376
|
-
projectLabel.removeAttribute("aria-label");
|
|
377
|
-
$("#content").innerHTML = '<div class="empty">No projects yet</div>';
|
|
378
|
-
}
|
|
444
|
+
else applyRoute(routeFromHash());
|
|
379
445
|
toast('project "' + slug + '" deleted');
|
|
380
446
|
} catch (e) {
|
|
381
447
|
toast("delete failed: " + e.message, true);
|
|
@@ -418,6 +484,16 @@ function applySidebar() {
|
|
|
418
484
|
}
|
|
419
485
|
applySidebar();
|
|
420
486
|
|
|
487
|
+
let projectMenuExpanded = localStorage.getItem("mindroot_project_menu") !== "collapsed";
|
|
488
|
+
function toggleProjectMenu() { setProjectMenu(!projectMenuExpanded); }
|
|
489
|
+
function setProjectMenu(expanded) {
|
|
490
|
+
projectMenuExpanded = expanded;
|
|
491
|
+
localStorage.setItem("mindroot_project_menu", expanded ? "expanded" : "collapsed");
|
|
492
|
+
$("#projectMenuToggle").setAttribute("aria-expanded", String(expanded));
|
|
493
|
+
$("#projList").hidden = !expanded;
|
|
494
|
+
}
|
|
495
|
+
setProjectMenu(projectMenuExpanded);
|
|
496
|
+
|
|
421
497
|
function renderMd(text) {
|
|
422
498
|
if (window.marked && window.DOMPurify) {
|
|
423
499
|
marked.setOptions({ gfm: true, breaks: true });
|
|
@@ -486,19 +562,6 @@ async function renderEditor(view, docs) {
|
|
|
486
562
|
}
|
|
487
563
|
|
|
488
564
|
async function renderMemories(view) {
|
|
489
|
-
const form = document.createElement("form");
|
|
490
|
-
form.className = "row";
|
|
491
|
-
form.innerHTML = '<input type="text" placeholder="Memory text" id="rtxt"><input type="text" placeholder="Optional link: note.md::Heading" id="rlink"><button class="primary">Add memory</button>';
|
|
492
|
-
form.onsubmit = async (e) => {
|
|
493
|
-
e.preventDefault();
|
|
494
|
-
try {
|
|
495
|
-
await api("POST", "/api/projects/" + currentProject + "/memories", {
|
|
496
|
-
text: $("#rtxt").value, target_path: $("#rlink").value || undefined,
|
|
497
|
-
});
|
|
498
|
-
renderTabs();
|
|
499
|
-
} catch (e2) { toast(e2.message, true); }
|
|
500
|
-
};
|
|
501
|
-
view.appendChild(form);
|
|
502
565
|
const rows = await api("GET", "/api/projects/" + currentProject + "/memories");
|
|
503
566
|
for (const r of rows) {
|
|
504
567
|
const div = document.createElement("div");
|
|
@@ -519,29 +582,132 @@ async function renderMemories(view) {
|
|
|
519
582
|
a.onclick = (e) => { e.preventDefault(); navTo(routeUrl(currentProject, "notes", a.dataset.p)); });
|
|
520
583
|
}
|
|
521
584
|
|
|
522
|
-
async function
|
|
523
|
-
const
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
585
|
+
async function renderSkills(view) {
|
|
586
|
+
const slug = currentProject;
|
|
587
|
+
if (currentDoc !== null) return renderSkillEditor(view, slug, currentDoc);
|
|
588
|
+
const rows = await api("GET", "/api/projects/" + encodeURIComponent(slug) + "/skills");
|
|
589
|
+
if (!view.isConnected) return;
|
|
590
|
+
for (const skill of rows) {
|
|
591
|
+
const row = document.createElement("button");
|
|
592
|
+
row.className = "docrow skillrow";
|
|
593
|
+
row.innerHTML = '<div class="doc-head"><span class="t"></span><span class="p"></span></div><div class="skill-summary"></div>';
|
|
594
|
+
row.querySelector(".t").textContent = skill.name;
|
|
595
|
+
row.querySelector(".p").textContent = "Revision " + skill.revision;
|
|
596
|
+
row.querySelector(".skill-summary").textContent = skill.description;
|
|
597
|
+
row.onclick = () => navTo(routeUrl(slug, "skills", skill.name));
|
|
598
|
+
view.appendChild(row);
|
|
599
|
+
}
|
|
600
|
+
if (!rows.length) view.innerHTML = '<div class="empty">No skills yet. Saved procedures from your agents will appear here.</div>';
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
async function renderSkillEditor(view, slug, name) {
|
|
604
|
+
const url = "/api/projects/" + encodeURIComponent(slug) + "/skills/" + encodeURIComponent(name);
|
|
605
|
+
let skill = await api("GET", url);
|
|
606
|
+
if (!view.isConnected) return;
|
|
607
|
+
view.className = "view editor-view";
|
|
608
|
+
const head = document.createElement("div");
|
|
609
|
+
head.className = "editor-head";
|
|
610
|
+
head.innerHTML =
|
|
611
|
+
'<div class="editorbar"><div class="editor-identity">' +
|
|
612
|
+
'<button class="back-button" title="Back to skills" aria-label="Back to skills">' +
|
|
613
|
+
'<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="m15 18-6-6 6-6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>' +
|
|
614
|
+
'<div class="editor-document"><span class="editor-title"></span></div></div>' +
|
|
615
|
+
'<span class="viewmode"><button data-m="edit">Write</button><button data-m="split">Split</button><button data-m="preview">Read</button></span>' +
|
|
616
|
+
'<div class="editor-actions"><button class="primary skill-save">Save</button><button class="danger skill-delete">Delete skill</button></div></div>' +
|
|
617
|
+
'<div class="skill-fields"><div class="skill-history"><label for="skillRevision">Revision</label><select id="skillRevision"></select><span class="skill-status" role="status"></span></div>' +
|
|
618
|
+
'<label class="skill-description-field" for="skillDescription">Description<input id="skillDescription" maxlength="2000" required></label><p class="skill-description"></p></div>';
|
|
619
|
+
head.querySelector(".editor-title").textContent = name;
|
|
620
|
+
head.querySelector(".editor-title").title = name;
|
|
621
|
+
const revision = head.querySelector("select");
|
|
622
|
+
const description = head.querySelector("input");
|
|
623
|
+
const save = head.querySelector(".skill-save");
|
|
624
|
+
const del = head.querySelector(".skill-delete");
|
|
625
|
+
const status = head.querySelector(".skill-status");
|
|
626
|
+
const wrap = document.createElement("div");
|
|
627
|
+
const ta = document.createElement("textarea");
|
|
628
|
+
ta.className = "editor";
|
|
629
|
+
ta.setAttribute("aria-label", "Skill instructions");
|
|
630
|
+
ta.spellcheck = false;
|
|
631
|
+
const preview = document.createElement("div");
|
|
632
|
+
preview.className = "md";
|
|
633
|
+
let mode = "preview";
|
|
634
|
+
let busy = false;
|
|
635
|
+
const dirty = () => description.value !== skill.description || ta.value !== skill.content;
|
|
636
|
+
const paint = () => { preview.innerHTML = renderMd(ta.value); };
|
|
637
|
+
const setMode = () => {
|
|
638
|
+
const historical = skill.revision !== skill.current_revision;
|
|
639
|
+
const shownMode = historical ? "preview" : mode;
|
|
640
|
+
wrap.className = "split " + shownMode;
|
|
641
|
+
wrap.replaceChildren(...(shownMode === "edit" ? [ta] : shownMode === "split" ? [ta, preview] : [preview]));
|
|
642
|
+
head.querySelector(".viewmode").hidden = historical;
|
|
643
|
+
head.querySelectorAll(".viewmode button").forEach((b) => {
|
|
644
|
+
b.classList.toggle("on", b.dataset.m === shownMode);
|
|
645
|
+
b.setAttribute("aria-pressed", String(b.dataset.m === shownMode));
|
|
646
|
+
});
|
|
647
|
+
head.querySelector(".skill-description-field").hidden = shownMode === "preview";
|
|
648
|
+
head.querySelector(".skill-description").hidden = shownMode !== "preview";
|
|
649
|
+
head.querySelector(".skill-description").textContent = description.value;
|
|
650
|
+
save.hidden = historical;
|
|
651
|
+
del.hidden = historical;
|
|
652
|
+
paint();
|
|
653
|
+
};
|
|
654
|
+
const load = (value) => {
|
|
655
|
+
skill = value;
|
|
656
|
+
description.value = skill.description;
|
|
657
|
+
ta.value = skill.content;
|
|
658
|
+
revision.replaceChildren();
|
|
659
|
+
for (let n = skill.current_revision; n >= 1; n--) {
|
|
660
|
+
revision.add(new Option("Revision " + n + (n === skill.current_revision ? " (latest)" : ""), String(n)));
|
|
540
661
|
}
|
|
662
|
+
revision.value = String(skill.revision);
|
|
663
|
+
status.textContent = (skill.revision === skill.current_revision ? "Updated " : "Read-only · Saved ") + new Date(skill.updated_at).toLocaleString();
|
|
664
|
+
setMode();
|
|
665
|
+
};
|
|
666
|
+
const setBusy = (value) => {
|
|
667
|
+
busy = value;
|
|
668
|
+
save.disabled = del.disabled = revision.disabled = description.disabled = ta.disabled = value;
|
|
669
|
+
};
|
|
670
|
+
head.querySelector(".back-button").onclick = () => {
|
|
671
|
+
if (!busy && (!dirty() || confirm("Discard unsaved skill changes?"))) navTo(routeUrl(slug, "skills"));
|
|
672
|
+
};
|
|
673
|
+
head.querySelectorAll(".viewmode button").forEach((b) => {
|
|
674
|
+
b.onclick = () => { mode = b.dataset.m; setMode(); };
|
|
675
|
+
});
|
|
676
|
+
ta.oninput = paint;
|
|
677
|
+
revision.onchange = async () => {
|
|
678
|
+
const selected = Number(revision.value);
|
|
679
|
+
if (dirty() && !confirm("Discard unsaved skill changes?")) { revision.value = String(skill.revision); return; }
|
|
680
|
+
setBusy(true);
|
|
681
|
+
try {
|
|
682
|
+
const value = await api("GET", url + (selected === skill.current_revision ? "" : "?revision=" + selected));
|
|
683
|
+
if (view.isConnected) load(value);
|
|
684
|
+
} catch (e) { revision.value = String(skill.revision); toast("Unable to load revision: " + e.message, true); }
|
|
685
|
+
finally { setBusy(false); }
|
|
686
|
+
};
|
|
687
|
+
save.onclick = async () => {
|
|
688
|
+
if (busy) return;
|
|
689
|
+
if (!description.value.trim() || !ta.value.trim()) { toast("Description and instructions are required", true); return; }
|
|
690
|
+
setBusy(true);
|
|
691
|
+
try {
|
|
692
|
+
const updated = await api("PUT", url, { description: description.value, content: ta.value, expected_revision: skill.revision });
|
|
693
|
+
if (view.isConnected) load({ ...skill, ...updated, content: ta.value, current_revision: updated.revision });
|
|
694
|
+
toast(updated.unchanged ? "No changes to save" : "Skill saved");
|
|
695
|
+
} catch (e) { toast("Save failed: " + e.message + ". Your edits are still here.", true); }
|
|
696
|
+
finally { setBusy(false); }
|
|
697
|
+
};
|
|
698
|
+
del.onclick = async () => {
|
|
699
|
+
if (busy || !confirm('Delete skill "' + name + '" and all its revisions?')) return;
|
|
700
|
+
setBusy(true);
|
|
701
|
+
try {
|
|
702
|
+
await api("DELETE", url);
|
|
703
|
+
if (view.isConnected) navTo(routeUrl(slug, "skills"));
|
|
704
|
+
await renderProjects();
|
|
705
|
+
toast("Skill deleted");
|
|
706
|
+
} catch (e) { toast("Delete failed: " + e.message, true); }
|
|
707
|
+
finally { setBusy(false); }
|
|
541
708
|
};
|
|
542
|
-
view.
|
|
543
|
-
|
|
544
|
-
$("#sq").focus();
|
|
709
|
+
view.append(head, wrap);
|
|
710
|
+
load(skill);
|
|
545
711
|
}
|
|
546
712
|
|
|
547
713
|
function toast(msg, isErr) {
|
package/src/embedder.js
CHANGED
|
@@ -9,6 +9,8 @@ export function getEmbedder() {
|
|
|
9
9
|
env.allowLocalModels = false;
|
|
10
10
|
extractorPromise = pipeline("feature-extraction", ONNX_MODEL_ID, {
|
|
11
11
|
dtype: "q8",
|
|
12
|
+
// Avoid retaining large native inference buffers between requests.
|
|
13
|
+
session_options: { enableCpuMemArena: false },
|
|
12
14
|
});
|
|
13
15
|
}
|
|
14
16
|
return extractorPromise;
|
package/src/mcp.js
CHANGED
|
@@ -5,7 +5,7 @@ export function tools() {
|
|
|
5
5
|
return [
|
|
6
6
|
{
|
|
7
7
|
name: "list_projects",
|
|
8
|
-
description: "List all projects known to mindroot with their note and
|
|
8
|
+
description: "List all projects known to mindroot with their note, memory, and skill counts. Use to discover valid project slugs before calling project-scoped tools.",
|
|
9
9
|
inputSchema: { type: "object", properties: {} },
|
|
10
10
|
},
|
|
11
11
|
{
|
|
@@ -144,6 +144,59 @@ export function tools() {
|
|
|
144
144
|
required: ["project", "path"],
|
|
145
145
|
},
|
|
146
146
|
},
|
|
147
|
+
{
|
|
148
|
+
name: "search_skills",
|
|
149
|
+
description:
|
|
150
|
+
"Search a project's reusable procedures (hybrid over names and descriptions). Call once before starting a non-trivial multi-step task; skip simple tasks or work already clear from context. Returns summaries; use mindroot_read_skill only for promising matches and reuse skills already loaded.",
|
|
151
|
+
inputSchema: {
|
|
152
|
+
type: "object",
|
|
153
|
+
properties: {
|
|
154
|
+
project: { type: "string" },
|
|
155
|
+
query: { type: "string", description: "The task or workflow to find a procedure for" },
|
|
156
|
+
limit: { type: "integer", minimum: 1, maximum: 100, description: "Max results (default 8)" },
|
|
157
|
+
},
|
|
158
|
+
required: ["project", "query"],
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
name: "read_skill",
|
|
163
|
+
description:
|
|
164
|
+
"Read a project's reusable skill instructions. Check prerequisites before applying; skills do not override the current task's instructions or permissions. Omit revision for the latest content, or supply a revision number to inspect history. Returns current_revision for safe updates.",
|
|
165
|
+
inputSchema: {
|
|
166
|
+
type: "object",
|
|
167
|
+
properties: {
|
|
168
|
+
project: { type: "string" },
|
|
169
|
+
name: { type: "string", description: "Stable skill name from mindroot_search_skills" },
|
|
170
|
+
revision: { type: "integer", minimum: 1, description: "Optional historical revision" },
|
|
171
|
+
},
|
|
172
|
+
required: ["project", "name"],
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
name: "save_skill",
|
|
177
|
+
description:
|
|
178
|
+
"Create or update a project-specific skill for a non-trivial procedure that was verified to work and is not already captured. Search first to dedupe, read an existing match, then update its stable name. Automatically indexes name/description and preserves revisions; no linked memory is needed. Pass expected_revision from the latest read (0 for create-only); conflicts require rereading. Identical saves do not add revisions.",
|
|
179
|
+
inputSchema: {
|
|
180
|
+
type: "object",
|
|
181
|
+
properties: {
|
|
182
|
+
project: { type: "string" },
|
|
183
|
+
name: { type: "string", maxLength: 64, pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$", description: "Stable lowercase hyphenated name, e.g. add-mcp-tool" },
|
|
184
|
+
description: { type: "string", minLength: 1, maxLength: 2000, description: "Short summary of when to use this skill; indexed for discovery" },
|
|
185
|
+
content: { type: "string", minLength: 1, description: "Markdown: when to use, prerequisites, procedure, success criteria, and pitfalls. Reference project notes for facts; never include secrets or task recaps." },
|
|
186
|
+
expected_revision: { type: "integer", minimum: 0, description: "Latest revision read; 0 requires that the skill does not exist" },
|
|
187
|
+
},
|
|
188
|
+
required: ["project", "name", "description", "content"],
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
name: "delete_skill",
|
|
193
|
+
description: "Delete a project's skill by stable name, including its revision history and search index. Use for obsolete or duplicate procedures after checking the existing skill.",
|
|
194
|
+
inputSchema: {
|
|
195
|
+
type: "object",
|
|
196
|
+
properties: { project: { type: "string" }, name: { type: "string" } },
|
|
197
|
+
required: ["project", "name"],
|
|
198
|
+
},
|
|
199
|
+
},
|
|
147
200
|
];
|
|
148
201
|
}
|
|
149
202
|
|
|
@@ -174,6 +227,14 @@ async function callTool(db, name, args) {
|
|
|
174
227
|
return core.updateSection(db, args.project, args.path, args.heading_path, args.content);
|
|
175
228
|
case "delete_note":
|
|
176
229
|
return core.deleteNote(db, args.project, args.path);
|
|
230
|
+
case "search_skills":
|
|
231
|
+
return core.searchSkills(db, args.project, args.query, args.limit ?? 8);
|
|
232
|
+
case "read_skill":
|
|
233
|
+
return core.readSkill(db, args.project, args.name, args.revision);
|
|
234
|
+
case "save_skill":
|
|
235
|
+
return core.saveSkill(db, args.project, args.name, args.description, args.content, args.expected_revision);
|
|
236
|
+
case "delete_skill":
|
|
237
|
+
return core.deleteSkill(db, args.project, args.name);
|
|
177
238
|
default:
|
|
178
239
|
throw new Error(`unknown tool: ${name}`);
|
|
179
240
|
}
|
package/src/paths.js
CHANGED
|
@@ -14,16 +14,28 @@ export const EMBED_DIM = 768;
|
|
|
14
14
|
export const DEFAULT_PORT = 7620;
|
|
15
15
|
|
|
16
16
|
export function loadConfig() {
|
|
17
|
+
let raw;
|
|
17
18
|
try {
|
|
18
|
-
|
|
19
|
+
raw = fs.readFileSync(CONFIG_PATH, "utf8");
|
|
20
|
+
} catch (err) {
|
|
21
|
+
if (err.code === "ENOENT") return {};
|
|
22
|
+
throw err;
|
|
23
|
+
}
|
|
24
|
+
let cfg;
|
|
25
|
+
try {
|
|
26
|
+
cfg = JSON.parse(raw);
|
|
19
27
|
} catch {
|
|
20
|
-
|
|
28
|
+
throw new Error(`invalid mindroot configuration at ${CONFIG_PATH}; fix or delete it before continuing`);
|
|
29
|
+
}
|
|
30
|
+
if (!cfg || typeof cfg !== "object" || Array.isArray(cfg)) {
|
|
31
|
+
throw new Error(`expected a JSON object in ${CONFIG_PATH}; fix or delete it before continuing`);
|
|
21
32
|
}
|
|
33
|
+
return cfg;
|
|
22
34
|
}
|
|
23
35
|
|
|
24
36
|
export function saveConfig(cfg) {
|
|
25
37
|
fs.mkdirSync(MINDROOT_DIR, { recursive: true });
|
|
26
|
-
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n");
|
|
38
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 });
|
|
27
39
|
}
|
|
28
40
|
|
|
29
41
|
export function ensureConfig() {
|
package/src/search.js
CHANGED
|
@@ -118,6 +118,34 @@ export function rankMemories(db, projectId, qvec, query, limit = 8) {
|
|
|
118
118
|
return rank(byId, limit);
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
export function rankSkills(db, projectId, qvec, query, limit = 8) {
|
|
122
|
+
const rows = db.prepare(
|
|
123
|
+
`SELECT s.id, s.name, s.description, s.revision, s.updated_at, e.vec
|
|
124
|
+
FROM skills s LEFT JOIN skill_embeddings e ON e.skill_id = s.id
|
|
125
|
+
WHERE s.project_id = ?`,
|
|
126
|
+
).all(projectId);
|
|
127
|
+
const byId = new Map();
|
|
128
|
+
for (const row of rows) {
|
|
129
|
+
const vec = row.vec && toFloat32(row.vec);
|
|
130
|
+
byId.set(row.id, {
|
|
131
|
+
kind: "skill", name: row.name, description: row.description,
|
|
132
|
+
revision: row.revision, updated_at: row.updated_at,
|
|
133
|
+
score: vec?.length === qvec.length ? cosine(qvec, vec) : 0,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
const fq = ftsQuery(query);
|
|
137
|
+
if (fq) {
|
|
138
|
+
const matches = db.prepare(
|
|
139
|
+
`SELECT s.id, bm25(skills_fts) AS rank FROM skills_fts
|
|
140
|
+
JOIN skills s ON s.id = skills_fts.rowid
|
|
141
|
+
WHERE skills_fts MATCH ? AND s.project_id = ? ORDER BY rank LIMIT 100`,
|
|
142
|
+
).all(fq, projectId);
|
|
143
|
+
const max = Math.max(0, ...matches.map((r) => -r.rank));
|
|
144
|
+
for (const match of matches) byId.get(match.id).fts = max ? -match.rank / max : 1;
|
|
145
|
+
}
|
|
146
|
+
return rank(byId, limit);
|
|
147
|
+
}
|
|
148
|
+
|
|
121
149
|
function applyFts(db, projectId, query, byId) {
|
|
122
150
|
const fq = ftsQuery(query);
|
|
123
151
|
if (!fq) return;
|