mcp-memory-bucket 0.3.0 → 0.4.2

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.
@@ -4,10 +4,30 @@
4
4
  <meta charset="utf-8" />
5
5
  <title>mem-bucket viewer</title>
6
6
  <style>
7
- :root { color-scheme: light dark; }
8
- body { font-family: system-ui, sans-serif; margin: 0; }
7
+ :root {
8
+ color-scheme: light dark;
9
+ --border: light-dark(#0000001f, #ffffff2e);
10
+ --border-strong: light-dark(#00000038, #ffffff4a);
11
+ --hover: light-dark(#00000010, #ffffff14);
12
+ --hover-strong: light-dark(#00000018, #ffffff20);
13
+ --bg: light-dark(#ffffff, #1a1a1a);
14
+ --bg-subtle: light-dark(#00000008, #ffffff0d);
15
+ --fg: light-dark(#111111, #f0f0f0);
16
+ --accent: light-dark(#2563eb, #3b82f6);
17
+ --accent-fg: #ffffff;
18
+ --accent-tint: light-dark(#2563eb11, #3b82f622);
19
+ --danger: light-dark(#dc2626, #fca5a5);
20
+ --purple: light-dark(#7c3aed, #a78bfa);
21
+ --purple-fg: light-dark(#6d28d9, #d8caff);
22
+ --purple-tint: #a78bfa33;
23
+ --shadow: light-dark(#00000026, #00000080);
24
+ --overlay: light-dark(#00000059, #000000b3);
25
+ }
26
+ :root[data-theme='light'] { color-scheme: light; }
27
+ :root[data-theme='dark'] { color-scheme: dark; }
28
+ body { font-family: system-ui, sans-serif; margin: 0; background: var(--bg); color: var(--fg); }
9
29
  </style>
10
- <script type="module" crossorigin src="/assets/index-DIO48C0V.js"></script>
30
+ <script type="module" crossorigin src="/assets/index-BXCTjiGA.js"></script>
11
31
  </head>
12
32
  <body>
13
33
  <mem-bucket-app></mem-bucket-app>
@@ -68,6 +68,15 @@ export function saveRoot(config, kind, root) {
68
68
  };
69
69
  fs.writeFileSync(config.configPath, JSON.stringify(next, null, 2) + '\n');
70
70
  }
71
+ /** Lowercase-hyphenate a folder-derived root name, same shape as skill names. */
72
+ export function sanitizeRootName(raw) {
73
+ return raw
74
+ .trim()
75
+ .toLowerCase()
76
+ .replace(/[^a-z0-9]+/g, '-')
77
+ .replace(/^-+|-+$/g, '')
78
+ .slice(0, 64);
79
+ }
71
80
  /**
72
81
  * Removes a named root from the config file by name. Matches both explicit
73
82
  * {name, path} entries and bare-string entries (via their derived name).
@@ -18,6 +18,7 @@ function rowToDoc(row) {
18
18
  status: row.status,
19
19
  related_to: row.related_to,
20
20
  deprecated: !!row.deprecated,
21
+ paused: !!row.paused,
21
22
  created_at: row.created_at ?? undefined,
22
23
  source_path: row.source_path,
23
24
  root: row.root,
@@ -74,12 +75,24 @@ export class MemoryRepository {
74
75
  this.watcher?.unwatch(removed.path);
75
76
  unregisterRoot(this.db, 'memory_docs', name);
76
77
  }
77
- /** Exact-match lookup by normalized key, per V0 (no fuzzy matching). */
78
- getByKey(key, docType) {
78
+ /**
79
+ * Exact-match lookup by normalized key, per V0 (no fuzzy matching).
80
+ * `includePaused` defaults to false: paused docs are hidden from discovery (see setPaused).
81
+ */
82
+ getByKey(key, docType, opts = {}) {
79
83
  const normalized = normalizeKey(key);
80
- const rows = docType
81
- ? this.db.prepare(`SELECT * FROM memory_docs WHERE key = ? AND doc_type = ?`).all(normalized, docType)
82
- : this.db.prepare(`SELECT * FROM memory_docs WHERE key = ?`).all(normalized);
84
+ const conditions = ['key = ?'];
85
+ const params = [normalized];
86
+ if (docType) {
87
+ conditions.push('doc_type = ?');
88
+ params.push(docType);
89
+ }
90
+ if (!opts.includePaused) {
91
+ conditions.push('paused = 0');
92
+ }
93
+ const rows = this.db
94
+ .prepare(`SELECT * FROM memory_docs WHERE ${conditions.join(' AND ')}`)
95
+ .all(...params);
83
96
  return rows.map(rowToDoc);
84
97
  }
85
98
  /**
@@ -89,7 +102,7 @@ export class MemoryRepository {
89
102
  * so pagination stays correct even when filtering narrows the FTS hit set.
90
103
  */
91
104
  search(query, opts = {}) {
92
- const { docType, status, root, tag, limit = 20, offset = 0 } = opts;
105
+ const { docType, status, root, tag, limit = 20, offset = 0, includePaused = false } = opts;
93
106
  const conditions = [];
94
107
  const params = [query];
95
108
  if (docType) {
@@ -108,6 +121,9 @@ export class MemoryRepository {
108
121
  conditions.push('EXISTS (SELECT 1 FROM json_each(m.tags) WHERE value = ?)');
109
122
  params.push(tag);
110
123
  }
124
+ if (!includePaused) {
125
+ conditions.push('m.paused = 0');
126
+ }
111
127
  params.push(limit, offset);
112
128
  try {
113
129
  const rows = this.db
@@ -165,7 +181,7 @@ export class MemoryRepository {
165
181
  };
166
182
  writeMarkdownFile(filePath, stripSourcePath(fm), input.body);
167
183
  upsertFile(this.db, this.syncSpec, filePath);
168
- return { ...fm, body: input.body };
184
+ return { ...fm, body: input.body, paused: false };
169
185
  }
170
186
  /**
171
187
  * Creates many memory docs in one call — each entry is the same shape as
@@ -187,8 +203,11 @@ export class MemoryRepository {
187
203
  const existing = this.get(id);
188
204
  if (!existing)
189
205
  throw new Error(`memory doc with id "${id}" not found`);
206
+ // `paused` is local-cache-only and must never reach writeMarkdownFile — split it off of
207
+ // `existing` before spreading the rest into the frontmatter that gets written to disk.
208
+ const { paused: existingPaused, ...existingForFile } = existing;
190
209
  const merged = {
191
- ...existing,
210
+ ...existingForFile,
192
211
  ...frontmatter,
193
212
  id: existing.id,
194
213
  key: frontmatter?.key ? normalizeKey(frontmatter.key) : existing.key,
@@ -196,7 +215,7 @@ export class MemoryRepository {
196
215
  const newBody = body ?? existing.body;
197
216
  writeMarkdownFile(existing.source_path, stripSourcePath(merged), newBody);
198
217
  upsertFile(this.db, this.syncSpec, existing.source_path);
199
- return { ...merged, body: newBody };
218
+ return { ...merged, body: newBody, paused: existingPaused };
200
219
  }
201
220
  /**
202
221
  * Applies the same frontmatter change to many memory docs at once — e.g.
@@ -230,6 +249,28 @@ export class MemoryRepository {
230
249
  }
231
250
  });
232
251
  }
252
+ /**
253
+ * Pauses/resumes memory docs by id — a local-only toggle stored directly in this cache file's
254
+ * `paused` column, never written to the doc's markdown file and never synced by the file
255
+ * watcher (see the comment on memoryColumns in store/sync.ts). Paused docs are hidden from
256
+ * getByKey()/search() by default but remain fetchable via get()/bulkGet(). Because it's
257
+ * local-only, the flag does not follow the doc to another machine's cache or survive the cache
258
+ * file being deleted. Returns per-id results so one bad id doesn't abort the rest of the batch.
259
+ */
260
+ setPaused(ids, paused) {
261
+ return ids.map((id) => {
262
+ try {
263
+ const existing = this.get(id);
264
+ if (!existing)
265
+ throw new Error(`memory doc with id "${id}" not found`);
266
+ this.db.prepare(`UPDATE memory_docs SET paused = ? WHERE id = ?`).run(paused ? 1 : 0, id);
267
+ return { id, ok: true };
268
+ }
269
+ catch (err) {
270
+ return { id, ok: false, error: err.message };
271
+ }
272
+ });
273
+ }
233
274
  delete(id) {
234
275
  const existing = this.get(id);
235
276
  if (!existing)
@@ -7,11 +7,12 @@ export function registerMemoryTools(mcp, repo) {
7
7
  const roots = repo.listRoots();
8
8
  const multiRoot = roots.length > 1;
9
9
  const rootNames = roots.map((r) => r.name).join(', ');
10
- mcp.tool('memory_get', 'Exact-match lookup of memory docs (plan/spec/sql/etc.) by normalized key — a ticket ID or a free-form name like "Spot Chart Design". Returns every doc under that key, or only the matching doc_type if provided.', {
10
+ mcp.tool('memory_get', 'Exact-match lookup of memory docs (plan/spec/sql/etc.) by normalized key — a ticket ID or a free-form name like "Spot Chart Design". Returns every doc under that key, or only the matching doc_type if provided. Paused docs are hidden by default — pass include_paused to see them.', {
11
11
  key: z.string(),
12
12
  doc_type: z.enum(MEMORY_DOC_TYPES).optional(),
13
- }, async ({ key, doc_type }) => {
14
- const docs = repo.getByKey(key, doc_type);
13
+ include_paused: z.boolean().optional().describe('include paused docs, which are hidden by default (see memory_set_paused)'),
14
+ }, async ({ key, doc_type, include_paused }) => {
15
+ const docs = repo.getByKey(key, doc_type, { includePaused: include_paused });
15
16
  return { content: [{ type: 'text', text: JSON.stringify(docs, null, 2) }] };
16
17
  });
17
18
  mcp.tool('memory_bulk_get', 'Fetches many memory docs by id in one call, including full markdown bodies — e.g. hydrating a batch of memory_search hits (which return ids, not keys). Missing ids are simply omitted from the result, not errors.', { ids: z.array(z.string()).min(1) }, async ({ ids }) => {
@@ -22,7 +23,7 @@ export function registerMemoryTools(mcp, repo) {
22
23
  const keys = repo.listKeys(key_prefix);
23
24
  return { content: [{ type: 'text', text: JSON.stringify(keys, null, 2) }] };
24
25
  });
25
- mcp.tool('memory_search', 'Full-text search over memory doc description/body/tags (grep/find-like, ranked by relevance) — unlike memory_get\'s exact-key lookup, this searches by content across all keys. `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 doc_type/status/tag filters. Returns ranked hits with a highlighted snippet, not the full body — call memory_get(key) or fetch by id for that.', {
26
+ mcp.tool('memory_search', 'Full-text search over memory doc description/body/tags (grep/find-like, ranked by relevance) — unlike memory_get\'s exact-key lookup, this searches by content across all keys. `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 doc_type/status/tag filters. Returns ranked hits with a highlighted snippet, not the full body — call memory_get(key) or fetch by id for that. Paused docs are hidden by default — pass include_paused to see them.', {
26
27
  query: z.string().describe('FTS5 match expression, e.g. `migration AND rollback` or `"blue green"`'),
27
28
  doc_type: z.enum(MEMORY_DOC_TYPES).optional(),
28
29
  status: z.enum(MEMORY_STATUS).optional(),
@@ -30,9 +31,10 @@ export function registerMemoryTools(mcp, repo) {
30
31
  ...(multiRoot ? { root: z.string().optional().describe(`filter to one root: ${rootNames}`) } : {}),
31
32
  limit: z.number().int().positive().max(100).optional(),
32
33
  offset: z.number().int().nonnegative().optional(),
33
- }, async ({ query, doc_type, status, tag, root, limit, offset }) => {
34
+ include_paused: z.boolean().optional().describe('include paused docs, which are hidden by default (see memory_set_paused)'),
35
+ }, async ({ query, doc_type, status, tag, root, limit, offset, include_paused }) => {
34
36
  try {
35
- const hits = repo.search(query, { docType: doc_type, status, tag, root, limit, offset });
37
+ const hits = repo.search(query, { docType: doc_type, status, tag, root, limit, offset, includePaused: include_paused });
36
38
  return { content: [{ type: 'text', text: JSON.stringify(hits, null, 2) }] };
37
39
  }
38
40
  catch (err) {
@@ -104,6 +106,13 @@ export function registerMemoryTools(mcp, repo) {
104
106
  return { content: [{ type: 'text', text: err.message }], isError: true };
105
107
  }
106
108
  });
109
+ mcp.tool('memory_set_paused', 'Pauses or resumes memory docs by id — a local-only toggle stored in this machine\'s SQLite cache, never written to the doc\'s markdown file, so it never touches the file on disk and never follows the doc to another machine\'s cache. Paused docs are hidden from memory_get/memory_search by default but stay fully intact and are still fetchable directly via memory_bulk_get — use this to temporarily stop a doc from surfacing during discovery without deprecating it. Returns per-id success/failure so one bad id doesn\'t abort the batch.', {
110
+ ids: z.array(z.string()).min(1),
111
+ paused: z.boolean().describe('true to pause (hide from memory_get/memory_search), false to resume'),
112
+ }, async ({ ids, paused }) => {
113
+ const results = repo.setPaused(ids, paused);
114
+ return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
115
+ });
107
116
  mcp.tool('memory_delete', 'Hard-deletes a memory doc by id — removes the markdown file, no tombstone.', { id: z.string() }, async ({ id }) => {
108
117
  try {
109
118
  repo.delete(id);
@@ -12,6 +12,7 @@ import { registerSkillTools } from './skills/tools.js';
12
12
  import { registerMemoryTools } from './memory/tools.js';
13
13
  import { registerRelocateTool } from './shared/relocate-tool.js';
14
14
  import { registerSearchTool } from './shared/search-tool.js';
15
+ import { registerBucketRootTools } from './shared/bucket-root-tool.js';
15
16
  import { buildWebRouter } from './web/routes.js';
16
17
  import { registerUiTool } from './web/ui-tool.js';
17
18
  // server.ts is rebuilt from `buildMcpServer()` on every /mcp request (see below),
@@ -48,13 +49,14 @@ if (config.skillRoots.length === 0 && config.memoryRoots.length === 0) {
48
49
  // this server — surfaced both in serverInfo.description and instructions so
49
50
  // clients that expose either to the model can make that association.
50
51
  const SERVER_DESCRIPTION = 'Also known as "memory bucket", "mem bucket", or "skill bucket" — if the user refers to this server by any of those names, they mean this one.';
51
- const SERVER_INSTRUCTIONS = `${SERVER_DESCRIPTION} Exposes skill_* (reusable coding patterns, stored as agentskills.io-standard SKILL.md folders) and memory_* (point-in-time working context — plans, specs, SQL, session summaries — looked up by key) tools, plus shared relocate/bucket_search tools. Use skill_search/memory_search/bucket_search for full-text search over body content (not just metadata) — bucket_search when you don't know which bucket something landed in. Most operations have a _bulk_ variant (bulk_get/bulk_create/bulk_update/bulk_delete, relocate_bulk) that take a list and return per-item success/failure — prefer these over looping single calls when acting on more than one item. Before calling any *_create/*_update/relocate tool, call skill_get("memory-bucket-authoring") first to learn the exact frontmatter schema — don't guess the shape.`;
52
+ const SERVER_INSTRUCTIONS = `${SERVER_DESCRIPTION} Exposes skill_* (reusable coding patterns, stored as agentskills.io-standard SKILL.md folders) and memory_* (point-in-time working context — plans, specs, SQL, session summaries — looked up by key) tools, plus shared relocate/bucket_search/bucket_*_root tools. Use skill_search/memory_search/bucket_search for full-text search over body content (not just metadata) — bucket_search when you don't know which bucket something landed in. Use bucket_list_roots to see what named source directories (roots) are configured before passing a root argument elsewhere, and bucket_create_root/bucket_delete_root to register or unregister one. Most operations have a _bulk_ variant (bulk_get/bulk_create/bulk_update/bulk_delete/bulk_rename, relocate_bulk) that take a list and return per-item success/failure — prefer these over looping single calls when acting on more than one item. A memory doc's key can be changed in place via memory_update(id, key: ...) — no separate rename tool needed. Before calling any *_create/*_update/relocate tool, call skill_get("memory-bucket-authoring") first to learn the exact frontmatter schema — don't guess the shape.`;
52
53
  function buildMcpServer() {
53
54
  const server = new McpServer({ name: 'memory-bucket', version: '0.1.0', description: SERVER_DESCRIPTION }, { capabilities: {}, instructions: SERVER_INSTRUCTIONS });
54
55
  registerSkillTools(server, skillRepo);
55
56
  registerMemoryTools(server, memoryRepo);
56
57
  registerRelocateTool(server, skillRepo, memoryRepo);
57
58
  registerSearchTool(server, db);
59
+ registerBucketRootTools(server, config, skillRepo, memoryRepo, db, skillSpec, memorySpec);
58
60
  registerUiTool(server, PORT);
59
61
  return server;
60
62
  }
@@ -87,6 +89,7 @@ app.get('/mcp', methodNotAllowed);
87
89
  app.delete('/mcp', methodNotAllowed);
88
90
  app.listen(PORT, () => {
89
91
  console.error(`[memory-bucket] MCP server listening on http://localhost:${PORT}/mcp`);
92
+ console.error(`[memory-bucket] UI available at http://localhost:${PORT}`);
90
93
  console.error(`[memory-bucket] skill roots: ${config.skillRoots.map((r) => `${r.name}=${r.path}`).join(', ') || '(none)'}`);
91
94
  console.error(`[memory-bucket] memory roots: ${config.memoryRoots.map((r) => `${r.name}=${r.path}`).join(', ') || '(none)'}`);
92
95
  });
@@ -0,0 +1,66 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { z } from 'zod';
4
+ import { saveRoot, removeRoot as removeRootFromConfig, sanitizeRootName } from '../config.js';
5
+ import { initialScan } from '../store/sync.js';
6
+ const KIND = z.enum(['skill', 'memory']);
7
+ export function registerBucketRootTools(mcp, config, skillRepo, memoryRepo, db, skillSpec, memorySpec) {
8
+ mcp.tool('bucket_list_roots', 'Lists the configured skill and memory roots (the named source directories skills/memory docs live under, e.g. "super-skills", "demo-skills", "builtin") — use this to see what roots exist before passing a `root` argument to a create/list/search tool, or before adding/removing one.', {}, async () => {
9
+ const skill = skillRepo.listRoots().map((r) => ({ ...r, kind: 'skill' }));
10
+ const memory = memoryRepo.listRoots().map((r) => ({ ...r, kind: 'memory' }));
11
+ return { content: [{ type: 'text', text: JSON.stringify({ skill, memory }, null, 2) }] };
12
+ });
13
+ mcp.tool('bucket_create_root', 'Registers a new skill or memory root: an existing absolute directory path becomes a new named source that skill_create/memory_create can target via `root`. Scans it once and starts watching it live — never creates the directory itself, it must already exist.', {
14
+ kind: KIND,
15
+ path: z.string().describe('absolute path to an existing directory'),
16
+ name: z.string().optional().describe('name for the root; defaults to a sanitized version of the directory\'s basename'),
17
+ }, async ({ kind, path: dirPath, name }) => {
18
+ try {
19
+ if (!path.isAbsolute(dirPath)) {
20
+ return { content: [{ type: 'text', text: 'path must be an absolute directory path' }], isError: true };
21
+ }
22
+ if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
23
+ return { content: [{ type: 'text', text: `not a directory: ${dirPath}` }], isError: true };
24
+ }
25
+ const rootName = sanitizeRootName(name || path.basename(dirPath));
26
+ if (!rootName) {
27
+ return { content: [{ type: 'text', text: 'could not derive a valid root name — provide one explicitly' }], isError: true };
28
+ }
29
+ const repo = kind === 'skill' ? skillRepo : memoryRepo;
30
+ repo.addRoot({ name: rootName, path: dirPath });
31
+ saveRoot(config, kind, { name: rootName, path: dirPath });
32
+ return { content: [{ type: 'text', text: JSON.stringify({ name: rootName, path: dirPath, kind }, null, 2) }] };
33
+ }
34
+ catch (err) {
35
+ return { content: [{ type: 'text', text: err.message }], isError: true };
36
+ }
37
+ });
38
+ mcp.tool('bucket_delete_root', 'Unregisters a skill or memory root by name: stops watching it and drops its cached entries from the index. Never touches files on disk — the directory and its contents are left in place.', { kind: KIND, name: z.string() }, async ({ kind, name }) => {
39
+ try {
40
+ const repo = kind === 'skill' ? skillRepo : memoryRepo;
41
+ repo.removeRoot(name);
42
+ removeRootFromConfig(config, kind, name);
43
+ return { content: [{ type: 'text', text: `Removed ${kind} root "${name}"` }] };
44
+ }
45
+ catch (err) {
46
+ return { content: [{ type: 'text', text: err.message }], isError: true };
47
+ }
48
+ });
49
+ mcp.tool('bucket_rebuild_cache', 'EMERGENCY USE ONLY. Wipes the entire SQLite cache (all skills, memory docs, the full-text search index, and the date index) and rebuilds it from scratch by rescanning every configured root from disk. Source markdown files on disk are never touched — this only affects the derived cache, which is always safe to discard and regenerate. Use this only when other tools return results that contradict what you can see in the actual files (e.g. stale search hits, a doc that clearly exists on disk but skill_get/memory_get can\'t find, or search_by_date returning wrong dates) and a normal create/update/relocate call hasn\'t resolved it — this is a last resort, not a routine maintenance step. Takes a moment to complete on a large root; nothing else should be called until it returns.', {}, async () => {
50
+ try {
51
+ db.exec(`DELETE FROM skills; DELETE FROM memory_docs; DELETE FROM search_index; DELETE FROM doc_dates;`);
52
+ initialScan(db, skillSpec);
53
+ initialScan(db, memorySpec);
54
+ const skillCount = db.prepare(`SELECT COUNT(*) AS n FROM skills`).get().n;
55
+ const memoryCount = db.prepare(`SELECT COUNT(*) AS n FROM memory_docs`).get().n;
56
+ return {
57
+ content: [
58
+ { type: 'text', text: `Cache rebuilt from disk: ${skillCount} skill(s), ${memoryCount} memory doc(s) reindexed.` },
59
+ ],
60
+ };
61
+ }
62
+ catch (err) {
63
+ return { content: [{ type: 'text', text: err.message }], isError: true };
64
+ }
65
+ });
66
+ }
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { searchCombined, SearchQueryError } from '../store/search.js';
2
+ import { searchCombined, searchByDate, SearchQueryError } from '../store/search.js';
3
3
  export function registerSearchTool(mcp, db) {
4
4
  mcp.tool('bucket_search', 'Full-text search across BOTH skills and memory docs in one ranked list — use this when you don\'t know (or don\'t care) which bucket something landed in. `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". For filtering by doc_type/status/tag, use skill_search or memory_search instead. Returns ranked hits with a highlighted snippet, not full body — call skill_get/memory_get on a hit for that.', {
5
5
  query: z.string().describe('FTS5 match expression, e.g. `deploy AND rollback` or `"blue green"`'),
@@ -15,4 +15,19 @@ export function registerSearchTool(mcp, db) {
15
15
  return { content: [{ type: 'text', text: message }], isError: true };
16
16
  }
17
17
  });
18
+ mcp.tool('search_by_date', 'Finds skills and memory docs whose body text mentions a date, OR whose created_at falls, within [start, end] (inclusive, ISO YYYY-MM-DD). Matches any date extracted from the document body plus its created_at — ANY-match, no priority between them. created_at is stored converted to the server\'s local calendar date, so "today"/"this week" ranges built from local time line up correctly without any timezone adjustment. Useful for period-based recall, e.g. "what did I work on this week": the caller computes the date range itself, this tool does not parse natural language. Returns ranked hits (earliest matched date first) with a highlighted snippet showing the matched date in context (or a note that it matched via created_at, when the date isn\'t literally in the body), not the full body — call skill_get/memory_get on a hit for that.', {
19
+ start: z.string().describe('ISO date YYYY-MM-DD, inclusive'),
20
+ end: z.string().describe('ISO date YYYY-MM-DD, inclusive'),
21
+ table: z.enum(['skills', 'memory_docs']).optional().describe('restrict to one bucket; omit to search both'),
22
+ limit: z.number().int().positive().max(100).optional(),
23
+ offset: z.number().int().nonnegative().optional(),
24
+ }, async ({ start, end, table, limit, offset }) => {
25
+ try {
26
+ const hits = searchByDate(db, start, end, { table, limit, offset });
27
+ return { content: [{ type: 'text', text: JSON.stringify(hits, null, 2) }] };
28
+ }
29
+ catch (err) {
30
+ return { content: [{ type: 'text', text: err.message }], isError: true };
31
+ }
32
+ });
18
33
  }
@@ -557,6 +557,19 @@ given, ask for both before calling it; don't guess a key from context.
557
557
  tools when you don't know (or don't care) which bucket something
558
558
  landed in. All three return snippets, not full bodies — follow up with
559
559
  `skill_get`/`memory_get` (or the bulk variants below) for the rest.
560
+ - **`search_by_date(start, end)`** — finds skills and memory docs whose
561
+ **body text mentions a date, or whose `created_at` falls,** within an
562
+ inclusive ISO `YYYY-MM-DD` range, e.g. "what did I work on this week"
563
+ once you've resolved "this week" into concrete start/end dates
564
+ yourself (it does not parse natural language). Both a date written
565
+ inside the content and the doc's `created_at` count as candidate
566
+ matches — whichever is earliest wins, no priority between them. Like
567
+ `bucket_search`, it covers both skills and memory docs at once (pass
568
+ `table` to restrict to one). Returns a highlighted snippet around the
569
+ matched date (or a note that it matched via `created_at`, when the
570
+ date isn't literally in the body), not the full body. `created_at` is
571
+ indexed as the server's local calendar date, so "today"/"this week"
572
+ ranges built from local time just work — no timezone conversion needed.
560
573
  - **`skill_get`/`memory_get`** — exact-key lookup when you already know
561
574
  the name/key.
562
575
 
@@ -13,6 +13,7 @@ function rowToDoc(row) {
13
13
  trigger_phrases: JSON.parse(row.trigger_phrases),
14
14
  metadata: { owner: row.owner, status: row.status, extends: row.extends },
15
15
  deprecated: !!row.deprecated,
16
+ paused: !!row.paused,
16
17
  created_at: row.created_at ?? undefined,
17
18
  source_path: row.source_path,
18
19
  root: row.root,
@@ -72,14 +73,21 @@ export class SkillRepository {
72
73
  this.watcher?.unwatch(removed.path);
73
74
  unregisterRoot(this.db, 'skills', name);
74
75
  }
75
- list(query, root) {
76
- const rows = root
77
- ? this.db
78
- .prepare(`SELECT id, description, owner, status, tags, trigger_phrases, root FROM skills WHERE root = ?`)
79
- .all(root)
80
- : this.db
81
- .prepare(`SELECT id, description, owner, status, tags, trigger_phrases, root FROM skills`)
82
- .all();
76
+ /** `includePaused` defaults to false: paused skills are hidden from discovery (see setPaused). */
77
+ list(query, root, opts = {}) {
78
+ const conditions = [];
79
+ const params = [];
80
+ if (root) {
81
+ conditions.push('root = ?');
82
+ params.push(root);
83
+ }
84
+ if (!opts.includePaused) {
85
+ conditions.push('paused = 0');
86
+ }
87
+ const where = conditions.length ? ` WHERE ${conditions.join(' AND ')}` : '';
88
+ const rows = this.db
89
+ .prepare(`SELECT id, description, owner, status, tags, trigger_phrases, root, paused FROM skills${where}`)
90
+ .all(...params);
83
91
  const needle = query?.trim().toLowerCase();
84
92
  const items = rows.map((r) => ({
85
93
  name: r.id,
@@ -89,6 +97,7 @@ export class SkillRepository {
89
97
  tags: JSON.parse(r.tags),
90
98
  triggerPhrases: JSON.parse(r.trigger_phrases),
91
99
  root: r.root,
100
+ paused: !!r.paused,
92
101
  }));
93
102
  const filtered = needle
94
103
  ? items.filter((item) => item.description.toLowerCase().includes(needle) ||
@@ -104,7 +113,7 @@ export class SkillRepository {
104
113
  * so pagination stays correct even when filtering narrows the FTS hit set.
105
114
  */
106
115
  search(query, opts = {}) {
107
- const { root, status, owner, tag, limit = 20, offset = 0 } = opts;
116
+ const { root, status, owner, tag, limit = 20, offset = 0, includePaused = false } = opts;
108
117
  const conditions = [];
109
118
  const params = [query];
110
119
  if (root) {
@@ -123,6 +132,9 @@ export class SkillRepository {
123
132
  conditions.push('EXISTS (SELECT 1 FROM json_each(s.tags) WHERE value = ?)');
124
133
  params.push(tag);
125
134
  }
135
+ if (!includePaused) {
136
+ conditions.push('s.paused = 0');
137
+ }
126
138
  params.push(limit, offset);
127
139
  try {
128
140
  const rows = this.db
@@ -186,7 +198,7 @@ export class SkillRepository {
186
198
  };
187
199
  writeMarkdownFile(filePath, stripSourcePath(fm), body);
188
200
  upsertFile(this.db, this.syncSpec, filePath);
189
- return { ...fm, body };
201
+ return { ...fm, body, paused: false };
190
202
  }
191
203
  /**
192
204
  * Creates many skills in one call — each entry is the same shape as create()'s
@@ -212,11 +224,14 @@ export class SkillRepository {
212
224
  const existing = this.get(name);
213
225
  if (!existing)
214
226
  throw new Error(`skill with name "${name}" not found`);
227
+ // `paused` is local-cache-only and must never reach writeMarkdownFile — split it off of
228
+ // `existing` before spreading the rest into the frontmatter that gets written to disk.
229
+ const { paused: existingPaused, ...existingForFile } = existing;
215
230
  // Builtin skills (e.g. memory-bucket-authoring) are the server's own always-present
216
231
  // documentation, not user content — deprecating them would hide guidance every session needs.
217
232
  const deprecated = this.isBuiltin(existing) ? existing.deprecated : frontmatter?.deprecated;
218
233
  const merged = {
219
- ...existing,
234
+ ...existingForFile,
220
235
  ...frontmatter,
221
236
  deprecated,
222
237
  name: existing.name, // name is immutable post-creation (it's also the folder name)
@@ -230,7 +245,7 @@ export class SkillRepository {
230
245
  const newBody = body ?? existing.body;
231
246
  writeMarkdownFile(existing.source_path, stripSourcePath(merged), newBody);
232
247
  upsertFile(this.db, this.syncSpec, existing.source_path);
233
- return { ...merged, body: newBody };
248
+ return { ...merged, body: newBody, paused: existingPaused };
234
249
  }
235
250
  /**
236
251
  * Renames a skill: moves <sourceDir>/[folder/]<oldName>/ to .../<newName>/ (keeping any
@@ -253,11 +268,14 @@ export class SkillRepository {
253
268
  }
254
269
  fs.renameSync(oldDir, newDir);
255
270
  const newFilePath = path.join(newDir, 'SKILL.md');
256
- const merged = { ...existing, name: newName };
271
+ // Rename changes the skill's id, so it becomes a fresh cache row — paused (local-only,
272
+ // keyed by id) does not carry over, same as it wouldn't survive deleting the cache file.
273
+ const { paused: _existingPaused, ...existingForFile } = existing;
274
+ const merged = { ...existingForFile, name: newName };
257
275
  writeMarkdownFile(newFilePath, stripSourcePath(merged), existing.body);
258
276
  removeFile(this.db, 'skills', existing.source_path);
259
277
  upsertFile(this.db, this.syncSpec, newFilePath);
260
- return { ...merged, body: existing.body };
278
+ return { ...merged, body: existing.body, paused: false };
261
279
  }
262
280
  /**
263
281
  * Applies the same frontmatter change to many skills at once — e.g. add/remove
@@ -293,6 +311,46 @@ export class SkillRepository {
293
311
  }
294
312
  });
295
313
  }
314
+ /**
315
+ * Pauses/resumes skills by name — a local-only toggle stored directly in this cache file's
316
+ * `paused` column, never written to SKILL.md and never synced by the file watcher (see the
317
+ * comment on skillColumns in store/sync.ts). Paused skills are hidden from list()/search() by
318
+ * default but remain fetchable via get()/bulkGet(). Because it's local-only, the flag does not
319
+ * follow the skill to another machine's cache, survive a rename, or survive the cache file
320
+ * being deleted. Returns per-name results so one bad name doesn't abort the rest of the batch.
321
+ */
322
+ setPaused(names, paused) {
323
+ return names.map((name) => {
324
+ try {
325
+ const existing = this.get(name);
326
+ if (!existing)
327
+ throw new Error(`skill with name "${name}" not found`);
328
+ if (this.isBuiltin(existing))
329
+ throw new Error(`skill "${name}" is builtin and cannot be paused`);
330
+ this.db.prepare(`UPDATE skills SET paused = ? WHERE id = ?`).run(paused ? 1 : 0, name);
331
+ return { name, ok: true };
332
+ }
333
+ catch (err) {
334
+ return { name, ok: false, error: err.message };
335
+ }
336
+ });
337
+ }
338
+ /**
339
+ * Renames many skills at once — each entry is a {name, new_name} pair, same
340
+ * semantics as rename(). Returns per-entry results so one bad pair (unknown
341
+ * name, name collision) doesn't abort the rest of the batch.
342
+ */
343
+ bulkRename(entries) {
344
+ return entries.map(({ name, new_name }) => {
345
+ try {
346
+ this.rename(name, new_name);
347
+ return { name, new_name, ok: true };
348
+ }
349
+ catch (err) {
350
+ return { name, new_name, ok: false, error: err.message };
351
+ }
352
+ });
353
+ }
296
354
  /** Removes the whole skill directory, including any scripts/references/assets alongside SKILL.md. */
297
355
  delete(name) {
298
356
  const existing = this.get(name);
@@ -6,23 +6,31 @@ export function registerSkillTools(mcp, repo) {
6
6
  const roots = repo.listRoots();
7
7
  const multiRoot = roots.length > 1;
8
8
  const rootNames = roots.map((r) => r.name).join(', ');
9
- 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.', multiRoot
10
- ? { query: z.string().optional(), root: z.string().optional().describe(`filter to one root: ${rootNames}`) }
11
- : { query: z.string().optional() }, async ({ query, root }) => {
12
- const items = repo.list(query, root);
9
+ 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
10
+ ? {
11
+ query: z.string().optional(),
12
+ root: z.string().optional().describe(`filter to one root: ${rootNames}`),
13
+ include_paused: z.boolean().optional().describe('include paused skills, which are hidden by default (see skill_set_paused)'),
14
+ }
15
+ : {
16
+ query: z.string().optional(),
17
+ include_paused: z.boolean().optional().describe('include paused skills, which are hidden by default (see skill_set_paused)'),
18
+ }, async ({ query, root, include_paused }) => {
19
+ const items = repo.list(query, root, { includePaused: include_paused });
13
20
  return { content: [{ type: 'text', text: JSON.stringify(items, null, 2) }] };
14
21
  });
15
- 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.', {
22
+ 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.', {
16
23
  query: z.string().describe('FTS5 match expression, e.g. `deploy AND rollback` or `"blue green"`'),
17
24
  status: z.enum(SKILL_STATUS).optional(),
18
25
  owner: z.string().optional(),
19
26
  tag: z.string().optional(),
20
27
  limit: z.number().int().positive().max(100).optional(),
21
28
  offset: z.number().int().nonnegative().optional(),
29
+ include_paused: z.boolean().optional().describe('include paused skills, which are hidden by default (see skill_set_paused)'),
22
30
  ...(multiRoot ? { root: z.string().optional().describe(`filter to one root: ${rootNames}`) } : {}),
23
- }, async ({ query, status, owner, tag, limit, offset, root }) => {
31
+ }, async ({ query, status, owner, tag, limit, offset, root, include_paused }) => {
24
32
  try {
25
- const hits = repo.search(query, { root, status, owner, tag, limit, offset });
33
+ const hits = repo.search(query, { root, status, owner, tag, limit, offset, includePaused: include_paused });
26
34
  return { content: [{ type: 'text', text: JSON.stringify(hits, null, 2) }] };
27
35
  }
28
36
  catch (err) {
@@ -143,6 +151,21 @@ export function registerSkillTools(mcp, repo) {
143
151
  return { content: [{ type: 'text', text: err.message }], isError: true };
144
152
  }
145
153
  });
154
+ mcp.tool('skill_bulk_rename', 'Renames many skills at once — each entry is a {name, new_name} pair, same semantics as skill_rename (moves the folder, updates frontmatter `name`). Returns per-entry success/failure so one bad pair (unknown name, name collision) doesn\'t abort the rest of the batch.', {
155
+ entries: z
156
+ .array(z.object({ name: z.string().describe('current skill name'), new_name: z.string().describe(SKILL_NAME_DESCRIPTION) }))
157
+ .min(1),
158
+ }, async ({ entries }) => {
159
+ const results = repo.bulkRename(entries);
160
+ return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
161
+ });
162
+ mcp.tool('skill_set_paused', 'Pauses or resumes skills by name — a local-only toggle stored in this machine\'s SQLite cache, never written to SKILL.md, so it never touches the file on disk and never follows the skill to another machine\'s cache. Paused skills are hidden from skill_list/skill_search by default but stay fully intact and are still fetchable directly via skill_get/skill_bulk_get — use this to temporarily stop a skill from being surfaced during discovery without deprecating (retiring) it. Returns per-name success/failure so one bad name doesn\'t abort the batch.', {
163
+ names: z.array(z.string()).min(1),
164
+ paused: z.boolean().describe('true to pause (hide from skill_list/skill_search), false to resume'),
165
+ }, async ({ names, paused }) => {
166
+ const results = repo.setPaused(names, paused);
167
+ return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
168
+ });
146
169
  mcp.tool('skill_delete', 'Hard-deletes a skill by name — removes the whole skill folder (SKILL.md plus any scripts/references/assets), no tombstone.', { name: z.string() }, async ({ name }) => {
147
170
  try {
148
171
  repo.delete(name);