@sksoftofficial/mindroot 1.0.4 → 1.0.6
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 +1 -1
- package/package.json +1 -1
- package/src/core.js +4 -3
- package/src/markdown.js +6 -0
- package/src/mcp.js +25 -25
- package/test/markdown.test.js +6 -1
- package/test/mcp.test.js +39 -19
package/README.md
CHANGED
|
@@ -143,7 +143,7 @@ All tools are prefixed `mindroot_`.
|
|
|
143
143
|
| `list_notes` | `project*` | List notes with section paths |
|
|
144
144
|
| `read_note` | `project*`, `path`, `section?` | Full note content, or one section's body via `section` (hash-verified, auto-reindexed) |
|
|
145
145
|
| `save_note` | `project*`, `path`, `content` | Create/overwrite a note |
|
|
146
|
-
| `update_section` | `project*`, `path`, `heading_path`, `content` | Replace one section body,
|
|
146
|
+
| `update_section` | `project*`, `path`, `heading_path`, `content` | Replace one section body, or append the section when missing |
|
|
147
147
|
| `delete_note` | `project*`, `path` | Delete a note (clears its linked memories) |
|
|
148
148
|
|
|
149
149
|
## REST API
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sksoftofficial/mindroot",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
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",
|
package/src/core.js
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
reindexDoc,
|
|
11
11
|
httpError,
|
|
12
12
|
} from "./indexer.js";
|
|
13
|
-
import { parseMarkdown, replaceSectionBody } from "./markdown.js";
|
|
13
|
+
import { appendSection, parseMarkdown, replaceSectionBody } from "./markdown.js";
|
|
14
14
|
import { embedDoc } from "./embedder.js";
|
|
15
15
|
import { ONNX_MODEL_ID } from "./paths.js";
|
|
16
16
|
import { searchMemories as hybridSearchMemories, searchNotes as hybridSearchNotes, searchProjects } from "./search.js";
|
|
@@ -137,8 +137,9 @@ export async function updateSection(db, slug, relPath, headingPath, newContent)
|
|
|
137
137
|
const { content } = await verifyAndLoadDoc(db, slug, relPath);
|
|
138
138
|
const parsed = parseMarkdown(content);
|
|
139
139
|
const sec = parsed.sections.find((s) => s.path === headingPath);
|
|
140
|
-
|
|
141
|
-
|
|
140
|
+
const updated = sec
|
|
141
|
+
? replaceSectionBody(content, sec, newContent)
|
|
142
|
+
: appendSection(content, headingPath, newContent);
|
|
142
143
|
return saveNote(db, slug, relPath, updated);
|
|
143
144
|
}
|
|
144
145
|
|
package/src/markdown.js
CHANGED
|
@@ -70,3 +70,9 @@ export function replaceSectionBody(raw, sec, newBody) {
|
|
|
70
70
|
const replacement = newBody.replace(/\n+$/, "").split("\n");
|
|
71
71
|
return [...lines.slice(0, sec.bodyStart), ...replacement, ...lines.slice(sec.bodyEnd)].join("\n");
|
|
72
72
|
}
|
|
73
|
+
|
|
74
|
+
export function appendSection(raw, headingPath, body) {
|
|
75
|
+
const note = raw.replace(/\n+$/, "");
|
|
76
|
+
const content = body.replace(/\n+$/, "");
|
|
77
|
+
return `${note ? `${note}\n\n` : ""}## ${headingPath}${content ? `\n\n${content}` : ""}\n`;
|
|
78
|
+
}
|
package/src/mcp.js
CHANGED
|
@@ -4,12 +4,12 @@ const PROTOCOL_VERSION = "2025-06-18";
|
|
|
4
4
|
export function tools() {
|
|
5
5
|
return [
|
|
6
6
|
{
|
|
7
|
-
name: "
|
|
7
|
+
name: "list_projects",
|
|
8
8
|
description: "List all projects known to mindroot with their note and memory counts. Use to discover valid project slugs before calling project-scoped tools.",
|
|
9
9
|
inputSchema: { type: "object", properties: {} },
|
|
10
10
|
},
|
|
11
11
|
{
|
|
12
|
-
name: "
|
|
12
|
+
name: "search_projects",
|
|
13
13
|
description:
|
|
14
14
|
"Find projects by fuzzy name. Matches project slugs semantically and by token overlap (e.g. 'mem-agent-mcp' finds 'proper-agent-memory'). Use to discover the right project slug before calling project-scoped tools.",
|
|
15
15
|
inputSchema: {
|
|
@@ -19,7 +19,7 @@ export function tools() {
|
|
|
19
19
|
},
|
|
20
20
|
},
|
|
21
21
|
{
|
|
22
|
-
name: "
|
|
22
|
+
name: "rename_project",
|
|
23
23
|
description:
|
|
24
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
25
|
inputSchema: {
|
|
@@ -32,7 +32,7 @@ export function tools() {
|
|
|
32
32
|
},
|
|
33
33
|
},
|
|
34
34
|
{
|
|
35
|
-
name: "
|
|
35
|
+
name: "search_memories",
|
|
36
36
|
description:
|
|
37
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: {
|
|
@@ -46,7 +46,7 @@ export function tools() {
|
|
|
46
46
|
},
|
|
47
47
|
},
|
|
48
48
|
{
|
|
49
|
-
name: "
|
|
49
|
+
name: "search_notes",
|
|
50
50
|
description:
|
|
51
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: {
|
|
@@ -60,7 +60,7 @@ export function tools() {
|
|
|
60
60
|
},
|
|
61
61
|
},
|
|
62
62
|
{
|
|
63
|
-
name: "
|
|
63
|
+
name: "save_memory",
|
|
64
64
|
description:
|
|
65
65
|
"Save a memory — a short, retrieval-optimized text about a durable fact, convention, decision, or gotcha for a project. Optionally link it to a note path ('notes/foo.md') or section ('notes/foo.md::Heading::Subheading'). Write memories so they answer 'when would someone need this'.",
|
|
66
66
|
inputSchema: {
|
|
@@ -74,7 +74,7 @@ export function tools() {
|
|
|
74
74
|
},
|
|
75
75
|
},
|
|
76
76
|
{
|
|
77
|
-
name: "
|
|
77
|
+
name: "delete_memory",
|
|
78
78
|
description: "Delete a memory by id. Use to dedupe or remove stale entries. Ids come from mindroot_search_memories hits.",
|
|
79
79
|
inputSchema: {
|
|
80
80
|
type: "object",
|
|
@@ -83,7 +83,7 @@ export function tools() {
|
|
|
83
83
|
},
|
|
84
84
|
},
|
|
85
85
|
{
|
|
86
|
-
name: "
|
|
86
|
+
name: "list_notes",
|
|
87
87
|
description:
|
|
88
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.",
|
|
89
89
|
inputSchema: {
|
|
@@ -93,7 +93,7 @@ export function tools() {
|
|
|
93
93
|
},
|
|
94
94
|
},
|
|
95
95
|
{
|
|
96
|
-
name: "
|
|
96
|
+
name: "read_note",
|
|
97
97
|
description:
|
|
98
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.",
|
|
99
99
|
inputSchema: {
|
|
@@ -107,7 +107,7 @@ export function tools() {
|
|
|
107
107
|
},
|
|
108
108
|
},
|
|
109
109
|
{
|
|
110
|
-
name: "
|
|
110
|
+
name: "save_note",
|
|
111
111
|
description:
|
|
112
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.",
|
|
113
113
|
inputSchema: {
|
|
@@ -121,9 +121,9 @@ export function tools() {
|
|
|
121
121
|
},
|
|
122
122
|
},
|
|
123
123
|
{
|
|
124
|
-
name: "
|
|
124
|
+
name: "update_section",
|
|
125
125
|
description:
|
|
126
|
-
"Replace the body of
|
|
126
|
+
"Replace the body of an existing heading-section, or append it to the note when missing.",
|
|
127
127
|
inputSchema: {
|
|
128
128
|
type: "object",
|
|
129
129
|
properties: {
|
|
@@ -136,7 +136,7 @@ export function tools() {
|
|
|
136
136
|
},
|
|
137
137
|
},
|
|
138
138
|
{
|
|
139
|
-
name: "
|
|
139
|
+
name: "delete_note",
|
|
140
140
|
description: "Delete a memory note and its sections index entries.",
|
|
141
141
|
inputSchema: {
|
|
142
142
|
type: "object",
|
|
@@ -150,29 +150,29 @@ export function tools() {
|
|
|
150
150
|
async function callTool(db, name, args) {
|
|
151
151
|
const core = await import("./core.js");
|
|
152
152
|
switch (name) {
|
|
153
|
-
case "
|
|
153
|
+
case "list_projects":
|
|
154
154
|
return core.listProjects(db);
|
|
155
|
-
case "
|
|
155
|
+
case "search_projects":
|
|
156
156
|
return core.searchProjects(db, args.query);
|
|
157
|
-
case "
|
|
157
|
+
case "rename_project":
|
|
158
158
|
return core.renameProject(db, args.project, args.new_slug);
|
|
159
|
-
case "
|
|
159
|
+
case "search_memories":
|
|
160
160
|
return core.searchMemories(db, args.project, args.query, args.limit ?? 8);
|
|
161
|
-
case "
|
|
161
|
+
case "search_notes":
|
|
162
162
|
return core.searchNotes(db, args.project, args.query, args.limit ?? 8);
|
|
163
|
-
case "
|
|
163
|
+
case "save_memory":
|
|
164
164
|
return core.addMemory(db, args.project, args.text, args.target_path);
|
|
165
|
-
case "
|
|
165
|
+
case "delete_memory":
|
|
166
166
|
return core.deleteMemory(db, args.project, Number(args.id));
|
|
167
|
-
case "
|
|
167
|
+
case "list_notes":
|
|
168
168
|
return core.listDocs(db, args.project);
|
|
169
|
-
case "
|
|
169
|
+
case "read_note":
|
|
170
170
|
return core.readNote(db, args.project, args.path, args.section);
|
|
171
|
-
case "
|
|
171
|
+
case "save_note":
|
|
172
172
|
return core.saveNote(db, args.project, args.path, args.content);
|
|
173
|
-
case "
|
|
173
|
+
case "update_section":
|
|
174
174
|
return core.updateSection(db, args.project, args.path, args.heading_path, args.content);
|
|
175
|
-
case "
|
|
175
|
+
case "delete_note":
|
|
176
176
|
return core.deleteNote(db, args.project, args.path);
|
|
177
177
|
default:
|
|
178
178
|
throw new Error(`unknown tool: ${name}`);
|
package/test/markdown.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { parseMarkdown, docTitle, replaceSectionBody } from "../src/markdown.js";
|
|
3
|
+
import { appendSection, parseMarkdown, docTitle, replaceSectionBody } from "../src/markdown.js";
|
|
4
4
|
|
|
5
5
|
test("parseMarkdown builds nested heading paths and excludes H1", () => {
|
|
6
6
|
const md = "# Title\n\nintro\n\n## Arch\n\ntop\n\n### Arch::Storage\n\nnested\n";
|
|
@@ -30,3 +30,8 @@ test("replaceSectionBody splices only the target section", () => {
|
|
|
30
30
|
assert.match(out, /keep me/);
|
|
31
31
|
assert.match(out, /^# T/);
|
|
32
32
|
});
|
|
33
|
+
|
|
34
|
+
test("appendSection adds a missing section at the end", () => {
|
|
35
|
+
const md = "# T\n\n## Existing\n\nkeep me\n";
|
|
36
|
+
assert.equal(appendSection(md, "Added", "new body"), `${md}\n## Added\n\nnew body\n`);
|
|
37
|
+
});
|
package/test/mcp.test.js
CHANGED
|
@@ -10,18 +10,18 @@ let db;
|
|
|
10
10
|
let open;
|
|
11
11
|
|
|
12
12
|
const EXPECTED_TOOLS = [
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
13
|
+
"list_projects",
|
|
14
|
+
"search_projects",
|
|
15
|
+
"rename_project",
|
|
16
|
+
"search_memories",
|
|
17
|
+
"search_notes",
|
|
18
|
+
"save_memory",
|
|
19
|
+
"delete_memory",
|
|
20
|
+
"list_notes",
|
|
21
|
+
"read_note",
|
|
22
|
+
"save_note",
|
|
23
|
+
"update_section",
|
|
24
|
+
"delete_note",
|
|
25
25
|
];
|
|
26
26
|
|
|
27
27
|
before(async () => {
|
|
@@ -58,19 +58,19 @@ test("unknown tool returns clean error", async () => {
|
|
|
58
58
|
});
|
|
59
59
|
|
|
60
60
|
test("search without project is a clean isError", async () => {
|
|
61
|
-
const res = await call("
|
|
61
|
+
const res = await call("search_memories", { query: "x" });
|
|
62
62
|
assert.equal(res.body.result.isError, true);
|
|
63
63
|
assert.match(res.body.result.content[0].text, /project is required/);
|
|
64
64
|
});
|
|
65
65
|
|
|
66
66
|
test("invalid slug is rejected before any lookup", async () => {
|
|
67
|
-
const res = await call("
|
|
67
|
+
const res = await call("search_memories", { project: "../evil", query: "x" });
|
|
68
68
|
assert.equal(res.body.result.isError, true);
|
|
69
69
|
assert.match(res.body.result.content[0].text, /invalid project slug/);
|
|
70
70
|
});
|
|
71
71
|
|
|
72
72
|
test("unknown project is a clean error", async () => {
|
|
73
|
-
const res = await call("
|
|
73
|
+
const res = await call("search_memories", { project: "ghost", query: "x" });
|
|
74
74
|
assert.match(res.body.result.content[0].text, /unknown project: ghost/);
|
|
75
75
|
});
|
|
76
76
|
|
|
@@ -89,23 +89,43 @@ test("rename_project moves slug, dir, and embeddings", async () => {
|
|
|
89
89
|
fs.mkdirSync(path.join(NOTES_DIR, "old-name"), { recursive: true });
|
|
90
90
|
fs.writeFileSync(path.join(NOTES_DIR, "old-name", "overview.md"), "# Old\n");
|
|
91
91
|
|
|
92
|
-
const res = await call("
|
|
92
|
+
const res = await call("rename_project", { project: "old-name", new_slug: "new-name" });
|
|
93
93
|
const data = JSON.parse(res.body.result.content[0].text);
|
|
94
94
|
assert.deepEqual(data.renamed, { from: "old-name", to: "new-name" });
|
|
95
95
|
assert.equal(db.prepare("SELECT slug FROM projects WHERE id = ?").get(pid).slug, "new-name");
|
|
96
96
|
assert.ok(fs.existsSync(path.join(NOTES_DIR, "new-name", "overview.md")));
|
|
97
97
|
assert.ok(!fs.existsSync(path.join(NOTES_DIR, "old-name")));
|
|
98
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("
|
|
99
|
+
const listed = JSON.parse((await call("list_projects", {})).body.result.content[0].text);
|
|
100
100
|
const row = listed.find((p) => p.slug === "new-name");
|
|
101
101
|
assert.equal(row.memories, 1);
|
|
102
102
|
assert.ok(!listed.some((p) => p.slug === "old-name"));
|
|
103
103
|
|
|
104
|
-
const dup = await call("
|
|
104
|
+
const dup = await call("rename_project", { project: "new-name", new_slug: "new-name" });
|
|
105
105
|
assert.equal(dup.body.result.isError, true);
|
|
106
106
|
assert.match(dup.body.result.content[0].text, /already named/);
|
|
107
107
|
|
|
108
|
-
const bad = await call("
|
|
108
|
+
const bad = await call("rename_project", { project: "new-name", new_slug: "../evil" });
|
|
109
109
|
assert.equal(bad.body.result.isError, true);
|
|
110
110
|
assert.match(bad.body.result.content[0].text, /invalid project slug/);
|
|
111
111
|
});
|
|
112
|
+
|
|
113
|
+
test("update_section appends a missing section", async () => {
|
|
114
|
+
await call("save_note", {
|
|
115
|
+
project: "section-upsert",
|
|
116
|
+
path: "note.md",
|
|
117
|
+
content: "# Note\n\n## Existing\n\nkeep me\n",
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const res = await call("update_section", {
|
|
121
|
+
project: "section-upsert",
|
|
122
|
+
path: "note.md",
|
|
123
|
+
heading_path: "Added",
|
|
124
|
+
content: "new body",
|
|
125
|
+
});
|
|
126
|
+
assert.equal(res.body.result.isError, undefined);
|
|
127
|
+
|
|
128
|
+
const note = JSON.parse(res.body.result.content[0].text);
|
|
129
|
+
assert.equal(note.content, "# Note\n\n## Existing\n\nkeep me\n\n## Added\n\nnew body\n");
|
|
130
|
+
assert.ok(note.sections.some((section) => section.path === "Added"));
|
|
131
|
+
});
|