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.
@@ -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-6BCcM6pL.js"></script>
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 {};
@@ -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
  }
@@ -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
- return { content: [{ type: 'text', text: JSON.stringify(doc, null, 2) }] };
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 };
@@ -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);
@@ -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();
@@ -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 ? [query, table, limit, offset] : [query, limit, offset]));
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(query, limit, offset);
136
+ .all(sanitized, limit, offset);
114
137
  }
115
138
  catch (err) {
116
139
  throw new SearchQueryError(query, err);
@@ -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))