mcp-memory-bucket 0.4.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.
@@ -27,7 +27,7 @@
27
27
  :root[data-theme='dark'] { color-scheme: dark; }
28
28
  body { font-family: system-ui, sans-serif; margin: 0; background: var(--bg); color: var(--fg); }
29
29
  </style>
30
- <script type="module" crossorigin src="/assets/index-vdbzlLML.js"></script>
30
+ <script type="module" crossorigin src="/assets/index-BXCTjiGA.js"></script>
31
31
  </head>
32
32
  <body>
33
33
  <mem-bucket-app></mem-bucket-app>
@@ -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);
@@ -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,30 @@ 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
+ }
296
338
  /**
297
339
  * Renames many skills at once — each entry is a {name, new_name} pair, same
298
340
  * semantics as rename(). Returns per-entry results so one bad pair (unknown
@@ -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) {
@@ -151,6 +159,13 @@ export function registerSkillTools(mcp, repo) {
151
159
  const results = repo.bulkRename(entries);
152
160
  return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
153
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
+ });
154
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 }) => {
155
170
  try {
156
171
  repo.delete(name);
@@ -14,6 +14,7 @@ export function openCache(dbPath) {
14
14
  source_path TEXT NOT NULL UNIQUE, -- path to SKILL.md
15
15
  root TEXT NOT NULL DEFAULT '', -- name of the configured root this file lives under
16
16
  deprecated INTEGER NOT NULL DEFAULT 0,
17
+ paused INTEGER NOT NULL DEFAULT 0, -- local-only: never synced from/to SKILL.md, cache-file scoped
17
18
  created_at TEXT,
18
19
  body TEXT NOT NULL,
19
20
  mtime_ms INTEGER NOT NULL
@@ -31,6 +32,7 @@ export function openCache(dbPath) {
31
32
  source_path TEXT NOT NULL UNIQUE,
32
33
  root TEXT NOT NULL DEFAULT '', -- name of the configured root this file lives under
33
34
  deprecated INTEGER NOT NULL DEFAULT 0,
35
+ paused INTEGER NOT NULL DEFAULT 0, -- local-only: never synced from/to the doc's markdown file, cache-file scoped
34
36
  created_at TEXT,
35
37
  body TEXT NOT NULL,
36
38
  mtime_ms INTEGER NOT NULL
@@ -59,11 +61,13 @@ export function openCache(dbPath) {
59
61
  ensureColumns(db, 'skills', [
60
62
  ['root', "TEXT NOT NULL DEFAULT ''"],
61
63
  ['deprecated', 'INTEGER NOT NULL DEFAULT 0'],
64
+ ['paused', 'INTEGER NOT NULL DEFAULT 0'],
62
65
  ['created_at', 'TEXT'],
63
66
  ]);
64
67
  ensureColumns(db, 'memory_docs', [
65
68
  ['root', "TEXT NOT NULL DEFAULT ''"],
66
69
  ['deprecated', 'INTEGER NOT NULL DEFAULT 0'],
70
+ ['paused', 'INTEGER NOT NULL DEFAULT 0'],
67
71
  ['created_at', 'TEXT'],
68
72
  ]);
69
73
  backfillSearchIndex(db);
@@ -4,6 +4,9 @@ import chokidar, {} from 'chokidar';
4
4
  import { readMarkdownFile } from './markdown-file.js';
5
5
  import { flattenTags } from './db.js';
6
6
  import { extractDates, toLocalDate } from './date-extract.js';
7
+ // `paused` is deliberately absent from both lists: it's a local-only cache column (see
8
+ // SkillRepository/MemoryRepository#setPaused) that never round-trips through frontmatter, so a
9
+ // file add/change/rescan must never overwrite it via the INSERT/ON CONFLICT UPDATE below.
7
10
  const skillColumns = ['id', 'description', 'owner', 'status', 'tags', 'trigger_phrases', 'extends', 'deprecated', 'created_at'];
8
11
  const memoryColumns = ['id', 'key', 'key_type', 'description', 'doc_type', 'tags', 'status', 'related_to', 'deprecated', 'created_at'];
9
12
  export function skillSyncSpec(sources) {
@@ -25,6 +25,8 @@ function queryEntries(db, req) {
25
25
  const q = req.query.q?.trim();
26
26
  const deprecatedParam = req.query.deprecated;
27
27
  const deprecated = deprecatedParam === '0' || deprecatedParam === '1' ? deprecatedParam : undefined;
28
+ const pausedParam = req.query.paused;
29
+ const paused = pausedParam === '0' || pausedParam === '1' ? pausedParam : undefined;
28
30
  const dateFrom = req.query.date_from?.trim() || undefined;
29
31
  const dateTo = req.query.date_to?.trim() || undefined;
30
32
  const matchedIds = q
@@ -39,10 +41,10 @@ function queryEntries(db, req) {
39
41
  }
40
42
  const results = [];
41
43
  if (type === 'skill' || type === 'all') {
42
- results.push(...queryTable(db, 'skills', { tags, statuses, owners, docTypes: [], keyTypes: [], roots, deprecated }, intersectIds(matchedIds?.skills, dateIds?.skills)));
44
+ results.push(...queryTable(db, 'skills', { tags, statuses, owners, docTypes: [], keyTypes: [], roots, deprecated, paused }, intersectIds(matchedIds?.skills, dateIds?.skills)));
43
45
  }
44
46
  if (type === 'memory' || type === 'all') {
45
- results.push(...queryTable(db, 'memory_docs', { tags, statuses, owners: [], docTypes, keyTypes, roots, deprecated }, intersectIds(matchedIds?.memory_docs, dateIds?.memory_docs)));
47
+ results.push(...queryTable(db, 'memory_docs', { tags, statuses, owners: [], docTypes, keyTypes, roots, deprecated, paused }, intersectIds(matchedIds?.memory_docs, dateIds?.memory_docs)));
46
48
  }
47
49
  const sort = req.query.sort ?? 'mtime_desc';
48
50
  results.sort((a, b) => {
@@ -95,13 +97,17 @@ function queryTable(db, table, filters, restrictToIds) {
95
97
  where += ` AND deprecated = ?`;
96
98
  params.push(filters.deprecated === '1' ? 1 : 0);
97
99
  }
100
+ if (filters.paused !== undefined) {
101
+ where += ` AND paused = ?`;
102
+ params.push(filters.paused === '1' ? 1 : 0);
103
+ }
98
104
  if (restrictToIds) {
99
105
  where += ` AND id IN (${[...restrictToIds].map(() => '?').join(', ')})`;
100
106
  params.push(...restrictToIds);
101
107
  }
102
108
  if (table === 'skills') {
103
109
  const rows = db
104
- .prepare(`SELECT id, description, owner, status, tags, root, mtime_ms, deprecated, created_at FROM skills WHERE ${where}`)
110
+ .prepare(`SELECT id, description, owner, status, tags, root, mtime_ms, deprecated, paused, created_at FROM skills WHERE ${where}`)
105
111
  .all(...params);
106
112
  return rows.map((r) => ({
107
113
  _table: 'skills',
@@ -116,11 +122,12 @@ function queryTable(db, table, filters, restrictToIds) {
116
122
  root: r.root,
117
123
  mtime_ms: r.mtime_ms,
118
124
  deprecated: !!r.deprecated,
125
+ paused: !!r.paused,
119
126
  created_at: r.created_at,
120
127
  }));
121
128
  }
122
129
  const rows = db
123
- .prepare(`SELECT id, key, description, doc_type, key_type, status, tags, root, mtime_ms, deprecated, created_at FROM memory_docs WHERE ${where}`)
130
+ .prepare(`SELECT id, key, description, doc_type, key_type, status, tags, root, mtime_ms, deprecated, paused, created_at FROM memory_docs WHERE ${where}`)
124
131
  .all(...params);
125
132
  return rows.map((r) => ({
126
133
  _table: 'memory_docs',
@@ -135,6 +142,7 @@ function queryTable(db, table, filters, restrictToIds) {
135
142
  root: r.root,
136
143
  mtime_ms: r.mtime_ms,
137
144
  deprecated: !!r.deprecated,
145
+ paused: !!r.paused,
138
146
  created_at: r.created_at,
139
147
  }));
140
148
  }
@@ -305,6 +313,44 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo) {
305
313
  const results = table === 'skills' ? skillRepo.bulkUpdate(ids, { deprecated }) : memoryRepo.bulkUpdate(ids, { deprecated });
306
314
  res.json({ results });
307
315
  });
316
+ // `paused` is a local-only cache toggle (see SkillRepository/MemoryRepository#setPaused) — it
317
+ // never touches the source file, so this goes through setPaused, not update()/bulkUpdate().
318
+ router.patch('/api/entries/:table/:id/paused', (req, res) => {
319
+ const { table, id } = req.params;
320
+ const { paused } = req.body;
321
+ if (table !== 'skills' && table !== 'memory_docs') {
322
+ res.status(400).json({ error: 'table must be "skills" or "memory_docs"' });
323
+ return;
324
+ }
325
+ if (!id) {
326
+ res.status(400).json({ error: 'id is required' });
327
+ return;
328
+ }
329
+ if (typeof paused !== 'boolean') {
330
+ res.status(400).json({ error: 'body must be { paused: boolean }' });
331
+ return;
332
+ }
333
+ const [result] = table === 'skills' ? skillRepo.setPaused([id], paused) : memoryRepo.setPaused([id], paused);
334
+ if (!result?.ok) {
335
+ res.status(404).json({ error: result?.error ?? 'not found' });
336
+ return;
337
+ }
338
+ res.json({ id, paused });
339
+ });
340
+ router.post('/api/entries/:table/bulk/paused', (req, res) => {
341
+ const { table } = req.params;
342
+ const { ids, paused } = req.body;
343
+ if (table !== 'skills' && table !== 'memory_docs') {
344
+ res.status(400).json({ error: 'table must be "skills" or "memory_docs"' });
345
+ return;
346
+ }
347
+ if (!Array.isArray(ids) || ids.length === 0 || typeof paused !== 'boolean') {
348
+ res.status(400).json({ error: 'body must be { ids: string[], paused: boolean }' });
349
+ return;
350
+ }
351
+ const results = table === 'skills' ? skillRepo.setPaused(ids, paused) : memoryRepo.setPaused(ids, paused);
352
+ res.json({ results });
353
+ });
308
354
  router.delete('/api/entries/:table/:id', (req, res) => {
309
355
  const { table, id } = req.params;
310
356
  if (table !== 'skills' && table !== 'memory_docs') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-memory-bucket",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "type": "module",
5
5
  "description": "MCP server exposing skill_* (reusable coding patterns) and memory_* (point-in-time working context) tools over a markdown+frontmatter source, cached into SQLite at runtime.",
6
6
  "repository": {
@@ -29,7 +29,7 @@
29
29
  "copy:builtin-skills": "mkdir -p dist/src/skills/builtin && cp -r src/skills/builtin/. dist/src/skills/builtin/",
30
30
  "prepublishOnly": "npm run build",
31
31
  "start": "tsx src/server.ts",
32
- "start_in_folder": "tsx src/server.ts --memory-dir",
32
+ "start_in_folder": "vite build && tsx src/server.ts --memory-dir",
33
33
  "typecheck": "tsc --noEmit -p tsconfig.server.json && tsc --noEmit -p tsconfig.client.json",
34
34
  "test": "node --import tsx --test test/**/*.test.ts"
35
35
  },