mcp-memory-bucket 0.7.1 → 0.7.4
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.
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { distance } from 'fastest-levenshtein';
|
|
2
|
+
/**
|
|
3
|
+
* Mirrors mcp-tenant-lib's channel-search.ts — duplicated rather than
|
|
4
|
+
* imported since this package has no other dependency on mcp-tenant-lib.
|
|
5
|
+
* See that file for the full design rationale behind the stem/typo split.
|
|
6
|
+
*/
|
|
7
|
+
function words(name) {
|
|
8
|
+
return name.split(/[_-]+/).filter(Boolean);
|
|
9
|
+
}
|
|
10
|
+
function typoScore(a, b) {
|
|
11
|
+
const maxLen = Math.max(a.length, b.length);
|
|
12
|
+
if (maxLen === 0)
|
|
13
|
+
return 1;
|
|
14
|
+
return 1 - distance(a, b) / maxLen;
|
|
15
|
+
}
|
|
16
|
+
function stemScore(a, b) {
|
|
17
|
+
const [shorter, longer] = a.length <= b.length ? [a, b] : [b, a];
|
|
18
|
+
if (shorter.length < 3 || !longer.startsWith(shorter))
|
|
19
|
+
return 0;
|
|
20
|
+
return shorter.length / longer.length;
|
|
21
|
+
}
|
|
22
|
+
function wordScore(query, word) {
|
|
23
|
+
const stem = stemScore(query, word);
|
|
24
|
+
if (Math.abs(query.length - word.length) > 2)
|
|
25
|
+
return stem;
|
|
26
|
+
return Math.max(stem, typoScore(query, word));
|
|
27
|
+
}
|
|
28
|
+
export function scoreChannelMatch(query, candidate) {
|
|
29
|
+
const q = query.toLowerCase();
|
|
30
|
+
const c = candidate.toLowerCase();
|
|
31
|
+
const whole = Math.max(typoScore(q, c), stemScore(q, c));
|
|
32
|
+
const perWord = words(c).map((w) => wordScore(q, w));
|
|
33
|
+
return Math.max(whole, ...perWord, 0);
|
|
34
|
+
}
|
|
35
|
+
export function findChannelMatches(query, candidates, minScore = 0.5) {
|
|
36
|
+
return candidates
|
|
37
|
+
.map((name) => ({ name, score: scoreChannelMatch(query, name) }))
|
|
38
|
+
.filter((m) => m.score >= minScore)
|
|
39
|
+
.sort((a, b) => b.score - a.score);
|
|
40
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL-safe slug rule for agent-chosen channel names: letters, digits,
|
|
3
|
+
* underscore, hyphen only. Mirrors mcp-tenant-lib's isValidChannelName —
|
|
4
|
+
* duplicated rather than imported since this package has no other
|
|
5
|
+
* dependency on mcp-tenant-lib and one shared regex isn't worth adding one.
|
|
6
|
+
*/
|
|
7
|
+
export function isValidChannelName(name) {
|
|
8
|
+
return /^[a-zA-Z0-9_-]+$/.test(name);
|
|
9
|
+
}
|
|
10
|
+
const channels = new Map();
|
|
11
|
+
/** Auto-vivifies on first reference, same as mcp-tenant-lib's getOrCreateTenant. */
|
|
12
|
+
export function getOrCreateChannel(name) {
|
|
13
|
+
let channel = channels.get(name);
|
|
14
|
+
if (!channel) {
|
|
15
|
+
channel = { name, content: '', lastActivityAt: Date.now() };
|
|
16
|
+
channels.set(name, channel);
|
|
17
|
+
}
|
|
18
|
+
return channel;
|
|
19
|
+
}
|
|
20
|
+
export function getChannel(name) {
|
|
21
|
+
return channels.get(name);
|
|
22
|
+
}
|
|
23
|
+
export function listChannels() {
|
|
24
|
+
return [...channels.values()];
|
|
25
|
+
}
|
|
26
|
+
function envMs(name, defaultMs) {
|
|
27
|
+
const raw = process.env[name];
|
|
28
|
+
if (!raw)
|
|
29
|
+
return defaultMs;
|
|
30
|
+
const n = Number(raw);
|
|
31
|
+
return Number.isFinite(n) && n > 0 ? n : defaultMs;
|
|
32
|
+
}
|
|
33
|
+
// Long-lived by design: a cross-agent discussion may span much longer gaps
|
|
34
|
+
// between contributions than a live form-fill session (mcp-tenant-lib's
|
|
35
|
+
// tenants default to a 30-minute idle timeout) — default here is ~24h.
|
|
36
|
+
const MEMORY_CHANNEL_IDLE_TIMEOUT_MS = envMs('MEMORY_CHANNEL_IDLE_TIMEOUT_MS', 24 * 60 * 60 * 1000);
|
|
37
|
+
const MEMORY_CHANNEL_SWEEP_INTERVAL_MS = envMs('MEMORY_CHANNEL_SWEEP_INTERVAL_MS', 30 * 60 * 1000);
|
|
38
|
+
export function startChannelSweep(onSweep) {
|
|
39
|
+
const sweepInterval = setInterval(() => {
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
for (const [name, channel] of channels) {
|
|
42
|
+
if (now - channel.lastActivityAt > MEMORY_CHANNEL_IDLE_TIMEOUT_MS) {
|
|
43
|
+
onSweep(name);
|
|
44
|
+
channels.delete(name);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}, MEMORY_CHANNEL_SWEEP_INTERVAL_MS);
|
|
48
|
+
sweepInterval.unref();
|
|
49
|
+
return sweepInterval;
|
|
50
|
+
}
|
|
51
|
+
// Test-only escape hatch, mirroring how mcp-tenant-lib's tests reach into
|
|
52
|
+
// its module-level `tenants` Map directly (see tenant.ts's `tenants` export).
|
|
53
|
+
export { channels };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { getOrCreateChannel, getChannel, listChannels, isValidChannelName } from './store.js';
|
|
3
|
+
import { findChannelMatches } from './search.js';
|
|
4
|
+
export function registerMemoryChannelTools(mcp) {
|
|
5
|
+
mcp.tool('memory_channel_read', 'Reads a memory channel: ephemeral, in-memory-only text shared live between agent sessions on this server ' +
|
|
6
|
+
'— distinct from persisted memory_* docs (never on disk, never indexed by search). Empty content if the ' +
|
|
7
|
+
'channel doesn\'t exist yet — reading never creates one (memory_channel_post does). ' +
|
|
8
|
+
'IMPORTANT: empty is ambiguous ("nothing posted yet" vs "wrong name" — matching is EXACT, no fuzz). Before ' +
|
|
9
|
+
'saying a discussion doesn\'t exist, call memory_channel_find or list_memory_channels to check for a ' +
|
|
10
|
+
'similarly-named channel (e.g. "pets" vs "pet_discussion").', { channel: z.string().describe('Channel name. Letters/digits/underscore/hyphen only.') }, async ({ channel }) => {
|
|
11
|
+
if (!isValidChannelName(channel)) {
|
|
12
|
+
return {
|
|
13
|
+
content: [{ type: 'text', text: `Error: "${channel}" is not a valid channel name — use only letters, digits, underscore, and hyphen.` }],
|
|
14
|
+
isError: true,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
const existing = getChannel(channel);
|
|
18
|
+
return {
|
|
19
|
+
content: [{ type: 'text', text: JSON.stringify({ content: existing?.content ?? '', lastActivityAt: existing?.lastActivityAt ?? null }, null, 2) }],
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
mcp.tool('memory_channel_post', 'Writes to a memory channel, replacing its content (last-write-wins) — auto-creates it if missing. ' +
|
|
23
|
+
'Ephemeral, in-memory only: never on disk, never indexed by search, gone on restart or after a long idle ' +
|
|
24
|
+
'period. This ALWAYS replaces content — "append to a discussion" vs "hand over a fresh summary" is your ' +
|
|
25
|
+
'choice, not a mode: to preserve history, read first then post the old content plus your addition ' +
|
|
26
|
+
'concatenated; to hand over cleanly, just post fresh content. Names must match EXACTLY — if continuing an ' +
|
|
27
|
+
'existing discussion, double-check via memory_channel_find/list_memory_channels first, or a typo silently ' +
|
|
28
|
+
'starts an unrelated empty channel instead of erroring.', {
|
|
29
|
+
channel: z.string().describe('Channel name. Letters/digits/underscore/hyphen only.'),
|
|
30
|
+
content: z.string().describe('The full new content of the channel — replaces whatever was there before.'),
|
|
31
|
+
}, async ({ channel, content }) => {
|
|
32
|
+
if (!isValidChannelName(channel)) {
|
|
33
|
+
return {
|
|
34
|
+
content: [{ type: 'text', text: `Error: "${channel}" is not a valid channel name — use only letters, digits, underscore, and hyphen.` }],
|
|
35
|
+
isError: true,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const ch = getOrCreateChannel(channel);
|
|
39
|
+
ch.content = content;
|
|
40
|
+
ch.lastActivityAt = Date.now();
|
|
41
|
+
return {
|
|
42
|
+
content: [{ type: 'text', text: `Posted ${content.length} character(s) to channel "${channel}".` }],
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
mcp.tool('list_memory_channels', 'Lists every memory channel currently live on this server, with when each was last written or read. Use ' +
|
|
46
|
+
'this to discover an existing channel by name before calling memory_channel_read/post on it.', {}, async () => {
|
|
47
|
+
const channels = listChannels().map((c) => ({ name: c.name, lastActivityAt: c.lastActivityAt }));
|
|
48
|
+
return { content: [{ type: 'text', text: JSON.stringify(channels, null, 2) }] };
|
|
49
|
+
});
|
|
50
|
+
mcp.tool('memory_channel_find', 'Fuzzy-searches existing memory channel names for a loose query (e.g. "the pets discussion") instead of ' +
|
|
51
|
+
'guessing at memory_channel_read/post or eyeballing list_memory_channels yourself. Matches whole-name ' +
|
|
52
|
+
'similarity (typos) and per-word similarity on underscore/hyphen-split parts (e.g. "pets" matches ' +
|
|
53
|
+
'"pet_food_memory" via "pet"). Returns ranked {name, score} (0..1, higher better), empty if nothing scores ' +
|
|
54
|
+
'above threshold. Read-only — does not read or create anything. Call memory_channel_read yourself on the ' +
|
|
55
|
+
'right result; if scores are close, ask the user to disambiguate rather than guessing.', { query: z.string().describe('Loose/partial channel name or topic to search for, e.g. "pets".') }, async ({ query }) => {
|
|
56
|
+
const matches = findChannelMatches(query, listChannels().map((c) => c.name));
|
|
57
|
+
return { content: [{ type: 'text', text: JSON.stringify(matches, null, 2) }] };
|
|
58
|
+
});
|
|
59
|
+
}
|
package/dist/src/server.js
CHANGED
|
@@ -17,6 +17,8 @@ import { registerAttachmentTools } from './attachments/tools.js';
|
|
|
17
17
|
import { AttachmentRepository } from './attachments/repository.js';
|
|
18
18
|
import { buildWebRouter } from './web/routes.js';
|
|
19
19
|
import { registerUiTool } from './web/ui-tool.js';
|
|
20
|
+
import { registerMemoryChannelTools } from './channels/tools.js';
|
|
21
|
+
import { startChannelSweep } from './channels/store.js';
|
|
20
22
|
// server.ts is rebuilt from `buildMcpServer()` on every /mcp request (see below),
|
|
21
23
|
// so tool schemas (which conditionally include `folder` based on folder count) always
|
|
22
24
|
// reflect the current folders — no restart needed after an add/remove-folder call.
|
|
@@ -52,7 +54,7 @@ if (config.skillFolders.length === 0 && config.memoryFolders.length === 0) {
|
|
|
52
54
|
// this server — surfaced both in serverInfo.description and instructions so
|
|
53
55
|
// clients that expose either to the model can make that association.
|
|
54
56
|
const SERVER_DESCRIPTION = 'Also known as "memory bucket", "mem bucket", or "skill bucket" — if the user refers to this server by any of those names, they mean this one.';
|
|
55
|
-
const SERVER_INSTRUCTIONS = `${SERVER_DESCRIPTION} Exposes skill_* (reusable coding patterns, stored as agentskills.io-standard SKILL.md folders) and memory_* (point-in-time working context — plans, specs, SQL, session summaries — looked up by key) tools, plus shared relocate/bucket_search/bucket_*_folder tools. Use skill_search/memory_search/bucket_search for full-text search over body content (not just metadata) — bucket_search when you don't know which bucket something landed in. Use bucket_list_folders to see what named source directories (folders) are configured before passing a folder argument elsewhere, and bucket_create_folder/bucket_delete_folder to register or unregister one. Most operations have a _bulk_ variant (bulk_get/bulk_create/bulk_update/bulk_delete/bulk_rename, relocate_bulk) that take a list and return per-item success/failure — prefer these over looping single calls when acting on more than one item. A memory doc's key can be changed in place via memory_update(id, key: ...) — no separate rename tool needed. Before calling any *_create/*_update/relocate tool, call skill_get("memory-bucket-authoring") first to learn the exact frontmatter schema — don't guess the shape.`;
|
|
57
|
+
const SERVER_INSTRUCTIONS = `${SERVER_DESCRIPTION} Exposes skill_* (reusable coding patterns, stored as agentskills.io-standard SKILL.md folders) and memory_* (point-in-time working context — plans, specs, SQL, session summaries — looked up by key) tools, plus shared relocate/bucket_search/bucket_*_folder tools. Use skill_search/memory_search/bucket_search for full-text search over body content (not just metadata) — bucket_search when you don't know which bucket something landed in. Use bucket_list_folders to see what named source directories (folders) are configured before passing a folder argument elsewhere, and bucket_create_folder/bucket_delete_folder to register or unregister one. Most operations have a _bulk_ variant (bulk_get/bulk_create/bulk_update/bulk_delete/bulk_rename, relocate_bulk) that take a list and return per-item success/failure — prefer these over looping single calls when acting on more than one item. A memory doc's key can be changed in place via memory_update(id, key: ...) — no separate rename tool needed. Before calling any *_create/*_update/relocate tool, call skill_get("memory-bucket-authoring") first to learn the exact frontmatter schema — don't guess the shape. Also exposes memory_channel_read/memory_channel_post/list_memory_channels — a SEPARATE, ephemeral in-memory layer for live cross-agent coordination (a shared scratchpad/discussion channel by name), never written to disk and never indexed by memory_search/bucket_search; do not confuse these with the persisted memory_* docs above.`;
|
|
56
58
|
function buildMcpServer() {
|
|
57
59
|
const server = new McpServer({ name: 'memory-bucket', version: '0.1.0', description: SERVER_DESCRIPTION }, { capabilities: {}, instructions: SERVER_INSTRUCTIONS });
|
|
58
60
|
registerSkillTools(server, skillRepo);
|
|
@@ -62,8 +64,10 @@ function buildMcpServer() {
|
|
|
62
64
|
registerBucketFolderTools(server, config, skillRepo, memoryRepo, db, skillSpec, memorySpec);
|
|
63
65
|
registerAttachmentTools(server, attachmentRepo);
|
|
64
66
|
registerUiTool(server, PORT);
|
|
67
|
+
registerMemoryChannelTools(server);
|
|
65
68
|
return server;
|
|
66
69
|
}
|
|
70
|
+
startChannelSweep((name) => console.error(`[memory-bucket] sweeping idle memory channel: ${name}`));
|
|
67
71
|
const app = express();
|
|
68
72
|
app.use(express.json());
|
|
69
73
|
app.use(buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, memorySpec));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-memory-bucket",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.4",
|
|
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": {
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"better-sqlite3": "^11.8.0",
|
|
40
40
|
"chokidar": "^4.0.0",
|
|
41
41
|
"express": "^4.21.0",
|
|
42
|
+
"fastest-levenshtein": "^1.0.16",
|
|
42
43
|
"gray-matter": "^4.0.3",
|
|
43
44
|
"lit": "^3.3.3",
|
|
44
45
|
"marked": "^18.0.10",
|