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.
- package/README.md +47 -13
- package/dist/client/assets/{index-BxAZ48QK.js → index-vdbzlLML.js} +401 -162
- package/dist/client/index.html +23 -3
- package/dist/src/config.js +9 -0
- package/dist/src/memory/repository.js +117 -0
- package/dist/src/memory/tools.js +52 -0
- package/dist/src/server.js +6 -1
- package/dist/src/shared/bucket-root-tool.js +66 -0
- package/dist/src/shared/relocate-tool.js +32 -18
- package/dist/src/shared/relocate.js +9 -0
- package/dist/src/shared/search-tool.js +33 -0
- package/dist/src/skills/builtin/memory-bucket-authoring/SKILL.md +436 -7
- package/dist/src/skills/repository.js +145 -0
- package/dist/src/skills/tools.js +79 -0
- package/dist/src/store/date-extract.js +49 -0
- package/dist/src/store/db.js +30 -8
- package/dist/src/store/search.js +118 -0
- package/dist/src/store/sync.js +20 -2
- package/dist/src/web/routes.js +135 -14
- package/dist/src/web/ui-tool.js +1 -1
- package/package.json +1 -1
package/dist/src/skills/tools.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/src/store/db.js
CHANGED
|
@@ -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
|
-
|
|
47
|
-
|
|
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
|
|
52
|
-
function
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
+
}
|
package/dist/src/store/sync.js
CHANGED
|
@@ -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
|
-
|
|
7
|
-
const
|
|
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. */
|
package/dist/src/web/routes.js
CHANGED
|
@@ -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));
|
package/dist/src/web/ui-tool.js
CHANGED
|
@@ -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
|
|
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.
|
|
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": {
|