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.
- package/dist/src/attachments/repository.js +12 -2
- package/dist/src/attachments/storage.js +19 -4
- package/dist/src/attachments/tools.js +2 -2
- package/dist/src/remote/remote-sync.js +26 -1
- package/dist/src/server.js +33 -3
- package/dist/src/shared/bucket-folder-tool.js +11 -5
- package/dist/src/skills/repository.js +58 -1
- package/dist/src/store/sync.js +41 -4
- package/package.json +1 -1
|
@@ -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
|
-
|
|
65
|
-
fs.
|
|
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,
|
|
85
|
+
while (fs.existsSync(path.join(dir, candidate(n))))
|
|
83
86
|
n++;
|
|
84
|
-
return
|
|
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
|
-
|
|
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);
|
package/dist/src/server.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
+
}
|
package/dist/src/store/sync.js
CHANGED
|
@@ -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: {
|
|
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.
|
|
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": {
|