@sksoftofficial/mindroot 1.0.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/README.md +94 -0
- package/bin/mindroot.js +57 -0
- package/package.json +34 -0
- package/src/commands/cli-api.js +42 -0
- package/src/commands/human.js +79 -0
- package/src/commands/init.js +25 -0
- package/src/commands/start.js +162 -0
- package/src/core.js +176 -0
- package/src/dashboard/index.html +420 -0
- package/src/embedder.js +32 -0
- package/src/indexer.js +175 -0
- package/src/markdown.js +72 -0
- package/src/mcp.js +216 -0
- package/src/paths.js +38 -0
- package/src/search.js +196 -0
- package/src/server.js +192 -0
- package/src/store/db.js +107 -0
- package/test/markdown.test.js +32 -0
- package/test/mcp.test.js +74 -0
- package/test/search.test.js +87 -0
- package/test/store.test.js +34 -0
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<link rel="icon" href="data:,">
|
|
7
|
+
<title>mindroot</title>
|
|
8
|
+
<style>
|
|
9
|
+
:root { --bg:#1e1e1e; --bg2:#161616; --bg3:#2b2b2b; --hover:#303030; --fg:#dcddde; --dim:#999; --accent:#e9973f; --accent-strong:#d97721; --accent-soft:#442f20; --danger:#ff6b6b; --ok:#53c59f; --border:#363636; }
|
|
10
|
+
* { box-sizing:border-box; margin:0; padding:0; }
|
|
11
|
+
* { scrollbar-width:thin; scrollbar-color:#505050 transparent }
|
|
12
|
+
*::-webkit-scrollbar { width:10px; height:10px }
|
|
13
|
+
*::-webkit-scrollbar-track { background:transparent }
|
|
14
|
+
*::-webkit-scrollbar-thumb { background:#484848; border:2px solid transparent; border-radius:999px; background-clip:padding-box }
|
|
15
|
+
*::-webkit-scrollbar-thumb:hover { background:#606060; border:2px solid transparent; background-clip:padding-box }
|
|
16
|
+
*::-webkit-scrollbar-corner { background:transparent }
|
|
17
|
+
body { font:14px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:var(--bg); color:var(--fg); height:100vh; display:flex; flex-direction:column; }
|
|
18
|
+
header { display:flex; align-items:center; gap:12px; padding:9px 14px; background:var(--bg2); border-bottom:1px solid var(--border); }
|
|
19
|
+
header h1 { font-size:15px; font-weight:650; letter-spacing:-.01em; color:var(--fg); }
|
|
20
|
+
header .spacer { flex:1 }
|
|
21
|
+
.header-lock { width:32px; height:32px; padding:0; display:grid; place-items:center }
|
|
22
|
+
.header-lock svg { width:16px; height:16px; stroke:currentColor }
|
|
23
|
+
button { background:var(--bg3); color:var(--fg); border:1px solid var(--border); border-radius:5px; padding:5px 10px; cursor:pointer; font:inherit; }
|
|
24
|
+
button:hover { background:var(--hover); border-color:#4a4a4a }
|
|
25
|
+
button.primary { background:var(--accent-strong); color:#fff; border-color:var(--accent-strong); font-weight:600 }
|
|
26
|
+
button.primary:hover { background:#e58732; border-color:#e58732 }
|
|
27
|
+
button.danger { color:var(--danger) }
|
|
28
|
+
input, textarea { background:#242424; color:var(--fg); border:1px solid var(--border); border-radius:5px; padding:6px 9px; font:inherit }
|
|
29
|
+
input:focus, textarea:focus { outline:none; border-color:var(--accent); box-shadow:0 0 0 2px rgba(233,151,63,.12) }
|
|
30
|
+
#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
|
+
#gate input { width:100% }
|
|
32
|
+
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 }
|
|
34
|
+
.proj { padding:7px 10px; border-radius:5px; cursor:pointer; color:var(--dim) }
|
|
35
|
+
.proj:hover { background:var(--hover); color:var(--fg) }
|
|
36
|
+
.proj.active { background:var(--accent-soft); color:#f2b46f }
|
|
37
|
+
#content { flex:1; min-width:0; overflow:hidden; padding:18px 22px; display:flex; flex-direction:column }
|
|
38
|
+
.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) }
|
|
39
|
+
.tabs { display:flex; gap:4px; align-items:center }
|
|
40
|
+
.tab { padding:5px 12px; border-radius:5px; cursor:pointer; color:var(--dim); border:1px solid transparent }
|
|
41
|
+
.tab:hover { color:var(--fg); background:var(--hover) }
|
|
42
|
+
.tab.active { color:#f2b46f; border-color:#6a4930; background:var(--accent-soft) }
|
|
43
|
+
.project-actions { margin-left:auto; display:flex; align-items:center; gap:10px }
|
|
44
|
+
.delete-project { white-space:nowrap }
|
|
45
|
+
.view { flex:1; min-height:0; overflow-y:auto; width:100% }
|
|
46
|
+
.docrow { padding:11px 12px; border:1px solid var(--border); border-radius:7px; margin-bottom:8px; cursor:pointer; background:#242424 }
|
|
47
|
+
.docrow:hover { background:#282828; border-color:#555 }
|
|
48
|
+
.doc-head { display:flex; align-items:baseline; justify-content:space-between; gap:12px; margin-bottom:5px }
|
|
49
|
+
.docrow .t { color:var(--fg); font-weight:600 }
|
|
50
|
+
.docrow .p { color:var(--dim); font-size:12px }
|
|
51
|
+
.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
|
+
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
|
+
.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
|
+
.rem .txt { flex:1 }
|
|
55
|
+
.rem a { color:var(--accent); text-decoration:none; font-size:12px }
|
|
56
|
+
.rem .meta { color:var(--dim); font-size:11px }
|
|
57
|
+
.hit { border-left:3px solid var(--accent); padding:8px 12px; margin-bottom:10px; background:#242424; border-radius:0 7px 7px 0 }
|
|
58
|
+
.hit .kind { color:var(--accent); font-size:11px; text-transform:uppercase; letter-spacing:.08em }
|
|
59
|
+
.hit .tgt { color:var(--dim); font-size:12px }
|
|
60
|
+
.muted { color:var(--dim) }
|
|
61
|
+
.err { color:var(--danger) }
|
|
62
|
+
form.row { display:grid; grid-template-columns:minmax(0,1fr) 280px auto; gap:8px; margin:0 0 18px; align-items:center }
|
|
63
|
+
form.row.single { grid-template-columns:minmax(0,1fr) auto }
|
|
64
|
+
form.row input { min-width:0; height:36px }
|
|
65
|
+
form.row button { height:36px }
|
|
66
|
+
.empty { color:var(--dim); border:1px dashed var(--border); border-radius:7px; padding:28px 20px; text-align:center }
|
|
67
|
+
h2 { font-size:13px; text-transform:uppercase; letter-spacing:.1em; color:var(--dim); margin:18px 0 10px }
|
|
68
|
+
.editor-view { display:flex; flex-direction:column; overflow:hidden; padding-right:0 }
|
|
69
|
+
.editor-head { flex:none }
|
|
70
|
+
.editorbar { display:grid; grid-template-columns:minmax(0,1fr) auto minmax(0,1fr); align-items:center; gap:14px; min-height:42px; margin-bottom:10px }
|
|
71
|
+
.editor-identity, .editor-actions { display:flex; align-items:center; gap:8px; min-width:0 }
|
|
72
|
+
.back-button { width:34px; height:34px; padding:0; flex:none; display:grid; place-items:center }
|
|
73
|
+
.back-button svg { width:17px; height:17px; stroke:currentColor }
|
|
74
|
+
.editor-document { min-width:0 }
|
|
75
|
+
.editor-title { max-width:320px; color:var(--fg); font-size:13px; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
|
|
76
|
+
.editor-actions { justify-self:end }
|
|
77
|
+
.viewmode { display:inline-flex; gap:0; padding:2px; background:#191919; border:1px solid var(--border); border-radius:6px }
|
|
78
|
+
.viewmode button { padding:4px 10px; background:transparent; border-color:transparent }
|
|
79
|
+
.viewmode button:hover { background:var(--hover); border-color:transparent }
|
|
80
|
+
.viewmode button.on { background:var(--accent-soft); color:#f2b46f; border-color:transparent }
|
|
81
|
+
.split { display:flex; flex:1; min-height:0; gap:12px; align-items:stretch }
|
|
82
|
+
.split textarea.editor { flex:1; height:100%; min-height:0; resize:none; overflow-y:auto }
|
|
83
|
+
.split.preview { display:block; overflow-y:auto }
|
|
84
|
+
.split.preview .md { width:100%; min-height:100%; overflow:visible }
|
|
85
|
+
.md { flex:1; overflow-y:auto; padding:24px 28px; border:1px solid var(--border); border-radius:7px; background:#242424; font-family:-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif }
|
|
86
|
+
.md h1, .md h2, .md h3, .md h4 { margin:18px 0 10px; line-height:1.3; color:var(--fg) }
|
|
87
|
+
.md h1 { font-size:26px } .md h2 { font-size:20px } .md h3 { font-size:16px } .md h4 { font-size:14px }
|
|
88
|
+
.md h2 { text-transform:none; letter-spacing:normal }
|
|
89
|
+
.md > :first-child { margin-top:0 }
|
|
90
|
+
.md > :last-child { margin-bottom:0 }
|
|
91
|
+
.md p { margin:8px 0 }
|
|
92
|
+
.md ul, .md ol { margin:8px 0; padding-inline-start:24px }
|
|
93
|
+
.md li { margin:3px 0; padding-left:2px }
|
|
94
|
+
.md li::marker { color:var(--dim) }
|
|
95
|
+
.md li > ul, .md li > ol { margin:4px 0 }
|
|
96
|
+
.md a { color:var(--accent) }
|
|
97
|
+
.md code { background:var(--bg3); border-radius:4px; padding:1px 5px; font-family:ui-monospace, Menlo, monospace; font-size:13px }
|
|
98
|
+
.md pre { background:#191919; border:1px solid var(--border); border-radius:6px; padding:12px; overflow-x:auto; margin:10px 0 }
|
|
99
|
+
.md pre code { background:none; padding:0 }
|
|
100
|
+
.md blockquote { border-left:3px solid var(--accent); margin:10px 0; padding:2px 14px; color:var(--dim) }
|
|
101
|
+
.md table { border-collapse:collapse; margin:10px 0 }
|
|
102
|
+
.md th, .md td { border:1px solid var(--border); padding:5px 10px; text-align:left }
|
|
103
|
+
.md hr { border:none; border-top:1px solid var(--border); margin:16px 0 }
|
|
104
|
+
.md img { max-width:100% }
|
|
105
|
+
::selection { background:rgba(233,151,63,.3) }
|
|
106
|
+
@media (max-width:700px) {
|
|
107
|
+
main { flex-direction:column }
|
|
108
|
+
aside { width:100%; max-height:112px; border-right:0; border-bottom:1px solid var(--border); padding:7px 8px }
|
|
109
|
+
#projList { display:flex; gap:4px; overflow-x:auto }
|
|
110
|
+
.proj { flex:none; white-space:nowrap }
|
|
111
|
+
#content { padding:14px }
|
|
112
|
+
.workspace-head { align-items:flex-start; gap:8px; margin-bottom:12px; padding-bottom:10px }
|
|
113
|
+
.tab { padding:5px 8px }
|
|
114
|
+
.delete-project { padding:5px 7px }
|
|
115
|
+
form.row, form.row.single { grid-template-columns:1fr }
|
|
116
|
+
.editor-view { overflow-y:auto }
|
|
117
|
+
.editorbar { grid-template-columns:minmax(0,1fr) auto; gap:8px }
|
|
118
|
+
.editor-title { max-width:180px }
|
|
119
|
+
.viewmode { grid-column:1 / -1; grid-row:2; justify-self:center }
|
|
120
|
+
.split { flex:none; flex-direction:column }
|
|
121
|
+
.split textarea.editor, .md { min-height:360px }
|
|
122
|
+
.md { padding:20px }
|
|
123
|
+
.split.preview .md { min-height:calc(100vh - 240px) }
|
|
124
|
+
}
|
|
125
|
+
</style>
|
|
126
|
+
<script src="https://cdn.jsdelivr.net/npm/marked@12.0.2/marked.min.js"></script>
|
|
127
|
+
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.1.6/dist/purify.min.js"></script>
|
|
128
|
+
</head>
|
|
129
|
+
<body>
|
|
130
|
+
<header>
|
|
131
|
+
<h1>mindroot</h1>
|
|
132
|
+
<div class="spacer"></div>
|
|
133
|
+
<button class="header-lock" onclick="logout()" title="Lock dashboard" aria-label="Lock dashboard">
|
|
134
|
+
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
135
|
+
<rect x="5" y="10" width="14" height="10" rx="2" stroke-width="1.8"/>
|
|
136
|
+
<path d="M8 10V7a4 4 0 0 1 8 0v3" stroke-width="1.8" stroke-linecap="round"/>
|
|
137
|
+
</svg>
|
|
138
|
+
</button>
|
|
139
|
+
</header>
|
|
140
|
+
|
|
141
|
+
<div id="gate">
|
|
142
|
+
<h2 style="margin:0">API key required</h2>
|
|
143
|
+
<p class="muted">Find it in <b>~/.mindroot/config.json</b> or in the output of <b>mindroot init</b>.</p>
|
|
144
|
+
<input id="keyInput" type="password" placeholder="paste API key" onkeydown="if(event.key==='Enter')unlock()">
|
|
145
|
+
<button class="primary" onclick="unlock()">Unlock</button>
|
|
146
|
+
<p id="gateErr" class="err"></p>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
<main id="app" style="display:none">
|
|
150
|
+
<aside><div id="projList"></div></aside>
|
|
151
|
+
<section id="content"></section>
|
|
152
|
+
</main>
|
|
153
|
+
|
|
154
|
+
<script>
|
|
155
|
+
const $ = (s) => document.querySelector(s);
|
|
156
|
+
let KEY = localStorage.getItem("mindroot_key") || "";
|
|
157
|
+
let currentProject = null;
|
|
158
|
+
let currentTab = "notes";
|
|
159
|
+
let currentDoc = null;
|
|
160
|
+
|
|
161
|
+
async function api(method, url, body) {
|
|
162
|
+
const res = await fetch(url, {
|
|
163
|
+
method,
|
|
164
|
+
headers: { authorization: "Bearer " + KEY, "content-type": "application/json" },
|
|
165
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
166
|
+
});
|
|
167
|
+
if (res.status === 401) { showGate("wrong key"); throw new Error("unauthorized"); }
|
|
168
|
+
const data = await res.json().catch(() => ({}));
|
|
169
|
+
if (!res.ok) throw new Error(data.error || res.status);
|
|
170
|
+
return data;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function showGate(msg) {
|
|
174
|
+
$("#app").style.display = "none";
|
|
175
|
+
$("#gate").style.display = "flex";
|
|
176
|
+
if (msg) $("#gateErr").textContent = msg;
|
|
177
|
+
}
|
|
178
|
+
function unlock() {
|
|
179
|
+
KEY = $("#keyInput").value.trim();
|
|
180
|
+
localStorage.setItem("mindroot_key", KEY);
|
|
181
|
+
boot();
|
|
182
|
+
}
|
|
183
|
+
function logout() {
|
|
184
|
+
localStorage.removeItem("mindroot_key");
|
|
185
|
+
location.reload();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function boot() {
|
|
189
|
+
try {
|
|
190
|
+
await api("GET", "/api/projects");
|
|
191
|
+
$("#gate").style.display = "none";
|
|
192
|
+
$("#app").style.display = "flex";
|
|
193
|
+
await renderProjects();
|
|
194
|
+
if (currentProject) selectProject(currentProject);
|
|
195
|
+
} catch (e) {
|
|
196
|
+
if (e.message !== "unauthorized") showGate(String(e));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function renderProjects() {
|
|
201
|
+
const rows = await api("GET", "/api/projects");
|
|
202
|
+
const el = $("#projList");
|
|
203
|
+
el.innerHTML = "";
|
|
204
|
+
for (const r of rows) {
|
|
205
|
+
const div = document.createElement("div");
|
|
206
|
+
div.className = "proj" + (r.slug === currentProject ? " active" : "");
|
|
207
|
+
div.textContent = r.slug;
|
|
208
|
+
div.title = r.docs + " notes, " + r.memories + " memories";
|
|
209
|
+
div.onclick = () => selectProject(r.slug);
|
|
210
|
+
el.appendChild(div);
|
|
211
|
+
}
|
|
212
|
+
return rows;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function selectProject(slug) {
|
|
216
|
+
currentProject = slug;
|
|
217
|
+
currentDoc = null;
|
|
218
|
+
document.querySelectorAll(".proj").forEach((n) => n.classList.toggle("active", n.textContent === slug));
|
|
219
|
+
renderTabs();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function renderTabs() {
|
|
223
|
+
const c = $("#content");
|
|
224
|
+
c.innerHTML = "";
|
|
225
|
+
const head = document.createElement("div");
|
|
226
|
+
head.className = "workspace-head";
|
|
227
|
+
const tabs = document.createElement("div");
|
|
228
|
+
tabs.className = "tabs";
|
|
229
|
+
for (const [name, label] of [["notes","Notes"],["memories","Memories"],["search","Search"]]) {
|
|
230
|
+
const t = document.createElement("div");
|
|
231
|
+
t.className = "tab" + (currentTab === name ? " active" : "");
|
|
232
|
+
t.textContent = label;
|
|
233
|
+
t.onclick = () => { currentTab = name; currentDoc = null; renderTabs(); };
|
|
234
|
+
tabs.appendChild(t);
|
|
235
|
+
}
|
|
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
|
+
const view = document.createElement("div");
|
|
243
|
+
view.className = "view";
|
|
244
|
+
c.appendChild(view);
|
|
245
|
+
({notes:renderNotes, memories:renderMemories, search:renderSearch}[currentTab])(view);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function deleteCurrentProject() {
|
|
249
|
+
const slug = currentProject;
|
|
250
|
+
if (!slug || !confirm('Delete project "' + slug + '"?\n\nAll notes, memories, and search data for this project will be permanently removed.')) return;
|
|
251
|
+
try {
|
|
252
|
+
await api("DELETE", "/api/projects/" + encodeURIComponent(slug));
|
|
253
|
+
currentProject = null;
|
|
254
|
+
currentDoc = null;
|
|
255
|
+
const rows = await renderProjects();
|
|
256
|
+
if (rows.length) selectProject(rows[0].slug);
|
|
257
|
+
else $("#content").innerHTML = '<div class="empty">No projects yet</div>';
|
|
258
|
+
toast('project "' + slug + '" deleted');
|
|
259
|
+
} catch (e) {
|
|
260
|
+
toast("delete failed: " + e.message, true);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function renderNotes(view) {
|
|
265
|
+
const docs = await api("GET", "/api/projects/" + currentProject + "/docs");
|
|
266
|
+
if (currentDoc !== null) return renderEditor(view, docs);
|
|
267
|
+
for (const d of docs) {
|
|
268
|
+
const row = document.createElement("div");
|
|
269
|
+
row.className = "docrow";
|
|
270
|
+
row.innerHTML = '<div class="doc-head"><div class="t"></div><div class="p">' + d.sections.length + ' sections</div></div>';
|
|
271
|
+
row.querySelector(".t").textContent = d.title + " ";
|
|
272
|
+
const chips = d.sections.filter(Boolean).map((s) => '<span class="sec-chip">' + esc(s) + "</span>").join("");
|
|
273
|
+
row.insertAdjacentHTML("beforeend", '<div>' + chips + "</div>");
|
|
274
|
+
row.onclick = () => { currentDoc = d.path; renderTabs(); };
|
|
275
|
+
view.appendChild(row);
|
|
276
|
+
}
|
|
277
|
+
if (!docs.length) view.innerHTML = '<div class="empty">No notes yet. Agents create them with <b>mindroot_save_note</b>.</div>';
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
let mdMode = localStorage.getItem("mindroot_mdmode") || "split";
|
|
281
|
+
|
|
282
|
+
function renderMd(text) {
|
|
283
|
+
if (window.marked && window.DOMPurify) {
|
|
284
|
+
marked.setOptions({ gfm: true, breaks: true });
|
|
285
|
+
return DOMPurify.sanitize(marked.parse(text));
|
|
286
|
+
}
|
|
287
|
+
return '<p class="muted">markdown renderer unavailable (offline)</p><pre>' + esc(text) + "</pre>";
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function renderEditor(view, docs) {
|
|
291
|
+
view.className = "view editor-view";
|
|
292
|
+
const doc = await api("GET", "/api/projects/" + currentProject + "/doc?path=" + encodeURIComponent(currentDoc));
|
|
293
|
+
const head = document.createElement("div");
|
|
294
|
+
head.className = "editor-head";
|
|
295
|
+
head.innerHTML =
|
|
296
|
+
'<div class="editorbar"><div class="editor-identity">' +
|
|
297
|
+
'<button class="back-button" id="backBtn" title="Back to notes" aria-label="Back to notes">' +
|
|
298
|
+
'<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>' +
|
|
299
|
+
'<div class="editor-document"><span class="editor-title"></span></div></div>' +
|
|
300
|
+
'<span class="viewmode">' +
|
|
301
|
+
[["edit","Write"],["split","Split"],["preview","Read"]].map(([mode, label]) =>
|
|
302
|
+
'<button data-m="' + mode + '" aria-pressed="' + (mdMode === mode) + '" class="' + (mdMode === mode ? "on" : "") + '">' + label + "</button>").join("") +
|
|
303
|
+
'</span>' +
|
|
304
|
+
'<div class="editor-actions"><button class="primary" id="saveBtn">Save</button>' +
|
|
305
|
+
'<button class="danger" id="delBtn">Delete note</button></div></div>';
|
|
306
|
+
view.appendChild(head);
|
|
307
|
+
head.querySelector(".editor-title").textContent = currentDoc;
|
|
308
|
+
$("#backBtn").onclick = () => { currentDoc = null; renderTabs(); };
|
|
309
|
+
head.querySelectorAll(".viewmode button").forEach((b) =>
|
|
310
|
+
b.onclick = () => { mdMode = b.dataset.m; localStorage.setItem("mindroot_mdmode", mdMode); renderTabs(); });
|
|
311
|
+
|
|
312
|
+
const wrap = document.createElement("div");
|
|
313
|
+
wrap.className = "split " + mdMode;
|
|
314
|
+
const ta = document.createElement("textarea");
|
|
315
|
+
ta.className = "editor";
|
|
316
|
+
ta.value = doc.content;
|
|
317
|
+
ta.spellcheck = false;
|
|
318
|
+
let prev = null;
|
|
319
|
+
if (mdMode !== "edit") {
|
|
320
|
+
prev = document.createElement("div");
|
|
321
|
+
prev.className = "md";
|
|
322
|
+
}
|
|
323
|
+
const paint = () => { if (prev) prev.innerHTML = renderMd(ta.value); };
|
|
324
|
+
let t;
|
|
325
|
+
ta.oninput = () => { clearTimeout(t); t = setTimeout(paint, 150); };
|
|
326
|
+
if (prev) {
|
|
327
|
+
paint();
|
|
328
|
+
if (mdMode === "split") wrap.append(ta, prev);
|
|
329
|
+
else wrap.appendChild(prev);
|
|
330
|
+
} else wrap.appendChild(ta);
|
|
331
|
+
view.appendChild(wrap);
|
|
332
|
+
$("#saveBtn").onclick = async () => {
|
|
333
|
+
try {
|
|
334
|
+
await api("PUT", "/api/projects/" + currentProject + "/doc?path=" + encodeURIComponent(currentDoc), { content: ta.value });
|
|
335
|
+
toast("saved & reindexed");
|
|
336
|
+
} catch (e) { toast("save failed: " + e.message, true); }
|
|
337
|
+
};
|
|
338
|
+
$("#delBtn").onclick = async () => {
|
|
339
|
+
if (!confirm("delete " + currentDoc + "?")) return;
|
|
340
|
+
await api("DELETE", "/api/projects/" + currentProject + "/doc?path=" + encodeURIComponent(currentDoc));
|
|
341
|
+
currentDoc = null;
|
|
342
|
+
renderTabs();
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function renderMemories(view) {
|
|
347
|
+
const form = document.createElement("form");
|
|
348
|
+
form.className = "row";
|
|
349
|
+
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>';
|
|
350
|
+
form.onsubmit = async (e) => {
|
|
351
|
+
e.preventDefault();
|
|
352
|
+
try {
|
|
353
|
+
await api("POST", "/api/projects/" + currentProject + "/memories", {
|
|
354
|
+
text: $("#rtxt").value, target_path: $("#rlink").value || undefined,
|
|
355
|
+
});
|
|
356
|
+
renderTabs();
|
|
357
|
+
} catch (e2) { toast(e2.message, true); }
|
|
358
|
+
};
|
|
359
|
+
view.appendChild(form);
|
|
360
|
+
const rows = await api("GET", "/api/projects/" + currentProject + "/memories");
|
|
361
|
+
for (const r of rows) {
|
|
362
|
+
const div = document.createElement("div");
|
|
363
|
+
div.className = "rem";
|
|
364
|
+
const link = r.target_path ? '<a href="#" data-p="' + esc(r.target_path.split("::")[0]) + '">' + esc(r.target_path) + "</a>" : "";
|
|
365
|
+
div.innerHTML = '<div class="txt"><div class="txt-main"></div>' +
|
|
366
|
+
(link ? "<div>" + link + "</div>" : "") +
|
|
367
|
+
'<div class="meta">#' + r.id + " · " + r.target_type + " · " + new Date(r.updated_at).toISOString().slice(0,16).replace("T"," ") + "</div></div>";
|
|
368
|
+
div.querySelector(".txt-main").textContent = r.text;
|
|
369
|
+
const del = document.createElement("button");
|
|
370
|
+
del.className = "danger"; del.textContent = "Delete";
|
|
371
|
+
del.onclick = async () => { await api("DELETE", "/api/projects/" + currentProject + "/memories/" + r.id); renderTabs(); };
|
|
372
|
+
div.appendChild(del);
|
|
373
|
+
view.appendChild(div);
|
|
374
|
+
}
|
|
375
|
+
if (!rows.length) view.insertAdjacentHTML("beforeend", '<div class="empty">No memories yet</div>');
|
|
376
|
+
view.querySelectorAll("a[data-p]").forEach((a) =>
|
|
377
|
+
a.onclick = (e) => { e.preventDefault(); currentTab = "notes"; currentDoc = a.dataset.p; renderTabs(); });
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function renderSearch(view) {
|
|
381
|
+
const form = document.createElement("form");
|
|
382
|
+
form.className = "row single";
|
|
383
|
+
form.innerHTML = '<input type="text" placeholder="Search notes and memories" id="sq"><button class="primary">Search</button>';
|
|
384
|
+
const out = document.createElement("div");
|
|
385
|
+
out.innerHTML = '<div class="empty">Search this project\'s notes and memories</div>';
|
|
386
|
+
form.onsubmit = async (e) => {
|
|
387
|
+
e.preventDefault();
|
|
388
|
+
out.innerHTML = "";
|
|
389
|
+
const hits = await api("POST", "/api/search", { project: currentProject, query: $("#sq").value, limit: 15 });
|
|
390
|
+
if (!hits.length) out.innerHTML = '<div class="empty">No results</div>';
|
|
391
|
+
for (const h of hits) {
|
|
392
|
+
const div = document.createElement("div");
|
|
393
|
+
div.className = "hit";
|
|
394
|
+
div.innerHTML = '<div class="kind">' + h.kind + " · " + Number(h.score).toFixed(3) + '</div><div class="txt-main"></div><div class="tgt"></div>';
|
|
395
|
+
div.querySelector(".txt-main").textContent = h.text || "";
|
|
396
|
+
div.querySelector(".tgt").textContent = (h.doc_title ? '"' + h.doc_title + '" ' : "") + (h.target || "");
|
|
397
|
+
out.appendChild(div);
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
view.appendChild(form);
|
|
401
|
+
view.appendChild(out);
|
|
402
|
+
$("#sq").focus();
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function toast(msg, isErr) {
|
|
406
|
+
let t = $("#toast");
|
|
407
|
+
if (!t) { t = document.createElement("div"); t.id = "toast";
|
|
408
|
+
t.style.cssText = "position:fixed;bottom:20px;right:20px;background:var(--bg3);border:1px solid var(--border);padding:8px 14px;border-radius:8px";
|
|
409
|
+
document.body.appendChild(t); }
|
|
410
|
+
t.textContent = msg;
|
|
411
|
+
t.style.color = isErr ? "var(--danger)" : "var(--ok)";
|
|
412
|
+
setTimeout(() => t.remove(), 2500);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function esc(s) { return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/"/g,"""); }
|
|
416
|
+
|
|
417
|
+
if (KEY) boot(); else showGate("");
|
|
418
|
+
</script>
|
|
419
|
+
</body>
|
|
420
|
+
</html>
|
package/src/embedder.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { pipeline, env } from "@huggingface/transformers";
|
|
2
|
+
import { MODELS_DIR, ONNX_MODEL_ID } from "./paths.js";
|
|
3
|
+
|
|
4
|
+
let extractorPromise;
|
|
5
|
+
|
|
6
|
+
export function getEmbedder() {
|
|
7
|
+
if (!extractorPromise) {
|
|
8
|
+
env.cacheDir = MODELS_DIR;
|
|
9
|
+
env.allowLocalModels = false;
|
|
10
|
+
extractorPromise = pipeline("feature-extraction", ONNX_MODEL_ID, {
|
|
11
|
+
dtype: "q8",
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
return extractorPromise;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const QUERY_PREFIX = "task: search result | query: ";
|
|
18
|
+
const DOC_PREFIX = "title: none | text: ";
|
|
19
|
+
|
|
20
|
+
async function embed(text) {
|
|
21
|
+
const extractor = await getEmbedder();
|
|
22
|
+
const out = await extractor(text, { pooling: "mean", normalize: true });
|
|
23
|
+
return Float32Array.from(out.data);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function embedQuery(text) {
|
|
27
|
+
return embed(QUERY_PREFIX + text);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function embedDoc(text) {
|
|
31
|
+
return embed(DOC_PREFIX + text.slice(0, 4000));
|
|
32
|
+
}
|
package/src/indexer.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import { NOTES_DIR, ONNX_MODEL_ID } from "./paths.js";
|
|
5
|
+
import { parseMarkdown, docTitle } from "./markdown.js";
|
|
6
|
+
import { embedDoc } from "./embedder.js";
|
|
7
|
+
|
|
8
|
+
export const sha256 = (s) => crypto.createHash("sha256").update(s).digest("hex");
|
|
9
|
+
|
|
10
|
+
export function validSlug(slug) {
|
|
11
|
+
return typeof slug === "string" && /^[\w][\w.-]{0,63}$/.test(slug);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function projectDir(slug) {
|
|
15
|
+
return path.join(NOTES_DIR, slug);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function safeJoin(slug, relPath) {
|
|
19
|
+
if (typeof relPath !== "string" || !relPath.endsWith(".md")) {
|
|
20
|
+
throw httpError(400, "note path must end with .md");
|
|
21
|
+
}
|
|
22
|
+
const base = path.resolve(projectDir(slug));
|
|
23
|
+
const p = path.resolve(base, relPath);
|
|
24
|
+
if (p !== base && !p.startsWith(base + path.sep)) {
|
|
25
|
+
throw httpError(400, "invalid note path");
|
|
26
|
+
}
|
|
27
|
+
return p;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function httpError(status, message) {
|
|
31
|
+
return Object.assign(new Error(message), { status });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function walkMd(dir) {
|
|
35
|
+
const out = [];
|
|
36
|
+
let entries;
|
|
37
|
+
try {
|
|
38
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
39
|
+
} catch {
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
for (const e of entries) {
|
|
43
|
+
const p = path.join(dir, e.name);
|
|
44
|
+
if (e.isDirectory()) out.push(...(await walkMd(p)));
|
|
45
|
+
else if (e.isFile() && e.name.endsWith(".md")) out.push(path.relative(dir, p));
|
|
46
|
+
}
|
|
47
|
+
return out.sort();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function embedTextFor(title, headingPath, content) {
|
|
51
|
+
return `title: ${title} | text: ${headingPath ? headingPath + "\n" : ""}${content}`.slice(0, 4000);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function upsertSectionEmbedding(db, docId, headingPath, content, title) {
|
|
55
|
+
const now = Date.now();
|
|
56
|
+
db.prepare(
|
|
57
|
+
`INSERT INTO sections (doc_id, heading_path, content, updated_at)
|
|
58
|
+
VALUES (?, ?, ?, ?)
|
|
59
|
+
ON CONFLICT(doc_id, heading_path) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at`,
|
|
60
|
+
).run(docId, headingPath, content, now);
|
|
61
|
+
const row = db
|
|
62
|
+
.prepare("SELECT id FROM sections WHERE doc_id = ? AND heading_path = ?")
|
|
63
|
+
.get(docId, headingPath);
|
|
64
|
+
db.prepare("DELETE FROM embeddings WHERE owner_type = 'section' AND owner_id = ?").run(row.id);
|
|
65
|
+
const vec = await embedDoc(embedTextFor(title, headingPath, content));
|
|
66
|
+
db.prepare(
|
|
67
|
+
"INSERT INTO embeddings (owner_type, owner_id, dim, model, vec) VALUES ('section', ?, ?, ?, ?)",
|
|
68
|
+
).run(row.id, vec.length, ONNX_MODEL_ID, Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function reindexDoc(db, slug, relPath, content, mtime) {
|
|
72
|
+
ensureProject(db, slug);
|
|
73
|
+
const project = getProjectRow(db, slug);
|
|
74
|
+
let doc = db
|
|
75
|
+
.prepare("SELECT * FROM docs WHERE project_id = ? AND rel_path = ?")
|
|
76
|
+
.get(project.id, relPath);
|
|
77
|
+
if (!doc) {
|
|
78
|
+
const res = db
|
|
79
|
+
.prepare(
|
|
80
|
+
"INSERT INTO docs (project_id, rel_path, title, content_hash, mtime, indexed_at) VALUES (?, ?, '', NULL, ?, ?)",
|
|
81
|
+
)
|
|
82
|
+
.run(project.id, relPath, mtime, Date.now());
|
|
83
|
+
doc = { id: Number(res.lastInsertRowid) };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const parsed = parseMarkdown(content);
|
|
87
|
+
const title = docTitle(parsed, path.basename(relPath));
|
|
88
|
+
const hash = sha256(content);
|
|
89
|
+
db.prepare("UPDATE docs SET title = ?, content_hash = ?, mtime = ?, indexed_at = ? WHERE id = ?").run(
|
|
90
|
+
title,
|
|
91
|
+
hash,
|
|
92
|
+
mtime,
|
|
93
|
+
Date.now(),
|
|
94
|
+
doc.id,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const existing = new Map(
|
|
98
|
+
db.prepare("SELECT id, heading_path, content FROM sections WHERE doc_id = ?").all(doc.id).map((r) => [r.heading_path, r]),
|
|
99
|
+
);
|
|
100
|
+
for (const sec of parsed.sections) {
|
|
101
|
+
if (!sec.body.trim()) continue;
|
|
102
|
+
const prev = existing.get(sec.path);
|
|
103
|
+
existing.delete(sec.path);
|
|
104
|
+
if (prev && prev.content === sec.body) continue;
|
|
105
|
+
await upsertSectionEmbedding(db, doc.id, sec.path, sec.body, title);
|
|
106
|
+
}
|
|
107
|
+
for (const stale of existing.values()) {
|
|
108
|
+
db.prepare("DELETE FROM embeddings WHERE owner_type = 'section' AND owner_id = ?").run(stale.id);
|
|
109
|
+
db.prepare("DELETE FROM sections WHERE id = ?").run(stale.id);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function syncProject(db, slug) {
|
|
114
|
+
const dir = projectDir(slug);
|
|
115
|
+
const files = await walkMd(dir);
|
|
116
|
+
for (const rel of files) {
|
|
117
|
+
const full = path.join(dir, rel);
|
|
118
|
+
const stat = await fs.stat(full);
|
|
119
|
+
const content = await fs.readFile(full, "utf8");
|
|
120
|
+
await reindexDoc(db, slug, rel, content, Math.floor(stat.mtimeMs));
|
|
121
|
+
}
|
|
122
|
+
const project = getProjectRow(db, slug);
|
|
123
|
+
const known = new Set(files.map((f) => path.normalize(f)));
|
|
124
|
+
const rows = db.prepare("SELECT id, rel_path FROM docs WHERE project_id = ?").all(project.id);
|
|
125
|
+
for (const row of rows) {
|
|
126
|
+
if (!known.has(path.normalize(row.rel_path))) {
|
|
127
|
+
await removeDocRows(db, row.id);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function removeDocRows(db, docId) {
|
|
133
|
+
const ids = db.prepare("SELECT id FROM sections WHERE doc_id = ?").all(docId).map((r) => r.id);
|
|
134
|
+
for (const sid of ids) {
|
|
135
|
+
db.prepare("DELETE FROM embeddings WHERE owner_type = 'section' AND owner_id = ?").run(sid);
|
|
136
|
+
}
|
|
137
|
+
db.prepare("DELETE FROM docs WHERE id = ?").run(docId);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function verifyAndLoadDoc(db, slug, relPath) {
|
|
141
|
+
const project = getProjectRow(db, slug);
|
|
142
|
+
const full = safeJoin(slug, relPath);
|
|
143
|
+
let content;
|
|
144
|
+
try {
|
|
145
|
+
content = await fs.readFile(full, "utf8");
|
|
146
|
+
} catch {
|
|
147
|
+
const doc = db
|
|
148
|
+
.prepare("SELECT id FROM docs WHERE project_id = ? AND rel_path = ?")
|
|
149
|
+
.get(project.id, relPath);
|
|
150
|
+
if (doc) await removeDocRows(db, doc.id);
|
|
151
|
+
throw httpError(404, `note not found: ${relPath}`);
|
|
152
|
+
}
|
|
153
|
+
const stat = await fs.stat(full);
|
|
154
|
+
const hash = sha256(content);
|
|
155
|
+
const doc = db
|
|
156
|
+
.prepare("SELECT * FROM docs WHERE project_id = ? AND rel_path = ?")
|
|
157
|
+
.get(project.id, relPath);
|
|
158
|
+
if (!doc || doc.content_hash !== hash || !doc.content_hash) {
|
|
159
|
+
await reindexDoc(db, slug, relPath, content, Math.floor(stat.mtimeMs));
|
|
160
|
+
}
|
|
161
|
+
return { content, mtime: Math.floor(stat.mtimeMs), hash };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function getProjectRow(db, slug) {
|
|
165
|
+
if (!validSlug(slug)) throw httpError(400, `invalid project slug: ${slug}`);
|
|
166
|
+
const row = db.prepare("SELECT * FROM projects WHERE slug = ?").get(slug);
|
|
167
|
+
if (!row) throw httpError(404, `unknown project: ${slug}`);
|
|
168
|
+
return row;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function ensureProject(db, slug) {
|
|
172
|
+
if (!validSlug(slug)) throw httpError(400, `invalid project slug: ${slug}`);
|
|
173
|
+
db.prepare("INSERT OR IGNORE INTO projects (slug, created_at) VALUES (?, ?)").run(slug, Date.now());
|
|
174
|
+
return getProjectRow(db, slug);
|
|
175
|
+
}
|