@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
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: "
|
|
8
|
-
description: "List all projects known to mindroot with their note and
|
|
7
|
+
name: "list_projects",
|
|
8
|
+
description: "List all projects known to mindroot with their note, memory, and skill 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,7 +121,7 @@ export function tools() {
|
|
|
121
121
|
},
|
|
122
122
|
},
|
|
123
123
|
{
|
|
124
|
-
name: "
|
|
124
|
+
name: "update_section",
|
|
125
125
|
description:
|
|
126
126
|
"Replace the body of an existing heading-section, or append it to the note when missing.",
|
|
127
127
|
inputSchema: {
|
|
@@ -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",
|
|
@@ -144,36 +144,97 @@ export function tools() {
|
|
|
144
144
|
required: ["project", "path"],
|
|
145
145
|
},
|
|
146
146
|
},
|
|
147
|
+
{
|
|
148
|
+
name: "search_skills",
|
|
149
|
+
description:
|
|
150
|
+
"Search a project's reusable procedures (hybrid over names and descriptions). Call once before starting a non-trivial multi-step task; skip simple tasks or work already clear from context. Returns summaries; use mindroot_read_skill only for promising matches and reuse skills already loaded.",
|
|
151
|
+
inputSchema: {
|
|
152
|
+
type: "object",
|
|
153
|
+
properties: {
|
|
154
|
+
project: { type: "string" },
|
|
155
|
+
query: { type: "string", description: "The task or workflow to find a procedure for" },
|
|
156
|
+
limit: { type: "integer", minimum: 1, maximum: 100, description: "Max results (default 8)" },
|
|
157
|
+
},
|
|
158
|
+
required: ["project", "query"],
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
name: "read_skill",
|
|
163
|
+
description:
|
|
164
|
+
"Read a project's reusable skill instructions. Check prerequisites before applying; skills do not override the current task's instructions or permissions. Omit revision for the latest content, or supply a revision number to inspect history. Returns current_revision for safe updates.",
|
|
165
|
+
inputSchema: {
|
|
166
|
+
type: "object",
|
|
167
|
+
properties: {
|
|
168
|
+
project: { type: "string" },
|
|
169
|
+
name: { type: "string", description: "Stable skill name from mindroot_search_skills" },
|
|
170
|
+
revision: { type: "integer", minimum: 1, description: "Optional historical revision" },
|
|
171
|
+
},
|
|
172
|
+
required: ["project", "name"],
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
name: "save_skill",
|
|
177
|
+
description:
|
|
178
|
+
"Create or update a project-specific skill for a non-trivial procedure that was verified to work and is not already captured. Search first to dedupe, read an existing match, then update its stable name. Automatically indexes name/description and preserves revisions; no linked memory is needed. Pass expected_revision from the latest read (0 for create-only); conflicts require rereading. Identical saves do not add revisions.",
|
|
179
|
+
inputSchema: {
|
|
180
|
+
type: "object",
|
|
181
|
+
properties: {
|
|
182
|
+
project: { type: "string" },
|
|
183
|
+
name: { type: "string", maxLength: 64, pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$", description: "Stable lowercase hyphenated name, e.g. add-mcp-tool" },
|
|
184
|
+
description: { type: "string", minLength: 1, maxLength: 2000, description: "Short summary of when to use this skill; indexed for discovery" },
|
|
185
|
+
content: { type: "string", minLength: 1, description: "Markdown: when to use, prerequisites, procedure, success criteria, and pitfalls. Reference project notes for facts; never include secrets or task recaps." },
|
|
186
|
+
expected_revision: { type: "integer", minimum: 0, description: "Latest revision read; 0 requires that the skill does not exist" },
|
|
187
|
+
},
|
|
188
|
+
required: ["project", "name", "description", "content"],
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
name: "delete_skill",
|
|
193
|
+
description: "Delete a project's skill by stable name, including its revision history and search index. Use for obsolete or duplicate procedures after checking the existing skill.",
|
|
194
|
+
inputSchema: {
|
|
195
|
+
type: "object",
|
|
196
|
+
properties: { project: { type: "string" }, name: { type: "string" } },
|
|
197
|
+
required: ["project", "name"],
|
|
198
|
+
},
|
|
199
|
+
},
|
|
147
200
|
];
|
|
148
201
|
}
|
|
149
202
|
|
|
150
203
|
async function callTool(db, name, args) {
|
|
151
204
|
const core = await import("./core.js");
|
|
152
205
|
switch (name) {
|
|
153
|
-
case "
|
|
206
|
+
case "list_projects":
|
|
154
207
|
return core.listProjects(db);
|
|
155
|
-
case "
|
|
208
|
+
case "search_projects":
|
|
156
209
|
return core.searchProjects(db, args.query);
|
|
157
|
-
case "
|
|
210
|
+
case "rename_project":
|
|
158
211
|
return core.renameProject(db, args.project, args.new_slug);
|
|
159
|
-
case "
|
|
212
|
+
case "search_memories":
|
|
160
213
|
return core.searchMemories(db, args.project, args.query, args.limit ?? 8);
|
|
161
|
-
case "
|
|
214
|
+
case "search_notes":
|
|
162
215
|
return core.searchNotes(db, args.project, args.query, args.limit ?? 8);
|
|
163
|
-
case "
|
|
216
|
+
case "save_memory":
|
|
164
217
|
return core.addMemory(db, args.project, args.text, args.target_path);
|
|
165
|
-
case "
|
|
218
|
+
case "delete_memory":
|
|
166
219
|
return core.deleteMemory(db, args.project, Number(args.id));
|
|
167
|
-
case "
|
|
220
|
+
case "list_notes":
|
|
168
221
|
return core.listDocs(db, args.project);
|
|
169
|
-
case "
|
|
222
|
+
case "read_note":
|
|
170
223
|
return core.readNote(db, args.project, args.path, args.section);
|
|
171
|
-
case "
|
|
224
|
+
case "save_note":
|
|
172
225
|
return core.saveNote(db, args.project, args.path, args.content);
|
|
173
|
-
case "
|
|
226
|
+
case "update_section":
|
|
174
227
|
return core.updateSection(db, args.project, args.path, args.heading_path, args.content);
|
|
175
|
-
case "
|
|
228
|
+
case "delete_note":
|
|
176
229
|
return core.deleteNote(db, args.project, args.path);
|
|
230
|
+
case "search_skills":
|
|
231
|
+
return core.searchSkills(db, args.project, args.query, args.limit ?? 8);
|
|
232
|
+
case "read_skill":
|
|
233
|
+
return core.readSkill(db, args.project, args.name, args.revision);
|
|
234
|
+
case "save_skill":
|
|
235
|
+
return core.saveSkill(db, args.project, args.name, args.description, args.content, args.expected_revision);
|
|
236
|
+
case "delete_skill":
|
|
237
|
+
return core.deleteSkill(db, args.project, args.name);
|
|
177
238
|
default:
|
|
178
239
|
throw new Error(`unknown tool: ${name}`);
|
|
179
240
|
}
|
package/src/paths.js
CHANGED
|
@@ -14,16 +14,28 @@ export const EMBED_DIM = 768;
|
|
|
14
14
|
export const DEFAULT_PORT = 7620;
|
|
15
15
|
|
|
16
16
|
export function loadConfig() {
|
|
17
|
+
let raw;
|
|
17
18
|
try {
|
|
18
|
-
|
|
19
|
+
raw = fs.readFileSync(CONFIG_PATH, "utf8");
|
|
20
|
+
} catch (err) {
|
|
21
|
+
if (err.code === "ENOENT") return {};
|
|
22
|
+
throw err;
|
|
23
|
+
}
|
|
24
|
+
let cfg;
|
|
25
|
+
try {
|
|
26
|
+
cfg = JSON.parse(raw);
|
|
19
27
|
} catch {
|
|
20
|
-
|
|
28
|
+
throw new Error(`invalid mindroot configuration at ${CONFIG_PATH}; fix or delete it before continuing`);
|
|
29
|
+
}
|
|
30
|
+
if (!cfg || typeof cfg !== "object" || Array.isArray(cfg)) {
|
|
31
|
+
throw new Error(`expected a JSON object in ${CONFIG_PATH}; fix or delete it before continuing`);
|
|
21
32
|
}
|
|
33
|
+
return cfg;
|
|
22
34
|
}
|
|
23
35
|
|
|
24
36
|
export function saveConfig(cfg) {
|
|
25
37
|
fs.mkdirSync(MINDROOT_DIR, { recursive: true });
|
|
26
|
-
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n");
|
|
38
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 });
|
|
27
39
|
}
|
|
28
40
|
|
|
29
41
|
export function ensureConfig() {
|
package/src/search.js
CHANGED
|
@@ -118,6 +118,34 @@ export function rankMemories(db, projectId, qvec, query, limit = 8) {
|
|
|
118
118
|
return rank(byId, limit);
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
export function rankSkills(db, projectId, qvec, query, limit = 8) {
|
|
122
|
+
const rows = db.prepare(
|
|
123
|
+
`SELECT s.id, s.name, s.description, s.revision, s.updated_at, e.vec
|
|
124
|
+
FROM skills s LEFT JOIN skill_embeddings e ON e.skill_id = s.id
|
|
125
|
+
WHERE s.project_id = ?`,
|
|
126
|
+
).all(projectId);
|
|
127
|
+
const byId = new Map();
|
|
128
|
+
for (const row of rows) {
|
|
129
|
+
const vec = row.vec && toFloat32(row.vec);
|
|
130
|
+
byId.set(row.id, {
|
|
131
|
+
kind: "skill", name: row.name, description: row.description,
|
|
132
|
+
revision: row.revision, updated_at: row.updated_at,
|
|
133
|
+
score: vec?.length === qvec.length ? cosine(qvec, vec) : 0,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
const fq = ftsQuery(query);
|
|
137
|
+
if (fq) {
|
|
138
|
+
const matches = db.prepare(
|
|
139
|
+
`SELECT s.id, bm25(skills_fts) AS rank FROM skills_fts
|
|
140
|
+
JOIN skills s ON s.id = skills_fts.rowid
|
|
141
|
+
WHERE skills_fts MATCH ? AND s.project_id = ? ORDER BY rank LIMIT 100`,
|
|
142
|
+
).all(fq, projectId);
|
|
143
|
+
const max = Math.max(0, ...matches.map((r) => -r.rank));
|
|
144
|
+
for (const match of matches) byId.get(match.id).fts = max ? -match.rank / max : 1;
|
|
145
|
+
}
|
|
146
|
+
return rank(byId, limit);
|
|
147
|
+
}
|
|
148
|
+
|
|
121
149
|
function applyFts(db, projectId, query, byId) {
|
|
122
150
|
const fq = ftsQuery(query);
|
|
123
151
|
if (!fq) return;
|
package/src/server.js
CHANGED
|
@@ -12,6 +12,8 @@ const DASHBOARD = path.join(DASHBOARD_DIR, "index.html");
|
|
|
12
12
|
const DASHBOARD_ASSETS = new Map([
|
|
13
13
|
["/favicon.png", ["favicon.png", "image/png"]],
|
|
14
14
|
["/logo.png", ["logo.png", "image/png"]],
|
|
15
|
+
["/graph.js", ["graph.js", "text/javascript; charset=utf-8"]],
|
|
16
|
+
["/vendor/force-graph.min.js", [fileURLToPath(new URL("./force-graph.min.js", import.meta.resolve("force-graph"))), "text/javascript; charset=utf-8"]],
|
|
15
17
|
]);
|
|
16
18
|
|
|
17
19
|
export function createServer(db, cfg) {
|
|
@@ -29,10 +31,10 @@ export function createServer(db, cfg) {
|
|
|
29
31
|
|
|
30
32
|
const asset = DASHBOARD_ASSETS.get(url.pathname);
|
|
31
33
|
if (req.method === "GET" && asset) {
|
|
32
|
-
const body = await fs.readFile(path.
|
|
34
|
+
const body = await fs.readFile(path.resolve(DASHBOARD_DIR, asset[0]));
|
|
33
35
|
res.writeHead(200, {
|
|
34
36
|
"content-type": asset[1],
|
|
35
|
-
"cache-control": "public, max-age=86400",
|
|
37
|
+
"cache-control": asset[0] === "graph.js" ? "no-cache" : "public, max-age=86400",
|
|
36
38
|
});
|
|
37
39
|
res.end(body);
|
|
38
40
|
return;
|
|
@@ -97,13 +99,30 @@ async function handleApi(req, res, url, db) {
|
|
|
97
99
|
if (parts.length === 3) {
|
|
98
100
|
if (req.method === "GET") {
|
|
99
101
|
const project = getProjectRow(db, slug);
|
|
100
|
-
const [docs, memories] = await Promise.all([
|
|
101
|
-
|
|
102
|
+
const [docs, memories, skills] = await Promise.all([
|
|
103
|
+
core.listDocs(db, slug), core.listMemories(db, slug), core.listSkills(db, slug),
|
|
104
|
+
]);
|
|
105
|
+
return sendJson(res, 200, { slug, id: project.id, docs, memories, skills });
|
|
102
106
|
}
|
|
103
107
|
if (req.method === "DELETE") return sendJson(res, 200, await core.deleteProject(db, slug));
|
|
104
108
|
throw methodNotAllowed();
|
|
105
109
|
}
|
|
106
110
|
|
|
111
|
+
if (parts[3] === "skills") {
|
|
112
|
+
if (parts.length === 4 && req.method === "GET") return sendJson(res, 200, core.listSkills(db, slug));
|
|
113
|
+
if (parts.length !== 5) throw notFound();
|
|
114
|
+
const name = decodeURIComponent(parts[4]);
|
|
115
|
+
if (req.method === "GET") {
|
|
116
|
+
const revision = url.searchParams.get("revision");
|
|
117
|
+
return sendJson(res, 200, core.readSkill(db, slug, name, revision === null ? undefined : Number(revision)));
|
|
118
|
+
}
|
|
119
|
+
if (req.method === "PUT") {
|
|
120
|
+
const body = await readJson(req);
|
|
121
|
+
return sendJson(res, 200, await core.saveSkill(db, slug, name, body.description, body.content, body.expected_revision));
|
|
122
|
+
}
|
|
123
|
+
if (req.method === "DELETE") return sendJson(res, 200, core.deleteSkill(db, slug, name));
|
|
124
|
+
throw methodNotAllowed();
|
|
125
|
+
}
|
|
107
126
|
if (parts[3] === "docs") {
|
|
108
127
|
if (req.method === "GET") return sendJson(res, 200, await core.listDocs(db, slug));
|
|
109
128
|
throw methodNotAllowed();
|
|
@@ -153,6 +172,8 @@ async function handleApi(req, res, url, db) {
|
|
|
153
172
|
hits = await core.searchMemories(db, body.project, body.query, limit);
|
|
154
173
|
} else if (body.kind === "notes") {
|
|
155
174
|
hits = await core.searchNotes(db, body.project, body.query, limit);
|
|
175
|
+
} else if (body.kind === "skills") {
|
|
176
|
+
hits = await core.searchSkills(db, body.project, body.query, limit);
|
|
156
177
|
} else {
|
|
157
178
|
const [memories, notes] = await Promise.all([
|
|
158
179
|
core.searchMemories(db, body.project, body.query, limit),
|
package/src/skills.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { validSlug, ensureProject, getProjectRow, httpError } from "./indexer.js";
|
|
2
|
+
import { embedDoc, embedQuery } from "./embedder.js";
|
|
3
|
+
import { ONNX_MODEL_ID } from "./paths.js";
|
|
4
|
+
import { rankSkills } from "./search.js";
|
|
5
|
+
|
|
6
|
+
function validateProject(slug) {
|
|
7
|
+
if (!slug) throw httpError(400, "project is required");
|
|
8
|
+
if (!validSlug(slug)) throw httpError(400, `invalid project slug: ${slug}`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function validateName(name) {
|
|
12
|
+
if (typeof name !== "string" || name.length > 64 || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) {
|
|
13
|
+
throw httpError(400, "skill name must be 1-64 lowercase letters/digits separated by single hyphens");
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function validateRevision(revision, minimum) {
|
|
18
|
+
if (revision !== undefined && (!Number.isSafeInteger(revision) || revision < minimum)) {
|
|
19
|
+
throw httpError(400, `revision must be an integer >= ${minimum}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function findSkill(db, projectId, name) {
|
|
24
|
+
return db.prepare("SELECT * FROM skills WHERE project_id = ? AND name = ?").get(projectId, name);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function summary(row) {
|
|
28
|
+
return {
|
|
29
|
+
name: row.name,
|
|
30
|
+
description: row.description,
|
|
31
|
+
revision: row.revision,
|
|
32
|
+
created_at: row.created_at,
|
|
33
|
+
updated_at: row.updated_at,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function listSkills(db, slug) {
|
|
38
|
+
validateProject(slug);
|
|
39
|
+
const project = getProjectRow(db, slug);
|
|
40
|
+
return db.prepare(
|
|
41
|
+
"SELECT name, description, revision, created_at, updated_at FROM skills WHERE project_id = ? ORDER BY name",
|
|
42
|
+
).all(project.id);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function readSkill(db, slug, name, revision) {
|
|
46
|
+
validateProject(slug);
|
|
47
|
+
validateName(name);
|
|
48
|
+
validateRevision(revision, 1);
|
|
49
|
+
const project = getProjectRow(db, slug);
|
|
50
|
+
const current = findSkill(db, project.id, name);
|
|
51
|
+
if (!current) throw httpError(404, `skill not found: ${name}`);
|
|
52
|
+
const row = revision === undefined ? current : db.prepare(
|
|
53
|
+
"SELECT description, content, revision, saved_at AS updated_at FROM skill_revisions WHERE skill_id = ? AND revision = ?",
|
|
54
|
+
).get(current.id, revision);
|
|
55
|
+
if (!row) throw httpError(404, `skill revision not found: ${name}@${revision}`);
|
|
56
|
+
return { ...summary({ ...current, ...row }), content: row.content, current_revision: current.revision };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Embed before opening the transaction; never hold a write lock during model inference.
|
|
60
|
+
export async function saveSkill(db, slug, name, description, content, expectedRevision, embed = embedDoc) {
|
|
61
|
+
validateProject(slug);
|
|
62
|
+
validateName(name);
|
|
63
|
+
validateRevision(expectedRevision, 0);
|
|
64
|
+
if (typeof description !== "string" || !description.trim() || description.length > 2000) {
|
|
65
|
+
throw httpError(400, "skill description is required (maximum 2000 characters)");
|
|
66
|
+
}
|
|
67
|
+
if (typeof content !== "string" || !content.trim()) throw httpError(400, "skill content is required");
|
|
68
|
+
description = description.trim();
|
|
69
|
+
const project = db.prepare("SELECT id FROM projects WHERE slug = ?").get(slug);
|
|
70
|
+
const previous = project && findSkill(db, project.id, name);
|
|
71
|
+
const previousRevision = previous?.revision ?? 0;
|
|
72
|
+
if (expectedRevision !== undefined && expectedRevision !== previousRevision) {
|
|
73
|
+
throw httpError(409, "skill changed; read the latest revision before saving");
|
|
74
|
+
}
|
|
75
|
+
if (previous?.description === description && previous.content === content) {
|
|
76
|
+
return { ...summary(previous), unchanged: true };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const vec = await embed(`${name}\n${description}`);
|
|
80
|
+
db.exec("BEGIN IMMEDIATE");
|
|
81
|
+
try {
|
|
82
|
+
const currentProject = db.prepare("SELECT id FROM projects WHERE slug = ?").get(slug);
|
|
83
|
+
if (project && currentProject?.id !== project.id) {
|
|
84
|
+
throw httpError(409, "project changed while saving skill; resolve the project again");
|
|
85
|
+
}
|
|
86
|
+
const target = currentProject ?? ensureProject(db, slug);
|
|
87
|
+
const current = findSkill(db, target.id, name);
|
|
88
|
+
if ((current?.revision ?? 0) !== previousRevision || current?.id !== previous?.id) {
|
|
89
|
+
throw httpError(409, "skill changed; read the latest revision before saving");
|
|
90
|
+
}
|
|
91
|
+
const now = Date.now();
|
|
92
|
+
const revision = previousRevision + 1;
|
|
93
|
+
const row = db.prepare(
|
|
94
|
+
`INSERT INTO skills (project_id, name, description, content, revision, created_at, updated_at)
|
|
95
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
96
|
+
ON CONFLICT(project_id, name) DO UPDATE SET
|
|
97
|
+
description = excluded.description, content = excluded.content,
|
|
98
|
+
revision = excluded.revision, updated_at = excluded.updated_at
|
|
99
|
+
RETURNING *`,
|
|
100
|
+
).get(target.id, name, description, content, revision, now, now);
|
|
101
|
+
db.prepare(
|
|
102
|
+
"INSERT INTO skill_revisions (skill_id, revision, description, content, saved_at) VALUES (?, ?, ?, ?, ?)",
|
|
103
|
+
).run(row.id, revision, description, content, now);
|
|
104
|
+
db.prepare(
|
|
105
|
+
`INSERT INTO skill_embeddings (skill_id, dim, model, vec) VALUES (?, ?, ?, ?)
|
|
106
|
+
ON CONFLICT(skill_id) DO UPDATE SET dim = excluded.dim, model = excluded.model, vec = excluded.vec`,
|
|
107
|
+
).run(row.id, vec.length, ONNX_MODEL_ID, Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength));
|
|
108
|
+
db.exec("COMMIT");
|
|
109
|
+
return { ...summary(row), unchanged: false };
|
|
110
|
+
} catch (err) {
|
|
111
|
+
db.exec("ROLLBACK");
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function deleteSkill(db, slug, name) {
|
|
117
|
+
validateProject(slug);
|
|
118
|
+
validateName(name);
|
|
119
|
+
const project = getProjectRow(db, slug);
|
|
120
|
+
const result = db.prepare("DELETE FROM skills WHERE project_id = ? AND name = ?").run(project.id, name);
|
|
121
|
+
if (!result.changes) throw httpError(404, `skill not found: ${name}`);
|
|
122
|
+
return { deleted: name };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function searchSkills(db, slug, query, limit = 8) {
|
|
126
|
+
validateProject(slug);
|
|
127
|
+
if (typeof query !== "string" || !query.trim()) throw httpError(400, "query is required");
|
|
128
|
+
const project = getProjectRow(db, slug);
|
|
129
|
+
const n = Number(limit);
|
|
130
|
+
const count = Number.isFinite(n) ? Math.min(Math.max(Math.floor(n), 1), 100) : 8;
|
|
131
|
+
if (!db.prepare("SELECT 1 FROM skills WHERE project_id = ? LIMIT 1").get(project.id)) return [];
|
|
132
|
+
return rankSkills(db, project.id, await embedQuery(query), query, count);
|
|
133
|
+
}
|
package/src/store/db.js
CHANGED
|
@@ -55,6 +55,56 @@ CREATE TABLE IF NOT EXISTS project_embeddings (
|
|
|
55
55
|
vec BLOB NOT NULL
|
|
56
56
|
);
|
|
57
57
|
|
|
58
|
+
CREATE TABLE IF NOT EXISTS skills (
|
|
59
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
60
|
+
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
61
|
+
name TEXT NOT NULL,
|
|
62
|
+
description TEXT NOT NULL,
|
|
63
|
+
content TEXT NOT NULL,
|
|
64
|
+
revision INTEGER NOT NULL CHECK (revision > 0),
|
|
65
|
+
created_at INTEGER NOT NULL,
|
|
66
|
+
updated_at INTEGER NOT NULL,
|
|
67
|
+
UNIQUE(project_id, name)
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
CREATE TABLE IF NOT EXISTS skill_revisions (
|
|
71
|
+
skill_id INTEGER NOT NULL REFERENCES skills(id) ON DELETE CASCADE,
|
|
72
|
+
revision INTEGER NOT NULL,
|
|
73
|
+
description TEXT NOT NULL,
|
|
74
|
+
content TEXT NOT NULL,
|
|
75
|
+
saved_at INTEGER NOT NULL,
|
|
76
|
+
PRIMARY KEY(skill_id, revision)
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
CREATE TABLE IF NOT EXISTS skill_embeddings (
|
|
80
|
+
skill_id INTEGER PRIMARY KEY REFERENCES skills(id) ON DELETE CASCADE,
|
|
81
|
+
dim INTEGER NOT NULL,
|
|
82
|
+
model TEXT NOT NULL,
|
|
83
|
+
vec BLOB NOT NULL
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS skills_fts USING fts5(
|
|
87
|
+
name,
|
|
88
|
+
description,
|
|
89
|
+
content='skills',
|
|
90
|
+
content_rowid='id'
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
CREATE TRIGGER IF NOT EXISTS skills_ai AFTER INSERT ON skills BEGIN
|
|
94
|
+
INSERT INTO skills_fts(rowid, name, description) VALUES (new.id, new.name, new.description);
|
|
95
|
+
END;
|
|
96
|
+
|
|
97
|
+
CREATE TRIGGER IF NOT EXISTS skills_ad AFTER DELETE ON skills BEGIN
|
|
98
|
+
INSERT INTO skills_fts(skills_fts, rowid, name, description)
|
|
99
|
+
VALUES ('delete', old.id, old.name, old.description);
|
|
100
|
+
END;
|
|
101
|
+
|
|
102
|
+
CREATE TRIGGER IF NOT EXISTS skills_au AFTER UPDATE OF name, description ON skills BEGIN
|
|
103
|
+
INSERT INTO skills_fts(skills_fts, rowid, name, description)
|
|
104
|
+
VALUES ('delete', old.id, old.name, old.description);
|
|
105
|
+
INSERT INTO skills_fts(rowid, name, description) VALUES (new.id, new.name, new.description);
|
|
106
|
+
END;
|
|
107
|
+
|
|
58
108
|
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
|
|
59
109
|
text,
|
|
60
110
|
content='memories',
|