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.
@@ -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, scanSingleRoot, unregisterRoot, skillSyncSpec } from '../store/sync.js';
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
- root: row.root,
19
+ folder: row.folder,
20
20
  body: row.body,
21
21
  };
22
22
  }
23
23
  export class SkillRepository {
24
24
  db;
25
- roots;
25
+ folders;
26
26
  syncSpec;
27
27
  watcher;
28
- /** `roots[0]` is always the builtin skills dir — never exposed for create()/removal. */
29
- constructor(db, roots) {
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.roots = roots;
32
- this.syncSpec = skillSyncSpec(roots);
31
+ this.folders = folders;
32
+ this.syncSpec = skillSyncSpec(folders);
33
33
  }
34
- /** Attaches the live chokidar watcher so addRoot/removeRoot can mutate it without a restart. */
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 roots — excludes the always-present builtin skills dir at roots[0]. */
39
- listRoots() {
40
- return this.roots.slice(1);
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
- resolveRoot(rootName) {
43
- const userRoots = this.listRoots();
44
- if (rootName) {
45
- const found = userRoots.find((r) => r.name === rootName);
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 root "${rootName}" — valid roots: ${userRoots.map((r) => r.name).join(', ') || '(none configured)'}`);
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 (userRoots.length === 1)
52
- return userRoots[0];
53
- if (userRoots.length === 0) {
54
- throw new Error('no skill root configured — add one first (see bucket_open_ui)');
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 roots configured — specify root: one of ${userRoots.map((r) => r.name).join(', ')}`);
56
+ throw new Error(`multiple skill folders configured — specify folder: one of ${userFolders.map((f) => f.name).join(', ')}`);
57
57
  }
58
- /** Registers a new root: appends it, scans it once, and starts watching it live. */
59
- addRoot(root) {
60
- if (this.roots.some((r) => r.name === root.name)) {
61
- throw new Error(`a skill root named "${root.name}" already exists`);
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.roots.push(root);
64
- scanSingleRoot(this.db, this.syncSpec, root.path);
65
- this.watcher?.add(root.path);
63
+ this.folders.push(folder);
64
+ scanSingleFolder(this.db, this.syncSpec, folder.path);
65
+ this.watcher?.add(folder.path);
66
66
  }
67
- /** Unregisters a root: stops watching it and drops its cached rows. Never touches files on disk. */
68
- removeRoot(name) {
69
- const idx = this.roots.findIndex((r) => r.name === name);
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 root "${name}" not found or is not removable`); // index 0 is builtin
72
- const [removed] = this.roots.splice(idx, 1);
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
- unregisterRoot(this.db, 'skills', name);
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, root, opts = {}) {
77
+ list(query, folder, opts = {}) {
78
78
  const conditions = [];
79
79
  const params = [];
80
- if (root) {
81
- conditions.push('root = ?');
82
- params.push(root);
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, root, paused FROM skills${where}`)
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
- root: r.root,
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 (root/status/owner/tag) apply before limit/offset,
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 { root, status, owner, tag, limit = 20, offset = 0, includePaused = false } = opts;
116
+ const { folder, status, owner, tag, limit = 20, offset = 0, includePaused = false } = opts;
117
117
  const conditions = [];
118
118
  const params = [query];
119
- if (root) {
120
- conditions.push('s.root = ?');
121
- params.push(root);
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.root,
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 <root>/[folder/]<name>/SKILL.md — folder-per-skill, per the
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
- * `root` selects which configured skill root to write into; required only
168
- * when more than one user root is configured.
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, folder, root) {
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 targetRoot = this.resolveRoot(root);
176
- const skillDir = resolveWithinBase(targetRoot.path, folder, frontmatter.name);
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
- root: targetRoot.name,
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.folder, entry.root);
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 root (roots[0]) — never user content, never deprecatable. */
219
+ /** Name of the always-present, non-removable builtin folder (folders[0]) — never user content, never deprecatable. */
220
220
  isBuiltin(doc) {
221
- return doc.root === this.roots[0]?.name;
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>/[folder/]<oldName>/ to .../<newName>/ (keeping any
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, root: _root, ...rest } = fm;
383
+ const { source_path: _sp, folder: _folder, ...rest } = fm;
384
384
  return rest;
385
385
  }
@@ -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 roots = repo.listRoots();
8
- const multiRoot = roots.length > 1;
9
- const rootNames = roots.map((r) => r.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.', multiRoot
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
- root: z.string().optional().describe(`filter to one root: ${rootNames}`),
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, root, include_paused }) => {
20
- const items = repo.list(query, root, { includePaused: include_paused });
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
- ...(multiRoot ? { root: z.string().optional().describe(`filter to one root: ${rootNames}`) } : {}),
32
- }, async ({ query, status, owner, tag, limit, offset, root, include_paused }) => {
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, { root, status, owner, tag, limit, offset, includePaused: include_paused });
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 <root>/[folder/]<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}`, {
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
- folder: z.string().optional().describe('optional subdirectory under the skill root, e.g. "frontend"'),
79
- ...(multiRoot ? { root: z.string().describe(`which configured skill root to write into: ${rootNames}`) } : {}),
80
- }, async ({ name, description, body, license, compatibility, owner, status, tags, trigger_phrases, extends: extendsId, folder, root }) => {
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, folder, root);
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
- folder: z.string().optional(),
101
- ...(multiRoot ? { root: z.string().describe(`which configured skill root to write into: ${rootNames}`) } : {}),
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
  });
@@ -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
- root TEXT NOT NULL DEFAULT '', -- name of the configured root this file lives under
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
- root TEXT NOT NULL DEFAULT '', -- name of the configured root this file lives under
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
- ['root', "TEXT NOT NULL DEFAULT ''"],
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
- ['root', "TEXT NOT NULL DEFAULT ''"],
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'],
@@ -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.root, m.root) AS root,
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
@@ -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 root a file lives under, by longest matching path prefix. */
78
- function rootForFile(sources, filePath) {
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 root of sources) {
82
- const rootPath = path.resolve(root.path);
83
- if (resolved === rootPath || resolved.startsWith(rootPath + path.sep)) {
84
- if (!best || rootPath.length > path.resolve(best.path).length)
85
- best = root;
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 root = rootForFile(spec.sources, filePath);
107
- const cols = [...spec.columns, 'source_path', 'root', 'body', 'mtime_ms'];
108
- const values = [...spec.columns.map((c) => row[c]), filePath, root, parsed.body, parsed.mtimeMs];
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 root of spec.sources) {
142
- if (!fs.existsSync(root.path))
141
+ for (const folder of spec.sources) {
142
+ if (!fs.existsSync(folder.path))
143
143
  continue;
144
- for (const file of walkMarkdownFiles(root.path)) {
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 root is added live, after registering it in spec.sources. */
157
- export function scanSingleRoot(db, spec, dirPath) {
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 root. Never touches files on disk. */
172
- export function unregisterRoot(db, table, rootName) {
173
- const rows = db.prepare(`SELECT id FROM ${table} WHERE root = ?`).all(rootName);
174
- db.prepare(`DELETE FROM ${table} WHERE root = ?`).run(rootName);
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((r) => r.path), { ignoreInitial: true, persistent: true, depth: 10 });
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))
@@ -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 { saveRoot, removeRoot as removeRootFromConfig, sanitizeRootName } from '../config.js';
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 roots = asArray(req.query.root);
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: [], roots, deprecated, paused }, intersectIds(matchedIds?.skills, dateIds?.skills)));
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, roots, deprecated, paused }, intersectIds(matchedIds?.memory_docs, dateIds?.memory_docs)));
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.roots.length > 0) {
95
- where += ` AND root IN (${filters.roots.map(() => '?').join(', ')})`;
96
- params.push(...filters.roots);
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, root, mtime_ms, deprecated, paused, created_at FROM skills WHERE ${where}`)
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
- root: r.root,
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, root, mtime_ms, deprecated, paused, created_at FROM memory_docs WHERE ${where}`)
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
- root: r.root,
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 roots = new Set();
206
+ const folders = new Set();
207
207
  if (type === 'skill' || type === 'all') {
208
- const rows = db.prepare(`SELECT tags, status, owner, root FROM skills`).all();
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.root)
215
- roots.add(r.root);
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, root FROM memory_docs`).all();
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.root)
226
- roots.add(r.root);
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
- roots: [...roots].sort(),
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/roots', (_req, res) => {
497
+ router.get('/api/folders', (_req, res) => {
498
498
  res.json({
499
- skill: skillRepo.listRoots().map((r) => ({ ...r, kind: 'skill' })),
500
- memory: memoryRepo.listRoots().map((r) => ({ ...r, kind: 'memory' })),
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/roots', (req, res) => {
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 rootName = sanitizeRootName(name || path.basename(dirPath));
518
- if (!rootName) {
519
- res.status(400).json({ error: 'could not derive a valid root name — provide one explicitly' });
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.addRoot({ name: rootName, path: dirPath });
525
- saveRoot(config, kind, { name: rootName, path: dirPath });
526
- res.json({ name: rootName, path: dirPath, kind });
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/roots/:kind/:name', (req, res) => {
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: 'root name is required' });
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.removeRoot(name);
545
- removeRootFromConfig(config, kind, name);
544
+ repo.removeFolder(name);
545
+ removeFolderFromConfig(config, kind, name);
546
546
  res.json({ removed: name, kind });
547
547
  }
548
548
  catch (err) {