mcp-memory-bucket 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,6 +12,35 @@ export function registerSkillTools(mcp, repo) {
12
12
  const items = repo.list(query, root);
13
13
  return { content: [{ type: 'text', text: JSON.stringify(items, null, 2) }] };
14
14
  });
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.', {
16
+ query: z.string().describe('FTS5 match expression, e.g. `deploy AND rollback` or `"blue green"`'),
17
+ status: z.enum(SKILL_STATUS).optional(),
18
+ owner: z.string().optional(),
19
+ tag: z.string().optional(),
20
+ limit: z.number().int().positive().max(100).optional(),
21
+ offset: z.number().int().nonnegative().optional(),
22
+ ...(multiRoot ? { root: z.string().optional().describe(`filter to one root: ${rootNames}`) } : {}),
23
+ }, async ({ query, status, owner, tag, limit, offset, root }) => {
24
+ try {
25
+ const hits = repo.search(query, { root, status, owner, tag, limit, offset });
26
+ return { content: [{ type: 'text', text: JSON.stringify(hits, null, 2) }] };
27
+ }
28
+ catch (err) {
29
+ return { content: [{ type: 'text', text: err.message }], isError: true };
30
+ }
31
+ });
32
+ mcp.tool('skill_bulk_update', 'Applies the same frontmatter change to many skills at once by name — e.g. add/remove a tag across a batch found via skill_search, or flip status for a group. add_tags/remove_tags merge or subtract per-skill; owner/status/extends overwrite uniformly when provided. Body is never touched. Returns per-name success/failure so one bad name doesn\'t abort the batch.', {
33
+ names: z.array(z.string()).min(1),
34
+ add_tags: z.array(z.string()).optional(),
35
+ remove_tags: z.array(z.string()).optional(),
36
+ owner: z.string().nullable().optional(),
37
+ status: z.enum(SKILL_STATUS).optional(),
38
+ extends: z.string().nullable().optional(),
39
+ deprecated: z.boolean().optional().describe('marks skills as deprecated (or un-deprecates when false) — independent of status'),
40
+ }, async ({ names, add_tags, remove_tags, owner, status, extends: extendsId, deprecated }) => {
41
+ const results = repo.bulkUpdate(names, { add_tags, remove_tags, owner, status, extends: extendsId, deprecated });
42
+ return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
43
+ });
15
44
  mcp.tool('skill_get', 'Fetches a single skill by name, including its full markdown body.', { name: z.string() }, async ({ name }) => {
16
45
  const doc = repo.get(name);
17
46
  if (!doc) {
@@ -19,6 +48,10 @@ export function registerSkillTools(mcp, repo) {
19
48
  }
20
49
  return { content: [{ type: 'text', text: JSON.stringify(doc, null, 2) }] };
21
50
  });
51
+ mcp.tool('skill_bulk_get', 'Fetches many skills by name in one call, including full markdown bodies — e.g. hydrating a batch of skill_search hits. Missing names are simply omitted from the result, not errors.', { names: z.array(z.string()).min(1) }, async ({ names }) => {
52
+ const docs = repo.bulkGet(names);
53
+ return { content: [{ type: 'text', text: JSON.stringify(docs, null, 2) }] };
54
+ });
22
55
  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}`, {
23
56
  name: z.string().describe(SKILL_NAME_DESCRIPTION),
24
57
  description: z
@@ -44,6 +77,39 @@ export function registerSkillTools(mcp, repo) {
44
77
  return { content: [{ type: 'text', text: err.message }], isError: true };
45
78
  }
46
79
  });
80
+ const skillEntrySchema = z.object({
81
+ name: z.string().describe(SKILL_NAME_DESCRIPTION),
82
+ description: z.string().max(1024).describe('required by spec: what the skill does AND when to use it'),
83
+ body: z.string().describe('markdown body of SKILL.md'),
84
+ license: z.string().optional(),
85
+ compatibility: z.string().max(500).optional(),
86
+ owner: z.string().optional(),
87
+ status: z.enum(SKILL_STATUS).optional(),
88
+ tags: z.array(z.string()).optional(),
89
+ trigger_phrases: z.array(z.string()).optional(),
90
+ extends: z.string().optional(),
91
+ folder: z.string().optional(),
92
+ ...(multiRoot ? { root: z.string().describe(`which configured skill root to write into: ${rootNames}`) } : {}),
93
+ });
94
+ 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 }) => {
95
+ const results = repo.bulkCreate(entries.map((e) => ({
96
+ frontmatter: {
97
+ name: e.name,
98
+ description: e.description,
99
+ license: e.license,
100
+ compatibility: e.compatibility,
101
+ owner: e.owner,
102
+ status: e.status,
103
+ tags: e.tags,
104
+ trigger_phrases: e.trigger_phrases,
105
+ extends: e.extends,
106
+ },
107
+ body: e.body,
108
+ folder: e.folder,
109
+ root: e.root,
110
+ })));
111
+ return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
112
+ });
47
113
  mcp.tool('skill_update', `Edits an existing skill in place — frontmatter fields and/or body. Only provided fields change. Use skill_rename to change the name/folder. ${AUTHORING_SKILL_HINT}`, {
48
114
  name: z.string(),
49
115
  description: z.string().max(1024).optional(),
@@ -55,6 +121,7 @@ export function registerSkillTools(mcp, repo) {
55
121
  tags: z.array(z.string()).optional(),
56
122
  trigger_phrases: z.array(z.string()).optional(),
57
123
  extends: z.string().optional(),
124
+ deprecated: z.boolean().optional().describe('marks the skill as deprecated (or un-deprecates when false) — independent of status'),
58
125
  }, async ({ name, body, ...frontmatterFields }) => {
59
126
  try {
60
127
  const doc = repo.update(name, frontmatterFields, body);
@@ -76,6 +143,14 @@ export function registerSkillTools(mcp, repo) {
76
143
  return { content: [{ type: 'text', text: err.message }], isError: true };
77
144
  }
78
145
  });
146
+ 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.', {
147
+ entries: z
148
+ .array(z.object({ name: z.string().describe('current skill name'), new_name: z.string().describe(SKILL_NAME_DESCRIPTION) }))
149
+ .min(1),
150
+ }, async ({ entries }) => {
151
+ const results = repo.bulkRename(entries);
152
+ return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
153
+ });
79
154
  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 }) => {
80
155
  try {
81
156
  repo.delete(name);
@@ -85,4 +160,8 @@ export function registerSkillTools(mcp, repo) {
85
160
  return { content: [{ type: 'text', text: err.message }], isError: true };
86
161
  }
87
162
  });
163
+ mcp.tool('skill_bulk_delete', 'Hard-deletes many skills by name in one call — e.g. cleaning up a batch found via skill_search/skill_list. No tombstone. Returns per-name success/failure so one bad name doesn\'t abort the rest of the batch.', { names: z.array(z.string()).min(1) }, async ({ names }) => {
164
+ const results = repo.bulkDelete(names);
165
+ return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
166
+ });
88
167
  }
@@ -0,0 +1,49 @@
1
+ const MONTHS = {
2
+ jan: '01', feb: '02', mar: '03', apr: '04', may: '05', jun: '06',
3
+ jul: '07', aug: '08', sep: '09', oct: '10', nov: '11', dec: '12',
4
+ };
5
+ const ISO_DATE_RE = /\b(\d{4})-(\d{2})-(\d{2})\b/g;
6
+ const WRITTEN_MONTH_RE = /\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+(\d{1,2}),?\s+(\d{4})\b/gi;
7
+ function isValidDate(year, month, day) {
8
+ if (month < 1 || month > 12 || day < 1 || day > 31)
9
+ return false;
10
+ const d = new Date(Date.UTC(year, month - 1, day));
11
+ return d.getUTCFullYear() === year && d.getUTCMonth() === month - 1 && d.getUTCDate() === day;
12
+ }
13
+ function stripCodeBlocks(body) {
14
+ return body.replace(/```[\s\S]*?```/g, '');
15
+ }
16
+ /** Extracts unique ISO (YYYY-MM-DD) dates mentioned in free text — conservative by design: only unambiguous formats (ISO, written-month-with-year) are matched, code blocks and slash-dates are skipped entirely. */
17
+ export function extractDates(body) {
18
+ const text = stripCodeBlocks(body);
19
+ const dates = new Set();
20
+ for (const match of text.matchAll(ISO_DATE_RE)) {
21
+ const [, y, m, d] = match;
22
+ const year = Number(y);
23
+ const month = Number(m);
24
+ const day = Number(d);
25
+ if (isValidDate(year, month, day))
26
+ dates.add(`${y}-${m}-${d}`);
27
+ }
28
+ for (const match of text.matchAll(WRITTEN_MONTH_RE)) {
29
+ const [, monthName, dayStr, yearStr] = match;
30
+ const month = MONTHS[monthName.toLowerCase()];
31
+ const day = Number(dayStr);
32
+ const year = Number(yearStr);
33
+ if (month && isValidDate(year, Number(month), day)) {
34
+ dates.add(`${yearStr}-${month}-${String(day).padStart(2, '0')}`);
35
+ }
36
+ }
37
+ return Array.from(dates).sort();
38
+ }
39
+ /**
40
+ * Converts a UTC ISO timestamp (e.g. created_at) to the calendar date it
41
+ * falls on in the given IANA timezone (default: the OS timezone this
42
+ * process is running in). Distinct from extractDates()'s output, which is
43
+ * already timezone-naive text — this exists specifically so a UTC instant
44
+ * lands on the same calendar date a user in that timezone would call "today".
45
+ */
46
+ export function toLocalDate(isoTimestamp, timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone) {
47
+ const formatter = new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' });
48
+ return formatter.format(new Date(isoTimestamp));
49
+ }
@@ -13,6 +13,8 @@ export function openCache(dbPath) {
13
13
  extends TEXT,
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
+ deprecated INTEGER NOT NULL DEFAULT 0,
17
+ created_at TEXT,
16
18
  body TEXT NOT NULL,
17
19
  mtime_ms INTEGER NOT NULL
18
20
  );
@@ -28,6 +30,8 @@ export function openCache(dbPath) {
28
30
  related_to TEXT,
29
31
  source_path TEXT NOT NULL UNIQUE,
30
32
  root TEXT NOT NULL DEFAULT '', -- name of the configured root this file lives under
33
+ deprecated INTEGER NOT NULL DEFAULT 0,
34
+ created_at TEXT,
31
35
  body TEXT NOT NULL,
32
36
  mtime_ms INTEGER NOT NULL
33
37
  );
@@ -42,18 +46,36 @@ export function openCache(dbPath) {
42
46
  tags,
43
47
  tokenize = 'porter unicode61'
44
48
  );
49
+
50
+ CREATE TABLE IF NOT EXISTS doc_dates (
51
+ ref_table TEXT NOT NULL,
52
+ ref_id TEXT NOT NULL,
53
+ date TEXT NOT NULL
54
+ );
55
+
56
+ CREATE INDEX IF NOT EXISTS idx_doc_dates_date ON doc_dates(date);
57
+ CREATE INDEX IF NOT EXISTS idx_doc_dates_ref ON doc_dates(ref_table, ref_id);
45
58
  `);
46
- addRootColumnIfMissing(db, 'skills');
47
- addRootColumnIfMissing(db, 'memory_docs');
59
+ ensureColumns(db, 'skills', [
60
+ ['root', "TEXT NOT NULL DEFAULT ''"],
61
+ ['deprecated', 'INTEGER NOT NULL DEFAULT 0'],
62
+ ['created_at', 'TEXT'],
63
+ ]);
64
+ ensureColumns(db, 'memory_docs', [
65
+ ['root', "TEXT NOT NULL DEFAULT ''"],
66
+ ['deprecated', 'INTEGER NOT NULL DEFAULT 0'],
67
+ ['created_at', 'TEXT'],
68
+ ]);
48
69
  backfillSearchIndex(db);
49
70
  return db;
50
71
  }
51
- /** Migration for cache files created before the `root` column existed. */
52
- function addRootColumnIfMissing(db, table) {
53
- const columns = db.prepare(`PRAGMA table_info(${table})`).all();
54
- if (columns.some((c) => c.name === 'root'))
55
- return;
56
- db.exec(`ALTER TABLE ${table} ADD COLUMN root TEXT NOT NULL DEFAULT ''`);
72
+ /** Migration for cache files created before a given column existed. Safe to call every startup. */
73
+ function ensureColumns(db, table, columns) {
74
+ const existing = new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name));
75
+ for (const [name, ddlType] of columns) {
76
+ if (!existing.has(name))
77
+ db.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${ddlType}`);
78
+ }
57
79
  }
58
80
  /** One-time backfill for existing rows the first time search_index is introduced into a cache file. */
59
81
  function backfillSearchIndex(db) {
@@ -0,0 +1,118 @@
1
+ /** A quoted FTS5 syntax error, translated into something an agent can act on without seeing raw SQLite internals. */
2
+ export class SearchQueryError extends Error {
3
+ constructor(query, cause) {
4
+ super(`invalid search query "${query}": ${cause.message}. ` +
5
+ `FTS5 syntax notes: hyphenated/punctuated words must be quoted (e.g. "blue-green"), ` +
6
+ `bare words are AND'd by default, use OR/NOT for other logic, "exact phrase" for phrases, and prefix* for prefix matching.`);
7
+ this.name = 'SearchQueryError';
8
+ }
9
+ }
10
+ /**
11
+ * Full-text search over the shared FTS5 index, optionally scoped to one ref_table.
12
+ * `query` is passed through as raw FTS5 MATCH syntax — supports `AND`/`OR`/`NOT`,
13
+ * `"exact phrases"`, and `prefix*` — so callers get grep-like power for free.
14
+ * Ranked by bm25() (lower is better, so we negate for a "higher is better" score).
15
+ * Throws SearchQueryError on malformed FTS5 syntax instead of a raw SQLite error.
16
+ */
17
+ export function searchIndex(db, query, opts = {}) {
18
+ const { table, limit = 20, offset = 0 } = opts;
19
+ try {
20
+ const rows = db
21
+ .prepare(`SELECT ref_table, ref_id,
22
+ snippet(search_index, 3, '<<', '>>', '…', 20) AS snippet,
23
+ -bm25(search_index) AS score
24
+ FROM search_index
25
+ WHERE search_index MATCH ? ${table ? 'AND ref_table = ?' : ''}
26
+ ORDER BY bm25(search_index)
27
+ LIMIT ? OFFSET ?`)
28
+ .all(...(table ? [query, table, limit, offset] : [query, limit, offset]));
29
+ return rows;
30
+ }
31
+ catch (err) {
32
+ throw new SearchQueryError(query, err);
33
+ }
34
+ }
35
+ const SNIPPET_CONTEXT_WORDS = 20;
36
+ /**
37
+ * Builds a `<<...>>`-marked excerpt around the first occurrence of `date` in
38
+ * `body`, mirroring FTS5's snippet() style for visual consistency with the
39
+ * other search tools. `date` doesn't always appear literally in the body —
40
+ * it may have matched via created_at instead — in which case there's no
41
+ * position to excerpt around, so the marker stands alone with no context.
42
+ */
43
+ function buildDateSnippet(body, date) {
44
+ const idx = body.indexOf(date);
45
+ if (idx === -1)
46
+ return `<<${date}>> (matched via created_at, not mentioned in body)`;
47
+ const before = body.slice(0, idx).split(/\s+/).filter(Boolean).slice(-SNIPPET_CONTEXT_WORDS).join(' ');
48
+ const after = body
49
+ .slice(idx + date.length)
50
+ .split(/\s+/)
51
+ .filter(Boolean)
52
+ .slice(0, SNIPPET_CONTEXT_WORDS)
53
+ .join(' ');
54
+ return `${before ? '…' + before + ' ' : ''}<<${date}>>${after ? ' ' + after + '…' : ''}`;
55
+ }
56
+ /**
57
+ * Finds skills/memory docs whose body mentions a date, OR whose created_at
58
+ * falls, within [start, end] (inclusive, ISO YYYY-MM-DD) — driven by the
59
+ * `doc_dates` side table, populated at index time from both extractDates()
60
+ * on the body and the doc's created_at, not FTS5. ANY-match: a doc with
61
+ * multiple candidate dates matches if any falls in range; `matched_date` is
62
+ * the earliest match, with no priority between body-extracted and created_at.
63
+ */
64
+ export function searchByDate(db, start, end, opts = {}) {
65
+ if (start > end) {
66
+ throw new Error(`invalid date range: start "${start}" is after end "${end}"`);
67
+ }
68
+ const { table, limit = 20, offset = 0 } = opts;
69
+ const params = [start, end];
70
+ if (table)
71
+ params.push(table);
72
+ params.push(limit, offset);
73
+ const rows = db
74
+ .prepare(`SELECT ref_table, ref_id, MIN(date) AS matched_date
75
+ FROM doc_dates
76
+ WHERE date BETWEEN ? AND ? ${table ? 'AND ref_table = ?' : ''}
77
+ GROUP BY ref_table, ref_id
78
+ ORDER BY matched_date
79
+ LIMIT ? OFFSET ?`)
80
+ .all(...params);
81
+ return rows.map((row) => {
82
+ const bodyRow = db
83
+ .prepare(`SELECT body FROM ${row.ref_table} WHERE id = ?`)
84
+ .get(row.ref_id);
85
+ return {
86
+ ref_table: row.ref_table,
87
+ ref_id: row.ref_id,
88
+ matched_date: row.matched_date,
89
+ snippet: bodyRow ? buildDateSnippet(bodyRow.body, row.matched_date) : '',
90
+ };
91
+ });
92
+ }
93
+ /**
94
+ * Full-text search across BOTH skills and memory docs in one ranked list —
95
+ * for the common case of "find where I put X" when the caller doesn't know
96
+ * which bucket it landed in. No metadata filters (doc_type/status/tag differ
97
+ * per table); use skill_search/memory_search directly when filtering by those.
98
+ */
99
+ export function searchCombined(db, query, limit = 20, offset = 0) {
100
+ try {
101
+ return db
102
+ .prepare(`SELECT ref_table, ref_id AS id,
103
+ COALESCE(s.description, m.description) AS description,
104
+ COALESCE(s.root, m.root) AS root,
105
+ snippet(search_index, 3, '<<', '>>', '…', 20) AS snippet,
106
+ -bm25(search_index) AS score
107
+ FROM search_index
108
+ LEFT JOIN skills s ON search_index.ref_table = 'skills' AND s.id = search_index.ref_id
109
+ LEFT JOIN memory_docs m ON search_index.ref_table = 'memory_docs' AND m.id = search_index.ref_id
110
+ WHERE search_index MATCH ?
111
+ ORDER BY bm25(search_index)
112
+ LIMIT ? OFFSET ?`)
113
+ .all(query, limit, offset);
114
+ }
115
+ catch (err) {
116
+ throw new SearchQueryError(query, err);
117
+ }
118
+ }
@@ -3,8 +3,9 @@ import path from 'node:path';
3
3
  import chokidar, {} from 'chokidar';
4
4
  import { readMarkdownFile } from './markdown-file.js';
5
5
  import { flattenTags } from './db.js';
6
- const skillColumns = ['id', 'description', 'owner', 'status', 'tags', 'trigger_phrases', 'extends'];
7
- const memoryColumns = ['id', 'key', 'key_type', 'description', 'doc_type', 'tags', 'status', 'related_to'];
6
+ import { extractDates, toLocalDate } from './date-extract.js';
7
+ const skillColumns = ['id', 'description', 'owner', 'status', 'tags', 'trigger_phrases', 'extends', 'deprecated', 'created_at'];
8
+ const memoryColumns = ['id', 'key', 'key_type', 'description', 'doc_type', 'tags', 'status', 'related_to', 'deprecated', 'created_at'];
8
9
  export function skillSyncSpec(sources) {
9
10
  return {
10
11
  table: 'skills',
@@ -20,6 +21,8 @@ export function skillSyncSpec(sources) {
20
21
  tags: JSON.stringify(fm.tags ?? []),
21
22
  trigger_phrases: JSON.stringify(fm.trigger_phrases ?? []),
22
23
  extends: fm.metadata?.extends ?? null,
24
+ deprecated: fm.deprecated ? 1 : 0,
25
+ created_at: fm.created_at ?? null,
23
26
  }),
24
27
  };
25
28
  }
@@ -39,6 +42,8 @@ export function memorySyncSpec(sources) {
39
42
  tags: JSON.stringify(fm.tags ?? []),
40
43
  status: fm.status ?? 'active',
41
44
  related_to: fm.related_to ?? null,
45
+ deprecated: fm.deprecated ? 1 : 0,
46
+ created_at: fm.created_at ?? null,
42
47
  }),
43
48
  };
44
49
  }
@@ -86,12 +91,25 @@ export function upsertFile(db, spec, filePath) {
86
91
  ON CONFLICT(id) DO UPDATE SET ${updateClause}`).run(...values);
87
92
  db.prepare(`DELETE FROM search_index WHERE ref_table = ? AND ref_id = ?`).run(spec.table, id);
88
93
  db.prepare(`INSERT INTO search_index (ref_table, ref_id, description, body, tags) VALUES (?, ?, ?, ?, ?)`).run(spec.table, id, String(row.description ?? ''), parsed.body, flattenTags(String(row.tags ?? '[]')));
94
+ db.prepare(`DELETE FROM doc_dates WHERE ref_table = ? AND ref_id = ?`).run(spec.table, id);
95
+ const dates = new Set(extractDates(parsed.body));
96
+ // created_at is a UTC instant; convert to the OS-local calendar date so it
97
+ // lines up with what a user in this timezone would call "today", matching
98
+ // extractDates()'s output, which is already timezone-naive local text.
99
+ if (row.created_at)
100
+ dates.add(toLocalDate(String(row.created_at)));
101
+ if (dates.size > 0) {
102
+ const insertDate = db.prepare(`INSERT INTO doc_dates (ref_table, ref_id, date) VALUES (?, ?, ?)`);
103
+ for (const date of dates)
104
+ insertDate.run(spec.table, id, date);
105
+ }
89
106
  }
90
107
  export function removeFile(db, table, filePath) {
91
108
  const existing = db.prepare(`SELECT id FROM ${table} WHERE source_path = ?`).get(filePath);
92
109
  db.prepare(`DELETE FROM ${table} WHERE source_path = ?`).run(filePath);
93
110
  if (existing) {
94
111
  db.prepare(`DELETE FROM search_index WHERE ref_table = ? AND ref_id = ?`).run(table, existing.id);
112
+ db.prepare(`DELETE FROM doc_dates WHERE ref_table = ? AND ref_id = ?`).run(table, existing.id);
95
113
  }
96
114
  }
97
115
  /** Full scan of all configured source dirs — used once at startup before the watcher takes over. */
@@ -2,7 +2,7 @@ import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import express from 'express';
5
- import { saveRoot, removeRoot as removeRootFromConfig } from '../config.js';
5
+ import { saveRoot, removeRoot as removeRootFromConfig, sanitizeRootName } from '../config.js';
6
6
  function asArray(v) {
7
7
  if (v === undefined)
8
8
  return [];
@@ -23,18 +23,26 @@ function queryEntries(db, req) {
23
23
  const keyTypes = asArray(req.query.key_type);
24
24
  const roots = asArray(req.query.root);
25
25
  const q = req.query.q?.trim();
26
+ const deprecatedParam = req.query.deprecated;
27
+ const deprecated = deprecatedParam === '0' || deprecatedParam === '1' ? deprecatedParam : undefined;
28
+ const dateFrom = req.query.date_from?.trim() || undefined;
29
+ const dateTo = req.query.date_to?.trim() || undefined;
26
30
  const matchedIds = q
27
31
  ? matchSearch(db, q)
28
32
  : null;
29
33
  if (q && matchedIds && matchedIds.skills.size === 0 && matchedIds.memory_docs.size === 0) {
30
34
  return [];
31
35
  }
36
+ const dateIds = dateFrom || dateTo ? matchDateRange(db, dateFrom, dateTo) : null;
37
+ if (dateIds && dateIds.skills.size === 0 && dateIds.memory_docs.size === 0) {
38
+ return [];
39
+ }
32
40
  const results = [];
33
41
  if (type === 'skill' || type === 'all') {
34
- results.push(...queryTable(db, 'skills', { tags, statuses, owners, docTypes: [], keyTypes: [], roots }, matchedIds?.skills));
42
+ results.push(...queryTable(db, 'skills', { tags, statuses, owners, docTypes: [], keyTypes: [], roots, deprecated }, intersectIds(matchedIds?.skills, dateIds?.skills)));
35
43
  }
36
44
  if (type === 'memory' || type === 'all') {
37
- results.push(...queryTable(db, 'memory_docs', { tags, statuses, owners: [], docTypes, keyTypes, roots }, matchedIds?.memory_docs));
45
+ results.push(...queryTable(db, 'memory_docs', { tags, statuses, owners: [], docTypes, keyTypes, roots, deprecated }, intersectIds(matchedIds?.memory_docs, dateIds?.memory_docs)));
38
46
  }
39
47
  const sort = req.query.sort ?? 'mtime_desc';
40
48
  results.sort((a, b) => {
@@ -42,6 +50,15 @@ function queryEntries(db, req) {
42
50
  return a.mtime_ms - b.mtime_ms;
43
51
  if (sort === 'name_asc')
44
52
  return a.name.localeCompare(b.name);
53
+ if (sort === 'created_at_asc') {
54
+ if (!a.created_at && !b.created_at)
55
+ return 0;
56
+ if (!a.created_at)
57
+ return 1; // missing created_at sorts last
58
+ if (!b.created_at)
59
+ return -1;
60
+ return a.created_at.localeCompare(b.created_at);
61
+ }
45
62
  return b.mtime_ms - a.mtime_ms; // mtime_desc, default
46
63
  });
47
64
  return results;
@@ -74,13 +91,17 @@ function queryTable(db, table, filters, restrictToIds) {
74
91
  where += ` AND root IN (${filters.roots.map(() => '?').join(', ')})`;
75
92
  params.push(...filters.roots);
76
93
  }
94
+ if (filters.deprecated !== undefined) {
95
+ where += ` AND deprecated = ?`;
96
+ params.push(filters.deprecated === '1' ? 1 : 0);
97
+ }
77
98
  if (restrictToIds) {
78
99
  where += ` AND id IN (${[...restrictToIds].map(() => '?').join(', ')})`;
79
100
  params.push(...restrictToIds);
80
101
  }
81
102
  if (table === 'skills') {
82
103
  const rows = db
83
- .prepare(`SELECT id, description, owner, status, tags, root, mtime_ms FROM skills WHERE ${where}`)
104
+ .prepare(`SELECT id, description, owner, status, tags, root, mtime_ms, deprecated, created_at FROM skills WHERE ${where}`)
84
105
  .all(...params);
85
106
  return rows.map((r) => ({
86
107
  _table: 'skills',
@@ -94,10 +115,12 @@ function queryTable(db, table, filters, restrictToIds) {
94
115
  key_type: null,
95
116
  root: r.root,
96
117
  mtime_ms: r.mtime_ms,
118
+ deprecated: !!r.deprecated,
119
+ created_at: r.created_at,
97
120
  }));
98
121
  }
99
122
  const rows = db
100
- .prepare(`SELECT id, key, description, doc_type, key_type, status, tags, root, mtime_ms FROM memory_docs WHERE ${where}`)
123
+ .prepare(`SELECT id, key, description, doc_type, key_type, status, tags, root, mtime_ms, deprecated, created_at FROM memory_docs WHERE ${where}`)
101
124
  .all(...params);
102
125
  return rows.map((r) => ({
103
126
  _table: 'memory_docs',
@@ -111,8 +134,40 @@ function queryTable(db, table, filters, restrictToIds) {
111
134
  key_type: r.key_type,
112
135
  root: r.root,
113
136
  mtime_ms: r.mtime_ms,
137
+ deprecated: !!r.deprecated,
138
+ created_at: r.created_at,
114
139
  }));
115
140
  }
141
+ /** Combines two optional id-restriction sets (e.g. from `q` and a date range) into one, when both are present. */
142
+ function intersectIds(a, b) {
143
+ if (!a)
144
+ return b;
145
+ if (!b)
146
+ return a;
147
+ return new Set([...a].filter((id) => b.has(id)));
148
+ }
149
+ /** Queries the `doc_dates` side table for ids whose body-extracted or created_at date falls in [from, to], bucketed by source table. */
150
+ function matchDateRange(db, from, to) {
151
+ const skills = new Set();
152
+ const memory_docs = new Set();
153
+ const params = [];
154
+ let where = '1 = 1';
155
+ if (from) {
156
+ where += ' AND date >= ?';
157
+ params.push(from);
158
+ }
159
+ if (to) {
160
+ where += ' AND date <= ?';
161
+ params.push(to);
162
+ }
163
+ const rows = db
164
+ .prepare(`SELECT DISTINCT ref_table, ref_id FROM doc_dates WHERE ${where}`)
165
+ .all(...params);
166
+ for (const row of rows) {
167
+ (row.ref_table === 'skills' ? skills : memory_docs).add(row.ref_id);
168
+ }
169
+ return { skills, memory_docs };
170
+ }
116
171
  /** Runs the FTS5 query once and buckets matching ids by source table. */
117
172
  function matchSearch(db, q) {
118
173
  const skills = new Set();
@@ -190,15 +245,6 @@ function buildHealth(db) {
190
245
  .map((m) => m.id);
191
246
  return { danglingExtends, danglingRelatedTo, emptyTriggerPhrases, staleActiveMemoryDocs };
192
247
  }
193
- /** Lowercase-hyphenate a folder-derived root name, same shape as skill names. */
194
- function sanitizeRootName(raw) {
195
- return raw
196
- .trim()
197
- .toLowerCase()
198
- .replace(/[^a-z0-9]+/g, '-')
199
- .replace(/^-+|-+$/g, '')
200
- .slice(0, 64);
201
- }
202
248
  export function buildWebRouter(db, config, skillRepo, memoryRepo) {
203
249
  const router = express.Router();
204
250
  router.get('/api/entries', (req, res) => {
@@ -219,6 +265,81 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo) {
219
265
  const trigger_phrases = row.trigger_phrases ? JSON.parse(row.trigger_phrases) : undefined;
220
266
  res.json({ ...row, tags, trigger_phrases });
221
267
  });
268
+ router.patch('/api/entries/:table/:id/deprecated', (req, res) => {
269
+ const { table, id } = req.params;
270
+ const { deprecated } = req.body;
271
+ if (table !== 'skills' && table !== 'memory_docs') {
272
+ res.status(400).json({ error: 'table must be "skills" or "memory_docs"' });
273
+ return;
274
+ }
275
+ if (!id) {
276
+ res.status(400).json({ error: 'id is required' });
277
+ return;
278
+ }
279
+ if (typeof deprecated !== 'boolean') {
280
+ res.status(400).json({ error: 'body must be { deprecated: boolean }' });
281
+ return;
282
+ }
283
+ try {
284
+ if (table === 'skills')
285
+ skillRepo.update(id, { deprecated });
286
+ else
287
+ memoryRepo.update(id, { deprecated });
288
+ res.json({ id, deprecated });
289
+ }
290
+ catch (err) {
291
+ res.status(404).json({ error: err.message });
292
+ }
293
+ });
294
+ router.post('/api/entries/:table/bulk/deprecated', (req, res) => {
295
+ const { table } = req.params;
296
+ const { ids, deprecated } = req.body;
297
+ if (table !== 'skills' && table !== 'memory_docs') {
298
+ res.status(400).json({ error: 'table must be "skills" or "memory_docs"' });
299
+ return;
300
+ }
301
+ if (!Array.isArray(ids) || ids.length === 0 || typeof deprecated !== 'boolean') {
302
+ res.status(400).json({ error: 'body must be { ids: string[], deprecated: boolean }' });
303
+ return;
304
+ }
305
+ const results = table === 'skills' ? skillRepo.bulkUpdate(ids, { deprecated }) : memoryRepo.bulkUpdate(ids, { deprecated });
306
+ res.json({ results });
307
+ });
308
+ router.delete('/api/entries/:table/:id', (req, res) => {
309
+ const { table, id } = req.params;
310
+ if (table !== 'skills' && table !== 'memory_docs') {
311
+ res.status(400).json({ error: 'table must be "skills" or "memory_docs"' });
312
+ return;
313
+ }
314
+ if (!id) {
315
+ res.status(400).json({ error: 'id is required' });
316
+ return;
317
+ }
318
+ try {
319
+ if (table === 'skills')
320
+ skillRepo.delete(id);
321
+ else
322
+ memoryRepo.delete(id);
323
+ res.json({ deleted: id });
324
+ }
325
+ catch (err) {
326
+ res.status(404).json({ error: err.message });
327
+ }
328
+ });
329
+ router.post('/api/entries/:table/bulk/delete', (req, res) => {
330
+ const { table } = req.params;
331
+ const { ids } = req.body;
332
+ if (table !== 'skills' && table !== 'memory_docs') {
333
+ res.status(400).json({ error: 'table must be "skills" or "memory_docs"' });
334
+ return;
335
+ }
336
+ if (!Array.isArray(ids) || ids.length === 0) {
337
+ res.status(400).json({ error: 'body must be { ids: string[] }' });
338
+ return;
339
+ }
340
+ const results = table === 'skills' ? skillRepo.bulkDelete(ids) : memoryRepo.bulkDelete(ids);
341
+ res.json({ results });
342
+ });
222
343
  router.get('/api/facets', (req, res) => {
223
344
  const type = req.query.type ?? 'all';
224
345
  res.json(buildFacets(db, type));
@@ -1,5 +1,5 @@
1
1
  export function registerUiTool(mcp, port) {
2
- mcp.tool('bucket_open_ui', 'Returns the URL for the mem-bucket web viewer — a read-only browser UI for searching/filtering skills and memory docs by tag, status, owner, and fulltext. Not for editing; use the skill_*/memory_* tools for that.', {}, async () => {
2
+ mcp.tool('bucket_open_ui', 'Returns the URL for the mem-bucket web viewer — a browser UI for searching/filtering skills and memory docs by tag, status, owner, deprecated flag, and fulltext, sorting by creation date, and marking items deprecated or deleting them (single or bulk). For anything beyond that, use the skill_*/memory_* tools instead.', {}, async () => {
3
3
  const url = `http://localhost:${port}/`;
4
4
  return { content: [{ type: 'text', text: url }] };
5
5
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-memory-bucket",
3
- "version": "0.2.2",
3
+ "version": "0.4.0",
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": {