@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/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: "mindroot_list_projects",
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.",
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: "mindroot_search_projects",
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: "mindroot_rename_project",
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: "mindroot_search_memories",
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: "mindroot_search_notes",
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: "mindroot_save_memory",
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: "mindroot_delete_memory",
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: "mindroot_list_notes",
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: "mindroot_read_note",
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: "mindroot_save_note",
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: "mindroot_update_section",
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: "mindroot_delete_note",
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 "mindroot_list_projects":
206
+ case "list_projects":
154
207
  return core.listProjects(db);
155
- case "mindroot_search_projects":
208
+ case "search_projects":
156
209
  return core.searchProjects(db, args.query);
157
- case "mindroot_rename_project":
210
+ case "rename_project":
158
211
  return core.renameProject(db, args.project, args.new_slug);
159
- case "mindroot_search_memories":
212
+ case "search_memories":
160
213
  return core.searchMemories(db, args.project, args.query, args.limit ?? 8);
161
- case "mindroot_search_notes":
214
+ case "search_notes":
162
215
  return core.searchNotes(db, args.project, args.query, args.limit ?? 8);
163
- case "mindroot_save_memory":
216
+ case "save_memory":
164
217
  return core.addMemory(db, args.project, args.text, args.target_path);
165
- case "mindroot_delete_memory":
218
+ case "delete_memory":
166
219
  return core.deleteMemory(db, args.project, Number(args.id));
167
- case "mindroot_list_notes":
220
+ case "list_notes":
168
221
  return core.listDocs(db, args.project);
169
- case "mindroot_read_note":
222
+ case "read_note":
170
223
  return core.readNote(db, args.project, args.path, args.section);
171
- case "mindroot_save_note":
224
+ case "save_note":
172
225
  return core.saveNote(db, args.project, args.path, args.content);
173
- case "mindroot_update_section":
226
+ case "update_section":
174
227
  return core.updateSection(db, args.project, args.path, args.heading_path, args.content);
175
- case "mindroot_delete_note":
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
- return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
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
- return {};
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.join(DASHBOARD_DIR, asset[0]));
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([core.listDocs(db, slug), core.listMemories(db, slug)]);
101
- return sendJson(res, 200, { slug, id: project.id, docs, memories });
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',