@sksoftofficial/mindroot 1.0.5 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/INSTRUCTIONS.md +41 -0
- package/README.md +80 -13
- package/bin/mindroot.js +4 -0
- package/package.json +6 -2
- package/src/commands/install.js +210 -0
- package/src/core.js +9 -2
- package/src/dashboard/graph.js +175 -0
- package/src/dashboard/index.html +246 -80
- package/src/mcp.js +86 -25
- package/src/paths.js +15 -3
- package/src/search.js +28 -0
- package/src/server.js +25 -4
- package/src/skills.js +133 -0
- package/src/store/db.js +50 -0
- package/test/install-clients.test.js +187 -0
- package/test/install.test.js +127 -0
- package/test/mcp.test.js +25 -21
- package/test/paths.test.js +53 -0
- package/test/skills.test.js +253 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { test, before, after } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { DatabaseSync } from "node:sqlite";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
|
|
8
|
+
let core, ensureSchema, rankSkills, handleRpc, createServer, dir;
|
|
9
|
+
const vector = async () => new Float32Array([1, 0]);
|
|
10
|
+
const content = "## Procedure\n\n1. Follow the project's API conventions.\n";
|
|
11
|
+
const description = "Add an API endpoint following router conventions";
|
|
12
|
+
|
|
13
|
+
before(async () => {
|
|
14
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), "mindroot-skills-"));
|
|
15
|
+
process.env.MINDROOT_DIR = dir;
|
|
16
|
+
core = await import("../src/core.js");
|
|
17
|
+
({ ensureSchema } = await import("../src/store/db.js"));
|
|
18
|
+
({ rankSkills } = await import("../src/search.js"));
|
|
19
|
+
({ handleRpc } = await import("../src/mcp.js"));
|
|
20
|
+
({ createServer } = await import("../src/server.js"));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
24
|
+
|
|
25
|
+
function database(t) {
|
|
26
|
+
const db = new DatabaseSync(":memory:");
|
|
27
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
28
|
+
ensureSchema(db);
|
|
29
|
+
t.after(() => db.close());
|
|
30
|
+
return db;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function save(db, project = "alpha", name = "add-api-route", desc = description, body = content, expected, embed = vector) {
|
|
34
|
+
return core.saveSkill(db, project, name, desc, body, expected, embed);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function call(db, name, args) {
|
|
38
|
+
return handleRpc(db, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
test("skill saves auto-create projects, index summaries, and preserve revisions without extra memories", async (t) => {
|
|
42
|
+
const db = database(t);
|
|
43
|
+
let embedded;
|
|
44
|
+
const first = await save(db, "alpha", "add-api-route", description, content, 0, async (text) => {
|
|
45
|
+
embedded = text;
|
|
46
|
+
return vector();
|
|
47
|
+
});
|
|
48
|
+
assert.equal(first.revision, 1);
|
|
49
|
+
assert.equal(embedded, `add-api-route\n${description}`);
|
|
50
|
+
assert.equal(first.content, undefined);
|
|
51
|
+
assert.equal(core.readSkill(db, "alpha", first.name).content, content);
|
|
52
|
+
assert.equal(db.prepare("SELECT COUNT(*) n FROM memories").get().n, 0);
|
|
53
|
+
const id = db.prepare("SELECT id FROM skills").get().id;
|
|
54
|
+
|
|
55
|
+
const unchanged = await save(db, "alpha", first.name, description, content, 1, () => {
|
|
56
|
+
throw new Error("identical content should not be embedded");
|
|
57
|
+
});
|
|
58
|
+
assert.equal(unchanged.unchanged, true);
|
|
59
|
+
assert.equal(unchanged.revision, 1);
|
|
60
|
+
|
|
61
|
+
const updated = await save(db, "alpha", first.name, "Publish release tags", "## Procedure\nPublish a tag.", 1);
|
|
62
|
+
assert.equal(updated.revision, 2);
|
|
63
|
+
assert.equal(updated.created_at, first.created_at);
|
|
64
|
+
assert.equal(db.prepare("SELECT id FROM skills").get().id, id);
|
|
65
|
+
assert.equal(core.readSkill(db, "alpha", first.name, 1).content, content);
|
|
66
|
+
assert.equal(core.readSkill(db, "alpha", first.name, 1).current_revision, 2);
|
|
67
|
+
assert.equal(core.readSkill(db, "alpha", first.name, 2).description, "Publish release tags");
|
|
68
|
+
assert.equal(db.prepare("SELECT COUNT(*) n FROM skills_fts WHERE skills_fts MATCH 'endpoint'").get().n, 0);
|
|
69
|
+
assert.equal(db.prepare("SELECT COUNT(*) n FROM skills_fts WHERE skills_fts MATCH 'release'").get().n, 1);
|
|
70
|
+
assert.equal(db.prepare("SELECT COUNT(*) n FROM skill_embeddings").get().n, 1);
|
|
71
|
+
assert.equal(db.prepare("SELECT COUNT(*) n FROM skill_revisions").get().n, 2);
|
|
72
|
+
assert.equal((await core.listProjects(db))[0].skills, 1);
|
|
73
|
+
assert.equal(core.listSkills(db, "alpha")[0].content, undefined);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("invalid input and stale revisions fail before embedding or writing", async (t) => {
|
|
77
|
+
const db = database(t);
|
|
78
|
+
const cases = [
|
|
79
|
+
[undefined, "valid", description, content],
|
|
80
|
+
["../escape", "valid", description, content],
|
|
81
|
+
["alpha", "../escape", description, content],
|
|
82
|
+
["alpha", "UpperCase", description, content],
|
|
83
|
+
["alpha", "valid", " ", content],
|
|
84
|
+
["alpha", "valid", "a".repeat(2001), content],
|
|
85
|
+
["alpha", "valid", description, 42],
|
|
86
|
+
["alpha", "valid", description, " "],
|
|
87
|
+
["alpha", "valid", description, content, -1],
|
|
88
|
+
["alpha", "valid", description, content, "1"],
|
|
89
|
+
];
|
|
90
|
+
for (const [project, name, desc, body, expected] of cases) {
|
|
91
|
+
await assert.rejects(core.saveSkill(db, project, name, desc, body, expected, vector), { status: 400 });
|
|
92
|
+
}
|
|
93
|
+
assert.equal(db.prepare("SELECT COUNT(*) n FROM projects").get().n, 0);
|
|
94
|
+
await save(db);
|
|
95
|
+
await assert.rejects(save(db, "alpha", "add-api-route", description, content, 0), { status: 409 });
|
|
96
|
+
await assert.rejects(save(db, "alpha", "add-api-route", description, content, 2), { status: 409 });
|
|
97
|
+
assert.throws(() => core.readSkill(db, "alpha", "add-api-route", 0), { status: 400 });
|
|
98
|
+
assert.throws(() => core.readSkill(db, "alpha", "add-api-route", 20), { status: 404 });
|
|
99
|
+
await assert.rejects(core.searchSkills(db, "alpha", null), { status: 400 });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("embedding and database failures leave no partial skill, history, index, or project", async (t) => {
|
|
103
|
+
const db = database(t);
|
|
104
|
+
await assert.rejects(save(db, "alpha", "add-api-route", description, content, 0, async () => {
|
|
105
|
+
throw new Error("model unavailable");
|
|
106
|
+
}), /model unavailable/);
|
|
107
|
+
assert.equal(db.prepare("SELECT COUNT(*) n FROM projects").get().n, 0);
|
|
108
|
+
|
|
109
|
+
db.exec(`CREATE TRIGGER reject_skill_embedding BEFORE INSERT ON skill_embeddings
|
|
110
|
+
BEGIN SELECT RAISE(ABORT, 'embedding write failed'); END;`);
|
|
111
|
+
await assert.rejects(save(db), /embedding write failed/);
|
|
112
|
+
for (const table of ["projects", "skills", "skill_revisions", "skill_embeddings", "skills_fts"]) {
|
|
113
|
+
assert.equal(db.prepare(`SELECT COUNT(*) n FROM ${table}`).get().n, 0);
|
|
114
|
+
}
|
|
115
|
+
db.exec("DROP TRIGGER reject_skill_embedding");
|
|
116
|
+
await save(db);
|
|
117
|
+
db.exec(`CREATE TRIGGER reject_revision BEFORE INSERT ON skill_revisions
|
|
118
|
+
BEGIN SELECT RAISE(ABORT, 'revision write failed'); END;`);
|
|
119
|
+
await assert.rejects(save(db, "alpha", "add-api-route", "Changed summary", "Changed content", 1), /revision write failed/);
|
|
120
|
+
assert.equal(core.readSkill(db, "alpha", "add-api-route").revision, 1);
|
|
121
|
+
assert.equal(core.readSkill(db, "alpha", "add-api-route").content, content);
|
|
122
|
+
assert.equal(db.prepare("SELECT COUNT(*) n FROM skills_fts WHERE skills_fts MATCH 'endpoint'").get().n, 1);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("concurrent writers cannot silently overwrite a skill, even without expected_revision", async (t) => {
|
|
126
|
+
const db = database(t);
|
|
127
|
+
await save(db);
|
|
128
|
+
let release;
|
|
129
|
+
const waiting = new Promise((resolve) => { release = resolve; });
|
|
130
|
+
const slow = save(db, "alpha", "add-api-route", "Slow writer", "Slow content", undefined, async () => {
|
|
131
|
+
await waiting;
|
|
132
|
+
return vector();
|
|
133
|
+
});
|
|
134
|
+
await save(db, "alpha", "add-api-route", "Fast writer", "Fast content", 1);
|
|
135
|
+
release();
|
|
136
|
+
await assert.rejects(slow, { status: 409 });
|
|
137
|
+
assert.equal(core.readSkill(db, "alpha", "add-api-route").content, "Fast content");
|
|
138
|
+
assert.equal(core.readSkill(db, "alpha", "add-api-route").revision, 2);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("search blends semantics and keywords, scopes before limiting, and returns no procedure", async (t) => {
|
|
142
|
+
const db = database(t);
|
|
143
|
+
await save(db, "alpha", "release", "Publish release tags", content);
|
|
144
|
+
await save(db, "alpha", "cooking", "Boil pasta", "body-only-keyword", undefined, async () => new Float32Array([0, 1]));
|
|
145
|
+
// More than the FTS limit of matching skills in another project must not crowd out alpha.
|
|
146
|
+
for (let i = 0; i < 105; i++) await save(db, "beta", `release-${i}`, "Publish release tags", "Other project");
|
|
147
|
+
const pid = db.prepare("SELECT id FROM projects WHERE slug = 'alpha'").get().id;
|
|
148
|
+
const hits = rankSkills(db, pid, new Float32Array([1, 0]), "release tags", 1);
|
|
149
|
+
assert.equal(hits.length, 1);
|
|
150
|
+
assert.equal(hits[0].name, "release");
|
|
151
|
+
assert.equal(hits[0].kind, "skill");
|
|
152
|
+
assert.ok(hits[0].score > 0.75);
|
|
153
|
+
assert.equal(hits[0].content, undefined);
|
|
154
|
+
assert.equal(db.prepare("SELECT COUNT(*) n FROM skills_fts WHERE skills_fts MATCH 'body'").get().n, 0);
|
|
155
|
+
assert.equal(rankSkills(db, pid, new Float32Array([1, 0]), "release tags", 8).length, 2);
|
|
156
|
+
assert.doesNotThrow(() => rankSkills(db, pid, new Float32Array([1, 0, 0]), '" OR *', 8));
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("same names are project-specific; rename preserves skills and deletion cascades", async (t) => {
|
|
160
|
+
const db = database(t);
|
|
161
|
+
await save(db);
|
|
162
|
+
await save(db, "beta", "add-api-route", description, "Beta instructions");
|
|
163
|
+
await core.renameProject(db, "alpha", "renamed");
|
|
164
|
+
assert.equal(core.readSkill(db, "renamed", "add-api-route", 1).content, content);
|
|
165
|
+
assert.equal(core.readSkill(db, "beta", "add-api-route").content, "Beta instructions");
|
|
166
|
+
assert.throws(() => core.readSkill(db, "alpha", "add-api-route"), { status: 404 });
|
|
167
|
+
core.deleteSkill(db, "renamed", "add-api-route");
|
|
168
|
+
assert.throws(() => core.deleteSkill(db, "renamed", "add-api-route"), { status: 404 });
|
|
169
|
+
assert.deepEqual(await core.searchSkills(db, "renamed", "add API route"), []);
|
|
170
|
+
for (const table of ["skills", "skill_revisions", "skill_embeddings", "skills_fts"]) {
|
|
171
|
+
assert.equal(db.prepare(`SELECT COUNT(*) n FROM ${table}`).get().n, 1);
|
|
172
|
+
}
|
|
173
|
+
await core.deleteProject(db, "beta");
|
|
174
|
+
for (const table of ["skills", "skill_revisions", "skill_embeddings", "skills_fts"]) {
|
|
175
|
+
assert.equal(db.prepare(`SELECT COUNT(*) n FROM ${table}`).get().n, 0);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("schema additions preserve existing memory data and survive reopen", async (t) => {
|
|
180
|
+
const filename = path.join(dir, "upgrade.db");
|
|
181
|
+
let db = new DatabaseSync(filename);
|
|
182
|
+
t.after(() => db.close());
|
|
183
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
184
|
+
ensureSchema(db);
|
|
185
|
+
// Recreate the pre-skills schema without changing the existing note/memory tables.
|
|
186
|
+
db.exec("DROP TABLE skill_embeddings; DROP TABLE skill_revisions; DROP TABLE skills; DROP TABLE skills_fts;");
|
|
187
|
+
db.exec("INSERT INTO projects (slug, created_at) VALUES ('legacy', 1)");
|
|
188
|
+
db.exec("INSERT INTO memories (project_id, text, target_type, created_at, updated_at) VALUES (1, 'existing fact', 'standalone', 1, 1)");
|
|
189
|
+
ensureSchema(db);
|
|
190
|
+
ensureSchema(db);
|
|
191
|
+
await save(db, "legacy");
|
|
192
|
+
db.close();
|
|
193
|
+
db = new DatabaseSync(filename);
|
|
194
|
+
ensureSchema(db);
|
|
195
|
+
assert.equal(core.readSkill(db, "legacy", "add-api-route", 1).content, content);
|
|
196
|
+
assert.equal(db.prepare("SELECT COUNT(*) n FROM memories_fts WHERE memories_fts MATCH 'existing'").get().n, 1);
|
|
197
|
+
assert.equal(db.prepare("SELECT COUNT(*) n FROM skill_embeddings").get().n, 1);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("MCP dispatches skill reads, updates, conflicts, validation, search, and deletion", async (t) => {
|
|
201
|
+
const db = database(t);
|
|
202
|
+
await save(db);
|
|
203
|
+
const read = await call(db, "read_skill", { project: "alpha", name: "add-api-route", revision: 1 });
|
|
204
|
+
assert.equal(JSON.parse(read.body.result.content[0].text).content, content);
|
|
205
|
+
const update = await call(db, "save_skill", { project: "alpha", name: "add-api-route", description, content, expected_revision: 1 });
|
|
206
|
+
assert.equal(JSON.parse(update.body.result.content[0].text).unchanged, true);
|
|
207
|
+
const stale = await call(db, "save_skill", { project: "alpha", name: "add-api-route", description, content, expected_revision: 0 });
|
|
208
|
+
assert.equal(stale.body.result.isError, true);
|
|
209
|
+
assert.match(stale.body.result.content[0].text, /skill changed/);
|
|
210
|
+
for (const name of ["search_skills", "read_skill", "save_skill", "delete_skill"]) {
|
|
211
|
+
const invalid = await call(db, name, {});
|
|
212
|
+
assert.equal(invalid.body.result.isError, true);
|
|
213
|
+
assert.match(invalid.body.result.content[0].text, /project is required/);
|
|
214
|
+
}
|
|
215
|
+
const deleted = await call(db, "delete_skill", { project: "alpha", name: "add-api-route" });
|
|
216
|
+
assert.equal(JSON.parse(deleted.body.result.content[0].text).deleted, "add-api-route");
|
|
217
|
+
const empty = await call(db, "search_skills", { project: "alpha", query: "API route" });
|
|
218
|
+
assert.deepEqual(JSON.parse(empty.body.result.content[0].text), []);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("REST exposes authenticated skill summaries, content, conflicts, search, and deletion", async (t) => {
|
|
222
|
+
const db = database(t);
|
|
223
|
+
await save(db);
|
|
224
|
+
const server = createServer(db, { port: 0, apiKey: "test-key" });
|
|
225
|
+
await new Promise((resolve, reject) => {
|
|
226
|
+
server.once("error", reject);
|
|
227
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
228
|
+
});
|
|
229
|
+
t.after(() => new Promise((resolve) => server.close(resolve)));
|
|
230
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
231
|
+
const headers = { Authorization: "Bearer test-key", "Content-Type": "application/json" };
|
|
232
|
+
const route = "/api/projects/alpha/skills/add-api-route";
|
|
233
|
+
assert.equal((await fetch(base + route)).status, 401);
|
|
234
|
+
const listed = await fetch(base + "/api/projects/alpha/skills", { headers });
|
|
235
|
+
assert.equal((await listed.json())[0].content, undefined);
|
|
236
|
+
const read = await fetch(base + route + "?revision=1", { headers });
|
|
237
|
+
assert.equal((await read.json()).content, content);
|
|
238
|
+
assert.equal((await fetch(base + route + "?revision=0", { headers })).status, 400);
|
|
239
|
+
const saved = await fetch(base + route, {
|
|
240
|
+
method: "PUT", headers, body: JSON.stringify({ description, content, expected_revision: 1 }),
|
|
241
|
+
});
|
|
242
|
+
assert.equal((await saved.json()).unchanged, true);
|
|
243
|
+
const conflict = await fetch(base + route, {
|
|
244
|
+
method: "PUT", headers, body: JSON.stringify({ description, content, expected_revision: 0 }),
|
|
245
|
+
});
|
|
246
|
+
assert.equal(conflict.status, 409);
|
|
247
|
+
assert.equal((await fetch(base + route, { method: "DELETE", headers })).status, 200);
|
|
248
|
+
const empty = await fetch(base + "/api/search", {
|
|
249
|
+
method: "POST", headers, body: JSON.stringify({ project: "alpha", query: "API route", kind: "skills" }),
|
|
250
|
+
});
|
|
251
|
+
assert.deepEqual(await empty.json(), []);
|
|
252
|
+
assert.equal((await fetch(base + route, { headers })).status, 404);
|
|
253
|
+
});
|