mcp-memory-bucket 0.10.13 → 0.10.15

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.
@@ -61,8 +61,9 @@ export class AttachmentRepository {
61
61
  // (remote/write-order.ts).
62
62
  const entry = buildAttachmentEntry(dir, filename, data);
63
63
  await writeRemoteThenLocal(() => this.pushAttachmentToRemoteIfNeeded(kind, doc.folder, path.join(dir, entry.filename), data, guessMimeType(entry.filename)), () => {
64
- fs.mkdirSync(dir, { recursive: true });
65
- fs.writeFileSync(resolveWithinBase(dir, undefined, entry.filename), data);
64
+ const safePath = resolveWithinBase(dir, undefined, entry.filename);
65
+ fs.mkdirSync(path.dirname(safePath), { recursive: true }); // creates nested subdirs too, e.g. attachments/references/
66
+ fs.writeFileSync(safePath, data);
66
67
  });
67
68
  const existing = doc.attachments ?? [];
68
69
  await this.saveAttachmentsList(kind, folder, docIdOrName, [...existing, entry]);
@@ -87,6 +88,7 @@ export class AttachmentRepository {
87
88
  };
88
89
  const safePath = resolveWithinBase(dir, undefined, filename);
89
90
  await writeRemoteThenLocal(() => this.pushAttachmentToRemoteIfNeeded(kind, doc.folder, safePath, data, guessMimeType(filename)), () => {
91
+ fs.mkdirSync(path.dirname(safePath), { recursive: true }); // harmless if already present; guards a nested dir removed out-of-band
90
92
  fs.rmSync(safePath, { force: true });
91
93
  fs.writeFileSync(safePath, data);
92
94
  });
@@ -106,6 +108,14 @@ export class AttachmentRepository {
106
108
  // disk locally.
107
109
  await this.trashAttachmentOnRemoteIfNeeded(kind, doc.folder, safePath);
108
110
  fs.rmSync(safePath, { force: true });
111
+ // Clean up now-empty intermediate subdirectories (e.g. attachments/references/ after its last
112
+ // file is removed) — walks upward from the removed file's own parent dir, stopping at `dir`
113
+ // (attachments/ itself, which the check below already handles).
114
+ let cleanupDir = path.dirname(safePath);
115
+ while (cleanupDir !== dir && fs.existsSync(cleanupDir) && fs.readdirSync(cleanupDir).length === 0) {
116
+ fs.rmdirSync(cleanupDir);
117
+ cleanupDir = path.dirname(cleanupDir);
118
+ }
109
119
  const next = (doc.attachments ?? []).filter((a) => a.filename !== filename);
110
120
  await this.saveAttachmentsList(kind, folder, docIdOrName, next);
111
121
  if (listAttachmentFiles(dir).length === 0) {
@@ -71,17 +71,20 @@ export function guessMimeType(filename) {
71
71
  return MIME_BY_EXT[ext];
72
72
  return BINARY_EXTENSIONS.has(ext) ? 'application/octet-stream' : 'text/plain';
73
73
  }
74
+ /** `filename` may include a relative subpath (e.g. "references/foo.md") to nest it inside `dir`. */
74
75
  export function uniqueFilename(dir, filename) {
75
76
  // Validate that the filename doesn't escape the directory
76
77
  resolveWithinBase(dir, undefined, filename);
77
78
  if (!fs.existsSync(path.join(dir, filename)))
78
79
  return filename;
80
+ const dirPart = path.dirname(filename); // '.' for a bare filename, or e.g. "references"
79
81
  const ext = path.extname(filename);
80
82
  const base = path.basename(filename, ext);
83
+ const candidate = (n) => (dirPart === '.' ? `${base}-${n}${ext}` : path.join(dirPart, `${base}-${n}${ext}`));
81
84
  let n = 2;
82
- while (fs.existsSync(path.join(dir, `${base}-${n}${ext}`)))
85
+ while (fs.existsSync(path.join(dir, candidate(n))))
83
86
  n++;
84
- return `${base}-${n}${ext}`;
87
+ return candidate(n);
85
88
  }
86
89
  /**
87
90
  * Resolves the collision-avoided final filename and builds the resulting AttachmentEntry metadata
@@ -108,15 +111,27 @@ export function buildAttachmentEntry(dir, filename, data) {
108
111
  /** `dir` is the attachments directory itself, as returned by attachmentsDirFor. */
109
112
  export function writeAttachmentFile(dir, filename, data) {
110
113
  const entry = buildAttachmentEntry(dir, filename, data);
111
- fs.mkdirSync(dir, { recursive: true });
112
114
  const safePath = resolveWithinBase(dir, undefined, entry.filename);
115
+ fs.mkdirSync(path.dirname(safePath), { recursive: true }); // creates nested subdirs too, e.g. attachments/references/
113
116
  fs.writeFileSync(safePath, data);
114
117
  return entry;
115
118
  }
119
+ /** Recurses into subdirectories — an attachment's `filename` may be a nested relative path (e.g. "references/foo.md"). Returns paths relative to `dir`. */
116
120
  export function listAttachmentFiles(dir) {
117
121
  if (!fs.existsSync(dir))
118
122
  return [];
119
- return fs.readdirSync(dir).filter((name) => fs.statSync(path.join(dir, name)).isFile());
123
+ const out = [];
124
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
125
+ const full = path.join(dir, entry.name);
126
+ if (entry.isDirectory()) {
127
+ for (const nested of listAttachmentFiles(full))
128
+ out.push(path.join(entry.name, nested));
129
+ }
130
+ else if (entry.isFile()) {
131
+ out.push(entry.name);
132
+ }
133
+ }
134
+ return out;
120
135
  }
121
136
  /**
122
137
  * Reconciles a memory doc's attachments wrapper directory after an EXTERNAL rename (the file was
@@ -15,7 +15,7 @@ export function registerAttachmentTools(mcp, attachRepo) {
15
15
  kind: kindSchema,
16
16
  ...folderSchema,
17
17
  doc: z.string().describe('memory doc filename or skill name'),
18
- filename: z.string(),
18
+ filename: z.string().describe('filename to store it under — may include a relative subpath (e.g. "references/foo.md") to nest it inside attachments/'),
19
19
  file_path: z.string().describe('local filesystem path to the file to attach'),
20
20
  }, async ({ kind, folder, doc, filename, file_path }) => {
21
21
  try {
@@ -41,7 +41,7 @@ export function registerAttachmentTools(mcp, attachRepo) {
41
41
  return { content: [{ type: 'text', text: err.message }], isError: true };
42
42
  }
43
43
  });
44
- mcp.tool('attachment_update', "Replaces an attachment's content in place.", { kind: kindSchema, ...folderSchema, doc: z.string(), filename: z.string(), file_path: z.string() }, async ({ kind, folder, doc, filename, file_path }) => {
44
+ mcp.tool('attachment_update', "Replaces an attachment's content in place.", { kind: kindSchema, ...folderSchema, doc: z.string(), filename: z.string().describe('may include a relative subpath (e.g. "references/foo.md")'), file_path: z.string() }, async ({ kind, folder, doc, filename, file_path }) => {
45
45
  try {
46
46
  assertFileSizeOk(file_path);
47
47
  const data = fs.readFileSync(file_path);
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { upsertFile, removeFile, walkMarkdownFiles, walkAttachmentFiles } from '../store/sync.js';
3
+ import { upsertFile, removeFile, walkMarkdownFiles, walkAttachmentFiles, walkSkillSiblingFiles } from '../store/sync.js';
4
4
  import { getLastChanged, getChangedSince, readFile, FolderfooAuthError } from './folderfoo-client.js';
5
5
  import { isUnderAttachmentsDir } from '../attachments/storage.js';
6
6
  // Fixed for every remote source in v1 - no per-source tuning knob, per the
@@ -79,6 +79,13 @@ async function reconcileDeletions(db, spec, folder, credentialsBaseDir) {
79
79
  if (!fs.existsSync(folder.mirrorDir))
80
80
  return;
81
81
  for (const mirrorFilePath of walkMarkdownFiles(folder.mirrorDir)) {
82
+ // For skills, this loop must only ever act on SKILL.md itself — a sibling .md file (e.g.
83
+ // references/foo.md) is handled by the dedicated sibling-diff loop below instead, which compares
84
+ // it against the remote listing under its own real name (no remoteFilename translation, unlike
85
+ // SKILL.md's fixed "SKILL" remote name). Without this guard, a sibling .md would look
86
+ // deleted-remotely here (it was never pushed under "SKILL") and get wrongly deleted.
87
+ if (spec.table === 'skills' && path.basename(mirrorFilePath) !== 'SKILL.md')
88
+ continue;
82
89
  const relPath = path.relative(folder.mirrorDir, mirrorFilePath);
83
90
  // remoteFiles' entries are keyed by folderfoo's own filename grammar, which may differ from
84
91
  // the local mirror's filename (see spec.remoteFilename's doc comment — skills push under the
@@ -112,6 +119,24 @@ async function reconcileDeletions(db, spec, folder, credentialsBaseDir) {
112
119
  if (!remoteRelPaths.has(relPath))
113
120
  fs.unlinkSync(mirrorFilePath);
114
121
  }
122
+ // Prunes a stale SKILL SIBLING file (references/foo.md, scripts/bar.mjs, etc. — anything under a
123
+ // skill's own directory besides SKILL.md itself and attachments/) once it's gone from folderfoo's
124
+ // listing. Reuses the SAME remoteRelPaths already fetched above — no extra network call. Siblings
125
+ // are never indexed as their own doc row (see walkSkillSiblingFiles's doc comment), so like
126
+ // attachments there's no removeFile/DB row to clean up, just the mirror file itself. Pushed under
127
+ // its own literal filename (see pushSkillSiblingFileIfNeeded) with no remoteFilename translation,
128
+ // so a plain relative-path membership check is enough, same as the attachment loop above.
129
+ if (spec.table === 'skills') {
130
+ const skillRows = db.prepare(`SELECT source_path FROM skills WHERE folder = ?`).all(folder.name);
131
+ for (const { source_path: sourcePath } of skillRows) {
132
+ const skillDir = path.dirname(sourcePath);
133
+ for (const siblingPath of walkSkillSiblingFiles(skillDir)) {
134
+ const relPath = path.relative(folder.mirrorDir, siblingPath);
135
+ if (!remoteRelPaths.has(relPath))
136
+ fs.unlinkSync(siblingPath);
137
+ }
138
+ }
139
+ }
115
140
  if (spec.table === 'memory_docs') {
116
141
  reconcileOrphanedAttachmentWrappers(folder.mirrorDir);
117
142
  reconcileMisindexedAttachmentRows(db, folder.mirrorDir);
@@ -1,3 +1,4 @@
1
+ import fs from 'node:fs';
1
2
  import path from 'node:path';
2
3
  import { fileURLToPath } from 'node:url';
3
4
  import express from 'express';
@@ -6,7 +7,7 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
6
7
  import { loadConfig } from './config.js';
7
8
  import { openCache } from './store/db.js';
8
9
  import { initialScan, watchSources, skillSyncSpec, memorySyncSpec } from './store/sync.js';
9
- import { SkillRepository } from './skills/repository.js';
10
+ import { SkillRepository, findSkillDirAncestor } from './skills/repository.js';
10
11
  import { MemoryRepository } from './memory/repository.js';
11
12
  import { registerSkillTools } from './skills/tools.js';
12
13
  import { registerMemoryTools } from './memory/tools.js';
@@ -68,12 +69,41 @@ if (config.folderfooHost) {
68
69
  const skillFolders = [{ name: 'builtin', path: builtinSkillsDir }, ...config.skillFolders];
69
70
  const skillSpec = skillSyncSpec(skillFolders);
70
71
  const memorySpec = memorySyncSpec(config.memoryFolders);
72
+ // Built before watchSources below so onUnmatchedFileChange's closure can reference skillRepo —
73
+ // SkillRepository's own constructor doesn't need the watcher itself (that's attached separately via
74
+ // setWatcher once it exists).
75
+ const skillRepo = new SkillRepository(db, skillFolders, config.remoteSkillFolders, config.baseDir, identity);
76
+ const memoryRepo = new MemoryRepository(db, config.memoryFolders, config.remoteMemoryFolders, config.baseDir, identity);
77
+ // Reacts to a direct filesystem write under an existing skill's directory (any file that isn't
78
+ // SKILL.md itself, outside attachments/) that bypassed every MCP tool — e.g. an agent using a
79
+ // generic file-write tool to drop references/foo.md straight into a skill's directory. Pushes/
80
+ // re-pushes it to the skill's remote folder if that folder is remote-backed (no-op for a local
81
+ // folder — pushSkillSiblingFileIfNeeded/trashSkillSiblingFileIfNeeded already guard on that), and
82
+ // trashes it remotely on unlink. Kept out of the generic sync.ts layer since "parent dir contains
83
+ // SKILL.md" is a skill-specific convention.
84
+ skillSpec.onUnmatchedFileChange = (filePath, changeType) => {
85
+ if (path.basename(filePath) === '.last-synced')
86
+ return; // remote poller's own watermark sidecar file, not skill content
87
+ const folder = skillFolders.find((f) => filePath.startsWith(f.path + path.sep));
88
+ if (!folder)
89
+ return;
90
+ const skillDir = findSkillDirAncestor(filePath, folder.path);
91
+ if (!skillDir)
92
+ return; // not actually under any skill's own directory (e.g. a stray file at the folder root)
93
+ if (changeType === 'unlink') {
94
+ skillRepo.trashSkillSiblingFileIfNeeded(folder.name, filePath).catch((err) => console.error(`[memory-bucket] failed to trash sibling ${filePath}:`, err));
95
+ }
96
+ else {
97
+ fs.promises
98
+ .readFile(filePath)
99
+ .then((data) => skillRepo.pushSkillSiblingFileIfNeeded(folder.name, filePath, data))
100
+ .catch((err) => console.error(`[memory-bucket] failed to push sibling ${filePath}:`, err));
101
+ }
102
+ };
71
103
  initialScan(db, skillSpec);
72
104
  initialScan(db, memorySpec);
73
105
  const skillWatcher = watchSources(db, skillSpec);
74
106
  const memoryWatcher = watchSources(db, memorySpec);
75
- const skillRepo = new SkillRepository(db, skillFolders, config.remoteSkillFolders, config.baseDir, identity);
76
- const memoryRepo = new MemoryRepository(db, config.memoryFolders, config.remoteMemoryFolders, config.baseDir, identity);
77
107
  skillRepo.setWatcher(skillWatcher);
78
108
  memoryRepo.setWatcher(memoryWatcher);
79
109
  const attachmentRepo = new AttachmentRepository(memoryRepo, skillRepo, db);
@@ -73,7 +73,7 @@ export function registerBucketFolderTools(mcp, config, skillRepo, memoryRepo, db
73
73
  mcp.tool('bucket_connect_remote_folder', "Connects one of the user's own folderfoo folders (see bucket_list_remote_folders for `folderPath` values) as a new skill or memory source — syncs like a folder added via bucket_create_folder, so skill_create/memory_create can target it via `folder` and edits push back to folderfoo automatically. Idempotent: if this exact folderPath is already connected under this kind, returns that existing folder instead of creating a duplicate. Same login requirement as bucket_list_remote_folders.", {
74
74
  kind: KIND,
75
75
  folderPath: z.string().describe("a `path` value from bucket_list_remote_folders ('' for the folderfoo root)"),
76
- name: z.string().optional().describe("name for the folder; defaults to a sanitized version of folderPath's last segment"),
76
+ name: z.string().optional().describe("name for the folder; defaults to a sanitized version of folderPath's last segment. Auto-suffixed with the connecting username if it collides with a folder connected under a different folderfoo login."),
77
77
  }, async ({ kind, folderPath, name }) => {
78
78
  if (config.folderfooMode === 'off' || !config.folderfooHost) {
79
79
  return {
@@ -99,13 +99,19 @@ export function registerBucketFolderTools(mcp, config, skillRepo, memoryRepo, db
99
99
  if (!folderName) {
100
100
  return { content: [{ type: 'text', text: 'could not derive a valid folder name — provide one explicitly' }], isError: true };
101
101
  }
102
- const mirrorDir = mirrorDirFor(config.baseDir, current.mode, current.username, folderName);
103
- const remote = { name: folderName, server, tenantId: TENANT_ID, folderPath, mirrorDir, mode: current.mode, username: current.username };
104
102
  try {
103
+ // Resolved FIRST (before deriving mirrorDir) — auto-suffixes the requested name when it
104
+ // collides with a folder connected under a DIFFERENT folderfoo login (e.g. two different users
105
+ // each naturally wanting "bbbmemz"), still rejecting a collision against the CALLER's own
106
+ // identity. Mirrors the web route's POST /api/remote-folders logic (routes.ts) — see
107
+ // repo.resolveAvailableName's doc comment.
108
+ const resolvedName = repo.resolveAvailableName(folderName, current.username);
109
+ const mirrorDir = mirrorDirFor(config.baseDir, current.mode, current.username, resolvedName);
110
+ const remote = { name: resolvedName, server, tenantId: TENANT_ID, folderPath, mirrorDir, mode: current.mode, username: current.username };
105
111
  repo.registerRemoteFolder(remote);
106
- saveRemoteFolder(config, kind, { name: folderName, server, tenantId: TENANT_ID, folderPath, mode: current.mode, username: current.username });
112
+ saveRemoteFolder(config, kind, { name: resolvedName, server, tenantId: TENANT_ID, folderPath, mode: current.mode, username: current.username });
107
113
  await pollOne(db, specFor(kind), remote, config.baseDir);
108
- return { content: [{ type: 'text', text: JSON.stringify({ name: folderName, server, tenantId: TENANT_ID, folderPath, kind }, null, 2) }] };
114
+ return { content: [{ type: 'text', text: JSON.stringify({ name: resolvedName, server, tenantId: TENANT_ID, folderPath, kind }, null, 2) }] };
109
115
  }
110
116
  catch (err) {
111
117
  return { content: [{ type: 'text', text: err.message }], isError: true };
@@ -4,11 +4,12 @@ import matter from 'gray-matter';
4
4
  import { writeMarkdownFile, formatMarkdownFile } from '../store/markdown-file.js';
5
5
  import { assertValidSkillName } from '../store/skill-name.js';
6
6
  import { resolveWithinBase } from '../store/safe-path.js';
7
- import { upsertFile, removeFile, scanSingleFolder, unregisterFolder, skillSyncSpec } from '../store/sync.js';
7
+ import { upsertFile, removeFile, scanSingleFolder, unregisterFolder, skillSyncSpec, walkSkillSiblingFiles } from '../store/sync.js';
8
8
  import { SearchQueryError, sanitizeFtsQuery } from '../store/search.js';
9
9
  import { applyBodyEdits } from '../shared/body-edits.js';
10
10
  import { rebaseFolderPath } from '../config.js';
11
11
  import { readFile as readRemoteFile, writeFile as writeRemoteFile, writeBinaryFile as writeRemoteBinaryFile, trashFile as trashRemoteFile, trashFolder as trashRemoteFolder, renameFolder as renameRemoteFolder, joinRemoteFolderPath, assertRemoteFolderExists } from '../remote/folderfoo-client.js';
12
+ import { guessMimeType } from '../attachments/storage.js';
12
13
  import { isFolderVisible } from '../remote/identity.js';
13
14
  import { writeRemoteThenLocal } from '../remote/write-order.js';
14
15
  function rowToDoc(row) {
@@ -119,6 +120,35 @@ export class SkillRepository {
119
120
  const dirRelPath = joinRemoteFolderPath(remote.folderPath, path.relative(remote.mirrorDir, path.dirname(attachmentFilePath)));
120
121
  await trashRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dirRelPath, path.basename(attachmentFilePath), remote.owner);
121
122
  }
123
+ /**
124
+ * Pushes one arbitrary file (by its LOCAL absolute path) that lives alongside SKILL.md in a
125
+ * skill's own directory — the sibling-file counterpart to pushToRemoteIfNeeded (SKILL.md only,
126
+ * fixed remote name 'SKILL') and pushAttachmentIfNeeded (attachments/ only). Pushed under its own
127
+ * real filename, since folderfoo has no reason to rename it. No-op for a local folder.
128
+ */
129
+ async pushSkillSiblingFileIfNeeded(folderName, filePath, content) {
130
+ const remote = this.remoteFor(folderName);
131
+ if (!remote || !this.credentialsBaseDir)
132
+ return;
133
+ await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, folderName, remote.owner);
134
+ const dirRelPath = joinRemoteFolderPath(remote.folderPath, path.relative(remote.mirrorDir, path.dirname(filePath)));
135
+ const name = path.basename(filePath);
136
+ if (typeof content === 'string') {
137
+ await writeRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dirRelPath, name, content, remote.owner);
138
+ }
139
+ else {
140
+ await writeRemoteBinaryFile(remote.server, this.credentialsBaseDir, remote.tenantId, dirRelPath, name, content, guessMimeType(name), remote.owner);
141
+ }
142
+ }
143
+ /** Trashes one sibling file's remote copy on folderfoo — the sibling-file counterpart to trashAttachmentIfNeeded. No-op for a local folder. */
144
+ async trashSkillSiblingFileIfNeeded(folderName, filePath) {
145
+ const remote = this.remoteFor(folderName);
146
+ if (!remote || !this.credentialsBaseDir)
147
+ return;
148
+ await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, folderName, remote.owner);
149
+ const dirRelPath = joinRemoteFolderPath(remote.folderPath, path.relative(remote.mirrorDir, path.dirname(filePath)));
150
+ await trashRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dirRelPath, path.basename(filePath), remote.owner);
151
+ }
122
152
  /** Attaches the live chokidar watcher so addFolder/removeFolder can mutate it without a restart. */
123
153
  setWatcher(watcher) {
124
154
  this.watcher = watcher;
@@ -451,6 +481,13 @@ export class SkillRepository {
451
481
  fs.writeFileSync(filePath, fileContents, 'utf-8');
452
482
  });
453
483
  upsertFile(this.db, this.syncSpec, filePath);
484
+ // Pushes any sibling files (references/, scripts/, etc.) an agent already populated the skill
485
+ // dir with before calling create() — e.g. a portable agentskills.io skill assembled on disk
486
+ // first. No-op when the dir was empty besides SKILL.md, and a no-op push for a local folder.
487
+ for (const siblingPath of walkSkillSiblingFiles(skillDir)) {
488
+ const data = fs.readFileSync(siblingPath);
489
+ await this.pushSkillSiblingFileIfNeeded(targetFolder.name, siblingPath, data);
490
+ }
454
491
  return { ...fm, body, paused: false };
455
492
  }
456
493
  /**
@@ -681,3 +718,23 @@ function stripSourcePath(fm) {
681
718
  const { source_path: _sp, folder: _folder, ...rest } = fm;
682
719
  return rest;
683
720
  }
721
+ /**
722
+ * Walks upward from `filePath` looking for the nearest ancestor directory that contains a
723
+ * SKILL.md — i.e. "is this file a sibling somewhere under some skill's own directory." A sibling
724
+ * can be arbitrarily deep (e.g. references/sub/foo.md), so this must walk multiple levels up, not
725
+ * just check the immediate parent. Stops at `folderRoot` (the configured skill folder's own root)
726
+ * so a stray file sitting directly at the folder root — not under any skill's directory at all —
727
+ * correctly returns null instead of walking past the folder entirely.
728
+ */
729
+ export function findSkillDirAncestor(filePath, folderRoot) {
730
+ let dir = path.dirname(filePath);
731
+ const root = path.resolve(folderRoot);
732
+ while (dir.startsWith(root)) {
733
+ if (fs.existsSync(path.join(dir, 'SKILL.md')))
734
+ return dir;
735
+ if (dir === root)
736
+ break;
737
+ dir = path.dirname(dir);
738
+ }
739
+ return null;
740
+ }
@@ -19,7 +19,15 @@ export function skillSyncSpec(sources) {
19
19
  matchesFile: (filePath) => path.basename(filePath) === 'SKILL.md',
20
20
  columns: skillColumns,
21
21
  getId: (fm) => fm.name,
22
- remoteFilename: { toRemote: () => 'SKILL', toLocal: () => 'SKILL.md' },
22
+ remoteFilename: {
23
+ toRemote: () => 'SKILL',
24
+ // SKILL.md itself pushes/pulls under the fixed opaque remote name "SKILL" (translated to/from
25
+ // "SKILL.md" locally) — but a sibling file (references/foo.md, scripts/bar.mjs) pushes under
26
+ // its own real name (see SkillRepository.pushSkillSiblingFileIfNeeded) and must pull back down
27
+ // under that SAME real name, not get coerced into "SKILL.md". Only translate the one name this
28
+ // spec actually owns; anything else passes through unchanged.
29
+ toLocal: (remoteName) => (remoteName === 'SKILL' ? 'SKILL.md' : remoteName),
30
+ },
23
31
  toRow: (fm, _sourcePath, mtimeMs) => ({
24
32
  id: fm.name,
25
33
  description: fm.description,
@@ -269,12 +277,37 @@ export function* walkAttachmentFiles(dir) {
269
277
  }
270
278
  }
271
279
  }
280
+ /**
281
+ * Yields every file under `skillDir` (a single skill's own directory, i.e. dirname(SKILL.md's
282
+ * source_path)) EXCEPT SKILL.md itself and anything under attachments/ — the "sibling files" a
283
+ * portable agentskills.io skill keeps alongside SKILL.md (references/, scripts/, assets/, etc).
284
+ * Used by push (walk-and-push on create()) and by remote-sync.ts's reconcileDeletions to prune
285
+ * stale siblings. A sibling is never indexed as its own doc row — see skillSyncSpec's matchesFile,
286
+ * which only ever matches SKILL.md itself.
287
+ */
288
+ export function* walkSkillSiblingFiles(skillDir) {
289
+ if (!fs.existsSync(skillDir))
290
+ return;
291
+ for (const entry of fs.readdirSync(skillDir, { withFileTypes: true })) {
292
+ const full = path.join(skillDir, entry.name);
293
+ if (entry.isDirectory()) {
294
+ if (isUnderAttachmentsDir(entry.name))
295
+ continue;
296
+ yield* walkSkillSiblingFiles(full);
297
+ }
298
+ else if (entry.isFile() && entry.name !== 'SKILL.md') {
299
+ yield full;
300
+ }
301
+ }
302
+ }
272
303
  export function watchSources(db, spec) {
273
304
  const watcher = chokidar.watch(spec.sources.map((f) => f.path), { ignoreInitial: true, persistent: true, depth: 10, ignored: (filePath) => isUnderAttachmentsDir(filePath) });
274
305
  watcher
275
306
  .on('add', (filePath) => {
276
- if (!spec.matchesFile(filePath))
307
+ if (!spec.matchesFile(filePath)) {
308
+ spec.onUnmatchedFileChange?.(filePath, 'add');
277
309
  return;
310
+ }
278
311
  try {
279
312
  upsertFile(db, spec, filePath);
280
313
  }
@@ -283,8 +316,10 @@ export function watchSources(db, spec) {
283
316
  }
284
317
  })
285
318
  .on('change', (filePath) => {
286
- if (!spec.matchesFile(filePath))
319
+ if (!spec.matchesFile(filePath)) {
320
+ spec.onUnmatchedFileChange?.(filePath, 'change');
287
321
  return;
322
+ }
288
323
  try {
289
324
  upsertFile(db, spec, filePath);
290
325
  }
@@ -293,8 +328,10 @@ export function watchSources(db, spec) {
293
328
  }
294
329
  })
295
330
  .on('unlink', (filePath) => {
296
- if (!spec.matchesFile(filePath))
331
+ if (!spec.matchesFile(filePath)) {
332
+ spec.onUnmatchedFileChange?.(filePath, 'unlink');
297
333
  return;
334
+ }
298
335
  removeFile(db, spec.table, filePath);
299
336
  });
300
337
  return watcher;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-memory-bucket",
3
- "version": "0.10.13",
3
+ "version": "0.10.15",
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": {