@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.
@@ -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, .hit .kind { font-variant-numeric:tabular-nums }
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:112px; border-right:0; border-bottom:1px solid var(--border-soft); padding:7px 8px; box-shadow:0 8px 20px rgba(0,0,0,.12) }
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
- form.row, form.row.single { grid-template-columns:1fr }
164
- form.row input, form.row button { height:44px }
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="projList"></div>
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(routeUrl(projects[0].slug, "notes"));
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: rawSlug ? decodeURIComponent(rawSlug) : null,
286
- tab: ["notes", "memories", "search"].includes(rawTab) ? rawTab : "notes",
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
- if (currentProject) {
301
- $(".header-context").hidden = false;
302
- const projectLabel = $("#headerProject");
303
- projectLabel.textContent = slug;
304
- projectLabel.title = slug;
305
- projectLabel.setAttribute("aria-label", "Current project: " + slug);
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
- document.querySelectorAll(".proj").forEach((n) => n.classList.toggle("active", n.textContent === slug));
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("div");
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 (!(currentTab === "notes" && currentDoc !== null)) {
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"],["search","Search"]]) {
342
- const t = document.createElement("div");
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, search:renderSearch}[currentTab])(view);
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 search data for this project will be permanently removed.')) return;
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 renderSearch(view) {
523
- const form = document.createElement("form");
524
- form.className = "row single";
525
- form.innerHTML = '<input type="text" placeholder="Search notes and memories" id="sq"><button class="primary">Search</button>';
526
- const out = document.createElement("div");
527
- out.innerHTML = '<div class="empty">Search this project\'s notes and memories</div>';
528
- form.onsubmit = async (e) => {
529
- e.preventDefault();
530
- out.innerHTML = "";
531
- const hits = await api("POST", "/api/search", { project: currentProject, query: $("#sq").value, limit: 15 });
532
- if (!hits.length) out.innerHTML = '<div class="empty">No results</div>';
533
- for (const h of hits) {
534
- const div = document.createElement("div");
535
- div.className = "hit";
536
- div.innerHTML = '<div class="kind">' + h.kind + " · " + Number(h.score).toFixed(3) + '</div><div class="txt-main"></div><div class="tgt"></div>';
537
- div.querySelector(".txt-main").textContent = h.text || "";
538
- div.querySelector(".tgt").textContent = (h.doc_title ? '"' + h.doc_title + '" ' : "") + (h.target || "");
539
- out.appendChild(div);
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.appendChild(form);
543
- view.appendChild(out);
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 memory counts. Use to discover valid project slugs before calling project-scoped tools.",
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
- return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
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
- return {};
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;