mcp-memory-bucket 0.5.4 → 0.6.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-6BCcM6pL.js → index-PIigsGBG.js} +195 -108
- package/dist/client/index.html +1 -1
- package/dist/src/attachments/repository.js +78 -0
- package/dist/src/attachments/storage.js +67 -0
- package/dist/src/attachments/tools.js +85 -0
- package/dist/src/attachments/types.js +1 -0
- package/dist/src/config.js +3 -0
- package/dist/src/memory/repository.js +42 -2
- package/dist/src/memory/tools.js +12 -1
- package/dist/src/server.js +4 -0
- package/dist/src/skills/builtin/memory-bucket-authoring/SKILL.md +30 -0
- package/dist/src/skills/repository.js +3 -2
- package/dist/src/store/db.js +34 -4
- package/dist/src/store/search.js +25 -2
- package/dist/src/store/sync.js +9 -5
- package/dist/src/web/routes.js +71 -10
- package/package.json +1 -1
package/dist/client/index.html
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
:root[data-theme='dark'] { color-scheme: dark; }
|
|
28
28
|
body { font-family: system-ui, sans-serif; margin: 0; background: var(--bg); color: var(--fg); }
|
|
29
29
|
</style>
|
|
30
|
-
<script type="module" crossorigin src="/assets/index-
|
|
30
|
+
<script type="module" crossorigin src="/assets/index-PIigsGBG.js"></script>
|
|
31
31
|
</head>
|
|
32
32
|
<body>
|
|
33
33
|
<mem-bucket-app></mem-bucket-app>
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { attachmentsDirFor, writeAttachmentFile, listAttachmentFiles } from './storage.js';
|
|
4
|
+
import { resolveWithinBase } from '../store/safe-path.js';
|
|
5
|
+
export class AttachmentRepository {
|
|
6
|
+
memoryRepo;
|
|
7
|
+
skillRepo;
|
|
8
|
+
constructor(memoryRepo, skillRepo) {
|
|
9
|
+
this.memoryRepo = memoryRepo;
|
|
10
|
+
this.skillRepo = skillRepo;
|
|
11
|
+
}
|
|
12
|
+
getDoc(kind, docIdOrName) {
|
|
13
|
+
const doc = kind === 'memory' ? this.memoryRepo.get(docIdOrName) : this.skillRepo.get(docIdOrName);
|
|
14
|
+
if (!doc)
|
|
15
|
+
throw new Error(`${kind} doc "${docIdOrName}" not found`);
|
|
16
|
+
return doc;
|
|
17
|
+
}
|
|
18
|
+
saveAttachmentsList(kind, docIdOrName, attachments) {
|
|
19
|
+
if (kind === 'memory') {
|
|
20
|
+
this.memoryRepo.update(docIdOrName, { attachments });
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
this.skillRepo.update(docIdOrName, { attachments });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
add(kind, docIdOrName, filename, data) {
|
|
27
|
+
const doc = this.getDoc(kind, docIdOrName);
|
|
28
|
+
const dir = attachmentsDirFor(doc.source_path, kind);
|
|
29
|
+
const entry = writeAttachmentFile(dir, filename, data);
|
|
30
|
+
const existing = doc.attachments ?? [];
|
|
31
|
+
this.saveAttachmentsList(kind, docIdOrName, [...existing, entry]);
|
|
32
|
+
return entry;
|
|
33
|
+
}
|
|
34
|
+
get(kind, docIdOrName, filename) {
|
|
35
|
+
const doc = this.getDoc(kind, docIdOrName);
|
|
36
|
+
return (doc.attachments ?? []).find((a) => a.filename === filename);
|
|
37
|
+
}
|
|
38
|
+
update(kind, docIdOrName, filename, data) {
|
|
39
|
+
const doc = this.getDoc(kind, docIdOrName);
|
|
40
|
+
const dir = attachmentsDirFor(doc.source_path, kind);
|
|
41
|
+
const existingEntry = (doc.attachments ?? []).find((a) => a.filename === filename);
|
|
42
|
+
if (!existingEntry)
|
|
43
|
+
throw new Error(`attachment "${filename}" not found on ${kind} doc "${docIdOrName}"`);
|
|
44
|
+
const safePath = resolveWithinBase(dir, undefined, filename);
|
|
45
|
+
fs.rmSync(safePath, { force: true });
|
|
46
|
+
const written = writeAttachmentFile(dir, filename, data);
|
|
47
|
+
// Full replace: preserve position, overwrite metadata for this filename entry.
|
|
48
|
+
const next = (doc.attachments ?? []).map((a) => (a.filename === filename ? written : a));
|
|
49
|
+
this.saveAttachmentsList(kind, docIdOrName, next);
|
|
50
|
+
return written;
|
|
51
|
+
}
|
|
52
|
+
remove(kind, docIdOrName, filename) {
|
|
53
|
+
const doc = this.getDoc(kind, docIdOrName);
|
|
54
|
+
const dir = attachmentsDirFor(doc.source_path, kind);
|
|
55
|
+
const safePath = resolveWithinBase(dir, undefined, filename);
|
|
56
|
+
fs.rmSync(safePath, { force: true });
|
|
57
|
+
const next = (doc.attachments ?? []).filter((a) => a.filename !== filename);
|
|
58
|
+
this.saveAttachmentsList(kind, docIdOrName, next);
|
|
59
|
+
}
|
|
60
|
+
list(kind, docIdOrName) {
|
|
61
|
+
return this.getDoc(kind, docIdOrName).attachments ?? [];
|
|
62
|
+
}
|
|
63
|
+
/** Absolute filesystem path for an attachment's file, for callers (e.g. MCP tools) that need to Read it. */
|
|
64
|
+
absolutePathFor(kind, docIdOrName, filename) {
|
|
65
|
+
const doc = this.getDoc(kind, docIdOrName);
|
|
66
|
+
return path.join(attachmentsDirFor(doc.source_path, kind), filename);
|
|
67
|
+
}
|
|
68
|
+
reconcile(kind, docIdOrName) {
|
|
69
|
+
const doc = this.getDoc(kind, docIdOrName);
|
|
70
|
+
const dir = attachmentsDirFor(doc.source_path, kind);
|
|
71
|
+
const declared = new Set((doc.attachments ?? []).map((a) => a.filename));
|
|
72
|
+
const onDisk = new Set(listAttachmentFiles(dir));
|
|
73
|
+
const orphans = [...declared].filter((f) => !onDisk.has(f));
|
|
74
|
+
const unlisted = [...onDisk].filter((f) => !declared.has(f));
|
|
75
|
+
return { orphans, unlisted };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export { attachmentsDirFor };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { resolveWithinBase } from '../store/safe-path.js';
|
|
4
|
+
import { ATTACHMENT_MAX_BYTES } from '../config.js';
|
|
5
|
+
export { ATTACHMENT_MAX_BYTES };
|
|
6
|
+
/**
|
|
7
|
+
* Skills already live one-per-directory (<skillDir>/SKILL.md), so attachments
|
|
8
|
+
* sit directly in that directory. Memory docs are flat files (<id>.md) with no
|
|
9
|
+
* per-doc directory, so attachments get a new sibling directory named after
|
|
10
|
+
* the doc's id, alongside the existing file.
|
|
11
|
+
*/
|
|
12
|
+
export function attachmentsDirFor(docSourcePath, kind) {
|
|
13
|
+
if (kind === 'skill') {
|
|
14
|
+
return path.join(path.dirname(docSourcePath), 'attachments');
|
|
15
|
+
}
|
|
16
|
+
const dir = path.dirname(docSourcePath);
|
|
17
|
+
const id = path.basename(docSourcePath, '.md');
|
|
18
|
+
return path.join(dir, id, 'attachments');
|
|
19
|
+
}
|
|
20
|
+
const MIME_BY_EXT = {
|
|
21
|
+
'.json': 'application/json',
|
|
22
|
+
'.xml': 'application/xml',
|
|
23
|
+
'.txt': 'text/plain',
|
|
24
|
+
'.png': 'image/png',
|
|
25
|
+
'.jpg': 'image/jpeg',
|
|
26
|
+
'.jpeg': 'image/jpeg',
|
|
27
|
+
'.gif': 'image/gif',
|
|
28
|
+
'.pdf': 'application/pdf',
|
|
29
|
+
'.csv': 'text/csv',
|
|
30
|
+
};
|
|
31
|
+
function guessMimeType(filename) {
|
|
32
|
+
return MIME_BY_EXT[path.extname(filename).toLowerCase()] ?? 'application/octet-stream';
|
|
33
|
+
}
|
|
34
|
+
function uniqueFilename(dir, filename) {
|
|
35
|
+
// Validate that the filename doesn't escape the directory
|
|
36
|
+
resolveWithinBase(dir, undefined, filename);
|
|
37
|
+
if (!fs.existsSync(path.join(dir, filename)))
|
|
38
|
+
return filename;
|
|
39
|
+
const ext = path.extname(filename);
|
|
40
|
+
const base = path.basename(filename, ext);
|
|
41
|
+
let n = 2;
|
|
42
|
+
while (fs.existsSync(path.join(dir, `${base}-${n}${ext}`)))
|
|
43
|
+
n++;
|
|
44
|
+
return `${base}-${n}${ext}`;
|
|
45
|
+
}
|
|
46
|
+
/** `dir` is the attachments directory itself, as returned by attachmentsDirFor. */
|
|
47
|
+
export function writeAttachmentFile(dir, filename, data) {
|
|
48
|
+
if (data.byteLength > ATTACHMENT_MAX_BYTES) {
|
|
49
|
+
throw new Error(`attachment "${filename}" (${data.byteLength} bytes) exceeds the ${ATTACHMENT_MAX_BYTES}-byte limit`);
|
|
50
|
+
}
|
|
51
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
52
|
+
const finalName = uniqueFilename(dir, filename);
|
|
53
|
+
const safePath = resolveWithinBase(dir, undefined, finalName);
|
|
54
|
+
fs.writeFileSync(safePath, data);
|
|
55
|
+
return {
|
|
56
|
+
filename: finalName,
|
|
57
|
+
path: path.join('attachments', finalName),
|
|
58
|
+
mime_type: guessMimeType(finalName),
|
|
59
|
+
size: data.byteLength,
|
|
60
|
+
added_at: new Date().toISOString(),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export function listAttachmentFiles(dir) {
|
|
64
|
+
if (!fs.existsSync(dir))
|
|
65
|
+
return [];
|
|
66
|
+
return fs.readdirSync(dir).filter((name) => fs.statSync(path.join(dir, name)).isFile());
|
|
67
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { ATTACHMENT_MAX_BYTES } from './storage.js';
|
|
4
|
+
const kindSchema = z.enum(['memory', 'skill']);
|
|
5
|
+
/** Cheap pre-check so we never read a too-large file's bytes into memory before rejecting it. */
|
|
6
|
+
function assertFileSizeOk(file_path) {
|
|
7
|
+
const { size } = fs.statSync(file_path);
|
|
8
|
+
if (size > ATTACHMENT_MAX_BYTES) {
|
|
9
|
+
throw new Error(`file "${file_path}" (${size} bytes) exceeds the ${ATTACHMENT_MAX_BYTES}-byte limit`);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function registerAttachmentTools(mcp, attachRepo) {
|
|
13
|
+
mcp.tool('attachment_add', 'Attaches a raw file (JSON, image, XML, etc.) to a memory doc or skill. Auto-renames on filename collision.', {
|
|
14
|
+
kind: kindSchema,
|
|
15
|
+
doc: z.string().describe('memory doc id or skill name'),
|
|
16
|
+
filename: z.string(),
|
|
17
|
+
file_path: z.string().describe('local filesystem path to the file to attach'),
|
|
18
|
+
}, async ({ kind, doc, filename, file_path }) => {
|
|
19
|
+
try {
|
|
20
|
+
assertFileSizeOk(file_path);
|
|
21
|
+
const data = fs.readFileSync(file_path);
|
|
22
|
+
const entry = attachRepo.add(kind, doc, filename, data);
|
|
23
|
+
const absolute_path = attachRepo.absolutePathFor(kind, doc, entry.filename);
|
|
24
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...entry, absolute_path }, null, 2) }] };
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
return { content: [{ type: 'text', text: err.message }], isError: true };
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
mcp.tool('attachment_get', 'Returns the on-disk path (not content) of an attachment, plus its metadata. Use Read on the returned absolute_path for the content.', { kind: kindSchema, doc: z.string(), filename: z.string() }, async ({ kind, doc, filename }) => {
|
|
31
|
+
try {
|
|
32
|
+
const entry = attachRepo.get(kind, doc, filename);
|
|
33
|
+
if (!entry)
|
|
34
|
+
throw new Error(`attachment "${filename}" not found`);
|
|
35
|
+
const absolute_path = attachRepo.absolutePathFor(kind, doc, entry.filename);
|
|
36
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...entry, absolute_path }, null, 2) }] };
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
return { content: [{ type: 'text', text: err.message }], isError: true };
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
mcp.tool('attachment_update', "Replaces an attachment's content in place.", { kind: kindSchema, doc: z.string(), filename: z.string(), file_path: z.string() }, async ({ kind, doc, filename, file_path }) => {
|
|
43
|
+
try {
|
|
44
|
+
assertFileSizeOk(file_path);
|
|
45
|
+
const data = fs.readFileSync(file_path);
|
|
46
|
+
const entry = attachRepo.update(kind, doc, filename, data);
|
|
47
|
+
const absolute_path = attachRepo.absolutePathFor(kind, doc, entry.filename);
|
|
48
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...entry, absolute_path }, null, 2) }] };
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
return { content: [{ type: 'text', text: err.message }], isError: true };
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
mcp.tool('attachment_remove', 'Deletes an attachment from a memory doc or skill.', { kind: kindSchema, doc: z.string(), filename: z.string() }, async ({ kind, doc, filename }) => {
|
|
55
|
+
try {
|
|
56
|
+
attachRepo.remove(kind, doc, filename);
|
|
57
|
+
return { content: [{ type: 'text', text: `removed "${filename}"` }] };
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
return { content: [{ type: 'text', text: err.message }], isError: true };
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
mcp.tool('attachment_list', 'Lists all attachments on a memory doc or skill, each with its absolute on-disk path.', { kind: kindSchema, doc: z.string() }, async ({ kind, doc }) => {
|
|
64
|
+
try {
|
|
65
|
+
const entries = attachRepo.list(kind, doc);
|
|
66
|
+
const withPaths = entries.map((entry) => ({
|
|
67
|
+
...entry,
|
|
68
|
+
absolute_path: attachRepo.absolutePathFor(kind, doc, entry.filename),
|
|
69
|
+
}));
|
|
70
|
+
return { content: [{ type: 'text', text: JSON.stringify(withPaths, null, 2) }] };
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
return { content: [{ type: 'text', text: err.message }], isError: true };
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
mcp.tool('attachment_reconcile', "Compares a doc's declared attachments against what is actually on disk; reports orphans (declared but missing) and unlisted files (present but not declared). Report-only, does not repair.", { kind: kindSchema, doc: z.string() }, async ({ kind, doc }) => {
|
|
77
|
+
try {
|
|
78
|
+
const result = attachRepo.reconcile(kind, doc);
|
|
79
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
return { content: [{ type: 'text', text: err.message }], isError: true };
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/src/config.js
CHANGED
|
@@ -97,3 +97,6 @@ export function removeFolder(config, kind, name) {
|
|
|
97
97
|
const next = { ...current, [key]: filtered };
|
|
98
98
|
fs.writeFileSync(config.configPath, JSON.stringify(next, null, 2) + '\n');
|
|
99
99
|
}
|
|
100
|
+
export const ATTACHMENT_MAX_BYTES = process.env.ATTACHMENT_MAX_BYTES
|
|
101
|
+
? Number(process.env.ATTACHMENT_MAX_BYTES)
|
|
102
|
+
: 20 * 1024 * 1024; // 20MB default
|
|
@@ -5,8 +5,14 @@ import { writeMarkdownFile } from '../store/markdown-file.js';
|
|
|
5
5
|
import { slugify } from '../store/slug.js';
|
|
6
6
|
import { resolveWithinBase } from '../store/safe-path.js';
|
|
7
7
|
import { upsertFile, removeFile, scanSingleFolder, unregisterFolder, memorySyncSpec } from '../store/sync.js';
|
|
8
|
-
import { SearchQueryError } from '../store/search.js';
|
|
8
|
+
import { SearchQueryError, sanitizeFtsQuery } from '../store/search.js';
|
|
9
|
+
import { attachmentsDirFor } from '../attachments/storage.js';
|
|
9
10
|
import { normalizeKey } from '../types.js';
|
|
11
|
+
/** Uppercases and strips everything but letters/digits — used to compare keys that differ only in
|
|
12
|
+
* punctuation/whitespace formatting (e.g. `RMXS-15` and `RMXS15` strip to the same `RMXS15`). */
|
|
13
|
+
export function stripKey(s) {
|
|
14
|
+
return s.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
|
15
|
+
}
|
|
10
16
|
function rowToDoc(row) {
|
|
11
17
|
return {
|
|
12
18
|
id: row.id,
|
|
@@ -20,6 +26,7 @@ function rowToDoc(row) {
|
|
|
20
26
|
deprecated: !!row.deprecated,
|
|
21
27
|
paused: !!row.paused,
|
|
22
28
|
created_at: row.created_at ?? undefined,
|
|
29
|
+
attachments: row.attachments ? JSON.parse(row.attachments) : undefined,
|
|
23
30
|
source_path: row.source_path,
|
|
24
31
|
folder: row.folder,
|
|
25
32
|
body: row.body,
|
|
@@ -104,7 +111,7 @@ export class MemoryRepository {
|
|
|
104
111
|
search(query, opts = {}) {
|
|
105
112
|
const { docType, status, folder, tag, limit = 20, offset = 0, includePaused = false } = opts;
|
|
106
113
|
const conditions = [];
|
|
107
|
-
const params = [query];
|
|
114
|
+
const params = [sanitizeFtsQuery(query)];
|
|
108
115
|
if (docType) {
|
|
109
116
|
conditions.push('m.doc_type = ?');
|
|
110
117
|
params.push(docType);
|
|
@@ -159,6 +166,35 @@ export class MemoryRepository {
|
|
|
159
166
|
.filter((r) => !prefix || r.key.startsWith(prefix))
|
|
160
167
|
.map((r) => ({ key: r.key, docCount: r.doc_count }));
|
|
161
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* Fuzzy key lookup for a partial/drifted key, comparing keys with all punctuation stripped so
|
|
171
|
+
* `RMXS15` and `RMXS-15` are treated as the same candidate. Ranked: exact stripped match first,
|
|
172
|
+
* then stripped-prefix, then stripped-substring; alphabetical tiebreak within each tier.
|
|
173
|
+
*/
|
|
174
|
+
suggestKeys(partial, limit = 5) {
|
|
175
|
+
const strippedPartial = stripKey(partial);
|
|
176
|
+
if (!strippedPartial)
|
|
177
|
+
return [];
|
|
178
|
+
const rows = this.db
|
|
179
|
+
.prepare(`SELECT key, COUNT(*) as doc_count FROM memory_docs GROUP BY key`)
|
|
180
|
+
.all();
|
|
181
|
+
return rows
|
|
182
|
+
.map((r) => ({ key: r.key, docCount: r.doc_count, stripped: stripKey(r.key) }))
|
|
183
|
+
.filter((r) => r.stripped.includes(strippedPartial))
|
|
184
|
+
.sort((a, b) => {
|
|
185
|
+
const aExact = a.stripped === strippedPartial ? 0 : 1;
|
|
186
|
+
const bExact = b.stripped === strippedPartial ? 0 : 1;
|
|
187
|
+
if (aExact !== bExact)
|
|
188
|
+
return aExact - bExact;
|
|
189
|
+
const aPrefix = a.stripped.startsWith(strippedPartial) ? 0 : 1;
|
|
190
|
+
const bPrefix = b.stripped.startsWith(strippedPartial) ? 0 : 1;
|
|
191
|
+
if (aPrefix !== bPrefix)
|
|
192
|
+
return aPrefix - bPrefix;
|
|
193
|
+
return a.key.localeCompare(b.key);
|
|
194
|
+
})
|
|
195
|
+
.slice(0, limit)
|
|
196
|
+
.map(({ key, docCount }) => ({ key, docCount }));
|
|
197
|
+
}
|
|
162
198
|
create(input) {
|
|
163
199
|
const normalizedKey = normalizeKey(input.key);
|
|
164
200
|
const id = `${slugify(normalizedKey)}-${slugify(input.description)}-${randomUUID().slice(0, 8)}`;
|
|
@@ -275,6 +311,10 @@ export class MemoryRepository {
|
|
|
275
311
|
const existing = this.get(id);
|
|
276
312
|
if (!existing)
|
|
277
313
|
throw new Error(`memory doc with id "${id}" not found`);
|
|
314
|
+
// The <id>/ wrapper directory exists solely to hold attachments/ for this doc (memory docs
|
|
315
|
+
// are otherwise flat <id>.md files), so remove the whole wrapper — not just attachments/ —
|
|
316
|
+
// to avoid leaving an orphaned empty <id>/ directory behind. No-op if it never existed.
|
|
317
|
+
fs.rmSync(path.dirname(attachmentsDirFor(existing.source_path, 'memory')), { recursive: true, force: true });
|
|
278
318
|
fs.unlinkSync(existing.source_path);
|
|
279
319
|
removeFile(this.db, 'memory_docs', existing.source_path);
|
|
280
320
|
}
|
package/dist/src/memory/tools.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { stripKey } from './repository.js';
|
|
2
3
|
import { statusSchema } from '../shared/status.js';
|
|
4
|
+
import { normalizeKey } from '../types.js';
|
|
3
5
|
const MEMORY_DOC_TYPES = ['plan', 'spec', 'sql', 'testing-todo', 'discovery', 'session-summary', 'other'];
|
|
4
6
|
const MEMORY_KEY_TYPES = ['ticket', 'freeform'];
|
|
5
7
|
const MEMORY_STATUS_DEFAULTS = ['active', 'shipped', 'abandoned'];
|
|
@@ -66,8 +68,17 @@ export function registerMemoryTools(mcp, repo) {
|
|
|
66
68
|
...(multiFolder ? { folder: z.string().describe(`which configured memory folder to write into: ${folderNames}`) } : {}),
|
|
67
69
|
}, async ({ key, key_type, doc_type, description, body, tags, status, related_to, subfolder, folder }) => {
|
|
68
70
|
try {
|
|
71
|
+
const normalized = normalizeKey(key);
|
|
72
|
+
const strippedNew = stripKey(normalized);
|
|
73
|
+
const nearDuplicate = repo
|
|
74
|
+
.suggestKeys(key, 3)
|
|
75
|
+
.find((m) => stripKey(m.key) === strippedNew && m.key !== normalized);
|
|
69
76
|
const doc = repo.create({ key, key_type, doc_type, description, body, tags, status, related_to, subfolder, folder });
|
|
70
|
-
|
|
77
|
+
const result = { ...doc };
|
|
78
|
+
if (nearDuplicate) {
|
|
79
|
+
result.key_warning = `A similarly-formatted key "${nearDuplicate.key}" already exists with ${nearDuplicate.docCount} doc(s) — did you mean to use that key instead of "${normalized}"? This doc was still created under "${normalized}".`;
|
|
80
|
+
}
|
|
81
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
71
82
|
}
|
|
72
83
|
catch (err) {
|
|
73
84
|
return { content: [{ type: 'text', text: err.message }], isError: true };
|
package/dist/src/server.js
CHANGED
|
@@ -13,6 +13,8 @@ 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
15
|
import { registerBucketFolderTools } from './shared/bucket-folder-tool.js';
|
|
16
|
+
import { registerAttachmentTools } from './attachments/tools.js';
|
|
17
|
+
import { AttachmentRepository } from './attachments/repository.js';
|
|
16
18
|
import { buildWebRouter } from './web/routes.js';
|
|
17
19
|
import { registerUiTool } from './web/ui-tool.js';
|
|
18
20
|
// server.ts is rebuilt from `buildMcpServer()` on every /mcp request (see below),
|
|
@@ -41,6 +43,7 @@ const skillRepo = new SkillRepository(db, [{ name: 'builtin', path: builtinSkill
|
|
|
41
43
|
const memoryRepo = new MemoryRepository(db, config.memoryFolders);
|
|
42
44
|
skillRepo.setWatcher(skillWatcher);
|
|
43
45
|
memoryRepo.setWatcher(memoryWatcher);
|
|
46
|
+
const attachmentRepo = new AttachmentRepository(memoryRepo, skillRepo);
|
|
44
47
|
if (config.skillFolders.length === 0 && config.memoryFolders.length === 0) {
|
|
45
48
|
console.error(`[memory-bucket] no folders configured — open http://localhost:${PORT} to add one`);
|
|
46
49
|
}
|
|
@@ -57,6 +60,7 @@ function buildMcpServer() {
|
|
|
57
60
|
registerRelocateTool(server, skillRepo, memoryRepo);
|
|
58
61
|
registerSearchTool(server, db);
|
|
59
62
|
registerBucketFolderTools(server, config, skillRepo, memoryRepo, db, skillSpec, memorySpec);
|
|
63
|
+
registerAttachmentTools(server, attachmentRepo);
|
|
60
64
|
registerUiTool(server, PORT);
|
|
61
65
|
return server;
|
|
62
66
|
}
|
|
@@ -251,6 +251,36 @@ body: >-
|
|
|
251
251
|
conversation, or ask if it's genuinely unclear.
|
|
252
252
|
|
|
253
253
|
|
|
254
|
+
**Tags are for cross-cutting labels, not grouping.** `key` is the one and
|
|
255
|
+
|
|
256
|
+
only grouping concept for memory docs — never add a doc's own key (or a
|
|
257
|
+
|
|
258
|
+
related doc's key) as a tag to link things together; that's what `key`
|
|
259
|
+
|
|
260
|
+
already does. Use `tags` for labels that cut across many keys (e.g.
|
|
261
|
+
|
|
262
|
+
`viz-team`, `datalab`), not as a second grouping axis.
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
**Before creating a doc under a `ticket`-type key, check for an existing
|
|
266
|
+
|
|
267
|
+
near-match first** — e.g. `memory_list("RMXS-15")` or `memory_search` —
|
|
268
|
+
|
|
269
|
+
rather than writing a fresh key that only differs in punctuation or
|
|
270
|
+
|
|
271
|
+
casing (`RMXS15` vs `RMXS-15` vs `Rmxs 15` are three different keys to
|
|
272
|
+
|
|
273
|
+
this system, even though they're clearly meant to be the same ticket).
|
|
274
|
+
|
|
275
|
+
Reuse the existing key's exact casing/format if one is found.
|
|
276
|
+
|
|
277
|
+
`memory_create`'s response includes a `key_warning` field if it detects a
|
|
278
|
+
|
|
279
|
+
likely near-duplicate after the fact — treat that as a signal to double
|
|
280
|
+
|
|
281
|
+
check, not to ignore.
|
|
282
|
+
|
|
283
|
+
|
|
254
284
|
### Saving a session
|
|
255
285
|
|
|
256
286
|
|
|
@@ -4,7 +4,7 @@ import { writeMarkdownFile } from '../store/markdown-file.js';
|
|
|
4
4
|
import { assertValidSkillName } from '../store/skill-name.js';
|
|
5
5
|
import { resolveWithinBase } from '../store/safe-path.js';
|
|
6
6
|
import { upsertFile, removeFile, scanSingleFolder, unregisterFolder, skillSyncSpec } from '../store/sync.js';
|
|
7
|
-
import { SearchQueryError } from '../store/search.js';
|
|
7
|
+
import { SearchQueryError, sanitizeFtsQuery } from '../store/search.js';
|
|
8
8
|
function rowToDoc(row) {
|
|
9
9
|
return {
|
|
10
10
|
name: row.id,
|
|
@@ -15,6 +15,7 @@ function rowToDoc(row) {
|
|
|
15
15
|
deprecated: !!row.deprecated,
|
|
16
16
|
paused: !!row.paused,
|
|
17
17
|
created_at: row.created_at ?? undefined,
|
|
18
|
+
attachments: row.attachments ? JSON.parse(row.attachments) : undefined,
|
|
18
19
|
source_path: row.source_path,
|
|
19
20
|
folder: row.folder,
|
|
20
21
|
body: row.body,
|
|
@@ -115,7 +116,7 @@ export class SkillRepository {
|
|
|
115
116
|
search(query, opts = {}) {
|
|
116
117
|
const { folder, status, owner, tag, limit = 20, offset = 0, includePaused = false } = opts;
|
|
117
118
|
const conditions = [];
|
|
118
|
-
const params = [query];
|
|
119
|
+
const params = [sanitizeFtsQuery(query)];
|
|
119
120
|
if (folder) {
|
|
120
121
|
conditions.push('s.folder = ?');
|
|
121
122
|
params.push(folder);
|
package/dist/src/store/db.js
CHANGED
|
@@ -16,6 +16,7 @@ export function openCache(dbPath) {
|
|
|
16
16
|
deprecated INTEGER NOT NULL DEFAULT 0,
|
|
17
17
|
paused INTEGER NOT NULL DEFAULT 0, -- local-only: never synced from/to SKILL.md, cache-file scoped
|
|
18
18
|
created_at TEXT,
|
|
19
|
+
attachments TEXT, -- JSON array of AttachmentEntry, mirrors frontmatter
|
|
19
20
|
body TEXT NOT NULL,
|
|
20
21
|
mtime_ms INTEGER NOT NULL
|
|
21
22
|
);
|
|
@@ -34,6 +35,7 @@ export function openCache(dbPath) {
|
|
|
34
35
|
deprecated INTEGER NOT NULL DEFAULT 0,
|
|
35
36
|
paused INTEGER NOT NULL DEFAULT 0, -- local-only: never synced from/to the doc's markdown file, cache-file scoped
|
|
36
37
|
created_at TEXT,
|
|
38
|
+
attachments TEXT, -- JSON array of AttachmentEntry, mirrors frontmatter
|
|
37
39
|
body TEXT NOT NULL,
|
|
38
40
|
mtime_ms INTEGER NOT NULL
|
|
39
41
|
);
|
|
@@ -46,6 +48,7 @@ export function openCache(dbPath) {
|
|
|
46
48
|
description,
|
|
47
49
|
body,
|
|
48
50
|
tags,
|
|
51
|
+
key,
|
|
49
52
|
tokenize = 'porter unicode61'
|
|
50
53
|
);
|
|
51
54
|
|
|
@@ -63,16 +66,43 @@ export function openCache(dbPath) {
|
|
|
63
66
|
['deprecated', 'INTEGER NOT NULL DEFAULT 0'],
|
|
64
67
|
['paused', 'INTEGER NOT NULL DEFAULT 0'],
|
|
65
68
|
['created_at', 'TEXT'],
|
|
69
|
+
['attachments', 'TEXT'],
|
|
66
70
|
]);
|
|
67
71
|
ensureColumns(db, 'memory_docs', [
|
|
68
72
|
['folder', "TEXT NOT NULL DEFAULT ''"],
|
|
69
73
|
['deprecated', 'INTEGER NOT NULL DEFAULT 0'],
|
|
70
74
|
['paused', 'INTEGER NOT NULL DEFAULT 0'],
|
|
71
75
|
['created_at', 'TEXT'],
|
|
76
|
+
['attachments', 'TEXT'],
|
|
72
77
|
]);
|
|
78
|
+
ensureSearchIndexHasKeyColumn(db);
|
|
73
79
|
backfillSearchIndex(db);
|
|
74
80
|
return db;
|
|
75
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* search_index is an FTS5 virtual table — existing cache files created before the `key` column
|
|
84
|
+
* existed need it added. FTS5 doesn't support ALTER TABLE ADD COLUMN reliably across versions, and
|
|
85
|
+
* search_index is a disposable cache (rebuilt from skills/memory_docs, not the source of truth), so
|
|
86
|
+
* the simplest safe migration is: drop and recreate with the new schema, then let backfillSearchIndex
|
|
87
|
+
* repopulate everything.
|
|
88
|
+
*/
|
|
89
|
+
function ensureSearchIndexHasKeyColumn(db) {
|
|
90
|
+
const cols = db.prepare(`PRAGMA table_info(search_index)`).all();
|
|
91
|
+
if (cols.length === 0 || cols.some((c) => c.name === 'key'))
|
|
92
|
+
return; // fresh table, or already migrated
|
|
93
|
+
db.exec(`DROP TABLE search_index`);
|
|
94
|
+
db.exec(`
|
|
95
|
+
CREATE VIRTUAL TABLE search_index USING fts5(
|
|
96
|
+
ref_table UNINDEXED,
|
|
97
|
+
ref_id UNINDEXED,
|
|
98
|
+
description,
|
|
99
|
+
body,
|
|
100
|
+
tags,
|
|
101
|
+
key,
|
|
102
|
+
tokenize = 'porter unicode61'
|
|
103
|
+
);
|
|
104
|
+
`);
|
|
105
|
+
}
|
|
76
106
|
/** Migration for cache files created before a given column existed. Safe to call every startup. */
|
|
77
107
|
function ensureColumns(db, table, columns) {
|
|
78
108
|
const existing = new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name));
|
|
@@ -87,16 +117,16 @@ function backfillSearchIndex(db) {
|
|
|
87
117
|
if (indexed > 0)
|
|
88
118
|
return;
|
|
89
119
|
const skillRows = db.prepare(`SELECT id, description, body, tags FROM skills`).all();
|
|
90
|
-
const memoryRows = db.prepare(`SELECT id, description, body, tags FROM memory_docs`).all();
|
|
120
|
+
const memoryRows = db.prepare(`SELECT id, key, description, body, tags FROM memory_docs`).all();
|
|
91
121
|
if (skillRows.length === 0 && memoryRows.length === 0)
|
|
92
122
|
return;
|
|
93
|
-
const insert = db.prepare(`INSERT INTO search_index (ref_table, ref_id, description, body, tags) VALUES (?, ?, ?, ?, ?)`);
|
|
123
|
+
const insert = db.prepare(`INSERT INTO search_index (ref_table, ref_id, description, body, tags, key) VALUES (?, ?, ?, ?, ?, ?)`);
|
|
94
124
|
const insertAll = db.transaction(() => {
|
|
95
125
|
for (const row of skillRows) {
|
|
96
|
-
insert.run('skills', row.id, row.description, row.body, flattenTags(row.tags));
|
|
126
|
+
insert.run('skills', row.id, row.description, row.body, flattenTags(row.tags), '');
|
|
97
127
|
}
|
|
98
128
|
for (const row of memoryRows) {
|
|
99
|
-
insert.run('memory_docs', row.id, row.description, row.body, flattenTags(row.tags));
|
|
129
|
+
insert.run('memory_docs', row.id, row.description, row.body, flattenTags(row.tags), row.key);
|
|
100
130
|
}
|
|
101
131
|
});
|
|
102
132
|
insertAll();
|
package/dist/src/store/search.js
CHANGED
|
@@ -7,6 +7,27 @@ export class SearchQueryError extends Error {
|
|
|
7
7
|
this.name = 'SearchQueryError';
|
|
8
8
|
}
|
|
9
9
|
}
|
|
10
|
+
const FTS_BOOLEAN_KEYWORDS = new Set(['AND', 'OR', 'NOT']);
|
|
11
|
+
/**
|
|
12
|
+
* Auto-quotes bareword tokens that contain punctuation (e.g. a bare hyphenated ticket key like
|
|
13
|
+
* `RMXS-15`) so FTS5 doesn't interpret a bare `-` as its NOT/column-filter operator and silently
|
|
14
|
+
* return zero rows. Already-quoted phrases, `AND`/`OR`/`NOT` operators, and `prefix*` wildcards are
|
|
15
|
+
* left untouched so existing power-user query syntax keeps working exactly as before.
|
|
16
|
+
*/
|
|
17
|
+
export function sanitizeFtsQuery(query) {
|
|
18
|
+
const tokens = query.match(/"[^"]*"|\S+/g) ?? [];
|
|
19
|
+
return tokens
|
|
20
|
+
.map((token) => {
|
|
21
|
+
if (token.startsWith('"'))
|
|
22
|
+
return token; // already an explicit phrase
|
|
23
|
+
if (FTS_BOOLEAN_KEYWORDS.has(token.toUpperCase()))
|
|
24
|
+
return token; // boolean operator
|
|
25
|
+
if (/^[A-Za-z0-9_*]+$/.test(token))
|
|
26
|
+
return token; // plain word or prefix* wildcard — no punctuation
|
|
27
|
+
return `"${token.replace(/"/g, '""')}"`; // punctuation present (hyphen, colon, etc.) — quote it literally
|
|
28
|
+
})
|
|
29
|
+
.join(' ');
|
|
30
|
+
}
|
|
10
31
|
/**
|
|
11
32
|
* Full-text search over the shared FTS5 index, optionally scoped to one ref_table.
|
|
12
33
|
* `query` is passed through as raw FTS5 MATCH syntax — supports `AND`/`OR`/`NOT`,
|
|
@@ -16,6 +37,7 @@ export class SearchQueryError extends Error {
|
|
|
16
37
|
*/
|
|
17
38
|
export function searchIndex(db, query, opts = {}) {
|
|
18
39
|
const { table, limit = 20, offset = 0 } = opts;
|
|
40
|
+
const sanitized = sanitizeFtsQuery(query);
|
|
19
41
|
try {
|
|
20
42
|
const rows = db
|
|
21
43
|
.prepare(`SELECT ref_table, ref_id,
|
|
@@ -25,7 +47,7 @@ export function searchIndex(db, query, opts = {}) {
|
|
|
25
47
|
WHERE search_index MATCH ? ${table ? 'AND ref_table = ?' : ''}
|
|
26
48
|
ORDER BY bm25(search_index)
|
|
27
49
|
LIMIT ? OFFSET ?`)
|
|
28
|
-
.all(...(table ? [
|
|
50
|
+
.all(...(table ? [sanitized, table, limit, offset] : [sanitized, limit, offset]));
|
|
29
51
|
return rows;
|
|
30
52
|
}
|
|
31
53
|
catch (err) {
|
|
@@ -97,6 +119,7 @@ export function searchByDate(db, start, end, opts = {}) {
|
|
|
97
119
|
* per table); use skill_search/memory_search directly when filtering by those.
|
|
98
120
|
*/
|
|
99
121
|
export function searchCombined(db, query, limit = 20, offset = 0) {
|
|
122
|
+
const sanitized = sanitizeFtsQuery(query);
|
|
100
123
|
try {
|
|
101
124
|
return db
|
|
102
125
|
.prepare(`SELECT ref_table, ref_id AS id,
|
|
@@ -110,7 +133,7 @@ export function searchCombined(db, query, limit = 20, offset = 0) {
|
|
|
110
133
|
WHERE search_index MATCH ?
|
|
111
134
|
ORDER BY bm25(search_index)
|
|
112
135
|
LIMIT ? OFFSET ?`)
|
|
113
|
-
.all(
|
|
136
|
+
.all(sanitized, limit, offset);
|
|
114
137
|
}
|
|
115
138
|
catch (err) {
|
|
116
139
|
throw new SearchQueryError(query, err);
|
package/dist/src/store/sync.js
CHANGED
|
@@ -9,8 +9,8 @@ import { normalizeKey } from '../types.js';
|
|
|
9
9
|
// `paused` is deliberately absent from both lists: it's a local-only cache column (see
|
|
10
10
|
// SkillRepository/MemoryRepository#setPaused) that never round-trips through frontmatter, so a
|
|
11
11
|
// file add/change/rescan must never overwrite it via the INSERT/ON CONFLICT UPDATE below.
|
|
12
|
-
const skillColumns = ['id', 'description', 'owner', 'status', 'tags', 'trigger_phrases', 'extends', 'deprecated', 'created_at'];
|
|
13
|
-
const memoryColumns = ['id', 'key', 'key_type', 'description', 'doc_type', 'tags', 'status', 'related_to', 'deprecated', 'created_at'];
|
|
12
|
+
const skillColumns = ['id', 'description', 'owner', 'status', 'tags', 'trigger_phrases', 'extends', 'deprecated', 'created_at', 'attachments'];
|
|
13
|
+
const memoryColumns = ['id', 'key', 'key_type', 'description', 'doc_type', 'tags', 'status', 'related_to', 'deprecated', 'created_at', 'attachments'];
|
|
14
14
|
export function skillSyncSpec(sources) {
|
|
15
15
|
return {
|
|
16
16
|
table: 'skills',
|
|
@@ -28,6 +28,7 @@ export function skillSyncSpec(sources) {
|
|
|
28
28
|
extends: fm.metadata?.extends ?? null,
|
|
29
29
|
deprecated: fm.deprecated ? 1 : 0,
|
|
30
30
|
created_at: fm.created_at ?? new Date(mtimeMs).toISOString(),
|
|
31
|
+
attachments: fm.attachments ? JSON.stringify(fm.attachments) : null,
|
|
31
32
|
}),
|
|
32
33
|
};
|
|
33
34
|
}
|
|
@@ -65,6 +66,7 @@ export function memorySyncSpec(sources) {
|
|
|
65
66
|
related_to: fm.related_to ?? null,
|
|
66
67
|
deprecated: fm.deprecated ? 1 : 0,
|
|
67
68
|
created_at: fm.created_at ?? null,
|
|
69
|
+
attachments: fm.attachments ? JSON.stringify(fm.attachments) : null,
|
|
68
70
|
}),
|
|
69
71
|
};
|
|
70
72
|
}
|
|
@@ -114,7 +116,7 @@ export function upsertFile(db, spec, filePath) {
|
|
|
114
116
|
db.prepare(`INSERT INTO ${spec.table} (${cols.join(', ')}) VALUES (${placeholders})
|
|
115
117
|
ON CONFLICT(id) DO UPDATE SET ${updateClause}`).run(...values);
|
|
116
118
|
db.prepare(`DELETE FROM search_index WHERE ref_table = ? AND ref_id = ?`).run(spec.table, id);
|
|
117
|
-
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 ?? '[]')));
|
|
119
|
+
db.prepare(`INSERT INTO search_index (ref_table, ref_id, description, body, tags, key) VALUES (?, ?, ?, ?, ?, ?)`).run(spec.table, id, String(row.description ?? ''), parsed.body, flattenTags(String(row.tags ?? '[]')), String(row.key ?? ''));
|
|
118
120
|
db.prepare(`DELETE FROM doc_dates WHERE ref_table = ? AND ref_id = ?`).run(spec.table, id);
|
|
119
121
|
const dates = new Set(extractDates(parsed.body));
|
|
120
122
|
// created_at is a UTC instant; convert to the OS-local calendar date so it
|
|
@@ -176,10 +178,12 @@ export function unregisterFolder(db, table, folderName) {
|
|
|
176
178
|
db.prepare(`DELETE FROM search_index WHERE ref_table = ? AND ref_id = ?`).run(table, row.id);
|
|
177
179
|
}
|
|
178
180
|
}
|
|
179
|
-
function* walkMarkdownFiles(dir) {
|
|
181
|
+
export function* walkMarkdownFiles(dir) {
|
|
180
182
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
181
183
|
const full = path.join(dir, entry.name);
|
|
182
184
|
if (entry.isDirectory()) {
|
|
185
|
+
if (entry.name === 'attachments')
|
|
186
|
+
continue;
|
|
183
187
|
yield* walkMarkdownFiles(full);
|
|
184
188
|
}
|
|
185
189
|
else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
@@ -188,7 +192,7 @@ function* walkMarkdownFiles(dir) {
|
|
|
188
192
|
}
|
|
189
193
|
}
|
|
190
194
|
export function watchSources(db, spec) {
|
|
191
|
-
const watcher = chokidar.watch(spec.sources.map((f) => f.path), { ignoreInitial: true, persistent: true, depth: 10 });
|
|
195
|
+
const watcher = chokidar.watch(spec.sources.map((f) => f.path), { ignoreInitial: true, persistent: true, depth: 10, ignored: /(^|[/\\])attachments([/\\]|$)/ });
|
|
192
196
|
watcher
|
|
193
197
|
.on('add', (filePath) => {
|
|
194
198
|
if (!spec.matchesFile(filePath))
|