@sksoftofficial/mindroot 1.0.1 → 1.0.3
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 +82 -16
- package/bin/mindroot.js +8 -1
- package/package.json +18 -4
- package/src/commands/dashboard.js +57 -0
- package/src/commands/start.js +11 -1
- package/src/core.js +29 -1
- package/src/dashboard/favicon.png +0 -0
- package/src/dashboard/index.html +172 -56
- package/src/dashboard/logo.png +0 -0
- package/src/indexer.js +3 -16
- package/src/mcp.js +28 -8
- package/src/search.js +25 -18
- package/src/server.js +19 -2
- package/src/store/db.js +2 -0
- package/test/mcp.test.js +37 -0
- package/test/search.test.js +24 -5
package/README.md
CHANGED
|
@@ -1,26 +1,60 @@
|
|
|
1
1
|
# Mindroot
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
AI agents that never forget.
|
|
4
4
|
|
|
5
5
|

|
|
6
6
|
|
|
7
|
-
- **
|
|
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).
|
|
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
|
-
-
|
|
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").
|
|
60
|
-
- **Notes** (`save_note`, `update_section`, `read_note`, `delete_note`) — structured markdown docs parsed
|
|
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` | — |
|
|
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?` |
|
|
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 `§ion=`), 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
|
-
|
|
196
|
+
ISC
|
package/bin/mindroot.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { init } from "../src/commands/init.js";
|
|
4
|
-
import { start, stop, status } from "../src/commands/start.js";
|
|
4
|
+
import { start, stop, restart, status } from "../src/commands/start.js";
|
|
5
5
|
import { projects, searchCmd, saveMemoryCmd, memoriesCmd, notesCmd } from "../src/commands/human.js";
|
|
6
|
+
import { dashboard } from "../src/commands/dashboard.js";
|
|
6
7
|
|
|
7
8
|
const [command, ...rest] = process.argv.slice(2);
|
|
8
9
|
|
|
@@ -12,7 +13,9 @@ Usage:
|
|
|
12
13
|
mindroot init Initialize store and download model
|
|
13
14
|
mindroot start [--foreground] Start the service in the background (or foreground)
|
|
14
15
|
mindroot stop Stop the running service
|
|
16
|
+
mindroot restart Restart the service (stop + start)
|
|
15
17
|
mindroot status Show whether the service is running
|
|
18
|
+
mindroot dashboard Open the dashboard in your browser
|
|
16
19
|
mindroot projects List projects
|
|
17
20
|
mindroot notes --project <slug> [path] List notes (or show one)
|
|
18
21
|
mindroot memories --project <slug> List memories
|
|
@@ -33,8 +36,12 @@ async function main() {
|
|
|
33
36
|
return start(rest);
|
|
34
37
|
case "stop":
|
|
35
38
|
return stop();
|
|
39
|
+
case "restart":
|
|
40
|
+
return restart();
|
|
36
41
|
case "status":
|
|
37
42
|
return status();
|
|
43
|
+
case "dashboard":
|
|
44
|
+
return dashboard();
|
|
38
45
|
case "projects":
|
|
39
46
|
return projects();
|
|
40
47
|
case "notes":
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sksoftofficial/mindroot",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.0.3",
|
|
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
|
-
"
|
|
23
|
+
"long-term-memory",
|
|
24
|
+
"agent-memory",
|
|
23
25
|
"mcp",
|
|
24
|
-
"
|
|
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/commands/start.js
CHANGED
|
@@ -79,7 +79,7 @@ export async function start(argv = []) {
|
|
|
79
79
|
fsSync.writeFileSync(PID_FILE, String(child.pid));
|
|
80
80
|
|
|
81
81
|
console.log(`starting mindroot on http://127.0.0.1:${cfg.port} ...`);
|
|
82
|
-
if (await waitFor(cfg.port, 15000)) {
|
|
82
|
+
if (await waitFor(cfg.port, 15000) && pidAlive(child.pid)) {
|
|
83
83
|
console.log(`started (pid ${child.pid})`);
|
|
84
84
|
console.log(`dashboard: http://127.0.0.1:${cfg.port}/`);
|
|
85
85
|
console.log(`logs: ${LOG_FILE}`);
|
|
@@ -119,6 +119,16 @@ function runForeground(cfg) {
|
|
|
119
119
|
process.on("SIGTERM", shutdown);
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
export async function restart() {
|
|
123
|
+
const pid = readPid();
|
|
124
|
+
if (pid !== null && pidAlive(pid)) {
|
|
125
|
+
await stop();
|
|
126
|
+
} else {
|
|
127
|
+
try { fsSync.unlinkSync(PID_FILE); } catch {}
|
|
128
|
+
}
|
|
129
|
+
return start();
|
|
130
|
+
}
|
|
131
|
+
|
|
122
132
|
export async function stop() {
|
|
123
133
|
const pid = readPid();
|
|
124
134
|
if (pid === null || !pidAlive(pid)) {
|
package/src/core.js
CHANGED
|
@@ -39,6 +39,23 @@ export async function deleteProject(db, slug) {
|
|
|
39
39
|
return { deleted: slug };
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
export async function renameProject(db, from, to) {
|
|
43
|
+
const project = getProjectRow(db, from);
|
|
44
|
+
if (to === from) throw httpError(400, `project is already named: ${from}`);
|
|
45
|
+
if (!validSlug(to)) throw httpError(400, `invalid project slug: ${to}`);
|
|
46
|
+
if (db.prepare("SELECT id FROM projects WHERE slug = ?").get(to)) {
|
|
47
|
+
throw httpError(409, `project already exists: ${to}`);
|
|
48
|
+
}
|
|
49
|
+
const targetDir = projectDir(to);
|
|
50
|
+
if (await fs.stat(targetDir).then(() => true, () => false)) {
|
|
51
|
+
throw httpError(409, `directory already exists: ${to}`);
|
|
52
|
+
}
|
|
53
|
+
await fs.rename(projectDir(from), targetDir);
|
|
54
|
+
db.prepare("UPDATE projects SET slug = ? WHERE id = ?").run(to, project.id);
|
|
55
|
+
db.prepare("DELETE FROM project_embeddings WHERE project_id = ?").run(project.id);
|
|
56
|
+
return { renamed: { from, to } };
|
|
57
|
+
}
|
|
58
|
+
|
|
42
59
|
export async function listDocs(db, slug) {
|
|
43
60
|
const project = getProjectRow(db, slug);
|
|
44
61
|
const docs = db
|
|
@@ -55,7 +72,7 @@ export async function listDocs(db, slug) {
|
|
|
55
72
|
return result;
|
|
56
73
|
}
|
|
57
74
|
|
|
58
|
-
export async function readNote(db, slug, relPath) {
|
|
75
|
+
export async function readNote(db, slug, relPath, section) {
|
|
59
76
|
await verifyAndLoadDoc(db, slug, relPath);
|
|
60
77
|
const project = getProjectRow(db, slug);
|
|
61
78
|
const doc = db
|
|
@@ -63,6 +80,17 @@ export async function readNote(db, slug, relPath) {
|
|
|
63
80
|
.get(project.id, relPath);
|
|
64
81
|
const content = await fs.readFile(safeJoin(slug, relPath), "utf8");
|
|
65
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
|
+
}
|
|
66
94
|
return {
|
|
67
95
|
path: relPath,
|
|
68
96
|
title: doc.title,
|
|
Binary file
|
package/src/dashboard/index.html
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
-
<
|
|
7
|
-
<
|
|
6
|
+
<meta name="theme-color" content="#161616">
|
|
7
|
+
<link rel="icon" type="image/png" sizes="64x64" href="/favicon.png">
|
|
8
|
+
<title>Mindroot</title>
|
|
8
9
|
<style>
|
|
9
|
-
:root { --bg:#1e1e1e; --bg2:#161616; --bg3:#2b2b2b; --hover:#303030; --fg:#dcddde; --dim:#999; --accent:#e9973f; --accent-strong:#d97721; --accent-soft:#442f20; --danger:#ff6b6b; --ok:#53c59f; --border
|
|
10
|
+
:root { --bg:#1e1e1e; --bg2:#161616; --bg3:#2b2b2b; --hover:#303030; --fg:#dcddde; --dim:#999; --accent:#e9973f; --accent-strong:#d97721; --accent-soft:#442f20; --danger:#ff6b6b; --ok:#53c59f; --border:rgba(255,255,255,.055); --border-soft:rgba(255,255,255,.03); --border-hover:rgba(255,255,255,.11); --border-accent:rgba(233,151,63,.22); }
|
|
10
11
|
* { box-sizing:border-box; margin:0; padding:0; }
|
|
11
12
|
* { scrollbar-width:thin; scrollbar-color:#505050 transparent }
|
|
12
13
|
*::-webkit-scrollbar { width:10px; height:10px }
|
|
@@ -14,23 +15,36 @@
|
|
|
14
15
|
*::-webkit-scrollbar-thumb { background:#484848; border:2px solid transparent; border-radius:999px; background-clip:padding-box }
|
|
15
16
|
*::-webkit-scrollbar-thumb:hover { background:#606060; border:2px solid transparent; background-clip:padding-box }
|
|
16
17
|
*::-webkit-scrollbar-corner { background:transparent }
|
|
17
|
-
body { font:14px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:var(--bg); color:var(--fg); height:100vh; display:flex; flex-direction:column; }
|
|
18
|
-
header { display:flex; align-items:center; gap:
|
|
19
|
-
|
|
18
|
+
body { font:14px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:var(--bg); color:var(--fg); height:100vh; height:100dvh; display:flex; flex-direction:column; }
|
|
19
|
+
header { position:relative; z-index:2; height:74px; flex:none; display:flex; align-items:center; gap:18px; padding:18px 14px; background:var(--bg2); border-bottom:1px solid var(--border-soft); box-shadow:0 8px 20px rgba(0,0,0,.14); }
|
|
20
|
+
.brand { display:flex; align-items:center; gap:10px; min-width:0 }
|
|
21
|
+
.brand-mark { width:36px; height:36px; flex:none; display:grid; place-items:center; background:#211c19; border:1px solid rgba(233,151,63,.14); border-radius:8px; box-shadow:0 1px 2px rgba(0,0,0,.3), inset 0 1px rgba(255,255,255,.025) }
|
|
22
|
+
.brand-logo { width:26px; height:26px; object-fit:contain; flex:none }
|
|
23
|
+
.brand-copy { min-width:0; line-height:1.15 }
|
|
24
|
+
header h1 { font-size:15px; font-weight:680; letter-spacing:-.015em; color:var(--fg); }
|
|
25
|
+
.brand-subtitle { display:block; margin-top:3px; color:#aaa; font-size:11px; white-space:nowrap }
|
|
26
|
+
.header-context { min-width:0; display:flex; align-items:center; gap:8px; color:var(--dim); font-size:12px }
|
|
27
|
+
.header-context[hidden] { display:none }
|
|
28
|
+
.header-divider { width:1px; height:24px; flex:none; background:var(--border-soft) }
|
|
29
|
+
.header-context svg { width:15px; height:15px; flex:none; stroke:currentColor }
|
|
30
|
+
#headerProject { max-width:280px; overflow:hidden; color:#b9b9b9; text-overflow:ellipsis; white-space:nowrap }
|
|
20
31
|
header .spacer { flex:1 }
|
|
21
|
-
.header-lock {
|
|
32
|
+
.header-lock { height:34px; padding:0 10px; display:flex; align-items:center; gap:7px; color:#aaa; background:transparent; transition:background-color 120ms ease-out, border-color 120ms ease-out, color 120ms ease-out }
|
|
33
|
+
.header-lock:hover { color:var(--fg); background:var(--hover) }
|
|
34
|
+
.header-lock:focus-visible { outline:2px solid var(--accent); outline-offset:2px }
|
|
22
35
|
.header-lock svg { width:16px; height:16px; stroke:currentColor }
|
|
36
|
+
.header-lock-label { font-size:12px }
|
|
23
37
|
button { background:var(--bg3); color:var(--fg); border:1px solid var(--border); border-radius:5px; padding:5px 10px; cursor:pointer; font:inherit; }
|
|
24
|
-
button:hover { background:var(--hover); border-color
|
|
38
|
+
button:hover { background:var(--hover); border-color:var(--border-hover) }
|
|
25
39
|
button.primary { background:var(--accent-strong); color:#fff; border-color:var(--accent-strong); font-weight:600 }
|
|
26
40
|
button.primary:hover { background:#e58732; border-color:#e58732 }
|
|
27
41
|
button.danger { color:var(--danger) }
|
|
28
42
|
input, textarea { background:#242424; color:var(--fg); border:1px solid var(--border); border-radius:5px; padding:6px 9px; font:inherit }
|
|
29
|
-
input:focus, textarea:focus { outline:none; border-color:
|
|
43
|
+
input:focus, textarea:focus { outline:none; border-color:rgba(233,151,63,.68); box-shadow:0 0 0 2px rgba(233,151,63,.12) }
|
|
30
44
|
#gate { margin:auto; text-align:center; display:flex; flex-direction:column; gap:14px; width:min(420px, calc(100% - 32px)); padding:28px; background:var(--bg2); border:1px solid var(--border); border-radius:10px; box-shadow:0 12px 40px rgba(0,0,0,.28) }
|
|
31
45
|
#gate input { width:100% }
|
|
32
46
|
main { display:flex; flex:1; overflow:hidden }
|
|
33
|
-
aside { width:230px; background:var(--bg2); border-right:1px solid var(--border); overflow-y:auto; padding:10px 8px }
|
|
47
|
+
aside { position:relative; z-index:1; width:230px; background:var(--bg2); border-right:1px solid var(--border-soft); overflow-y:auto; padding:10px 8px; box-shadow:8px 0 20px rgba(0,0,0,.12) }
|
|
34
48
|
.proj { padding:7px 10px; border-radius:5px; cursor:pointer; color:var(--dim) }
|
|
35
49
|
.proj:hover { background:var(--hover); color:var(--fg) }
|
|
36
50
|
.proj.active { background:var(--accent-soft); color:#f2b46f }
|
|
@@ -39,22 +53,23 @@
|
|
|
39
53
|
.tabs { display:flex; gap:4px; align-items:center }
|
|
40
54
|
.tab { padding:5px 12px; border-radius:5px; cursor:pointer; color:var(--dim); border:1px solid transparent }
|
|
41
55
|
.tab:hover { color:var(--fg); background:var(--hover) }
|
|
42
|
-
.tab.active { color:#f2b46f; border-color
|
|
56
|
+
.tab.active { color:#f2b46f; border-color:var(--border-accent); background:var(--accent-soft) }
|
|
43
57
|
.project-actions { margin-left:auto; display:flex; align-items:center; gap:10px }
|
|
44
58
|
.delete-project { white-space:nowrap }
|
|
45
59
|
.view { flex:1; min-height:0; overflow-y:auto; width:100% }
|
|
46
60
|
.docrow { padding:11px 12px; border:1px solid var(--border); border-radius:7px; margin-bottom:8px; cursor:pointer; background:#242424 }
|
|
47
|
-
.docrow:hover { background:#282828; border-color
|
|
61
|
+
.docrow:hover { background:#282828; border-color:var(--border-hover) }
|
|
48
62
|
.doc-head { display:flex; align-items:baseline; justify-content:space-between; gap:12px; margin-bottom:5px }
|
|
49
63
|
.docrow .t { color:var(--fg); font-weight:600 }
|
|
50
64
|
.docrow .p { color:var(--dim); font-size:12px }
|
|
65
|
+
.docrow .p, .rem .meta, .hit .kind { font-variant-numeric:tabular-nums }
|
|
51
66
|
.sec-chip { display:inline-block; background:var(--bg3); border-radius:4px; padding:0 6px; margin:3px 4px 0 0; font-size:12px; color:#aaa }
|
|
52
67
|
textarea.editor { width:100%; min-height:420px; resize:vertical; white-space:pre; line-height:1.5; font-family:ui-monospace, SFMono-Regular, Menlo, monospace }
|
|
53
68
|
.rem { border:1px solid var(--border); border-radius:7px; padding:10px 12px; margin-bottom:8px; display:flex; gap:10px; align-items:flex-start; background:#242424 }
|
|
54
69
|
.rem .txt { flex:1 }
|
|
55
70
|
.rem a { color:var(--accent); text-decoration:none; font-size:12px }
|
|
56
71
|
.rem .meta { color:var(--dim); font-size:11px }
|
|
57
|
-
.hit { border
|
|
72
|
+
.hit { border:1px solid var(--border); padding:8px 12px; margin-bottom:10px; background:#242424; border-radius:7px }
|
|
58
73
|
.hit .kind { color:var(--accent); font-size:11px; text-transform:uppercase; letter-spacing:.08em }
|
|
59
74
|
.hit .tgt { color:var(--dim); font-size:12px }
|
|
60
75
|
.muted { color:var(--dim) }
|
|
@@ -97,22 +112,46 @@
|
|
|
97
112
|
.md code { background:var(--bg3); border-radius:4px; padding:1px 5px; font-family:ui-monospace, Menlo, monospace; font-size:13px }
|
|
98
113
|
.md pre { background:#191919; border:1px solid var(--border); border-radius:6px; padding:12px; overflow-x:auto; margin:10px 0 }
|
|
99
114
|
.md pre code { background:none; padding:0 }
|
|
100
|
-
.md blockquote { border-left:
|
|
115
|
+
.md blockquote { border-left:2px solid rgba(233,151,63,.34); margin:10px 0; padding:2px 14px; color:var(--dim) }
|
|
101
116
|
.md table { border-collapse:collapse; margin:10px 0 }
|
|
102
117
|
.md th, .md td { border:1px solid var(--border); padding:5px 10px; text-align:left }
|
|
103
118
|
.md hr { border:none; border-top:1px solid var(--border); margin:16px 0 }
|
|
104
119
|
.md img { max-width:100% }
|
|
120
|
+
.md a img { -webkit-user-drag:none; user-select:none }
|
|
121
|
+
#toast { position:fixed; right:20px; bottom:20px; z-index:10; display:flex; align-items:center; gap:8px; padding:8px 14px; color:var(--fg); background:var(--bg3); border:1px solid var(--border); border-radius:8px; box-shadow:0 10px 28px rgba(0,0,0,.3), 0 1px 2px rgba(0,0,0,.35) }
|
|
122
|
+
#toast .toast-icon { width:16px; height:16px; flex:none }
|
|
123
|
+
#toast .toast-icon svg { display:block; width:100%; height:100%; stroke:currentColor }
|
|
124
|
+
#toast.success .toast-icon { color:var(--ok) }
|
|
125
|
+
#toast.error .toast-icon { color:var(--danger) }
|
|
105
126
|
::selection { background:rgba(233,151,63,.3) }
|
|
127
|
+
@media (pointer:coarse) {
|
|
128
|
+
.header-lock { min-width:44px; height:44px; justify-content:center }
|
|
129
|
+
}
|
|
106
130
|
@media (max-width:700px) {
|
|
131
|
+
header { gap:10px; padding:14px 10px }
|
|
132
|
+
.brand { gap:8px }
|
|
133
|
+
.brand-mark { width:34px; height:34px }
|
|
134
|
+
.brand-logo { width:24px; height:24px }
|
|
135
|
+
.brand-subtitle { display:none }
|
|
136
|
+
.header-context { flex:1 }
|
|
137
|
+
.header-divider { height:20px }
|
|
138
|
+
#headerProject { max-width:none }
|
|
139
|
+
header .spacer { display:none }
|
|
140
|
+
.header-lock { justify-content:center; padding:0 }
|
|
141
|
+
.header-lock-label { display:none }
|
|
107
142
|
main { flex-direction:column }
|
|
108
|
-
aside { width:100%; max-height:112px; border-right:0; border-bottom:1px solid var(--border); padding:7px 8px }
|
|
143
|
+
aside { width:100%; max-height:112px; border-right:0; border-bottom:1px solid var(--border-soft); padding:7px 8px; box-shadow:0 8px 20px rgba(0,0,0,.12) }
|
|
109
144
|
#projList { display:flex; gap:4px; overflow-x:auto }
|
|
110
145
|
.proj { flex:none; white-space:nowrap }
|
|
111
146
|
#content { padding:14px }
|
|
112
|
-
.workspace-head { align-items:flex-start; gap:8px; margin-bottom:12px; padding-bottom:10px }
|
|
147
|
+
.workspace-head { align-items:flex-start; flex-wrap:wrap; gap:8px; margin-bottom:12px; padding-bottom:10px }
|
|
148
|
+
button, input { min-height:44px; touch-action:manipulation }
|
|
149
|
+
.header-lock, .back-button { width:44px; height:44px }
|
|
150
|
+
.proj, .tab { min-height:44px; display:flex; align-items:center }
|
|
113
151
|
.tab { padding:5px 8px }
|
|
114
|
-
.delete-project { padding:5px
|
|
152
|
+
.delete-project { padding:5px 9px }
|
|
115
153
|
form.row, form.row.single { grid-template-columns:1fr }
|
|
154
|
+
form.row input, form.row button { height:44px }
|
|
116
155
|
.editor-view { overflow-y:auto }
|
|
117
156
|
.editorbar { grid-template-columns:minmax(0,1fr) auto; gap:8px }
|
|
118
157
|
.editor-title { max-width:180px }
|
|
@@ -128,13 +167,29 @@
|
|
|
128
167
|
</head>
|
|
129
168
|
<body>
|
|
130
169
|
<header>
|
|
131
|
-
<
|
|
170
|
+
<div class="brand">
|
|
171
|
+
<span class="brand-mark" aria-hidden="true">
|
|
172
|
+
<img class="brand-logo" src="/logo.png" alt="" draggable="false">
|
|
173
|
+
</span>
|
|
174
|
+
<div class="brand-copy">
|
|
175
|
+
<h1>mindroot</h1>
|
|
176
|
+
<span class="brand-subtitle">Persistent memory</span>
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
<div class="header-context" hidden>
|
|
180
|
+
<span class="header-divider" aria-hidden="true"></span>
|
|
181
|
+
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
182
|
+
<path d="M4 7.5h6l2 2h8v9H4z" stroke-width="1.7" stroke-linejoin="round"/>
|
|
183
|
+
</svg>
|
|
184
|
+
<span id="headerProject" aria-live="polite"></span>
|
|
185
|
+
</div>
|
|
132
186
|
<div class="spacer"></div>
|
|
133
|
-
<button class="header-lock" onclick="logout()" title="Lock dashboard" aria-label="Lock dashboard">
|
|
187
|
+
<button class="header-lock" type="button" onclick="logout()" title="Lock dashboard" aria-label="Lock dashboard">
|
|
134
188
|
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
135
189
|
<rect x="5" y="10" width="14" height="10" rx="2" stroke-width="1.8"/>
|
|
136
190
|
<path d="M8 10V7a4 4 0 0 1 8 0v3" stroke-width="1.8" stroke-linecap="round"/>
|
|
137
191
|
</svg>
|
|
192
|
+
<span class="header-lock-label">Lock</span>
|
|
138
193
|
</button>
|
|
139
194
|
</header>
|
|
140
195
|
|
|
@@ -190,13 +245,54 @@ async function boot() {
|
|
|
190
245
|
await api("GET", "/api/projects");
|
|
191
246
|
$("#gate").style.display = "none";
|
|
192
247
|
$("#app").style.display = "flex";
|
|
193
|
-
await renderProjects();
|
|
194
|
-
|
|
248
|
+
const projects = await renderProjects();
|
|
249
|
+
const r = routeFromHash();
|
|
250
|
+
if (projects.some((p) => p.slug === r.slug)) applyRoute(r);
|
|
251
|
+
else if (projects.length) navTo(routeUrl(projects[0].slug, "notes"));
|
|
252
|
+
else {
|
|
253
|
+
$(".header-context").hidden = true;
|
|
254
|
+
$("#content").innerHTML = '<div class="empty">No projects yet</div>';
|
|
255
|
+
}
|
|
195
256
|
} catch (e) {
|
|
196
257
|
if (e.message !== "unauthorized") showGate(String(e));
|
|
197
258
|
}
|
|
198
259
|
}
|
|
199
260
|
|
|
261
|
+
function routeUrl(slug, tab, doc) {
|
|
262
|
+
return "#/" + encodeURIComponent(slug) + "/" + tab + (doc ? "/" + encodeURIComponent(doc) : "");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function routeFromHash() {
|
|
266
|
+
const [rawSlug, rawTab, rawDoc] = location.hash.replace(/^#\/?/, "").split("/");
|
|
267
|
+
return {
|
|
268
|
+
slug: rawSlug ? decodeURIComponent(rawSlug) : null,
|
|
269
|
+
tab: ["notes", "memories", "search"].includes(rawTab) ? rawTab : "notes",
|
|
270
|
+
doc: rawDoc ? decodeURIComponent(rawDoc) : null,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function navTo(hash) {
|
|
275
|
+
if (location.hash === hash) applyRoute(routeFromHash());
|
|
276
|
+
else location.hash = hash;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function applyRoute({ slug = null, tab = "notes", doc = null } = {}) {
|
|
280
|
+
currentProject = slug;
|
|
281
|
+
currentTab = tab;
|
|
282
|
+
currentDoc = doc;
|
|
283
|
+
if (currentProject) {
|
|
284
|
+
$(".header-context").hidden = false;
|
|
285
|
+
const projectLabel = $("#headerProject");
|
|
286
|
+
projectLabel.textContent = slug;
|
|
287
|
+
projectLabel.title = slug;
|
|
288
|
+
projectLabel.setAttribute("aria-label", "Current project: " + slug);
|
|
289
|
+
}
|
|
290
|
+
document.querySelectorAll(".proj").forEach((n) => n.classList.toggle("active", n.textContent === slug));
|
|
291
|
+
renderTabs();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
window.addEventListener("hashchange", () => applyRoute(routeFromHash()));
|
|
295
|
+
|
|
200
296
|
async function renderProjects() {
|
|
201
297
|
const rows = await api("GET", "/api/projects");
|
|
202
298
|
const el = $("#projList");
|
|
@@ -213,32 +309,31 @@ async function renderProjects() {
|
|
|
213
309
|
}
|
|
214
310
|
|
|
215
311
|
function selectProject(slug) {
|
|
216
|
-
|
|
217
|
-
currentDoc = null;
|
|
218
|
-
document.querySelectorAll(".proj").forEach((n) => n.classList.toggle("active", n.textContent === slug));
|
|
219
|
-
renderTabs();
|
|
312
|
+
navTo(routeUrl(slug, "notes"));
|
|
220
313
|
}
|
|
221
314
|
|
|
222
315
|
function renderTabs() {
|
|
223
316
|
const c = $("#content");
|
|
224
317
|
c.innerHTML = "";
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
318
|
+
if (!(currentTab === "notes" && currentDoc !== null)) {
|
|
319
|
+
const head = document.createElement("div");
|
|
320
|
+
head.className = "workspace-head";
|
|
321
|
+
const tabs = document.createElement("div");
|
|
322
|
+
tabs.className = "tabs";
|
|
323
|
+
for (const [name, label] of [["notes","Notes"],["memories","Memories"],["search","Search"]]) {
|
|
324
|
+
const t = document.createElement("div");
|
|
325
|
+
t.className = "tab" + (currentTab === name ? " active" : "");
|
|
326
|
+
t.textContent = label;
|
|
327
|
+
t.onclick = () => navTo(routeUrl(currentProject, name));
|
|
328
|
+
tabs.appendChild(t);
|
|
329
|
+
}
|
|
330
|
+
const actions = document.createElement("div");
|
|
331
|
+
actions.className = "project-actions";
|
|
332
|
+
actions.innerHTML = '<button class="danger delete-project">Delete project</button>';
|
|
333
|
+
actions.querySelector("button").onclick = deleteCurrentProject;
|
|
334
|
+
head.append(tabs, actions);
|
|
335
|
+
c.appendChild(head);
|
|
235
336
|
}
|
|
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
337
|
const view = document.createElement("div");
|
|
243
338
|
view.className = "view";
|
|
244
339
|
c.appendChild(view);
|
|
@@ -252,9 +347,17 @@ async function deleteCurrentProject() {
|
|
|
252
347
|
await api("DELETE", "/api/projects/" + encodeURIComponent(slug));
|
|
253
348
|
currentProject = null;
|
|
254
349
|
currentDoc = null;
|
|
350
|
+
history.replaceState(null, "", location.pathname + location.search);
|
|
255
351
|
const rows = await renderProjects();
|
|
256
|
-
if (rows.length)
|
|
257
|
-
else
|
|
352
|
+
if (rows.length) navTo(routeUrl(rows[0].slug, "notes"));
|
|
353
|
+
else {
|
|
354
|
+
const projectLabel = $("#headerProject");
|
|
355
|
+
$(".header-context").hidden = true;
|
|
356
|
+
projectLabel.textContent = "";
|
|
357
|
+
projectLabel.removeAttribute("title");
|
|
358
|
+
projectLabel.removeAttribute("aria-label");
|
|
359
|
+
$("#content").innerHTML = '<div class="empty">No projects yet</div>';
|
|
360
|
+
}
|
|
258
361
|
toast('project "' + slug + '" deleted');
|
|
259
362
|
} catch (e) {
|
|
260
363
|
toast("delete failed: " + e.message, true);
|
|
@@ -271,13 +374,13 @@ async function renderNotes(view) {
|
|
|
271
374
|
row.querySelector(".t").textContent = d.title + " ";
|
|
272
375
|
const chips = d.sections.filter(Boolean).map((s) => '<span class="sec-chip">' + esc(s) + "</span>").join("");
|
|
273
376
|
row.insertAdjacentHTML("beforeend", '<div>' + chips + "</div>");
|
|
274
|
-
row.onclick = () =>
|
|
377
|
+
row.onclick = () => navTo(routeUrl(currentProject, "notes", d.path));
|
|
275
378
|
view.appendChild(row);
|
|
276
379
|
}
|
|
277
380
|
if (!docs.length) view.innerHTML = '<div class="empty">No notes yet. Agents create them with <b>mindroot_save_note</b>.</div>';
|
|
278
381
|
}
|
|
279
382
|
|
|
280
|
-
let mdMode = localStorage.getItem("mindroot_mdmode") || "
|
|
383
|
+
let mdMode = localStorage.getItem("mindroot_mdmode") || "preview";
|
|
281
384
|
|
|
282
385
|
function renderMd(text) {
|
|
283
386
|
if (window.marked && window.DOMPurify) {
|
|
@@ -305,7 +408,7 @@ async function renderEditor(view, docs) {
|
|
|
305
408
|
'<button class="danger" id="delBtn">Delete note</button></div></div>';
|
|
306
409
|
view.appendChild(head);
|
|
307
410
|
head.querySelector(".editor-title").textContent = currentDoc;
|
|
308
|
-
$("#backBtn").onclick = () =>
|
|
411
|
+
$("#backBtn").onclick = () => history.back();
|
|
309
412
|
head.querySelectorAll(".viewmode button").forEach((b) =>
|
|
310
413
|
b.onclick = () => { mdMode = b.dataset.m; localStorage.setItem("mindroot_mdmode", mdMode); renderTabs(); });
|
|
311
414
|
|
|
@@ -320,7 +423,11 @@ async function renderEditor(view, docs) {
|
|
|
320
423
|
prev = document.createElement("div");
|
|
321
424
|
prev.className = "md";
|
|
322
425
|
}
|
|
323
|
-
const paint = () => {
|
|
426
|
+
const paint = () => {
|
|
427
|
+
if (!prev) return;
|
|
428
|
+
prev.innerHTML = renderMd(ta.value);
|
|
429
|
+
prev.querySelectorAll("a img").forEach((img) => { img.draggable = false; });
|
|
430
|
+
};
|
|
324
431
|
let t;
|
|
325
432
|
ta.oninput = () => { clearTimeout(t); t = setTimeout(paint, 150); };
|
|
326
433
|
if (prev) {
|
|
@@ -338,8 +445,7 @@ async function renderEditor(view, docs) {
|
|
|
338
445
|
$("#delBtn").onclick = async () => {
|
|
339
446
|
if (!confirm("delete " + currentDoc + "?")) return;
|
|
340
447
|
await api("DELETE", "/api/projects/" + currentProject + "/doc?path=" + encodeURIComponent(currentDoc));
|
|
341
|
-
|
|
342
|
-
renderTabs();
|
|
448
|
+
navTo(routeUrl(currentProject, "notes"));
|
|
343
449
|
};
|
|
344
450
|
}
|
|
345
451
|
|
|
@@ -374,7 +480,7 @@ async function renderMemories(view) {
|
|
|
374
480
|
}
|
|
375
481
|
if (!rows.length) view.insertAdjacentHTML("beforeend", '<div class="empty">No memories yet</div>');
|
|
376
482
|
view.querySelectorAll("a[data-p]").forEach((a) =>
|
|
377
|
-
a.onclick = (e) => { e.preventDefault();
|
|
483
|
+
a.onclick = (e) => { e.preventDefault(); navTo(routeUrl(currentProject, "notes", a.dataset.p)); });
|
|
378
484
|
}
|
|
379
485
|
|
|
380
486
|
async function renderSearch(view) {
|
|
@@ -404,12 +510,22 @@ async function renderSearch(view) {
|
|
|
404
510
|
|
|
405
511
|
function toast(msg, isErr) {
|
|
406
512
|
let t = $("#toast");
|
|
407
|
-
if (!t) {
|
|
408
|
-
t
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
513
|
+
if (!t) {
|
|
514
|
+
t = document.createElement("div");
|
|
515
|
+
t.id = "toast";
|
|
516
|
+
document.body.appendChild(t);
|
|
517
|
+
}
|
|
518
|
+
clearTimeout(t.dismissTimer);
|
|
519
|
+
t.className = isErr ? "error" : "success";
|
|
520
|
+
t.setAttribute("role", isErr ? "alert" : "status");
|
|
521
|
+
t.setAttribute("aria-live", isErr ? "assertive" : "polite");
|
|
522
|
+
t.innerHTML = isErr
|
|
523
|
+
? '<span class="toast-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none"><path d="m7 7 10 10M17 7 7 17" stroke-width="2" stroke-linecap="round"/></svg></span>'
|
|
524
|
+
: '<span class="toast-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none"><path d="m5 12 4 4L19 6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></span>';
|
|
525
|
+
const text = document.createElement("span");
|
|
526
|
+
text.textContent = msg;
|
|
527
|
+
t.appendChild(text);
|
|
528
|
+
t.dismissTimer = setTimeout(() => t.remove(), 2500);
|
|
413
529
|
}
|
|
414
530
|
|
|
415
531
|
function esc(s) { return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/"/g,"""); }
|
|
Binary file
|
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
|
|
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
|
|
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
|
-
|
|
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
|
@@ -18,10 +18,23 @@ export function tools() {
|
|
|
18
18
|
required: ["query"],
|
|
19
19
|
},
|
|
20
20
|
},
|
|
21
|
+
{
|
|
22
|
+
name: "mindroot_rename_project",
|
|
23
|
+
description:
|
|
24
|
+
"Rename a project by slug. Moves its notes directory and updates the slug; notes, memories, and search indexes are preserved under the internal project id. The fuzzy-search vector for the new slug is rebuilt automatically on the next project search.",
|
|
25
|
+
inputSchema: {
|
|
26
|
+
type: "object",
|
|
27
|
+
properties: {
|
|
28
|
+
project: { type: "string", description: "Current project slug" },
|
|
29
|
+
new_slug: { type: "string", description: "New project slug" },
|
|
30
|
+
},
|
|
31
|
+
required: ["project", "new_slug"],
|
|
32
|
+
},
|
|
33
|
+
},
|
|
21
34
|
{
|
|
22
35
|
name: "mindroot_search_memories",
|
|
23
36
|
description:
|
|
24
|
-
"Search a project's memories — short, retrieval-optimized notes about durable facts, conventions, decisions, and gotchas.
|
|
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.",
|
|
25
38
|
inputSchema: {
|
|
26
39
|
type: "object",
|
|
27
40
|
properties: {
|
|
@@ -35,12 +48,12 @@ export function tools() {
|
|
|
35
48
|
{
|
|
36
49
|
name: "mindroot_search_notes",
|
|
37
50
|
description:
|
|
38
|
-
"
|
|
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.",
|
|
39
52
|
inputSchema: {
|
|
40
53
|
type: "object",
|
|
41
54
|
properties: {
|
|
42
55
|
project: { type: "string", description: "Project slug to search in" },
|
|
43
|
-
query: { type: "string", description: "
|
|
56
|
+
query: { type: "string", description: "Literal text to find (matched as whole words/terms)" },
|
|
44
57
|
limit: { type: "number", description: "Max results (default 8)" },
|
|
45
58
|
},
|
|
46
59
|
required: ["project", "query"],
|
|
@@ -71,7 +84,8 @@ export function tools() {
|
|
|
71
84
|
},
|
|
72
85
|
{
|
|
73
86
|
name: "mindroot_list_notes",
|
|
74
|
-
description:
|
|
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.",
|
|
75
89
|
inputSchema: {
|
|
76
90
|
type: "object",
|
|
77
91
|
properties: { project: { type: "string" } },
|
|
@@ -81,17 +95,21 @@ export function tools() {
|
|
|
81
95
|
{
|
|
82
96
|
name: "mindroot_read_note",
|
|
83
97
|
description:
|
|
84
|
-
"Read
|
|
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.",
|
|
85
99
|
inputSchema: {
|
|
86
100
|
type: "object",
|
|
87
|
-
properties: {
|
|
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
|
+
},
|
|
88
106
|
required: ["project", "path"],
|
|
89
107
|
},
|
|
90
108
|
},
|
|
91
109
|
{
|
|
92
110
|
name: "mindroot_save_note",
|
|
93
111
|
description:
|
|
94
|
-
"Create or fully overwrite a markdown memory note. Sections are parsed from headings (# .. ######) and
|
|
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.",
|
|
95
113
|
inputSchema: {
|
|
96
114
|
type: "object",
|
|
97
115
|
properties: {
|
|
@@ -136,6 +154,8 @@ async function callTool(db, name, args) {
|
|
|
136
154
|
return core.listProjects(db);
|
|
137
155
|
case "mindroot_search_projects":
|
|
138
156
|
return core.searchProjects(db, args.query);
|
|
157
|
+
case "mindroot_rename_project":
|
|
158
|
+
return core.renameProject(db, args.project, args.new_slug);
|
|
139
159
|
case "mindroot_search_memories":
|
|
140
160
|
return core.searchMemories(db, args.project, args.query, args.limit ?? 8);
|
|
141
161
|
case "mindroot_search_notes":
|
|
@@ -147,7 +167,7 @@ async function callTool(db, name, args) {
|
|
|
147
167
|
case "mindroot_list_notes":
|
|
148
168
|
return core.listDocs(db, args.project);
|
|
149
169
|
case "mindroot_read_note":
|
|
150
|
-
return core.readNote(db, args.project, args.path);
|
|
170
|
+
return core.readNote(db, args.project, args.path, args.section);
|
|
151
171
|
case "mindroot_save_note":
|
|
152
172
|
return core.saveNote(db, args.project, args.path, args.content);
|
|
153
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
|
|
155
|
-
return rankNotes(db, projectId,
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
167
|
+
WHERE d.project_id = $pid`,
|
|
168
168
|
)
|
|
169
169
|
.all({ pid: projectId });
|
|
170
170
|
|
|
171
|
-
const
|
|
171
|
+
const hits = [];
|
|
172
172
|
for (const row of rows) {
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
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.
|
|
178
|
-
text: row.
|
|
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:
|
|
182
|
-
updated_at: row.
|
|
188
|
+
score: matched / terms.length + (headingMatched ? 0.25 : 0),
|
|
189
|
+
updated_at: row.updated_at,
|
|
183
190
|
});
|
|
184
191
|
}
|
|
185
|
-
|
|
186
|
-
return
|
|
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
|
@@ -7,7 +7,12 @@ import { handleRpc } from "./mcp.js";
|
|
|
7
7
|
import { validSlug, ensureProject, getProjectRow } from "./indexer.js";
|
|
8
8
|
import * as core from "./core.js";
|
|
9
9
|
|
|
10
|
-
const
|
|
10
|
+
const DASHBOARD_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "dashboard");
|
|
11
|
+
const DASHBOARD = path.join(DASHBOARD_DIR, "index.html");
|
|
12
|
+
const DASHBOARD_ASSETS = new Map([
|
|
13
|
+
["/favicon.png", ["favicon.png", "image/png"]],
|
|
14
|
+
["/logo.png", ["logo.png", "image/png"]],
|
|
15
|
+
]);
|
|
11
16
|
|
|
12
17
|
export function createServer(db, cfg) {
|
|
13
18
|
return http.createServer(async (req, res) => {
|
|
@@ -22,6 +27,17 @@ export function createServer(db, cfg) {
|
|
|
22
27
|
return;
|
|
23
28
|
}
|
|
24
29
|
|
|
30
|
+
const asset = DASHBOARD_ASSETS.get(url.pathname);
|
|
31
|
+
if (req.method === "GET" && asset) {
|
|
32
|
+
const body = await fs.readFile(path.join(DASHBOARD_DIR, asset[0]));
|
|
33
|
+
res.writeHead(200, {
|
|
34
|
+
"content-type": asset[1],
|
|
35
|
+
"cache-control": "public, max-age=86400",
|
|
36
|
+
});
|
|
37
|
+
res.end(body);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
25
41
|
if (url.pathname === "/health") {
|
|
26
42
|
sendJson(res, 200, { ok: true });
|
|
27
43
|
return;
|
|
@@ -95,7 +111,8 @@ async function handleApi(req, res, url, db) {
|
|
|
95
111
|
if (parts[3] === "doc") {
|
|
96
112
|
const relPath = url.searchParams.get("path");
|
|
97
113
|
if (!relPath) throw badRequest("path query param required");
|
|
98
|
-
if (req.method === "GET")
|
|
114
|
+
if (req.method === "GET")
|
|
115
|
+
return sendJson(res, 200, await core.readNote(db, slug, relPath, url.searchParams.get("section") ?? undefined));
|
|
99
116
|
if (req.method === "PUT") {
|
|
100
117
|
const body = await readJson(req);
|
|
101
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() {
|
package/test/mcp.test.js
CHANGED
|
@@ -12,6 +12,7 @@ let open;
|
|
|
12
12
|
const EXPECTED_TOOLS = [
|
|
13
13
|
"mindroot_list_projects",
|
|
14
14
|
"mindroot_search_projects",
|
|
15
|
+
"mindroot_rename_project",
|
|
15
16
|
"mindroot_search_memories",
|
|
16
17
|
"mindroot_search_notes",
|
|
17
18
|
"mindroot_save_memory",
|
|
@@ -72,3 +73,39 @@ test("unknown project is a clean error", async () => {
|
|
|
72
73
|
const res = await call("mindroot_search_memories", { project: "ghost", query: "x" });
|
|
73
74
|
assert.match(res.body.result.content[0].text, /unknown project: ghost/);
|
|
74
75
|
});
|
|
76
|
+
|
|
77
|
+
test("rename_project moves slug, dir, and embeddings", async () => {
|
|
78
|
+
const { NOTES_DIR } = await import("../src/paths.js");
|
|
79
|
+
db.prepare("INSERT INTO projects (slug, created_at) VALUES ('old-name', 1)").run();
|
|
80
|
+
const pid = db.prepare("SELECT id FROM projects WHERE slug='old-name'").get().id;
|
|
81
|
+
db.prepare(
|
|
82
|
+
"INSERT INTO memories (project_id, text, target_type, created_at, updated_at) VALUES (?, 'durable fact', 'standalone', 1, 1)",
|
|
83
|
+
).run(pid);
|
|
84
|
+
const f = new Float32Array([1, 0]);
|
|
85
|
+
db.prepare("INSERT INTO project_embeddings (project_id, dim, model, vec) VALUES (?, 2, 'm', ?)").run(
|
|
86
|
+
pid,
|
|
87
|
+
Buffer.from(f.buffer, f.byteOffset, f.byteLength),
|
|
88
|
+
);
|
|
89
|
+
fs.mkdirSync(path.join(NOTES_DIR, "old-name"), { recursive: true });
|
|
90
|
+
fs.writeFileSync(path.join(NOTES_DIR, "old-name", "overview.md"), "# Old\n");
|
|
91
|
+
|
|
92
|
+
const res = await call("mindroot_rename_project", { project: "old-name", new_slug: "new-name" });
|
|
93
|
+
const data = JSON.parse(res.body.result.content[0].text);
|
|
94
|
+
assert.deepEqual(data.renamed, { from: "old-name", to: "new-name" });
|
|
95
|
+
assert.equal(db.prepare("SELECT slug FROM projects WHERE id = ?").get(pid).slug, "new-name");
|
|
96
|
+
assert.ok(fs.existsSync(path.join(NOTES_DIR, "new-name", "overview.md")));
|
|
97
|
+
assert.ok(!fs.existsSync(path.join(NOTES_DIR, "old-name")));
|
|
98
|
+
assert.equal(db.prepare("SELECT COUNT(*) n FROM project_embeddings WHERE project_id = ?").get(pid).n, 0);
|
|
99
|
+
const listed = JSON.parse((await call("mindroot_list_projects", {})).body.result.content[0].text);
|
|
100
|
+
const row = listed.find((p) => p.slug === "new-name");
|
|
101
|
+
assert.equal(row.memories, 1);
|
|
102
|
+
assert.ok(!listed.some((p) => p.slug === "old-name"));
|
|
103
|
+
|
|
104
|
+
const dup = await call("mindroot_rename_project", { project: "new-name", new_slug: "new-name" });
|
|
105
|
+
assert.equal(dup.body.result.isError, true);
|
|
106
|
+
assert.match(dup.body.result.content[0].text, /already named/);
|
|
107
|
+
|
|
108
|
+
const bad = await call("mindroot_rename_project", { project: "new-name", new_slug: "../evil" });
|
|
109
|
+
assert.equal(bad.body.result.isError, true);
|
|
110
|
+
assert.match(bad.body.result.content[0].text, /invalid project slug/);
|
|
111
|
+
});
|
package/test/search.test.js
CHANGED
|
@@ -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
|
|
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
|
|
67
|
-
).run(
|
|
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,
|
|
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 () => {
|