mcp-memory-bucket 0.3.0 → 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/dist/client/assets/{index-DIO48C0V.js → index-vdbzlLML.js} +317 -177
- package/dist/client/index.html +23 -3
- package/dist/src/config.js +9 -0
- package/dist/src/server.js +4 -1
- package/dist/src/shared/bucket-root-tool.js +66 -0
- package/dist/src/shared/search-tool.js +16 -1
- package/dist/src/skills/builtin/memory-bucket-authoring/SKILL.md +13 -0
- package/dist/src/skills/repository.js +16 -0
- package/dist/src/skills/tools.js +8 -0
- package/dist/src/store/date-extract.js +49 -0
- package/dist/src/store/db.js +9 -0
- package/dist/src/store/search.js +58 -0
- package/dist/src/store/sync.js +14 -0
- package/dist/src/web/routes.js +39 -12
- package/package.json +1 -1
package/dist/client/index.html
CHANGED
|
@@ -4,10 +4,30 @@
|
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<title>mem-bucket viewer</title>
|
|
6
6
|
<style>
|
|
7
|
-
:root {
|
|
8
|
-
|
|
7
|
+
:root {
|
|
8
|
+
color-scheme: light dark;
|
|
9
|
+
--border: light-dark(#0000001f, #ffffff2e);
|
|
10
|
+
--border-strong: light-dark(#00000038, #ffffff4a);
|
|
11
|
+
--hover: light-dark(#00000010, #ffffff14);
|
|
12
|
+
--hover-strong: light-dark(#00000018, #ffffff20);
|
|
13
|
+
--bg: light-dark(#ffffff, #1a1a1a);
|
|
14
|
+
--bg-subtle: light-dark(#00000008, #ffffff0d);
|
|
15
|
+
--fg: light-dark(#111111, #f0f0f0);
|
|
16
|
+
--accent: light-dark(#2563eb, #3b82f6);
|
|
17
|
+
--accent-fg: #ffffff;
|
|
18
|
+
--accent-tint: light-dark(#2563eb11, #3b82f622);
|
|
19
|
+
--danger: light-dark(#dc2626, #fca5a5);
|
|
20
|
+
--purple: light-dark(#7c3aed, #a78bfa);
|
|
21
|
+
--purple-fg: light-dark(#6d28d9, #d8caff);
|
|
22
|
+
--purple-tint: #a78bfa33;
|
|
23
|
+
--shadow: light-dark(#00000026, #00000080);
|
|
24
|
+
--overlay: light-dark(#00000059, #000000b3);
|
|
25
|
+
}
|
|
26
|
+
:root[data-theme='light'] { color-scheme: light; }
|
|
27
|
+
:root[data-theme='dark'] { color-scheme: dark; }
|
|
28
|
+
body { font-family: system-ui, sans-serif; margin: 0; background: var(--bg); color: var(--fg); }
|
|
9
29
|
</style>
|
|
10
|
-
<script type="module" crossorigin src="/assets/index-
|
|
30
|
+
<script type="module" crossorigin src="/assets/index-vdbzlLML.js"></script>
|
|
11
31
|
</head>
|
|
12
32
|
<body>
|
|
13
33
|
<mem-bucket-app></mem-bucket-app>
|
package/dist/src/config.js
CHANGED
|
@@ -68,6 +68,15 @@ export function saveRoot(config, kind, root) {
|
|
|
68
68
|
};
|
|
69
69
|
fs.writeFileSync(config.configPath, JSON.stringify(next, null, 2) + '\n');
|
|
70
70
|
}
|
|
71
|
+
/** Lowercase-hyphenate a folder-derived root name, same shape as skill names. */
|
|
72
|
+
export function sanitizeRootName(raw) {
|
|
73
|
+
return raw
|
|
74
|
+
.trim()
|
|
75
|
+
.toLowerCase()
|
|
76
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
77
|
+
.replace(/^-+|-+$/g, '')
|
|
78
|
+
.slice(0, 64);
|
|
79
|
+
}
|
|
71
80
|
/**
|
|
72
81
|
* Removes a named root from the config file by name. Matches both explicit
|
|
73
82
|
* {name, path} entries and bare-string entries (via their derived name).
|
package/dist/src/server.js
CHANGED
|
@@ -12,6 +12,7 @@ import { registerSkillTools } from './skills/tools.js';
|
|
|
12
12
|
import { registerMemoryTools } from './memory/tools.js';
|
|
13
13
|
import { registerRelocateTool } from './shared/relocate-tool.js';
|
|
14
14
|
import { registerSearchTool } from './shared/search-tool.js';
|
|
15
|
+
import { registerBucketRootTools } from './shared/bucket-root-tool.js';
|
|
15
16
|
import { buildWebRouter } from './web/routes.js';
|
|
16
17
|
import { registerUiTool } from './web/ui-tool.js';
|
|
17
18
|
// server.ts is rebuilt from `buildMcpServer()` on every /mcp request (see below),
|
|
@@ -48,13 +49,14 @@ if (config.skillRoots.length === 0 && config.memoryRoots.length === 0) {
|
|
|
48
49
|
// this server — surfaced both in serverInfo.description and instructions so
|
|
49
50
|
// clients that expose either to the model can make that association.
|
|
50
51
|
const SERVER_DESCRIPTION = 'Also known as "memory bucket", "mem bucket", or "skill bucket" — if the user refers to this server by any of those names, they mean this one.';
|
|
51
|
-
const SERVER_INSTRUCTIONS = `${SERVER_DESCRIPTION} Exposes skill_* (reusable coding patterns, stored as agentskills.io-standard SKILL.md folders) and memory_* (point-in-time working context — plans, specs, SQL, session summaries — looked up by key) tools, plus shared relocate/bucket_search tools. Use skill_search/memory_search/bucket_search for full-text search over body content (not just metadata) — bucket_search when you don't know which bucket something landed in. Most operations have a _bulk_ variant (bulk_get/bulk_create/bulk_update/bulk_delete, relocate_bulk) that take a list and return per-item success/failure — prefer these over looping single calls when acting on more than one item. Before calling any *_create/*_update/relocate tool, call skill_get("memory-bucket-authoring") first to learn the exact frontmatter schema — don't guess the shape.`;
|
|
52
|
+
const SERVER_INSTRUCTIONS = `${SERVER_DESCRIPTION} Exposes skill_* (reusable coding patterns, stored as agentskills.io-standard SKILL.md folders) and memory_* (point-in-time working context — plans, specs, SQL, session summaries — looked up by key) tools, plus shared relocate/bucket_search/bucket_*_root tools. Use skill_search/memory_search/bucket_search for full-text search over body content (not just metadata) — bucket_search when you don't know which bucket something landed in. Use bucket_list_roots to see what named source directories (roots) are configured before passing a root argument elsewhere, and bucket_create_root/bucket_delete_root to register or unregister one. Most operations have a _bulk_ variant (bulk_get/bulk_create/bulk_update/bulk_delete/bulk_rename, relocate_bulk) that take a list and return per-item success/failure — prefer these over looping single calls when acting on more than one item. A memory doc's key can be changed in place via memory_update(id, key: ...) — no separate rename tool needed. Before calling any *_create/*_update/relocate tool, call skill_get("memory-bucket-authoring") first to learn the exact frontmatter schema — don't guess the shape.`;
|
|
52
53
|
function buildMcpServer() {
|
|
53
54
|
const server = new McpServer({ name: 'memory-bucket', version: '0.1.0', description: SERVER_DESCRIPTION }, { capabilities: {}, instructions: SERVER_INSTRUCTIONS });
|
|
54
55
|
registerSkillTools(server, skillRepo);
|
|
55
56
|
registerMemoryTools(server, memoryRepo);
|
|
56
57
|
registerRelocateTool(server, skillRepo, memoryRepo);
|
|
57
58
|
registerSearchTool(server, db);
|
|
59
|
+
registerBucketRootTools(server, config, skillRepo, memoryRepo, db, skillSpec, memorySpec);
|
|
58
60
|
registerUiTool(server, PORT);
|
|
59
61
|
return server;
|
|
60
62
|
}
|
|
@@ -87,6 +89,7 @@ app.get('/mcp', methodNotAllowed);
|
|
|
87
89
|
app.delete('/mcp', methodNotAllowed);
|
|
88
90
|
app.listen(PORT, () => {
|
|
89
91
|
console.error(`[memory-bucket] MCP server listening on http://localhost:${PORT}/mcp`);
|
|
92
|
+
console.error(`[memory-bucket] UI available at http://localhost:${PORT}`);
|
|
90
93
|
console.error(`[memory-bucket] skill roots: ${config.skillRoots.map((r) => `${r.name}=${r.path}`).join(', ') || '(none)'}`);
|
|
91
94
|
console.error(`[memory-bucket] memory roots: ${config.memoryRoots.map((r) => `${r.name}=${r.path}`).join(', ') || '(none)'}`);
|
|
92
95
|
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { saveRoot, removeRoot as removeRootFromConfig, sanitizeRootName } from '../config.js';
|
|
5
|
+
import { initialScan } from '../store/sync.js';
|
|
6
|
+
const KIND = z.enum(['skill', 'memory']);
|
|
7
|
+
export function registerBucketRootTools(mcp, config, skillRepo, memoryRepo, db, skillSpec, memorySpec) {
|
|
8
|
+
mcp.tool('bucket_list_roots', 'Lists the configured skill and memory roots (the named source directories skills/memory docs live under, e.g. "super-skills", "demo-skills", "builtin") — use this to see what roots exist before passing a `root` argument to a create/list/search tool, or before adding/removing one.', {}, async () => {
|
|
9
|
+
const skill = skillRepo.listRoots().map((r) => ({ ...r, kind: 'skill' }));
|
|
10
|
+
const memory = memoryRepo.listRoots().map((r) => ({ ...r, kind: 'memory' }));
|
|
11
|
+
return { content: [{ type: 'text', text: JSON.stringify({ skill, memory }, null, 2) }] };
|
|
12
|
+
});
|
|
13
|
+
mcp.tool('bucket_create_root', 'Registers a new skill or memory root: an existing absolute directory path becomes a new named source that skill_create/memory_create can target via `root`. Scans it once and starts watching it live — never creates the directory itself, it must already exist.', {
|
|
14
|
+
kind: KIND,
|
|
15
|
+
path: z.string().describe('absolute path to an existing directory'),
|
|
16
|
+
name: z.string().optional().describe('name for the root; defaults to a sanitized version of the directory\'s basename'),
|
|
17
|
+
}, async ({ kind, path: dirPath, name }) => {
|
|
18
|
+
try {
|
|
19
|
+
if (!path.isAbsolute(dirPath)) {
|
|
20
|
+
return { content: [{ type: 'text', text: 'path must be an absolute directory path' }], isError: true };
|
|
21
|
+
}
|
|
22
|
+
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
|
|
23
|
+
return { content: [{ type: 'text', text: `not a directory: ${dirPath}` }], isError: true };
|
|
24
|
+
}
|
|
25
|
+
const rootName = sanitizeRootName(name || path.basename(dirPath));
|
|
26
|
+
if (!rootName) {
|
|
27
|
+
return { content: [{ type: 'text', text: 'could not derive a valid root name — provide one explicitly' }], isError: true };
|
|
28
|
+
}
|
|
29
|
+
const repo = kind === 'skill' ? skillRepo : memoryRepo;
|
|
30
|
+
repo.addRoot({ name: rootName, path: dirPath });
|
|
31
|
+
saveRoot(config, kind, { name: rootName, path: dirPath });
|
|
32
|
+
return { content: [{ type: 'text', text: JSON.stringify({ name: rootName, path: dirPath, kind }, null, 2) }] };
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
return { content: [{ type: 'text', text: err.message }], isError: true };
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
mcp.tool('bucket_delete_root', 'Unregisters a skill or memory root by name: stops watching it and drops its cached entries from the index. Never touches files on disk — the directory and its contents are left in place.', { kind: KIND, name: z.string() }, async ({ kind, name }) => {
|
|
39
|
+
try {
|
|
40
|
+
const repo = kind === 'skill' ? skillRepo : memoryRepo;
|
|
41
|
+
repo.removeRoot(name);
|
|
42
|
+
removeRootFromConfig(config, kind, name);
|
|
43
|
+
return { content: [{ type: 'text', text: `Removed ${kind} root "${name}"` }] };
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
return { content: [{ type: 'text', text: err.message }], isError: true };
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
mcp.tool('bucket_rebuild_cache', 'EMERGENCY USE ONLY. Wipes the entire SQLite cache (all skills, memory docs, the full-text search index, and the date index) and rebuilds it from scratch by rescanning every configured root from disk. Source markdown files on disk are never touched — this only affects the derived cache, which is always safe to discard and regenerate. Use this only when other tools return results that contradict what you can see in the actual files (e.g. stale search hits, a doc that clearly exists on disk but skill_get/memory_get can\'t find, or search_by_date returning wrong dates) and a normal create/update/relocate call hasn\'t resolved it — this is a last resort, not a routine maintenance step. Takes a moment to complete on a large root; nothing else should be called until it returns.', {}, async () => {
|
|
50
|
+
try {
|
|
51
|
+
db.exec(`DELETE FROM skills; DELETE FROM memory_docs; DELETE FROM search_index; DELETE FROM doc_dates;`);
|
|
52
|
+
initialScan(db, skillSpec);
|
|
53
|
+
initialScan(db, memorySpec);
|
|
54
|
+
const skillCount = db.prepare(`SELECT COUNT(*) AS n FROM skills`).get().n;
|
|
55
|
+
const memoryCount = db.prepare(`SELECT COUNT(*) AS n FROM memory_docs`).get().n;
|
|
56
|
+
return {
|
|
57
|
+
content: [
|
|
58
|
+
{ type: 'text', text: `Cache rebuilt from disk: ${skillCount} skill(s), ${memoryCount} memory doc(s) reindexed.` },
|
|
59
|
+
],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
return { content: [{ type: 'text', text: err.message }], isError: true };
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { searchCombined, SearchQueryError } from '../store/search.js';
|
|
2
|
+
import { searchCombined, searchByDate, SearchQueryError } from '../store/search.js';
|
|
3
3
|
export function registerSearchTool(mcp, db) {
|
|
4
4
|
mcp.tool('bucket_search', 'Full-text search across BOTH skills and memory docs in one ranked list — use this when you don\'t know (or don\'t care) which bucket something landed in. `query` is raw SQLite FTS5 MATCH syntax: bare words, "exact phrases", prefix* wildcards, AND/OR/NOT boolean operators; hyphenated/punctuated terms must be quoted, e.g. "blue-green". For filtering by doc_type/status/tag, use skill_search or memory_search instead. Returns ranked hits with a highlighted snippet, not full body — call skill_get/memory_get on a hit for that.', {
|
|
5
5
|
query: z.string().describe('FTS5 match expression, e.g. `deploy AND rollback` or `"blue green"`'),
|
|
@@ -15,4 +15,19 @@ export function registerSearchTool(mcp, db) {
|
|
|
15
15
|
return { content: [{ type: 'text', text: message }], isError: true };
|
|
16
16
|
}
|
|
17
17
|
});
|
|
18
|
+
mcp.tool('search_by_date', 'Finds skills and memory docs whose body text mentions a date, OR whose created_at falls, within [start, end] (inclusive, ISO YYYY-MM-DD). Matches any date extracted from the document body plus its created_at — ANY-match, no priority between them. created_at is stored converted to the server\'s local calendar date, so "today"/"this week" ranges built from local time line up correctly without any timezone adjustment. Useful for period-based recall, e.g. "what did I work on this week": the caller computes the date range itself, this tool does not parse natural language. Returns ranked hits (earliest matched date first) with a highlighted snippet showing the matched date in context (or a note that it matched via created_at, when the date isn\'t literally in the body), not the full body — call skill_get/memory_get on a hit for that.', {
|
|
19
|
+
start: z.string().describe('ISO date YYYY-MM-DD, inclusive'),
|
|
20
|
+
end: z.string().describe('ISO date YYYY-MM-DD, inclusive'),
|
|
21
|
+
table: z.enum(['skills', 'memory_docs']).optional().describe('restrict to one bucket; omit to search both'),
|
|
22
|
+
limit: z.number().int().positive().max(100).optional(),
|
|
23
|
+
offset: z.number().int().nonnegative().optional(),
|
|
24
|
+
}, async ({ start, end, table, limit, offset }) => {
|
|
25
|
+
try {
|
|
26
|
+
const hits = searchByDate(db, start, end, { table, limit, offset });
|
|
27
|
+
return { content: [{ type: 'text', text: JSON.stringify(hits, null, 2) }] };
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
return { content: [{ type: 'text', text: err.message }], isError: true };
|
|
31
|
+
}
|
|
32
|
+
});
|
|
18
33
|
}
|
|
@@ -557,6 +557,19 @@ given, ask for both before calling it; don't guess a key from context.
|
|
|
557
557
|
tools when you don't know (or don't care) which bucket something
|
|
558
558
|
landed in. All three return snippets, not full bodies — follow up with
|
|
559
559
|
`skill_get`/`memory_get` (or the bulk variants below) for the rest.
|
|
560
|
+
- **`search_by_date(start, end)`** — finds skills and memory docs whose
|
|
561
|
+
**body text mentions a date, or whose `created_at` falls,** within an
|
|
562
|
+
inclusive ISO `YYYY-MM-DD` range, e.g. "what did I work on this week"
|
|
563
|
+
once you've resolved "this week" into concrete start/end dates
|
|
564
|
+
yourself (it does not parse natural language). Both a date written
|
|
565
|
+
inside the content and the doc's `created_at` count as candidate
|
|
566
|
+
matches — whichever is earliest wins, no priority between them. Like
|
|
567
|
+
`bucket_search`, it covers both skills and memory docs at once (pass
|
|
568
|
+
`table` to restrict to one). Returns a highlighted snippet around the
|
|
569
|
+
matched date (or a note that it matched via `created_at`, when the
|
|
570
|
+
date isn't literally in the body), not the full body. `created_at` is
|
|
571
|
+
indexed as the server's local calendar date, so "today"/"this week"
|
|
572
|
+
ranges built from local time just work — no timezone conversion needed.
|
|
560
573
|
- **`skill_get`/`memory_get`** — exact-key lookup when you already know
|
|
561
574
|
the name/key.
|
|
562
575
|
|
|
@@ -293,6 +293,22 @@ export class SkillRepository {
|
|
|
293
293
|
}
|
|
294
294
|
});
|
|
295
295
|
}
|
|
296
|
+
/**
|
|
297
|
+
* Renames many skills at once — each entry is a {name, new_name} pair, same
|
|
298
|
+
* semantics as rename(). Returns per-entry results so one bad pair (unknown
|
|
299
|
+
* name, name collision) doesn't abort the rest of the batch.
|
|
300
|
+
*/
|
|
301
|
+
bulkRename(entries) {
|
|
302
|
+
return entries.map(({ name, new_name }) => {
|
|
303
|
+
try {
|
|
304
|
+
this.rename(name, new_name);
|
|
305
|
+
return { name, new_name, ok: true };
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
return { name, new_name, ok: false, error: err.message };
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
}
|
|
296
312
|
/** Removes the whole skill directory, including any scripts/references/assets alongside SKILL.md. */
|
|
297
313
|
delete(name) {
|
|
298
314
|
const existing = this.get(name);
|
package/dist/src/skills/tools.js
CHANGED
|
@@ -143,6 +143,14 @@ export function registerSkillTools(mcp, repo) {
|
|
|
143
143
|
return { content: [{ type: 'text', text: err.message }], isError: true };
|
|
144
144
|
}
|
|
145
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
|
+
});
|
|
146
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 }) => {
|
|
147
155
|
try {
|
|
148
156
|
repo.delete(name);
|
|
@@ -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
|
@@ -46,6 +46,15 @@ export function openCache(dbPath) {
|
|
|
46
46
|
tags,
|
|
47
47
|
tokenize = 'porter unicode61'
|
|
48
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);
|
|
49
58
|
`);
|
|
50
59
|
ensureColumns(db, 'skills', [
|
|
51
60
|
['root', "TEXT NOT NULL DEFAULT ''"],
|
package/dist/src/store/search.js
CHANGED
|
@@ -32,6 +32,64 @@ export function searchIndex(db, query, opts = {}) {
|
|
|
32
32
|
throw new SearchQueryError(query, err);
|
|
33
33
|
}
|
|
34
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
|
+
}
|
|
35
93
|
/**
|
|
36
94
|
* Full-text search across BOTH skills and memory docs in one ranked list —
|
|
37
95
|
* for the common case of "find where I put X" when the caller doesn't know
|
package/dist/src/store/sync.js
CHANGED
|
@@ -3,6 +3,7 @@ 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
|
+
import { extractDates, toLocalDate } from './date-extract.js';
|
|
6
7
|
const skillColumns = ['id', 'description', 'owner', 'status', 'tags', 'trigger_phrases', 'extends', 'deprecated', 'created_at'];
|
|
7
8
|
const memoryColumns = ['id', 'key', 'key_type', 'description', 'doc_type', 'tags', 'status', 'related_to', 'deprecated', 'created_at'];
|
|
8
9
|
export function skillSyncSpec(sources) {
|
|
@@ -90,12 +91,25 @@ export function upsertFile(db, spec, filePath) {
|
|
|
90
91
|
ON CONFLICT(id) DO UPDATE SET ${updateClause}`).run(...values);
|
|
91
92
|
db.prepare(`DELETE FROM search_index WHERE ref_table = ? AND ref_id = ?`).run(spec.table, id);
|
|
92
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
|
+
}
|
|
93
106
|
}
|
|
94
107
|
export function removeFile(db, table, filePath) {
|
|
95
108
|
const existing = db.prepare(`SELECT id FROM ${table} WHERE source_path = ?`).get(filePath);
|
|
96
109
|
db.prepare(`DELETE FROM ${table} WHERE source_path = ?`).run(filePath);
|
|
97
110
|
if (existing) {
|
|
98
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);
|
|
99
113
|
}
|
|
100
114
|
}
|
|
101
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 [];
|
|
@@ -25,18 +25,24 @@ 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 dateFrom = req.query.date_from?.trim() || undefined;
|
|
29
|
+
const dateTo = req.query.date_to?.trim() || undefined;
|
|
28
30
|
const matchedIds = q
|
|
29
31
|
? matchSearch(db, q)
|
|
30
32
|
: null;
|
|
31
33
|
if (q && matchedIds && matchedIds.skills.size === 0 && matchedIds.memory_docs.size === 0) {
|
|
32
34
|
return [];
|
|
33
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
|
+
}
|
|
34
40
|
const results = [];
|
|
35
41
|
if (type === 'skill' || type === 'all') {
|
|
36
|
-
results.push(...queryTable(db, 'skills', { tags, statuses, owners, docTypes: [], keyTypes: [], roots, deprecated }, matchedIds?.skills));
|
|
42
|
+
results.push(...queryTable(db, 'skills', { tags, statuses, owners, docTypes: [], keyTypes: [], roots, deprecated }, intersectIds(matchedIds?.skills, dateIds?.skills)));
|
|
37
43
|
}
|
|
38
44
|
if (type === 'memory' || type === 'all') {
|
|
39
|
-
results.push(...queryTable(db, 'memory_docs', { tags, statuses, owners: [], docTypes, keyTypes, roots, deprecated }, matchedIds?.memory_docs));
|
|
45
|
+
results.push(...queryTable(db, 'memory_docs', { tags, statuses, owners: [], docTypes, keyTypes, roots, deprecated }, intersectIds(matchedIds?.memory_docs, dateIds?.memory_docs)));
|
|
40
46
|
}
|
|
41
47
|
const sort = req.query.sort ?? 'mtime_desc';
|
|
42
48
|
results.sort((a, b) => {
|
|
@@ -132,6 +138,36 @@ function queryTable(db, table, filters, restrictToIds) {
|
|
|
132
138
|
created_at: r.created_at,
|
|
133
139
|
}));
|
|
134
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
|
+
}
|
|
135
171
|
/** Runs the FTS5 query once and buckets matching ids by source table. */
|
|
136
172
|
function matchSearch(db, q) {
|
|
137
173
|
const skills = new Set();
|
|
@@ -209,15 +245,6 @@ function buildHealth(db) {
|
|
|
209
245
|
.map((m) => m.id);
|
|
210
246
|
return { danglingExtends, danglingRelatedTo, emptyTriggerPhrases, staleActiveMemoryDocs };
|
|
211
247
|
}
|
|
212
|
-
/** Lowercase-hyphenate a folder-derived root name, same shape as skill names. */
|
|
213
|
-
function sanitizeRootName(raw) {
|
|
214
|
-
return raw
|
|
215
|
-
.trim()
|
|
216
|
-
.toLowerCase()
|
|
217
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
218
|
-
.replace(/^-+|-+$/g, '')
|
|
219
|
-
.slice(0, 64);
|
|
220
|
-
}
|
|
221
248
|
export function buildWebRouter(db, config, skillRepo, memoryRepo) {
|
|
222
249
|
const router = express.Router();
|
|
223
250
|
router.get('/api/entries', (req, res) => {
|
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": {
|