mcp-memory-bucket 0.5.3 → 0.5.4
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 +21 -21
- package/dist/client/assets/{index-CqgDoHFV.js → index-6BCcM6pL.js} +104 -104
- package/dist/client/index.html +1 -1
- package/dist/src/config.js +17 -17
- package/dist/src/memory/repository.js +41 -41
- package/dist/src/memory/tools.js +16 -16
- package/dist/src/server.js +13 -13
- package/dist/src/shared/bucket-folder-tool.js +66 -0
- package/dist/src/shared/relocate-tool.js +2 -2
- package/dist/src/shared/relocate.js +2 -2
- package/dist/src/skills/builtin/memory-bucket-authoring/SKILL.md +28 -28
- package/dist/src/skills/repository.js +58 -58
- package/dist/src/skills/tools.js +18 -18
- package/dist/src/store/db.js +4 -4
- package/dist/src/store/search.js +1 -1
- package/dist/src/store/sync.js +20 -20
- package/dist/src/web/routes.js +33 -33
- package/package.json +1 -1
|
@@ -3,7 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { writeMarkdownFile } from '../store/markdown-file.js';
|
|
4
4
|
import { assertValidSkillName } from '../store/skill-name.js';
|
|
5
5
|
import { resolveWithinBase } from '../store/safe-path.js';
|
|
6
|
-
import { upsertFile, removeFile,
|
|
6
|
+
import { upsertFile, removeFile, scanSingleFolder, unregisterFolder, skillSyncSpec } from '../store/sync.js';
|
|
7
7
|
import { SearchQueryError } from '../store/search.js';
|
|
8
8
|
function rowToDoc(row) {
|
|
9
9
|
return {
|
|
@@ -16,77 +16,77 @@ function rowToDoc(row) {
|
|
|
16
16
|
paused: !!row.paused,
|
|
17
17
|
created_at: row.created_at ?? undefined,
|
|
18
18
|
source_path: row.source_path,
|
|
19
|
-
|
|
19
|
+
folder: row.folder,
|
|
20
20
|
body: row.body,
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
23
|
export class SkillRepository {
|
|
24
24
|
db;
|
|
25
|
-
|
|
25
|
+
folders;
|
|
26
26
|
syncSpec;
|
|
27
27
|
watcher;
|
|
28
|
-
/** `
|
|
29
|
-
constructor(db,
|
|
28
|
+
/** `folders[0]` is always the builtin skills dir — never exposed for create()/removal. */
|
|
29
|
+
constructor(db, folders) {
|
|
30
30
|
this.db = db;
|
|
31
|
-
this.
|
|
32
|
-
this.syncSpec = skillSyncSpec(
|
|
31
|
+
this.folders = folders;
|
|
32
|
+
this.syncSpec = skillSyncSpec(folders);
|
|
33
33
|
}
|
|
34
|
-
/** Attaches the live chokidar watcher so
|
|
34
|
+
/** Attaches the live chokidar watcher so addFolder/removeFolder can mutate it without a restart. */
|
|
35
35
|
setWatcher(watcher) {
|
|
36
36
|
this.watcher = watcher;
|
|
37
37
|
}
|
|
38
|
-
/** User-addable
|
|
39
|
-
|
|
40
|
-
return this.
|
|
38
|
+
/** User-addable folders — excludes the always-present builtin skills dir at folders[0]. */
|
|
39
|
+
listFolders() {
|
|
40
|
+
return this.folders.slice(1);
|
|
41
41
|
}
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
if (
|
|
45
|
-
const found =
|
|
42
|
+
resolveFolder(folderName) {
|
|
43
|
+
const userFolders = this.listFolders();
|
|
44
|
+
if (folderName) {
|
|
45
|
+
const found = userFolders.find((f) => f.name === folderName);
|
|
46
46
|
if (!found) {
|
|
47
|
-
throw new Error(`unknown skill
|
|
47
|
+
throw new Error(`unknown skill folder "${folderName}" — valid folders: ${userFolders.map((f) => f.name).join(', ') || '(none configured)'}`);
|
|
48
48
|
}
|
|
49
49
|
return found;
|
|
50
50
|
}
|
|
51
|
-
if (
|
|
52
|
-
return
|
|
53
|
-
if (
|
|
54
|
-
throw new Error('no skill
|
|
51
|
+
if (userFolders.length === 1)
|
|
52
|
+
return userFolders[0];
|
|
53
|
+
if (userFolders.length === 0) {
|
|
54
|
+
throw new Error('no skill folder configured — add one first (see bucket_open_ui)');
|
|
55
55
|
}
|
|
56
|
-
throw new Error(`multiple skill
|
|
56
|
+
throw new Error(`multiple skill folders configured — specify folder: one of ${userFolders.map((f) => f.name).join(', ')}`);
|
|
57
57
|
}
|
|
58
|
-
/** Registers a new
|
|
59
|
-
|
|
60
|
-
if (this.
|
|
61
|
-
throw new Error(`a skill
|
|
58
|
+
/** Registers a new folder: appends it, scans it once, and starts watching it live. */
|
|
59
|
+
addFolder(folder) {
|
|
60
|
+
if (this.folders.some((f) => f.name === folder.name)) {
|
|
61
|
+
throw new Error(`a skill folder named "${folder.name}" already exists`);
|
|
62
62
|
}
|
|
63
|
-
this.
|
|
64
|
-
|
|
65
|
-
this.watcher?.add(
|
|
63
|
+
this.folders.push(folder);
|
|
64
|
+
scanSingleFolder(this.db, this.syncSpec, folder.path);
|
|
65
|
+
this.watcher?.add(folder.path);
|
|
66
66
|
}
|
|
67
|
-
/** Unregisters a
|
|
68
|
-
|
|
69
|
-
const idx = this.
|
|
67
|
+
/** Unregisters a folder: stops watching it and drops its cached rows. Never touches files on disk. */
|
|
68
|
+
removeFolder(name) {
|
|
69
|
+
const idx = this.folders.findIndex((f) => f.name === name);
|
|
70
70
|
if (idx <= 0)
|
|
71
|
-
throw new Error(`skill
|
|
72
|
-
const [removed] = this.
|
|
71
|
+
throw new Error(`skill folder "${name}" not found or is not removable`); // index 0 is builtin
|
|
72
|
+
const [removed] = this.folders.splice(idx, 1);
|
|
73
73
|
this.watcher?.unwatch(removed.path);
|
|
74
|
-
|
|
74
|
+
unregisterFolder(this.db, 'skills', name);
|
|
75
75
|
}
|
|
76
76
|
/** `includePaused` defaults to false: paused skills are hidden from discovery (see setPaused). */
|
|
77
|
-
list(query,
|
|
77
|
+
list(query, folder, opts = {}) {
|
|
78
78
|
const conditions = [];
|
|
79
79
|
const params = [];
|
|
80
|
-
if (
|
|
81
|
-
conditions.push('
|
|
82
|
-
params.push(
|
|
80
|
+
if (folder) {
|
|
81
|
+
conditions.push('folder = ?');
|
|
82
|
+
params.push(folder);
|
|
83
83
|
}
|
|
84
84
|
if (!opts.includePaused) {
|
|
85
85
|
conditions.push('paused = 0');
|
|
86
86
|
}
|
|
87
87
|
const where = conditions.length ? ` WHERE ${conditions.join(' AND ')}` : '';
|
|
88
88
|
const rows = this.db
|
|
89
|
-
.prepare(`SELECT id, description, owner, status, tags, trigger_phrases,
|
|
89
|
+
.prepare(`SELECT id, description, owner, status, tags, trigger_phrases, folder, paused FROM skills${where}`)
|
|
90
90
|
.all(...params);
|
|
91
91
|
const needle = query?.trim().toLowerCase();
|
|
92
92
|
const items = rows.map((r) => ({
|
|
@@ -96,7 +96,7 @@ export class SkillRepository {
|
|
|
96
96
|
status: r.status,
|
|
97
97
|
tags: JSON.parse(r.tags),
|
|
98
98
|
triggerPhrases: JSON.parse(r.trigger_phrases),
|
|
99
|
-
|
|
99
|
+
folder: r.folder,
|
|
100
100
|
paused: !!r.paused,
|
|
101
101
|
}));
|
|
102
102
|
const filtered = needle
|
|
@@ -109,16 +109,16 @@ export class SkillRepository {
|
|
|
109
109
|
/**
|
|
110
110
|
* Full-text search over skill description/body/tags via FTS5 — `query` is
|
|
111
111
|
* raw FTS5 MATCH syntax (AND/OR/NOT, "phrases", prefix*). Ranked by bm25.
|
|
112
|
-
* Optional metadata filters (
|
|
112
|
+
* Optional metadata filters (folder/status/owner/tag) apply before limit/offset,
|
|
113
113
|
* so pagination stays correct even when filtering narrows the FTS hit set.
|
|
114
114
|
*/
|
|
115
115
|
search(query, opts = {}) {
|
|
116
|
-
const {
|
|
116
|
+
const { folder, status, owner, tag, limit = 20, offset = 0, includePaused = false } = opts;
|
|
117
117
|
const conditions = [];
|
|
118
118
|
const params = [query];
|
|
119
|
-
if (
|
|
120
|
-
conditions.push('s.
|
|
121
|
-
params.push(
|
|
119
|
+
if (folder) {
|
|
120
|
+
conditions.push('s.folder = ?');
|
|
121
|
+
params.push(folder);
|
|
122
122
|
}
|
|
123
123
|
if (status) {
|
|
124
124
|
conditions.push('s.status = ?');
|
|
@@ -138,7 +138,7 @@ export class SkillRepository {
|
|
|
138
138
|
params.push(limit, offset);
|
|
139
139
|
try {
|
|
140
140
|
const rows = this.db
|
|
141
|
-
.prepare(`SELECT s.id AS name, s.description, s.
|
|
141
|
+
.prepare(`SELECT s.id AS name, s.description, s.folder,
|
|
142
142
|
snippet(search_index, 3, '<<', '>>', '…', 20) AS snippet,
|
|
143
143
|
-bm25(search_index) AS score
|
|
144
144
|
FROM search_index
|
|
@@ -162,18 +162,18 @@ export class SkillRepository {
|
|
|
162
162
|
return names.map((name) => this.get(name)).filter((doc) => doc !== null);
|
|
163
163
|
}
|
|
164
164
|
/**
|
|
165
|
-
* Creates <
|
|
165
|
+
* Creates <folder>/[subfolder/]<name>/SKILL.md — folder-per-skill, per the
|
|
166
166
|
* agentskills.io spec (`name` must equal the containing folder's name).
|
|
167
|
-
* `
|
|
168
|
-
* when more than one user
|
|
167
|
+
* `folder` selects which configured skill folder to write into; required only
|
|
168
|
+
* when more than one user folder is configured.
|
|
169
169
|
*/
|
|
170
|
-
create(frontmatter, body,
|
|
170
|
+
create(frontmatter, body, subfolder, folder) {
|
|
171
171
|
assertValidSkillName(frontmatter.name);
|
|
172
172
|
if (this.get(frontmatter.name)) {
|
|
173
173
|
throw new Error(`skill with name "${frontmatter.name}" already exists`);
|
|
174
174
|
}
|
|
175
|
-
const
|
|
176
|
-
const skillDir = resolveWithinBase(
|
|
175
|
+
const targetFolder = this.resolveFolder(folder);
|
|
176
|
+
const skillDir = resolveWithinBase(targetFolder.path, subfolder, frontmatter.name);
|
|
177
177
|
if (fs.existsSync(skillDir)) {
|
|
178
178
|
throw new Error(`skill directory already exists at ${skillDir}`);
|
|
179
179
|
}
|
|
@@ -194,7 +194,7 @@ export class SkillRepository {
|
|
|
194
194
|
deprecated: false,
|
|
195
195
|
created_at: new Date().toISOString(),
|
|
196
196
|
source_path: filePath,
|
|
197
|
-
|
|
197
|
+
folder: targetFolder.name,
|
|
198
198
|
};
|
|
199
199
|
writeMarkdownFile(filePath, stripSourcePath(fm), body);
|
|
200
200
|
upsertFile(this.db, this.syncSpec, filePath);
|
|
@@ -208,7 +208,7 @@ export class SkillRepository {
|
|
|
208
208
|
bulkCreate(entries) {
|
|
209
209
|
return entries.map((entry) => {
|
|
210
210
|
try {
|
|
211
|
-
this.create(entry.frontmatter, entry.body, entry.
|
|
211
|
+
this.create(entry.frontmatter, entry.body, entry.subfolder, entry.folder);
|
|
212
212
|
return { name: entry.frontmatter.name, ok: true };
|
|
213
213
|
}
|
|
214
214
|
catch (err) {
|
|
@@ -216,9 +216,9 @@ export class SkillRepository {
|
|
|
216
216
|
}
|
|
217
217
|
});
|
|
218
218
|
}
|
|
219
|
-
/** Name of the always-present, non-removable builtin
|
|
219
|
+
/** Name of the always-present, non-removable builtin folder (folders[0]) — never user content, never deprecatable. */
|
|
220
220
|
isBuiltin(doc) {
|
|
221
|
-
return doc.
|
|
221
|
+
return doc.folder === this.folders[0]?.name;
|
|
222
222
|
}
|
|
223
223
|
update(name, frontmatter, body) {
|
|
224
224
|
const existing = this.get(name);
|
|
@@ -248,7 +248,7 @@ export class SkillRepository {
|
|
|
248
248
|
return { ...merged, body: newBody, paused: existingPaused };
|
|
249
249
|
}
|
|
250
250
|
/**
|
|
251
|
-
* Renames a skill: moves <sourceDir>/[
|
|
251
|
+
* Renames a skill: moves <sourceDir>/[subfolder/]<oldName>/ to .../<newName>/ (keeping any
|
|
252
252
|
* scripts/references/assets alongside SKILL.md) and updates the `name` frontmatter field to match.
|
|
253
253
|
*/
|
|
254
254
|
rename(name, newName) {
|
|
@@ -380,6 +380,6 @@ export class SkillRepository {
|
|
|
380
380
|
}
|
|
381
381
|
}
|
|
382
382
|
function stripSourcePath(fm) {
|
|
383
|
-
const { source_path: _sp,
|
|
383
|
+
const { source_path: _sp, folder: _folder, ...rest } = fm;
|
|
384
384
|
return rest;
|
|
385
385
|
}
|
package/dist/src/skills/tools.js
CHANGED
|
@@ -4,20 +4,20 @@ const SKILL_STATUS_DEFAULTS = ['stable', 'beta', 'unreviewed'];
|
|
|
4
4
|
const SKILL_NAME_DESCRIPTION = 'stable id, must be 1-64 chars, lowercase letters/numbers/hyphens only, no leading/trailing/consecutive hyphens — this becomes the skill\'s folder name (agentskills.io spec requirement)';
|
|
5
5
|
const AUTHORING_SKILL_HINT = "Before your first call in a session, run skill_get(\"memory-bucket-authoring\") to learn the exact frontmatter schema and conventions — don't guess the shape.";
|
|
6
6
|
export function registerSkillTools(mcp, repo) {
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
mcp.tool('skill_list', 'Lists skills (reusable coding patterns, one SKILL.md per folder per the agentskills.io open standard), optionally filtered by a keyword matched against description/tags/trigger phrases. Paused skills are hidden by default — pass include_paused to see them.',
|
|
7
|
+
const folders = repo.listFolders();
|
|
8
|
+
const multiFolder = folders.length > 1;
|
|
9
|
+
const folderNames = folders.map((f) => f.name).join(', ');
|
|
10
|
+
mcp.tool('skill_list', 'Lists skills (reusable coding patterns, one SKILL.md per folder per the agentskills.io open standard), optionally filtered by a keyword matched against description/tags/trigger phrases. Paused skills are hidden by default — pass include_paused to see them.', multiFolder
|
|
11
11
|
? {
|
|
12
12
|
query: z.string().optional(),
|
|
13
|
-
|
|
13
|
+
folder: z.string().optional().describe(`filter to one folder: ${folderNames}`),
|
|
14
14
|
include_paused: z.boolean().optional().describe('include paused skills, which are hidden by default (see skill_set_paused)'),
|
|
15
15
|
}
|
|
16
16
|
: {
|
|
17
17
|
query: z.string().optional(),
|
|
18
18
|
include_paused: z.boolean().optional().describe('include paused skills, which are hidden by default (see skill_set_paused)'),
|
|
19
|
-
}, async ({ query,
|
|
20
|
-
const items = repo.list(query,
|
|
19
|
+
}, async ({ query, folder, include_paused }) => {
|
|
20
|
+
const items = repo.list(query, folder, { includePaused: include_paused });
|
|
21
21
|
return { content: [{ type: 'text', text: JSON.stringify(items, null, 2) }] };
|
|
22
22
|
});
|
|
23
23
|
mcp.tool('skill_search', 'Full-text search over skill description/body/tags (grep/find-like, ranked by relevance) — unlike skill_list\'s substring metadata filter, this searches the full markdown body. `query` is raw SQLite FTS5 MATCH syntax: bare words, "exact phrases", prefix* wildcards, AND/OR/NOT boolean operators; hyphenated/punctuated terms must be quoted, e.g. "blue-green". Can be combined with status/owner/tag filters. Returns ranked hits with a highlighted snippet, not the full body — call skill_get on a hit\'s name for that. Paused skills are hidden by default — pass include_paused to see them.', {
|
|
@@ -28,10 +28,10 @@ export function registerSkillTools(mcp, repo) {
|
|
|
28
28
|
limit: z.number().int().positive().max(100).optional(),
|
|
29
29
|
offset: z.number().int().nonnegative().optional(),
|
|
30
30
|
include_paused: z.boolean().optional().describe('include paused skills, which are hidden by default (see skill_set_paused)'),
|
|
31
|
-
...(
|
|
32
|
-
}, async ({ query, status, owner, tag, limit, offset,
|
|
31
|
+
...(multiFolder ? { folder: z.string().optional().describe(`filter to one folder: ${folderNames}`) } : {}),
|
|
32
|
+
}, async ({ query, status, owner, tag, limit, offset, folder, include_paused }) => {
|
|
33
33
|
try {
|
|
34
|
-
const hits = repo.search(query, {
|
|
34
|
+
const hits = repo.search(query, { folder, status, owner, tag, limit, offset, includePaused: include_paused });
|
|
35
35
|
return { content: [{ type: 'text', text: JSON.stringify(hits, null, 2) }] };
|
|
36
36
|
}
|
|
37
37
|
catch (err) {
|
|
@@ -61,7 +61,7 @@ export function registerSkillTools(mcp, repo) {
|
|
|
61
61
|
const docs = repo.bulkGet(names);
|
|
62
62
|
return { content: [{ type: 'text', text: JSON.stringify(docs, null, 2) }] };
|
|
63
63
|
});
|
|
64
|
-
mcp.tool('skill_create', `Creates a new skill as <
|
|
64
|
+
mcp.tool('skill_create', `Creates a new skill as <folder>/[subfolder/]<name>/SKILL.md, per the agentskills.io open standard — a folder containing SKILL.md, optionally alongside scripts/references/assets subfolders you create separately on disk. ${AUTHORING_SKILL_HINT}`, {
|
|
65
65
|
name: z.string().describe(SKILL_NAME_DESCRIPTION),
|
|
66
66
|
description: z
|
|
67
67
|
.string()
|
|
@@ -75,11 +75,11 @@ export function registerSkillTools(mcp, repo) {
|
|
|
75
75
|
tags: z.array(z.string()).optional(),
|
|
76
76
|
trigger_phrases: z.array(z.string()).optional(),
|
|
77
77
|
extends: z.string().optional().describe('reserved for a future overlay mechanism — stored in frontmatter.metadata'),
|
|
78
|
-
|
|
79
|
-
...(
|
|
80
|
-
}, async ({ name, description, body, license, compatibility, owner, status, tags, trigger_phrases, extends: extendsId,
|
|
78
|
+
subfolder: z.string().optional().describe('optional subdirectory under the skill folder, e.g. "frontend"'),
|
|
79
|
+
...(multiFolder ? { folder: z.string().describe(`which configured skill folder to write into: ${folderNames}`) } : {}),
|
|
80
|
+
}, async ({ name, description, body, license, compatibility, owner, status, tags, trigger_phrases, extends: extendsId, subfolder, folder }) => {
|
|
81
81
|
try {
|
|
82
|
-
const doc = repo.create({ name, description, license, compatibility, owner, status, tags, trigger_phrases, extends: extendsId }, body,
|
|
82
|
+
const doc = repo.create({ name, description, license, compatibility, owner, status, tags, trigger_phrases, extends: extendsId }, body, subfolder, folder);
|
|
83
83
|
return { content: [{ type: 'text', text: JSON.stringify(doc, null, 2) }] };
|
|
84
84
|
}
|
|
85
85
|
catch (err) {
|
|
@@ -97,8 +97,8 @@ export function registerSkillTools(mcp, repo) {
|
|
|
97
97
|
tags: z.array(z.string()).optional(),
|
|
98
98
|
trigger_phrases: z.array(z.string()).optional(),
|
|
99
99
|
extends: z.string().optional(),
|
|
100
|
-
|
|
101
|
-
...(
|
|
100
|
+
subfolder: z.string().optional(),
|
|
101
|
+
...(multiFolder ? { folder: z.string().describe(`which configured skill folder to write into: ${folderNames}`) } : {}),
|
|
102
102
|
});
|
|
103
103
|
mcp.tool('skill_bulk_create', `Creates many skills in one call — each entry is the same shape as skill_create's args. Returns per-name success/failure so one bad entry (duplicate name, invalid name, existing directory) doesn't abort the rest of the batch. ${AUTHORING_SKILL_HINT}`, { entries: z.array(skillEntrySchema).min(1) }, async ({ entries }) => {
|
|
104
104
|
const results = repo.bulkCreate(entries.map((e) => ({
|
|
@@ -114,8 +114,8 @@ export function registerSkillTools(mcp, repo) {
|
|
|
114
114
|
extends: e.extends,
|
|
115
115
|
},
|
|
116
116
|
body: e.body,
|
|
117
|
+
subfolder: e.subfolder,
|
|
117
118
|
folder: e.folder,
|
|
118
|
-
root: e.root,
|
|
119
119
|
})));
|
|
120
120
|
return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
|
|
121
121
|
});
|
package/dist/src/store/db.js
CHANGED
|
@@ -12,7 +12,7 @@ export function openCache(dbPath) {
|
|
|
12
12
|
trigger_phrases TEXT NOT NULL, -- JSON array
|
|
13
13
|
extends TEXT,
|
|
14
14
|
source_path TEXT NOT NULL UNIQUE, -- path to SKILL.md
|
|
15
|
-
|
|
15
|
+
folder TEXT NOT NULL DEFAULT '', -- name of the configured folder this file lives under
|
|
16
16
|
deprecated INTEGER NOT NULL DEFAULT 0,
|
|
17
17
|
paused INTEGER NOT NULL DEFAULT 0, -- local-only: never synced from/to SKILL.md, cache-file scoped
|
|
18
18
|
created_at TEXT,
|
|
@@ -30,7 +30,7 @@ export function openCache(dbPath) {
|
|
|
30
30
|
status TEXT NOT NULL,
|
|
31
31
|
related_to TEXT,
|
|
32
32
|
source_path TEXT NOT NULL UNIQUE,
|
|
33
|
-
|
|
33
|
+
folder TEXT NOT NULL DEFAULT '', -- name of the configured folder this file lives under
|
|
34
34
|
deprecated INTEGER NOT NULL DEFAULT 0,
|
|
35
35
|
paused INTEGER NOT NULL DEFAULT 0, -- local-only: never synced from/to the doc's markdown file, cache-file scoped
|
|
36
36
|
created_at TEXT,
|
|
@@ -59,13 +59,13 @@ export function openCache(dbPath) {
|
|
|
59
59
|
CREATE INDEX IF NOT EXISTS idx_doc_dates_ref ON doc_dates(ref_table, ref_id);
|
|
60
60
|
`);
|
|
61
61
|
ensureColumns(db, 'skills', [
|
|
62
|
-
['
|
|
62
|
+
['folder', "TEXT NOT NULL DEFAULT ''"],
|
|
63
63
|
['deprecated', 'INTEGER NOT NULL DEFAULT 0'],
|
|
64
64
|
['paused', 'INTEGER NOT NULL DEFAULT 0'],
|
|
65
65
|
['created_at', 'TEXT'],
|
|
66
66
|
]);
|
|
67
67
|
ensureColumns(db, 'memory_docs', [
|
|
68
|
-
['
|
|
68
|
+
['folder', "TEXT NOT NULL DEFAULT ''"],
|
|
69
69
|
['deprecated', 'INTEGER NOT NULL DEFAULT 0'],
|
|
70
70
|
['paused', 'INTEGER NOT NULL DEFAULT 0'],
|
|
71
71
|
['created_at', 'TEXT'],
|
package/dist/src/store/search.js
CHANGED
|
@@ -101,7 +101,7 @@ export function searchCombined(db, query, limit = 20, offset = 0) {
|
|
|
101
101
|
return db
|
|
102
102
|
.prepare(`SELECT ref_table, ref_id AS id,
|
|
103
103
|
COALESCE(s.description, m.description) AS description,
|
|
104
|
-
COALESCE(s.
|
|
104
|
+
COALESCE(s.folder, m.folder) AS folder,
|
|
105
105
|
snippet(search_index, 3, '<<', '>>', '…', 20) AS snippet,
|
|
106
106
|
-bm25(search_index) AS score
|
|
107
107
|
FROM search_index
|
package/dist/src/store/sync.js
CHANGED
|
@@ -74,15 +74,15 @@ export function memorySyncSpec(sources) {
|
|
|
74
74
|
* synchronously right after their own writes — the watcher's own event for
|
|
75
75
|
* that same write becomes a harmless no-op re-check once mtime matches.
|
|
76
76
|
*/
|
|
77
|
-
/** Which configured
|
|
78
|
-
function
|
|
77
|
+
/** Which configured folder a file lives under, by longest matching path prefix. */
|
|
78
|
+
function folderForFile(sources, filePath) {
|
|
79
79
|
const resolved = path.resolve(filePath);
|
|
80
80
|
let best;
|
|
81
|
-
for (const
|
|
82
|
-
const
|
|
83
|
-
if (resolved ===
|
|
84
|
-
if (!best ||
|
|
85
|
-
best =
|
|
81
|
+
for (const folder of sources) {
|
|
82
|
+
const folderPath = path.resolve(folder.path);
|
|
83
|
+
if (resolved === folderPath || resolved.startsWith(folderPath + path.sep)) {
|
|
84
|
+
if (!best || folderPath.length > path.resolve(best.path).length)
|
|
85
|
+
best = folder;
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
return best?.name ?? '';
|
|
@@ -103,9 +103,9 @@ export function upsertFile(db, spec, filePath) {
|
|
|
103
103
|
return;
|
|
104
104
|
}
|
|
105
105
|
const row = spec.toRow(frontmatter, filePath, parsed.mtimeMs);
|
|
106
|
-
const
|
|
107
|
-
const cols = [...spec.columns, 'source_path', '
|
|
108
|
-
const values = [...spec.columns.map((c) => row[c]), filePath,
|
|
106
|
+
const folder = folderForFile(spec.sources, filePath);
|
|
107
|
+
const cols = [...spec.columns, 'source_path', 'folder', 'body', 'mtime_ms'];
|
|
108
|
+
const values = [...spec.columns.map((c) => row[c]), filePath, folder, parsed.body, parsed.mtimeMs];
|
|
109
109
|
const placeholders = cols.map(() => '?').join(', ');
|
|
110
110
|
const updateClause = cols
|
|
111
111
|
.filter((c) => c !== 'id')
|
|
@@ -138,10 +138,10 @@ export function removeFile(db, table, filePath) {
|
|
|
138
138
|
}
|
|
139
139
|
/** Full scan of all configured source dirs — used once at startup before the watcher takes over. */
|
|
140
140
|
export function initialScan(db, spec) {
|
|
141
|
-
for (const
|
|
142
|
-
if (!fs.existsSync(
|
|
141
|
+
for (const folder of spec.sources) {
|
|
142
|
+
if (!fs.existsSync(folder.path))
|
|
143
143
|
continue;
|
|
144
|
-
for (const file of walkMarkdownFiles(
|
|
144
|
+
for (const file of walkMarkdownFiles(folder.path)) {
|
|
145
145
|
if (!spec.matchesFile(file))
|
|
146
146
|
continue;
|
|
147
147
|
try {
|
|
@@ -153,8 +153,8 @@ export function initialScan(db, spec) {
|
|
|
153
153
|
}
|
|
154
154
|
}
|
|
155
155
|
}
|
|
156
|
-
/** Full scan of a single dir — used when a new
|
|
157
|
-
export function
|
|
156
|
+
/** Full scan of a single dir — used when a new folder is added live, after registering it in spec.sources. */
|
|
157
|
+
export function scanSingleFolder(db, spec, dirPath) {
|
|
158
158
|
if (!fs.existsSync(dirPath))
|
|
159
159
|
return;
|
|
160
160
|
for (const file of walkMarkdownFiles(dirPath)) {
|
|
@@ -168,10 +168,10 @@ export function scanSingleRoot(db, spec, dirPath) {
|
|
|
168
168
|
}
|
|
169
169
|
}
|
|
170
170
|
}
|
|
171
|
-
/** Drops all cached rows (and search index entries) belonging to a removed
|
|
172
|
-
export function
|
|
173
|
-
const rows = db.prepare(`SELECT id FROM ${table} WHERE
|
|
174
|
-
db.prepare(`DELETE FROM ${table} WHERE
|
|
171
|
+
/** Drops all cached rows (and search index entries) belonging to a removed folder. Never touches files on disk. */
|
|
172
|
+
export function unregisterFolder(db, table, folderName) {
|
|
173
|
+
const rows = db.prepare(`SELECT id FROM ${table} WHERE folder = ?`).all(folderName);
|
|
174
|
+
db.prepare(`DELETE FROM ${table} WHERE folder = ?`).run(folderName);
|
|
175
175
|
for (const row of rows) {
|
|
176
176
|
db.prepare(`DELETE FROM search_index WHERE ref_table = ? AND ref_id = ?`).run(table, row.id);
|
|
177
177
|
}
|
|
@@ -188,7 +188,7 @@ function* walkMarkdownFiles(dir) {
|
|
|
188
188
|
}
|
|
189
189
|
}
|
|
190
190
|
export function watchSources(db, spec) {
|
|
191
|
-
const watcher = chokidar.watch(spec.sources.map((
|
|
191
|
+
const watcher = chokidar.watch(spec.sources.map((f) => f.path), { ignoreInitial: true, persistent: true, depth: 10 });
|
|
192
192
|
watcher
|
|
193
193
|
.on('add', (filePath) => {
|
|
194
194
|
if (!spec.matchesFile(filePath))
|
package/dist/src/web/routes.js
CHANGED
|
@@ -3,7 +3,7 @@ import os from 'node:os';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import express from 'express';
|
|
5
5
|
import matter from 'gray-matter';
|
|
6
|
-
import {
|
|
6
|
+
import { saveFolder, removeFolder as removeFolderFromConfig, sanitizeFolderName } from '../config.js';
|
|
7
7
|
import { initialScan } from '../store/sync.js';
|
|
8
8
|
function asArray(v) {
|
|
9
9
|
if (v === undefined)
|
|
@@ -23,7 +23,7 @@ function queryEntries(db, req) {
|
|
|
23
23
|
const owners = asArray(req.query.owner);
|
|
24
24
|
const docTypes = asArray(req.query.doc_type);
|
|
25
25
|
const keyTypes = asArray(req.query.key_type);
|
|
26
|
-
const
|
|
26
|
+
const folders = asArray(req.query.folder);
|
|
27
27
|
const q = req.query.q?.trim();
|
|
28
28
|
const deprecatedParam = req.query.deprecated;
|
|
29
29
|
const deprecated = deprecatedParam === '0' || deprecatedParam === '1' ? deprecatedParam : undefined;
|
|
@@ -43,10 +43,10 @@ function queryEntries(db, req) {
|
|
|
43
43
|
}
|
|
44
44
|
const results = [];
|
|
45
45
|
if (type === 'skill' || type === 'all') {
|
|
46
|
-
results.push(...queryTable(db, 'skills', { tags, statuses, owners, docTypes: [], keyTypes: [],
|
|
46
|
+
results.push(...queryTable(db, 'skills', { tags, statuses, owners, docTypes: [], keyTypes: [], folders, deprecated, paused }, intersectIds(matchedIds?.skills, dateIds?.skills)));
|
|
47
47
|
}
|
|
48
48
|
if (type === 'memory' || type === 'all') {
|
|
49
|
-
results.push(...queryTable(db, 'memory_docs', { tags, statuses, owners: [], docTypes, keyTypes,
|
|
49
|
+
results.push(...queryTable(db, 'memory_docs', { tags, statuses, owners: [], docTypes, keyTypes, folders, deprecated, paused }, intersectIds(matchedIds?.memory_docs, dateIds?.memory_docs)));
|
|
50
50
|
}
|
|
51
51
|
const sort = req.query.sort ?? 'mtime_desc';
|
|
52
52
|
results.sort((a, b) => {
|
|
@@ -91,9 +91,9 @@ function queryTable(db, table, filters, restrictToIds) {
|
|
|
91
91
|
where += ` AND key_type IN (${filters.keyTypes.map(() => '?').join(', ')})`;
|
|
92
92
|
params.push(...filters.keyTypes);
|
|
93
93
|
}
|
|
94
|
-
if (filters.
|
|
95
|
-
where += ` AND
|
|
96
|
-
params.push(...filters.
|
|
94
|
+
if (filters.folders.length > 0) {
|
|
95
|
+
where += ` AND folder IN (${filters.folders.map(() => '?').join(', ')})`;
|
|
96
|
+
params.push(...filters.folders);
|
|
97
97
|
}
|
|
98
98
|
if (filters.deprecated !== undefined) {
|
|
99
99
|
where += ` AND deprecated = ?`;
|
|
@@ -109,7 +109,7 @@ function queryTable(db, table, filters, restrictToIds) {
|
|
|
109
109
|
}
|
|
110
110
|
if (table === 'skills') {
|
|
111
111
|
const rows = db
|
|
112
|
-
.prepare(`SELECT id, description, owner, status, tags,
|
|
112
|
+
.prepare(`SELECT id, description, owner, status, tags, folder, mtime_ms, deprecated, paused, created_at FROM skills WHERE ${where}`)
|
|
113
113
|
.all(...params);
|
|
114
114
|
return rows.map((r) => ({
|
|
115
115
|
_table: 'skills',
|
|
@@ -121,7 +121,7 @@ function queryTable(db, table, filters, restrictToIds) {
|
|
|
121
121
|
owner: r.owner,
|
|
122
122
|
doc_type: null,
|
|
123
123
|
key_type: null,
|
|
124
|
-
|
|
124
|
+
folder: r.folder,
|
|
125
125
|
mtime_ms: r.mtime_ms,
|
|
126
126
|
deprecated: !!r.deprecated,
|
|
127
127
|
paused: !!r.paused,
|
|
@@ -129,7 +129,7 @@ function queryTable(db, table, filters, restrictToIds) {
|
|
|
129
129
|
}));
|
|
130
130
|
}
|
|
131
131
|
const rows = db
|
|
132
|
-
.prepare(`SELECT id, key, description, doc_type, key_type, status, tags,
|
|
132
|
+
.prepare(`SELECT id, key, description, doc_type, key_type, status, tags, folder, mtime_ms, deprecated, paused, created_at FROM memory_docs WHERE ${where}`)
|
|
133
133
|
.all(...params);
|
|
134
134
|
return rows.map((r) => ({
|
|
135
135
|
_table: 'memory_docs',
|
|
@@ -141,7 +141,7 @@ function queryTable(db, table, filters, restrictToIds) {
|
|
|
141
141
|
owner: null,
|
|
142
142
|
doc_type: r.doc_type,
|
|
143
143
|
key_type: r.key_type,
|
|
144
|
-
|
|
144
|
+
folder: r.folder,
|
|
145
145
|
mtime_ms: r.mtime_ms,
|
|
146
146
|
deprecated: !!r.deprecated,
|
|
147
147
|
paused: !!r.paused,
|
|
@@ -203,27 +203,27 @@ function buildFacets(db, type) {
|
|
|
203
203
|
const owners = new Set();
|
|
204
204
|
const docTypes = new Set();
|
|
205
205
|
const keyTypes = new Set();
|
|
206
|
-
const
|
|
206
|
+
const folders = new Set();
|
|
207
207
|
if (type === 'skill' || type === 'all') {
|
|
208
|
-
const rows = db.prepare(`SELECT tags, status, owner,
|
|
208
|
+
const rows = db.prepare(`SELECT tags, status, owner, folder FROM skills`).all();
|
|
209
209
|
for (const r of rows) {
|
|
210
210
|
JSON.parse(r.tags).forEach((t) => tags.add(t));
|
|
211
211
|
statuses.add(r.status);
|
|
212
212
|
if (r.owner)
|
|
213
213
|
owners.add(r.owner);
|
|
214
|
-
if (r.
|
|
215
|
-
|
|
214
|
+
if (r.folder)
|
|
215
|
+
folders.add(r.folder);
|
|
216
216
|
}
|
|
217
217
|
}
|
|
218
218
|
if (type === 'memory' || type === 'all') {
|
|
219
|
-
const rows = db.prepare(`SELECT tags, status, doc_type, key_type,
|
|
219
|
+
const rows = db.prepare(`SELECT tags, status, doc_type, key_type, folder FROM memory_docs`).all();
|
|
220
220
|
for (const r of rows) {
|
|
221
221
|
JSON.parse(r.tags).forEach((t) => tags.add(t));
|
|
222
222
|
statuses.add(r.status);
|
|
223
223
|
docTypes.add(r.doc_type);
|
|
224
224
|
keyTypes.add(r.key_type);
|
|
225
|
-
if (r.
|
|
226
|
-
|
|
225
|
+
if (r.folder)
|
|
226
|
+
folders.add(r.folder);
|
|
227
227
|
}
|
|
228
228
|
}
|
|
229
229
|
return {
|
|
@@ -232,7 +232,7 @@ function buildFacets(db, type) {
|
|
|
232
232
|
owners: [...owners].sort(),
|
|
233
233
|
doc_types: [...docTypes].sort(),
|
|
234
234
|
key_types: [...keyTypes].sort(),
|
|
235
|
-
|
|
235
|
+
folders: [...folders].sort(),
|
|
236
236
|
};
|
|
237
237
|
}
|
|
238
238
|
function buildHealth(db) {
|
|
@@ -494,13 +494,13 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, mem
|
|
|
494
494
|
router.get('/api/health', (_req, res) => {
|
|
495
495
|
res.json(buildHealth(db));
|
|
496
496
|
});
|
|
497
|
-
router.get('/api/
|
|
497
|
+
router.get('/api/folders', (_req, res) => {
|
|
498
498
|
res.json({
|
|
499
|
-
skill: skillRepo.
|
|
500
|
-
memory: memoryRepo.
|
|
499
|
+
skill: skillRepo.listFolders().map((f) => ({ ...f, kind: 'skill' })),
|
|
500
|
+
memory: memoryRepo.listFolders().map((f) => ({ ...f, kind: 'memory' })),
|
|
501
501
|
});
|
|
502
502
|
});
|
|
503
|
-
router.post('/api/
|
|
503
|
+
router.post('/api/folders', (req, res) => {
|
|
504
504
|
const { kind, name, path: dirPath } = req.body;
|
|
505
505
|
if (kind !== 'skill' && kind !== 'memory') {
|
|
506
506
|
res.status(400).json({ error: 'kind must be "skill" or "memory"' });
|
|
@@ -514,35 +514,35 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, mem
|
|
|
514
514
|
res.status(400).json({ error: `not a directory: ${dirPath}` });
|
|
515
515
|
return;
|
|
516
516
|
}
|
|
517
|
-
const
|
|
518
|
-
if (!
|
|
519
|
-
res.status(400).json({ error: 'could not derive a valid
|
|
517
|
+
const folderName = sanitizeFolderName(name || path.basename(dirPath));
|
|
518
|
+
if (!folderName) {
|
|
519
|
+
res.status(400).json({ error: 'could not derive a valid folder name — provide one explicitly' });
|
|
520
520
|
return;
|
|
521
521
|
}
|
|
522
522
|
const repo = kind === 'skill' ? skillRepo : memoryRepo;
|
|
523
523
|
try {
|
|
524
|
-
repo.
|
|
525
|
-
|
|
526
|
-
res.json({ name:
|
|
524
|
+
repo.addFolder({ name: folderName, path: dirPath });
|
|
525
|
+
saveFolder(config, kind, { name: folderName, path: dirPath });
|
|
526
|
+
res.json({ name: folderName, path: dirPath, kind });
|
|
527
527
|
}
|
|
528
528
|
catch (err) {
|
|
529
529
|
res.status(409).json({ error: err.message });
|
|
530
530
|
}
|
|
531
531
|
});
|
|
532
|
-
router.delete('/api/
|
|
532
|
+
router.delete('/api/folders/:kind/:name', (req, res) => {
|
|
533
533
|
const { kind, name } = req.params;
|
|
534
534
|
if (kind !== 'skill' && kind !== 'memory') {
|
|
535
535
|
res.status(400).json({ error: 'kind must be "skill" or "memory"' });
|
|
536
536
|
return;
|
|
537
537
|
}
|
|
538
538
|
if (!name) {
|
|
539
|
-
res.status(400).json({ error: '
|
|
539
|
+
res.status(400).json({ error: 'folder name is required' });
|
|
540
540
|
return;
|
|
541
541
|
}
|
|
542
542
|
const repo = kind === 'skill' ? skillRepo : memoryRepo;
|
|
543
543
|
try {
|
|
544
|
-
repo.
|
|
545
|
-
|
|
544
|
+
repo.removeFolder(name);
|
|
545
|
+
removeFolderFromConfig(config, kind, name);
|
|
546
546
|
res.json({ removed: name, kind });
|
|
547
547
|
}
|
|
548
548
|
catch (err) {
|