@sksoftofficial/mindroot 1.0.2 → 1.0.4

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 CHANGED
@@ -1,26 +1,60 @@
1
1
  # Mindroot
2
2
 
3
- Persistent two-layer memory system for AI agents. Embedding-searched **memories** over human-editable markdown **notes**, exposed through a local HTTP service with an MCP interface.
3
+ AI agents that never forget.
4
4
 
5
5
  ![Mindroot persistent AI agent memory system](https://skbilisim.com/assets/projects/mindroot.webp)
6
6
 
7
- - **Notes** (Layer 2): human-editable markdown files under `~/.mindroot/notes/<project>/`, parsed into heading-addressable sections. The unit of storage.
8
- - **Memories** (Layer 1): short, retrieval-optimized texts linked (or standalone) that point into notes. Embedded and full-text indexed. The unit of retrieval.
9
- - **Search**: hybrid semantic (embeddinggemma-300m ONNX) + keyword (SQLite FTS5/bm25), strictly project-scoped. Fuzzy project-name matching included (`search_projects`).
7
+ Mindroot is a tiny Node.js service that gives your AI agents a permanent, project-scoped memory: hybrid-searched **memories** over human-editable markdown **notes**, exposed through a local HTTP service with an MCP interface, a REST API, and a built-in dashboard. Everything runs on your machine — offline embeddings, SQLite, plain markdown files you own. No cloud, no API keys, no per-query cost.
10
8
 
11
9
  - Website: https://skbilisim.com/en/projects/mindroot
12
10
  - npm: https://www.npmjs.com/package/@sksoftofficial/mindroot
13
11
 
12
+ ## How it works
13
+
14
+ - **Notes** (Layer 2): human-editable markdown files under `~/.mindroot/notes/<project>/`, parsed into heading-addressable sections such as `Overview::API`. The unit of storage.
15
+ - **Memories** (Layer 1): short, retrieval-optimized texts that stand alone or point into a note or section via `target_path`. The unit of retrieval.
16
+ - **Search**: memories are the semantic index — hybrid ranking of cosine similarity (0.75) over local embeddings plus BM25 keyword search (0.25) through SQLite FTS5. Note sections use literal case-insensitive string matching. All content search is strictly project-scoped; project discovery uses its own fuzzy slug search with slug embeddings and token overlap.
17
+
18
+ ## Features
19
+
20
+ - **Two-layer memory** — markdown notes for depth, short memories for recall, linked via heading paths.
21
+ - **Local embeddings** — 768-dimensional q8 embeddings from `onnx-community/embeddinggemma-300m-ONNX` via transformers.js, computed fully offline and cached under `~/.mindroot/models`.
22
+ - **Hybrid search** — semantic + BM25 ranking over memories, literal string matching over note sections, fuzzy project-name matching across projects.
23
+ - **MCP server** — JSON-RPC endpoint at `/mcp` with 12 project/memory/note tools for agents.
24
+ - **REST API** — the same operations over `POST/GET/PUT/DELETE /api/*` for scripts and integrations.
25
+ - **Dashboard** — web UI at `/` for browsing notes and memories, editing markdown, and searching.
26
+ - **CLI** — init, start/stop/restart/status, and human-readable browsing commands.
27
+ - **Background service** — `mindroot start` daemonizes, writes a pidfile, and waits until healthy; `stop` and `restart` manage it.
28
+ - **Self-healing reads** — note reads verify a stored content hash and silently re-index files edited externally.
29
+ - **Surgical edits** — `update_section` splices only the targeted heading's line range, leaving the rest of the file byte-identical.
30
+ - **Link cleanup** — deleting a note clears memories linked to its exact path or one of its heading paths.
31
+ - **Auto-created projects** — any write to a new project slug creates it; no cwd detection, no disk scanning, no cross-project content search.
32
+ - **Local-first storage** — one SQLite database (`node:sqlite`, WAL mode, FTS5) plus plain markdown files you own.
33
+ - **Security** — constant-time Bearer API-key auth for every `/api` and `/mcp` request, server-side path-traversal and slug validation, localhost-only by default.
34
+
14
35
  ## Install
15
36
 
16
37
  Requires Node.js >= 22.
17
38
 
18
39
  ```sh
19
40
  npm install -g @sksoftofficial/mindroot # or: npm link from a clone
20
- mindroot init # creates store, caches the embedding model (~316MB)
41
+ mindroot init # creates store, schema, api key; caches the embedding model (~316MB)
21
42
  mindroot start # daemonizes and waits until healthy
22
43
  ```
23
44
 
45
+ ## Service management
46
+
47
+ ```sh
48
+ mindroot start # start in the background, wait for /health
49
+ mindroot start --foreground # run attached for debugging
50
+ mindroot stop # stop the pidfile process (escalates when needed)
51
+ mindroot restart # stop + start
52
+ mindroot status # pidfile, health endpoint, and key consistency check
53
+ mindroot dashboard # open the dashboard in your browser
54
+ ```
55
+
56
+ The default address is `127.0.0.1:7620`, configurable in `~/.mindroot/config.json`. There is no crash auto-restart.
57
+
24
58
  ## Quick start (MCP)
25
59
 
26
60
  Point your MCP client at the service:
@@ -37,7 +71,7 @@ Point your MCP client at the service:
37
71
  }
38
72
  ```
39
73
 
40
- The API key is generated by `mindroot init` and stored in `~/.mindroot/config.json` (never printed). Dashboard lives at `http://127.0.0.1:7620/`.
74
+ The API key is generated by `mindroot init` and stored in `~/.mindroot/config.json` (never printed). Tool failures return `isError: true` content instead of crashing the call.
41
75
 
42
76
  ## Agent memory policy
43
77
 
@@ -49,15 +83,15 @@ Add this policy to your agent's instructions:
49
83
 
50
84
  Use mindroot MCP tools as persistent memory for durable project facts. It's a project index, not a scratchpad.
51
85
 
52
- - Once per session, before relying on memory or exploring source: `list_projects` (or `search_projects` for fuzzy name matching), then `search_memories` / `read_note` on relevant subjects. Source files are ground truth; fall back to them when memory lacks the detail.
86
+ - EVERY task — not once per session; a single session can contain multiple unrelated tasks, so run this per task: resolve the project slug (`search_projects` with the repo name, or `list_projects`), `search_memories` with keywords from the task, then `read_note` the notes those memory hits link to. Always choose reading the facts from mindroot over exploring the codebase. Exploring source is the second option, ONLY when mindroot has no relevant notes or the notes don't match reality — then source is the truth (and correct the stale note).
53
87
  - Every content tool needs a project slug. A project is created automatically on first write.
54
88
 
55
89
  ## Notes vs memories
56
90
 
57
91
  Notes are storage; memories are the retrieval index into them. They work as a pair:
58
92
 
59
- - **Memories** (`save_memory`, `delete_memory`) — short, standalone atomic facts ("where email delivery lives", "never migrate during business hours"), optionally linked via `target_path` ("note.md::Heading"). No listing tool: `search_memories` is how you find them; hits carry the ids for `delete_memory`. When in doubt, save a memory.
60
- - **Notes** (`save_note`, `update_section`, `read_note`, `delete_note`) — structured markdown docs parsed by headings. `update_section` takes full heading paths (`Audit logging::API`); run `read_note` first and copy exact paths.
93
+ - **Memories** (`save_memory`, `delete_memory`) — short, standalone atomic facts ("where email delivery lives", "never migrate during business hours"), optionally linked via `target_path` ("note.md::Heading"). The semantic search layer: `search_memories` ranks hybrid (embeddings + keywords). No listing tool; hits carry the ids for `delete_memory`. When in doubt, save a memory.
94
+ - **Notes** (`save_note`, `update_section`, `read_note`, `delete_note`) — structured markdown docs parsed into heading-addressable sections. `read_note` without args returns full content plus the section list; with `section` ("Audit logging::API") returns only that section's body — prefer section reads when you don't need the whole note. `update_section` takes the same heading paths; run `read_note`/`list_notes` first and copy exact paths. `search_notes` is a literal case-insensitive string search over section text — a fallback for exact keywords/identifiers, not semantic search.
61
95
 
62
96
  After writing or updating a notable note section, also save 1–3 memories pointing at it (`target_path`) so future searches surface it — one per key fact a future agent would search for. If a memory stands alone (no note worth writing), that's fine too. Dedupe by searching first, then deleting stale hits — never stack near-copies.
63
97
 
@@ -77,12 +111,16 @@ After work that changes a durable fact, update the smallest relevant note sectio
77
111
 
78
112
  ## CLI
79
113
 
114
+ CLI output is human-readable; agents should use MCP tools.
115
+
80
116
  | Command | Description |
81
117
  |---|---|
82
118
  | `mindroot init` | Initialize store + model cache (idempotent) |
83
119
  | `mindroot start [--foreground]` | Daemonize the service (or run foreground for debugging) |
84
120
  | `mindroot stop` | Stop the running service |
121
+ | `mindroot restart` | Restart the service (stop + start) |
85
122
  | `mindroot status` | Show whether the service is running |
123
+ | `mindroot dashboard` | Open the dashboard in your browser |
86
124
  | `mindroot projects` | List projects with counts |
87
125
  | `mindroot notes --project <slug> [path]` | List notes, or print one |
88
126
  | `mindroot memories --project <slug>` | List memories |
@@ -95,17 +133,45 @@ All tools are prefixed `mindroot_`.
95
133
 
96
134
  | Tool | Params | Purpose |
97
135
  |---|---|---|
98
- | `list_projects` | — | Discover slugs |
99
- | `search_projects` | `query` | Fuzzy-find a project by name |
136
+ | `list_projects` | — | List projects with note/memory counts |
137
+ | `search_projects` | `query` | Fuzzy-find a project by name (semantic + token overlap) |
138
+ | `rename_project` | `project*`, `new_slug*` | Rename a project; moves notes and preserves memories and indexes |
100
139
  | `search_memories` | `project*`, `query`, `limit?` | Hybrid search over memories (hits carry ids) |
101
- | `search_notes` | `project*`, `query`, `limit?` | Search note sections |
140
+ | `search_notes` | `project*`, `query`, `limit?` | Literal string search over note sections (fallback to memories' semantic search) |
102
141
  | `save_memory` | `project*`, `text`, `target_path?` | Save a memory, optionally linked to a note/section |
103
142
  | `delete_memory` | `project*`, `id` | Delete a memory by id |
104
143
  | `list_notes` | `project*` | List notes with section paths |
105
- | `read_note` | `project*`, `path` | Full note content (hash-verified) |
144
+ | `read_note` | `project*`, `path`, `section?` | Full note content, or one section's body via `section` (hash-verified, auto-reindexed) |
106
145
  | `save_note` | `project*`, `path`, `content` | Create/overwrite a note |
107
- | `update_section` | `project*`, `path`, `heading_path`, `content` | Replace one section body |
108
- | `delete_note` | `project*`, `path` | Delete a note |
146
+ | `update_section` | `project*`, `path`, `heading_path`, `content` | Replace one section body, leave the rest untouched |
147
+ | `delete_note` | `project*`, `path` | Delete a note (clears its linked memories) |
148
+
149
+ ## REST API
150
+
151
+ Same operations as MCP, for scripts and integrations. All `/api/*` routes (and `/mcp`) require `Authorization: Bearer <apiKey>`; `/health` does not.
152
+
153
+ | Method | Path | Purpose |
154
+ |---|---|---|
155
+ | `GET` | `/health` | Liveness probe |
156
+ | `GET` | `/api/projects` | List projects |
157
+ | `POST` | `/api/projects` | Create project `{ slug }` |
158
+ | `GET` | `/api/projects/:slug` | Project detail with docs and memories |
159
+ | `DELETE` | `/api/projects/:slug` | Delete a project |
160
+ | `GET` | `/api/projects/:slug/docs` | List notes |
161
+ | `GET/PUT/DELETE` | `/api/projects/:slug/doc?path=` | Read (optionally `&section=`), write, or delete one note |
162
+ | `POST` | `/api/projects/:slug/sections` | Update one section `{ path, heading_path, content }` |
163
+ | `GET/POST` | `/api/projects/:slug/memories` | List or add memories `{ text, target_path? }` |
164
+ | `DELETE` | `/api/projects/:slug/memories/:id` | Delete a memory |
165
+ | `POST` | `/api/search` | Search `{ project, query, kind?: "memories"\|"notes", limit? }` |
166
+ | `POST` | `/mcp` | JSON-RPC MCP endpoint |
167
+
168
+ ## Dashboard
169
+
170
+ The dashboard at `http://127.0.0.1:7620/` stores its key in localStorage and offers:
171
+
172
+ - **Notes** view with a markdown editor in Write / Split / Read modes and rendered preview.
173
+ - **Memories** view for browsing and adding memories with optional note links.
174
+ - **Search** view across notes and memories.
109
175
 
110
176
  ## Configuration
111
177
 
@@ -127,4 +193,4 @@ node --test # runs the test suite in test/
127
193
 
128
194
  ## License
129
195
 
130
- MIT
196
+ ISC
package/bin/mindroot.js CHANGED
@@ -3,6 +3,7 @@
3
3
  import { init } from "../src/commands/init.js";
4
4
  import { start, stop, restart, status } from "../src/commands/start.js";
5
5
  import { projects, searchCmd, saveMemoryCmd, memoriesCmd, notesCmd } from "../src/commands/human.js";
6
+ import { dashboard } from "../src/commands/dashboard.js";
6
7
 
7
8
  const [command, ...rest] = process.argv.slice(2);
8
9
 
@@ -14,6 +15,7 @@ Usage:
14
15
  mindroot stop Stop the running service
15
16
  mindroot restart Restart the service (stop + start)
16
17
  mindroot status Show whether the service is running
18
+ mindroot dashboard Open the dashboard in your browser
17
19
  mindroot projects List projects
18
20
  mindroot notes --project <slug> [path] List notes (or show one)
19
21
  mindroot memories --project <slug> List memories
@@ -38,6 +40,8 @@ async function main() {
38
40
  return restart();
39
41
  case "status":
40
42
  return status();
43
+ case "dashboard":
44
+ return dashboard();
41
45
  case "projects":
42
46
  return projects();
43
47
  case "notes":
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sksoftofficial/mindroot",
3
- "version": "1.0.2",
4
- "description": "Persistent two-layer memory for AI agents: embedding-searched memories over human-editable markdown facts. Runs as a local background service.",
3
+ "version": "1.0.4",
4
+ "description": "Long-term memory for AI agents: hybrid-searched memories over human-editable markdown notes. 100% local — offline ONNX embeddings, SQLite FTS5, MCP server, REST API, CLI, dashboard. No cloud, no API keys.",
5
5
  "homepage": "https://skbilisim.com/en/projects/mindroot",
6
6
  "type": "module",
7
7
  "bin": {
@@ -18,10 +18,24 @@
18
18
  "keywords": [
19
19
  "ai",
20
20
  "agent",
21
+ "agents",
21
22
  "memory",
22
- "embeddings",
23
+ "long-term-memory",
24
+ "agent-memory",
23
25
  "mcp",
24
- "markdown"
26
+ "mcp-server",
27
+ "rag",
28
+ "embeddings",
29
+ "semantic-search",
30
+ "hybrid-search",
31
+ "markdown",
32
+ "notes",
33
+ "sqlite",
34
+ "local-first",
35
+ "offline",
36
+ "llm",
37
+ "context",
38
+ "knowledge-base"
25
39
  ],
26
40
  "author": "sksoftofficial",
27
41
  "license": "ISC",
@@ -0,0 +1,57 @@
1
+ import { spawn } from "node:child_process";
2
+ import { DEFAULT_PORT, ensureConfig } from "../paths.js";
3
+
4
+ const CANDIDATES = {
5
+ darwin: [
6
+ ["open", "-a", "Google Chrome"],
7
+ ["open", "-a", "Safari"],
8
+ ["open"],
9
+ ],
10
+ linux: [
11
+ ["google-chrome"],
12
+ ["google-chrome-stable"],
13
+ ["chromium"],
14
+ ["chromium-browser"],
15
+ ["firefox"],
16
+ ["xdg-open"],
17
+ ],
18
+ win32: [
19
+ ["cmd", "/c", "start", "chrome"],
20
+ ["cmd", "/c", "start", "msedge"],
21
+ ["cmd", "/c", "start"],
22
+ ],
23
+ };
24
+
25
+ function tryOpen(cmd, url) {
26
+ return new Promise((resolve) => {
27
+ const child = spawn(cmd[0], [...cmd.slice(1), url], { stdio: "ignore" });
28
+ child.on("error", () => resolve(false));
29
+ child.on("close", (code) => resolve(code === 0));
30
+ });
31
+ }
32
+
33
+ export async function dashboard() {
34
+ const { cfg } = ensureConfig();
35
+ const port = cfg.port ?? DEFAULT_PORT;
36
+ const url = `http://127.0.0.1:${port}/`;
37
+
38
+ let healthy = false;
39
+ try {
40
+ const res = await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(1500) });
41
+ healthy = res.ok;
42
+ } catch {}
43
+ if (!healthy) {
44
+ console.error("mindroot is not running — run `mindroot start` first");
45
+ process.exitCode = 1;
46
+ return;
47
+ }
48
+
49
+ for (const cmd of CANDIDATES[process.platform] ?? []) {
50
+ if (await tryOpen(cmd, url)) {
51
+ console.log(`opening dashboard: ${url}`);
52
+ return;
53
+ }
54
+ }
55
+ console.error(`could not open a browser — visit ${url} manually`);
56
+ process.exitCode = 1;
57
+ }
package/src/core.js CHANGED
@@ -72,7 +72,7 @@ export async function listDocs(db, slug) {
72
72
  return result;
73
73
  }
74
74
 
75
- export async function readNote(db, slug, relPath) {
75
+ export async function readNote(db, slug, relPath, section) {
76
76
  await verifyAndLoadDoc(db, slug, relPath);
77
77
  const project = getProjectRow(db, slug);
78
78
  const doc = db
@@ -80,6 +80,17 @@ export async function readNote(db, slug, relPath) {
80
80
  .get(project.id, relPath);
81
81
  const content = await fs.readFile(safeJoin(slug, relPath), "utf8");
82
82
  const parsed = parseMarkdown(content);
83
+ if (section !== undefined && section !== null) {
84
+ const sec = parsed.sections.find((s) => s.path === section);
85
+ if (!sec) throw httpError(404, `section not found: ${section}`);
86
+ return {
87
+ path: relPath,
88
+ title: doc.title,
89
+ section,
90
+ updated_at: doc.indexed_at,
91
+ content: sec.body,
92
+ };
93
+ }
83
94
  return {
84
95
  path: relPath,
85
96
  title: doc.title,
@@ -44,7 +44,16 @@
44
44
  #gate { margin:auto; text-align:center; display:flex; flex-direction:column; gap:14px; width:min(420px, calc(100% - 32px)); padding:28px; background:var(--bg2); border:1px solid var(--border); border-radius:10px; box-shadow:0 12px 40px rgba(0,0,0,.28) }
45
45
  #gate input { width:100% }
46
46
  main { display:flex; flex:1; overflow:hidden }
47
- aside { position:relative; z-index:1; width:230px; background:var(--bg2); border-right:1px solid var(--border-soft); overflow-y:auto; padding:10px 8px; box-shadow:8px 0 20px rgba(0,0,0,.12) }
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
+ .sidebar-toggle { flex:none; height:34px; padding:0; display:flex; align-items:center; justify-content:center }
49
+ .sidebar-toggle svg { width:17px; height:17px; stroke:currentColor; transition:transform 160ms ease-out }
50
+ @media (min-width:701px) {
51
+ aside.collapsed { width:56px; padding:10px 12px }
52
+ aside.collapsed .sidebar-toggle svg { transform:rotate(180deg) }
53
+ aside.collapsed .proj { padding:7px 0; text-align:center; font-size:0 }
54
+ aside.collapsed .proj::before { content:attr(data-initial); font-size:12px; line-height:inherit }
55
+ }
56
+ #projList { display:flex; flex-direction:column; gap:2px }
48
57
  .proj { padding:7px 10px; border-radius:5px; cursor:pointer; color:var(--dim) }
49
58
  .proj:hover { background:var(--hover); color:var(--fg) }
50
59
  .proj.active { background:var(--accent-soft); color:#f2b46f }
@@ -89,7 +98,7 @@
89
98
  .editor-document { min-width:0 }
90
99
  .editor-title { max-width:320px; color:var(--fg); font-size:13px; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
91
100
  .editor-actions { justify-self:end }
92
- .viewmode { display:inline-flex; gap:0; padding:2px; background:#191919; border:1px solid var(--border); border-radius:6px }
101
+ .viewmode { display:inline-flex; gap:2px; padding:2px; background:#191919; border:1px solid var(--border); border-radius:6px }
93
102
  .viewmode button { padding:4px 10px; background:transparent; border-color:transparent }
94
103
  .viewmode button:hover { background:var(--hover); border-color:transparent }
95
104
  .viewmode button.on { background:var(--accent-soft); color:#f2b46f; border-color:transparent }
@@ -141,12 +150,13 @@
141
150
  .header-lock-label { display:none }
142
151
  main { flex-direction:column }
143
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) }
144
- #projList { display:flex; gap:4px; overflow-x:auto }
153
+ #projList { flex-direction:row; gap:4px; overflow-x:auto }
145
154
  .proj { flex:none; white-space:nowrap }
146
155
  #content { padding:14px }
147
156
  .workspace-head { align-items:flex-start; flex-wrap:wrap; gap:8px; margin-bottom:12px; padding-bottom:10px }
148
157
  button, input { min-height:44px; touch-action:manipulation }
149
158
  .header-lock, .back-button { width:44px; height:44px }
159
+ .sidebar-toggle { display:none }
150
160
  .proj, .tab { min-height:44px; display:flex; align-items:center }
151
161
  .tab { padding:5px 8px }
152
162
  .delete-project { padding:5px 9px }
@@ -202,7 +212,14 @@
202
212
  </div>
203
213
 
204
214
  <main id="app" style="display:none">
205
- <aside><div id="projList"></div></aside>
215
+ <aside>
216
+ <button class="sidebar-toggle" type="button" title="Shrink menu" aria-label="Shrink menu" aria-expanded="true" onclick="toggleSidebar()">
217
+ <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
218
+ <path d="m14 7-5 5 5 5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
219
+ </svg>
220
+ </button>
221
+ <div id="projList"></div>
222
+ </aside>
206
223
  <section id="content"></section>
207
224
  </main>
208
225
 
@@ -246,14 +263,53 @@ async function boot() {
246
263
  $("#gate").style.display = "none";
247
264
  $("#app").style.display = "flex";
248
265
  const projects = await renderProjects();
249
- if (currentProject) selectProject(currentProject);
250
- else if (projects.length) selectProject(projects[0].slug);
251
- else $("#content").innerHTML = '<div class="empty">No projects yet</div>';
266
+ const r = routeFromHash();
267
+ 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
+ }
252
273
  } catch (e) {
253
274
  if (e.message !== "unauthorized") showGate(String(e));
254
275
  }
255
276
  }
256
277
 
278
+ function routeUrl(slug, tab, doc) {
279
+ return "#/" + encodeURIComponent(slug) + "/" + tab + (doc ? "/" + encodeURIComponent(doc) : "");
280
+ }
281
+
282
+ function routeFromHash() {
283
+ const [rawSlug, rawTab, rawDoc] = location.hash.replace(/^#\/?/, "").split("/");
284
+ return {
285
+ slug: rawSlug ? decodeURIComponent(rawSlug) : null,
286
+ tab: ["notes", "memories", "search"].includes(rawTab) ? rawTab : "notes",
287
+ doc: rawDoc ? decodeURIComponent(rawDoc) : null,
288
+ };
289
+ }
290
+
291
+ function navTo(hash) {
292
+ if (location.hash === hash) applyRoute(routeFromHash());
293
+ else location.hash = hash;
294
+ }
295
+
296
+ function applyRoute({ slug = null, tab = "notes", doc = null } = {}) {
297
+ currentProject = slug;
298
+ currentTab = tab;
299
+ 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);
306
+ }
307
+ document.querySelectorAll(".proj").forEach((n) => n.classList.toggle("active", n.textContent === slug));
308
+ renderTabs();
309
+ }
310
+
311
+ window.addEventListener("hashchange", () => applyRoute(routeFromHash()));
312
+
257
313
  async function renderProjects() {
258
314
  const rows = await api("GET", "/api/projects");
259
315
  const el = $("#projList");
@@ -261,6 +317,7 @@ async function renderProjects() {
261
317
  for (const r of rows) {
262
318
  const div = document.createElement("div");
263
319
  div.className = "proj" + (r.slug === currentProject ? " active" : "");
320
+ div.dataset.initial = r.slug.slice(0, 2).toUpperCase();
264
321
  div.textContent = r.slug;
265
322
  div.title = r.docs + " notes, " + r.memories + " memories";
266
323
  div.onclick = () => selectProject(r.slug);
@@ -270,15 +327,7 @@ async function renderProjects() {
270
327
  }
271
328
 
272
329
  function selectProject(slug) {
273
- currentProject = slug;
274
- currentDoc = null;
275
- $(".header-context").hidden = false;
276
- const projectLabel = $("#headerProject");
277
- projectLabel.textContent = slug;
278
- projectLabel.title = slug;
279
- projectLabel.setAttribute("aria-label", "Current project: " + slug);
280
- document.querySelectorAll(".proj").forEach((n) => n.classList.toggle("active", n.textContent === slug));
281
- renderTabs();
330
+ navTo(routeUrl(slug, "notes"));
282
331
  }
283
332
 
284
333
  function renderTabs() {
@@ -293,7 +342,7 @@ function renderTabs() {
293
342
  const t = document.createElement("div");
294
343
  t.className = "tab" + (currentTab === name ? " active" : "");
295
344
  t.textContent = label;
296
- t.onclick = () => { currentTab = name; currentDoc = null; renderTabs(); };
345
+ t.onclick = () => navTo(routeUrl(currentProject, name));
297
346
  tabs.appendChild(t);
298
347
  }
299
348
  const actions = document.createElement("div");
@@ -316,8 +365,9 @@ async function deleteCurrentProject() {
316
365
  await api("DELETE", "/api/projects/" + encodeURIComponent(slug));
317
366
  currentProject = null;
318
367
  currentDoc = null;
368
+ history.replaceState(null, "", location.pathname + location.search);
319
369
  const rows = await renderProjects();
320
- if (rows.length) selectProject(rows[0].slug);
370
+ if (rows.length) navTo(routeUrl(rows[0].slug, "notes"));
321
371
  else {
322
372
  const projectLabel = $("#headerProject");
323
373
  $(".header-context").hidden = true;
@@ -342,7 +392,7 @@ async function renderNotes(view) {
342
392
  row.querySelector(".t").textContent = d.title + " ";
343
393
  const chips = d.sections.filter(Boolean).map((s) => '<span class="sec-chip">' + esc(s) + "</span>").join("");
344
394
  row.insertAdjacentHTML("beforeend", '<div>' + chips + "</div>");
345
- row.onclick = () => { currentDoc = d.path; renderTabs(); };
395
+ row.onclick = () => navTo(routeUrl(currentProject, "notes", d.path));
346
396
  view.appendChild(row);
347
397
  }
348
398
  if (!docs.length) view.innerHTML = '<div class="empty">No notes yet. Agents create them with <b>mindroot_save_note</b>.</div>';
@@ -350,6 +400,24 @@ async function renderNotes(view) {
350
400
 
351
401
  let mdMode = localStorage.getItem("mindroot_mdmode") || "preview";
352
402
 
403
+ let sidebarCollapsed = localStorage.getItem("mindroot_sidebar") === "collapsed";
404
+ function toggleSidebar() { setSidebar(!sidebarCollapsed); }
405
+ function setSidebar(collapsed) {
406
+ sidebarCollapsed = collapsed;
407
+ localStorage.setItem("mindroot_sidebar", collapsed ? "collapsed" : "expanded");
408
+ applySidebar();
409
+ }
410
+ function applySidebar() {
411
+ const aside = document.querySelector("main aside");
412
+ const btn = $(".sidebar-toggle");
413
+ aside.classList.toggle("collapsed", sidebarCollapsed);
414
+ btn.setAttribute("aria-expanded", String(!sidebarCollapsed));
415
+ const label = sidebarCollapsed ? "Expand menu" : "Shrink menu";
416
+ btn.title = label;
417
+ btn.setAttribute("aria-label", label);
418
+ }
419
+ applySidebar();
420
+
353
421
  function renderMd(text) {
354
422
  if (window.marked && window.DOMPurify) {
355
423
  marked.setOptions({ gfm: true, breaks: true });
@@ -376,7 +444,7 @@ async function renderEditor(view, docs) {
376
444
  '<button class="danger" id="delBtn">Delete note</button></div></div>';
377
445
  view.appendChild(head);
378
446
  head.querySelector(".editor-title").textContent = currentDoc;
379
- $("#backBtn").onclick = () => { currentDoc = null; renderTabs(); };
447
+ $("#backBtn").onclick = () => history.back();
380
448
  head.querySelectorAll(".viewmode button").forEach((b) =>
381
449
  b.onclick = () => { mdMode = b.dataset.m; localStorage.setItem("mindroot_mdmode", mdMode); renderTabs(); });
382
450
 
@@ -413,8 +481,7 @@ async function renderEditor(view, docs) {
413
481
  $("#delBtn").onclick = async () => {
414
482
  if (!confirm("delete " + currentDoc + "?")) return;
415
483
  await api("DELETE", "/api/projects/" + currentProject + "/doc?path=" + encodeURIComponent(currentDoc));
416
- currentDoc = null;
417
- renderTabs();
484
+ navTo(routeUrl(currentProject, "notes"));
418
485
  };
419
486
  }
420
487
 
@@ -449,7 +516,7 @@ async function renderMemories(view) {
449
516
  }
450
517
  if (!rows.length) view.insertAdjacentHTML("beforeend", '<div class="empty">No memories yet</div>');
451
518
  view.querySelectorAll("a[data-p]").forEach((a) =>
452
- a.onclick = (e) => { e.preventDefault(); currentTab = "notes"; currentDoc = a.dataset.p; renderTabs(); });
519
+ a.onclick = (e) => { e.preventDefault(); navTo(routeUrl(currentProject, "notes", a.dataset.p)); });
453
520
  }
454
521
 
455
522
  async function renderSearch(view) {
package/src/indexer.js CHANGED
@@ -1,9 +1,8 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import crypto from "node:crypto";
4
- import { NOTES_DIR, ONNX_MODEL_ID } from "./paths.js";
4
+ import { NOTES_DIR } from "./paths.js";
5
5
  import { parseMarkdown, docTitle } from "./markdown.js";
6
- import { embedDoc } from "./embedder.js";
7
6
 
8
7
  export const sha256 = (s) => crypto.createHash("sha256").update(s).digest("hex");
9
8
 
@@ -47,25 +46,13 @@ async function walkMd(dir) {
47
46
  return out.sort();
48
47
  }
49
48
 
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) {
49
+ function upsertSection(db, docId, headingPath, content) {
55
50
  const now = Date.now();
56
51
  db.prepare(
57
52
  `INSERT INTO sections (doc_id, heading_path, content, updated_at)
58
53
  VALUES (?, ?, ?, ?)
59
54
  ON CONFLICT(doc_id, heading_path) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at`,
60
55
  ).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
56
  }
70
57
 
71
58
  export async function reindexDoc(db, slug, relPath, content, mtime) {
@@ -102,7 +89,7 @@ export async function reindexDoc(db, slug, relPath, content, mtime) {
102
89
  const prev = existing.get(sec.path);
103
90
  existing.delete(sec.path);
104
91
  if (prev && prev.content === sec.body) continue;
105
- await upsertSectionEmbedding(db, doc.id, sec.path, sec.body, title);
92
+ upsertSection(db, doc.id, sec.path, sec.body);
106
93
  }
107
94
  for (const stale of existing.values()) {
108
95
  db.prepare("DELETE FROM embeddings WHERE owner_type = 'section' AND owner_id = ?").run(stale.id);
package/src/mcp.js CHANGED
@@ -34,7 +34,7 @@ export function tools() {
34
34
  {
35
35
  name: "mindroot_search_memories",
36
36
  description:
37
- "Search a project's memories — short, retrieval-optimized notes about durable facts, conventions, decisions, and gotchas. Hybrid semantic + keyword ranking. Use before exploring a codebase or asking the user things that may already be remembered. Hits include ids usable with mindroot_delete_memory.",
37
+ "Search a project's memories — short, retrieval-optimized notes about durable facts, conventions, decisions, and gotchas. This is mindroot's primary semantic search layer (hybrid vector + keyword). Use before exploring a codebase or asking the user things that may already be remembered; mindroot_search_notes is only a literal string fallback. Hits include ids usable with mindroot_delete_memory.",
38
38
  inputSchema: {
39
39
  type: "object",
40
40
  properties: {
@@ -48,12 +48,12 @@ export function tools() {
48
48
  {
49
49
  name: "mindroot_search_notes",
50
50
  description:
51
- "Search a project's note sections — detailed markdown documentation parsed from headings. Hybrid semantic ranking. Returns the note path and section each hit comes from; follow up with mindroot_read_note for full content.",
51
+ "Literal case-insensitive string search over a project's note sections — no semantics. Memories are the semantic index: prefer mindroot_search_memories first and use this only as a fallback or to locate exact keywords/identifiers inside notes. Returns the note path and heading section per hit; fetch just that section with mindroot_read_note's section argument.",
52
52
  inputSchema: {
53
53
  type: "object",
54
54
  properties: {
55
55
  project: { type: "string", description: "Project slug to search in" },
56
- query: { type: "string", description: "Natural language search query" },
56
+ query: { type: "string", description: "Literal text to find (matched as whole words/terms)" },
57
57
  limit: { type: "number", description: "Max results (default 8)" },
58
58
  },
59
59
  required: ["project", "query"],
@@ -84,7 +84,8 @@ export function tools() {
84
84
  },
85
85
  {
86
86
  name: "mindroot_list_notes",
87
- description: "List all memory notes for a project with their titles and section paths.",
87
+ description:
88
+ "List all memory notes for a project with their titles and section heading paths. Use this to see a note's section layout, then read only the section you need via mindroot_read_note's section argument instead of fetching the whole note.",
88
89
  inputSchema: {
89
90
  type: "object",
90
91
  properties: { project: { type: "string" } },
@@ -94,17 +95,21 @@ export function tools() {
94
95
  {
95
96
  name: "mindroot_read_note",
96
97
  description:
97
- "Read the full content of a memory note. Verifies file integrity first and re-indexes automatically if the file was edited externally.",
98
+ "Read a memory note. Without `section`, returns full content plus the section list. With `section` (a heading path like 'Architecture::Storage' from mindroot_list_notes or mindroot_search_notes targets), returns only that section's body — prefer this when you don't need the whole note. Verifies file integrity first and re-indexes automatically if the file was edited externally.",
98
99
  inputSchema: {
99
100
  type: "object",
100
- properties: { project: { type: "string" }, path: { type: "string", description: "Note path relative to the project, e.g. 'architecture.md'" } },
101
+ properties: {
102
+ project: { type: "string" },
103
+ path: { type: "string", description: "Note path relative to the project, e.g. 'architecture.md'" },
104
+ section: { type: "string", description: "Optional heading path like 'Architecture::Storage' — return only that section" },
105
+ },
101
106
  required: ["project", "path"],
102
107
  },
103
108
  },
104
109
  {
105
110
  name: "mindroot_save_note",
106
111
  description:
107
- "Create or fully overwrite a markdown memory note. Sections are parsed from headings (# .. ######) and become individually searchable via mindroot_search_notes. After saving, consider saving 1-3 linked memories (mindroot_save_memory with target_path) so key facts surface in memory searches.",
112
+ "Create or fully overwrite a markdown memory note. Sections are parsed from headings (# .. ######) and are individually readable (mindroot_read_note `section`) and string-searchable (mindroot_search_notes); embeddings are generated for memories only. After saving, consider saving 1-3 linked memories (mindroot_save_memory with target_path) so key facts surface in memory searches.",
108
113
  inputSchema: {
109
114
  type: "object",
110
115
  properties: {
@@ -162,7 +167,7 @@ async function callTool(db, name, args) {
162
167
  case "mindroot_list_notes":
163
168
  return core.listDocs(db, args.project);
164
169
  case "mindroot_read_note":
165
- return core.readNote(db, args.project, args.path);
170
+ return core.readNote(db, args.project, args.path, args.section);
166
171
  case "mindroot_save_note":
167
172
  return core.saveNote(db, args.project, args.path, args.content);
168
173
  case "mindroot_update_section":
package/src/search.js CHANGED
@@ -151,39 +151,46 @@ function applyFts(db, projectId, query, byId) {
151
151
  }
152
152
  }
153
153
 
154
- export async function searchNotes(db, projectId, query, limit = 8) {
155
- return rankNotes(db, projectId, await embedQuery(query), limit);
154
+ export function searchNotes(db, projectId, query, limit = 8) {
155
+ return rankNotes(db, projectId, query, limit);
156
156
  }
157
157
 
158
- export function rankNotes(db, projectId, qvec, limit = 8) {
158
+ export function rankNotes(db, projectId, query, limit = 8) {
159
+ const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean).slice(0, 8);
160
+ if (!terms.length) return [];
159
161
  const rows = db
160
162
  .prepare(
161
- `SELECT e.owner_id, e.vec,
162
- s.content AS scontent, s.heading_path, s.updated_at AS sut,
163
+ `SELECT s.id, s.content, s.heading_path, s.updated_at,
163
164
  d.rel_path, d.title AS dtitle
164
- FROM embeddings e
165
- JOIN sections s ON s.id = e.owner_id
165
+ FROM sections s
166
166
  JOIN docs d ON d.id = s.doc_id
167
- WHERE e.owner_type = 'section' AND d.project_id = $pid`,
167
+ WHERE d.project_id = $pid`,
168
168
  )
169
169
  .all({ pid: projectId });
170
170
 
171
- const byId = new Map();
171
+ const hits = [];
172
172
  for (const row of rows) {
173
- const vec = toFloat32(row.vec);
174
- if (vec.length !== qvec.length) continue;
175
- byId.set(row.owner_id, {
173
+ const heading = row.heading_path.toLowerCase();
174
+ const content = (row.content ?? "").toLowerCase();
175
+ let matched = 0;
176
+ let headingMatched = false;
177
+ for (const term of terms) {
178
+ if (heading.includes(term)) headingMatched = true;
179
+ if (heading.includes(term) || content.includes(term)) matched++;
180
+ }
181
+ if (!matched) continue;
182
+ hits.push({
176
183
  kind: "note_section",
177
- id: row.owner_id,
178
- text: row.scontent?.slice(0, 300),
184
+ id: row.id,
185
+ text: row.content?.slice(0, 300),
179
186
  target: `${row.rel_path}::${row.heading_path}`,
180
187
  doc_title: row.dtitle,
181
- score: cosine(qvec, vec),
182
- updated_at: row.sut,
188
+ score: matched / terms.length + (headingMatched ? 0.25 : 0),
189
+ updated_at: row.updated_at,
183
190
  });
184
191
  }
185
-
186
- return rank(byId, limit);
192
+ hits.sort((a, b) => b.score - a.score || b.updated_at - a.updated_at);
193
+ return hits.slice(0, limit);
187
194
  }
188
195
 
189
196
  function rank(byId, limit) {
package/src/server.js CHANGED
@@ -111,7 +111,8 @@ async function handleApi(req, res, url, db) {
111
111
  if (parts[3] === "doc") {
112
112
  const relPath = url.searchParams.get("path");
113
113
  if (!relPath) throw badRequest("path query param required");
114
- if (req.method === "GET") return sendJson(res, 200, await core.readNote(db, slug, relPath));
114
+ if (req.method === "GET")
115
+ return sendJson(res, 200, await core.readNote(db, slug, relPath, url.searchParams.get("section") ?? undefined));
115
116
  if (req.method === "PUT") {
116
117
  const body = await readJson(req);
117
118
  return sendJson(res, 200, await core.saveNote(db, slug, relPath, body.content ?? ""));
package/src/store/db.js CHANGED
@@ -91,6 +91,8 @@ function migrate(db) {
91
91
  if (!docCols.includes("content_hash")) {
92
92
  db.exec("ALTER TABLE docs ADD COLUMN content_hash TEXT");
93
93
  }
94
+ // Section embeddings were removed from the design — memories are the only semantic note index.
95
+ db.prepare("DELETE FROM embeddings WHERE owner_type = 'section'").run();
94
96
  }
95
97
 
96
98
  export function open() {
@@ -49,7 +49,7 @@ test("rankMemories blends vector and keyword scores, respects limit", async () =
49
49
  assert.equal(rankMemories(db, pid, new Float32Array([1, 0]), "x", 10).some((h) => h.text === "dim mismatch"), false);
50
50
  });
51
51
 
52
- test("rankNotes ranks sections scoped to the project", async () => {
52
+ test("rankNotes literal-matches sections scoped to the project", async () => {
53
53
  const { rankNotes } = await import("../src/search.js");
54
54
  const db = open();
55
55
  db.prepare("INSERT INTO projects (slug, created_at) VALUES ('p2', 2)").run();
@@ -61,14 +61,33 @@ test("rankNotes ranks sections scoped to the project", async () => {
61
61
  db.prepare(
62
62
  "INSERT INTO sections (doc_id, heading_path, content, updated_at) VALUES (?, 'Deploy', 'tag push deploys', 1)",
63
63
  ).run(docId);
64
- const secId = db.prepare("SELECT id FROM sections").get().id;
65
64
  db.prepare(
66
- "INSERT INTO embeddings (owner_type, owner_id, dim, model, vec) VALUES ('section', ?, ?, 'm', ?)",
67
- ).run(secId, 2, vec([0.9, 0.1]));
65
+ "INSERT INTO sections (doc_id, heading_path, content, updated_at) VALUES (?, 'Cooking', 'boil pasta water', 1)",
66
+ ).run(docId);
67
+ // unrelated project section must not leak in
68
+ db.prepare("INSERT INTO projects (slug, created_at) VALUES ('p3', 3)").run();
69
+ const pid3 = db.prepare("SELECT id FROM projects WHERE slug='p3'").get().id;
70
+ const doc3 = Number(
71
+ db.prepare("INSERT INTO docs (project_id, rel_path, title, content_hash, mtime, indexed_at) VALUES (?, 'b.md', 'B', NULL, 1, 1)").run(pid3).lastInsertRowid,
72
+ );
73
+ db.prepare(
74
+ "INSERT INTO sections (doc_id, heading_path, content, updated_at) VALUES (?, 'Deploy', 'tag push deploys', 1)",
75
+ ).run(doc3);
68
76
 
69
- const hits = rankNotes(db, pid, new Float32Array([0.9, 0.1]), 8);
77
+ const hits = rankNotes(db, pid, "deploy", 8);
78
+ assert.equal(hits.length, 1);
70
79
  assert.equal(hits[0].kind, "note_section");
71
80
  assert.equal(hits[0].target, "a.md::Deploy");
81
+
82
+ // heading matches outrank body-only matches
83
+ db.prepare(
84
+ "INSERT INTO sections (doc_id, heading_path, content, updated_at) VALUES (?, 'Releases', 'unrelated body mentioning deploy word', 1)",
85
+ ).run(docId);
86
+ const ranked = rankNotes(db, pid, "deploy", 8);
87
+ assert.equal(ranked[0].target, "a.md::Deploy");
88
+
89
+ // no terms -> no results
90
+ assert.equal(rankNotes(db, pid, " ", 8).length, 0);
72
91
  });
73
92
 
74
93
  test("rankProjects applies token-overlap bonus and threshold", async () => {