The default memory bank becomes 'rcll-main'. hindsight-mempalace-mcp@1.0.0 (published, ~145 downloads/month) defaulted to 'mempalace-main', so an install that never set the variable and switches packages would open a different, empty bank. MEMPALACE_BANK is still honoured, and when neither variable is set the server now prints which bank it picked and how to keep reading the old one, instead of choosing silently. rcll-split.py moves to scripts/ — it documents how the fork series was built, which is not a root-level artifact of the product.
349 lines
12 KiB
JavaScript
349 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
||
|
||
/**
|
||
* RCLL — Standalone MCP Server
|
||
* (fork of vectorize-io/hindsight; self-hosted shared memory for a team of agents)
|
||
*
|
||
* Provides 5 memory tools over MCP (stdio transport):
|
||
* memory_retain — save memories with room/hall/layer classification
|
||
* memory_recall — scoped semantic search
|
||
* memory_reflect — deep reasoning over stored memories
|
||
* memory_compress — create closet summaries
|
||
* memory_bridge — cross-bank tunnels
|
||
*
|
||
* Connects to the RCLL backend (default: http://127.0.0.1:5100)
|
||
*
|
||
* Usage:
|
||
* RCLL_URL=http://localhost:5100 node server.js
|
||
*
|
||
* Claude Code config (~/.claude/mcp.json):
|
||
* {
|
||
* "mcpServers": {
|
||
* "rcll": {
|
||
* "command": "node",
|
||
* "args": ["/path/to/mcp-server/server.js"],
|
||
* "env": { "RCLL_URL": "http://localhost:5100" }
|
||
* }
|
||
* }
|
||
* }
|
||
*/
|
||
|
||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||
import { z } from 'zod';
|
||
|
||
// --- Config ---
|
||
|
||
// RCLL_* are the current names. The pre-rebrand names are still honored as a
|
||
// fallback so an existing install keeps working after `npm update` — each one
|
||
// hit prints a deprecation notice on stderr (stdout is the MCP transport).
|
||
const LEGACY_ENV = [
|
||
['RCLL_URL', 'HINDSIGHT_URL'],
|
||
['RCLL_BANK', 'MEMPALACE_BANK'],
|
||
];
|
||
|
||
function envWithFallback(current, legacy) {
|
||
if (process.env[current]) return process.env[current];
|
||
if (process.env[legacy]) {
|
||
console.error(`rcll-mcp: ${legacy} is deprecated — rename it to ${current}.`);
|
||
return process.env[legacy];
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
const RCLL_URL = envWithFallback(...LEGACY_ENV[0]) || 'http://127.0.0.1:5100';
|
||
const RCLL_BASE = `${RCLL_URL}/v1/default/banks`;
|
||
// The default bank is 'rcll-main'. The pre-rebrand package
|
||
// hindsight-mempalace-mcp@1.0.0 defaulted to 'mempalace-main', so an install that
|
||
// never set the env var and switches packages would land in a different, empty bank.
|
||
// That reads as "the update erased my memory", so we say it out loud instead of
|
||
// guessing: the legacy env var is still honoured, and defaulting is announced.
|
||
const LEGACY_DEFAULT_BANK = 'mempalace-main';
|
||
const DEFAULT_BANK = envWithFallback(...LEGACY_ENV[1]) || 'rcll-main';
|
||
if (!process.env.RCLL_BANK && !process.env.MEMPALACE_BANK) {
|
||
console.error(
|
||
`rcll-mcp: RCLL_BANK is unset, using '${DEFAULT_BANK}'. ` +
|
||
`Coming from hindsight-mempalace-mcp? Your memory is in '${LEGACY_DEFAULT_BANK}' — ` +
|
||
`set RCLL_BANK=${LEGACY_DEFAULT_BANK} to keep reading it.`
|
||
);
|
||
}
|
||
|
||
// --- RCLL HTTP client ---
|
||
|
||
async function hindsightRequest(method, path, body = null) {
|
||
const url = `${RCLL_BASE}${path}`;
|
||
const opts = {
|
||
method,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
};
|
||
if (body) opts.body = JSON.stringify(body);
|
||
|
||
const res = await fetch(url, opts);
|
||
const text = await res.text();
|
||
|
||
let data;
|
||
try {
|
||
data = JSON.parse(text);
|
||
} catch {
|
||
throw new Error(`RCLL backend returned non-JSON: ${text.slice(0, 200)}`);
|
||
}
|
||
|
||
if (!res.ok) {
|
||
throw new Error(data.detail || `RCLL API error ${res.status}: ${JSON.stringify(data)}`);
|
||
}
|
||
return data;
|
||
}
|
||
|
||
// --- MCP Server ---
|
||
|
||
const server = new McpServer({
|
||
name: 'rcll',
|
||
version: '1.0.0',
|
||
});
|
||
|
||
// Tool 1: memory_retain
|
||
server.tool(
|
||
'memory_retain',
|
||
'Save a fact, observation, or document to long-term memory with automatic room/hall classification. ' +
|
||
'Rooms: auth, pipeline, schema, infrastructure, ui, api, deployment, monitoring, agent, general. ' +
|
||
'Halls: fact, event, decision, preference, discovery, procedure, warning. ' +
|
||
'Layers: L0=Identity (always loaded), L1=Critical, L2=Session (default), L3=Deep.',
|
||
{
|
||
text: z.string().describe('The text to memorize — a fact, observation, or document content'),
|
||
bank_id: z.string().optional().describe(`Memory bank ID (default: ${DEFAULT_BANK})`),
|
||
context: z.string().optional().describe('Context label (e.g. "meeting notes", "client call")'),
|
||
document_id: z.string().optional().describe('Document ID to group related facts'),
|
||
tags: z.array(z.string()).optional().describe('Tags for categorization'),
|
||
room: z.string().optional().describe('Topic room (auto-classified if omitted)'),
|
||
hall: z.enum(['fact', 'event', 'decision', 'preference', 'discovery', 'procedure', 'warning']).optional().describe('Knowledge type (auto-classified if omitted)'),
|
||
layer: z.enum(['L0', 'L1', 'L2', 'L3']).optional().describe('Priority layer (default: L2)'),
|
||
},
|
||
async ({ text, bank_id, context, document_id, tags, room, hall, layer }) => {
|
||
const bankId = bank_id || DEFAULT_BANK;
|
||
const item = { content: text };
|
||
if (context) item.context = context;
|
||
if (document_id) item.document_id = document_id;
|
||
if (tags) item.tags = tags;
|
||
if (room) item.room = room;
|
||
if (hall) item.hall = hall;
|
||
if (layer) item.layer = layer;
|
||
|
||
const result = await hindsightRequest('POST', `/${bankId}/memories`, {
|
||
items: [item],
|
||
});
|
||
|
||
const storedIds = (result.items || []).map(i => i.id || i.uuid).filter(Boolean);
|
||
|
||
return {
|
||
content: [{
|
||
type: 'text',
|
||
text: JSON.stringify({
|
||
success: true,
|
||
bank_id: bankId,
|
||
items_stored: result.items_count || 1,
|
||
ids: storedIds.length ? storedIds : null,
|
||
room: room || 'auto',
|
||
hall: hall || 'auto',
|
||
layer: layer || 'L2',
|
||
}, null, 2),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
// Tool 2: memory_recall
|
||
server.tool(
|
||
'memory_recall',
|
||
'Search long-term memory for relevant facts. Uses semantic search with optional room/hall scoping ' +
|
||
'for significantly improved retrieval accuracy. Supports layer cascade (L0 results always prioritized).',
|
||
{
|
||
query: z.string().describe('What to search for in memory'),
|
||
bank_id: z.string().optional().describe(`Memory bank ID (default: ${DEFAULT_BANK})`),
|
||
limit: z.number().optional().describe('Max results (default: 10)'),
|
||
room: z.union([z.string(), z.array(z.string())]).optional().describe('Filter by room(s) — applied before semantic search'),
|
||
hall: z.union([z.string(), z.array(z.string())]).optional().describe('Filter by hall(s): fact, event, decision, etc.'),
|
||
max_layer: z.enum(['L0', 'L1', 'L2', 'L3']).optional().describe('Max layer depth to search (default: L3 = all)'),
|
||
},
|
||
async ({ query, bank_id, limit, room, hall, max_layer }) => {
|
||
const bankId = bank_id || DEFAULT_BANK;
|
||
|
||
const body = {
|
||
query,
|
||
limit: limit || 10,
|
||
};
|
||
if (room) body.room = Array.isArray(room) ? room : [room];
|
||
if (hall) body.hall = Array.isArray(hall) ? hall : [hall];
|
||
if (max_layer) body.max_layer = max_layer;
|
||
|
||
const result = await hindsightRequest('POST', `/${bankId}/memories/recall`, body);
|
||
|
||
const memories = (result.results || []).map(r => ({
|
||
id: r.id || r.uuid || null,
|
||
text: r.text,
|
||
type: r.type,
|
||
entities: r.entities,
|
||
occurred: r.occurred_start || null,
|
||
room: r.room || null,
|
||
hall: r.hall || null,
|
||
}));
|
||
|
||
return {
|
||
content: [{
|
||
type: 'text',
|
||
text: JSON.stringify({
|
||
success: true,
|
||
bank_id: bankId,
|
||
count: memories.length,
|
||
memories,
|
||
}, null, 2),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
// Tool 3: memory_reflect
|
||
server.tool(
|
||
'memory_reflect',
|
||
'Deep reasoning over memory — synthesizes facts, finds patterns, answers complex questions with citations. ' +
|
||
'Use for analysis: "What patterns emerge from recent events?" or "Summarize everything about X."',
|
||
{
|
||
query: z.string().describe('Question to reason about over stored memories'),
|
||
bank_id: z.string().optional().describe(`Memory bank ID (default: ${DEFAULT_BANK})`),
|
||
},
|
||
async ({ query, bank_id }) => {
|
||
const bankId = bank_id || DEFAULT_BANK;
|
||
|
||
const result = await hindsightRequest('POST', `/${bankId}/reflect`, { query });
|
||
|
||
return {
|
||
content: [{
|
||
type: 'text',
|
||
text: JSON.stringify({
|
||
success: true,
|
||
bank_id: bankId,
|
||
answer: result.answer || result.response || result.text || JSON.stringify(result),
|
||
citations: result.based_on || result.citations || [],
|
||
}, null, 2),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
// Tool 4: memory_compress
|
||
server.tool(
|
||
'memory_compress',
|
||
'Create compressed memory summaries (closets) from stored facts. Groups memories by room+hall ' +
|
||
'and creates AI-generated summaries with source pointers. Use when a topic has accumulated many facts.',
|
||
{
|
||
bank_id: z.string().optional().describe(`Memory bank ID (default: ${DEFAULT_BANK})`),
|
||
room: z.string().optional().describe('Topic to compress (e.g. "auth", "pipeline")'),
|
||
hall: z.string().optional().describe('Knowledge type to compress (e.g. "fact", "decision")'),
|
||
min_sources: z.number().optional().describe('Min memories needed to create a closet (default: 5)'),
|
||
query: z.string().optional().describe('Query to guide compression focus'),
|
||
},
|
||
async ({ bank_id, room, hall, min_sources, query }) => {
|
||
const bankId = bank_id || DEFAULT_BANK;
|
||
|
||
const body = {};
|
||
if (room) body.room = room;
|
||
if (hall) body.hall = hall;
|
||
if (min_sources) body.min_sources = min_sources;
|
||
if (query) body.query = query;
|
||
|
||
const result = await hindsightRequest('POST', `/${bankId}/closets`, body);
|
||
|
||
return {
|
||
content: [{
|
||
type: 'text',
|
||
text: JSON.stringify({
|
||
success: true,
|
||
bank_id: bankId,
|
||
closets_created: result.closets_created || 0,
|
||
closets: result.closets || [],
|
||
}, null, 2),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
// Tool 5: memory_bridge
|
||
server.tool(
|
||
'memory_bridge',
|
||
'Create a cross-bank memory bridge (tunnel) between two related memories in different banks. ' +
|
||
'Relations: same_concept, depends_on, contradicts, extends. Use when concepts in separate banks are related.',
|
||
{
|
||
source_bank: z.string().describe('Source bank ID'),
|
||
source_memory: z.string().describe('UUID of the source memory'),
|
||
target_bank: z.string().describe('Target bank ID'),
|
||
target_memory: z.string().describe('UUID of the target memory'),
|
||
relation: z.enum(['same_concept', 'depends_on', 'contradicts', 'extends']).describe('Relationship type'),
|
||
confidence: z.number().min(0).max(1).optional().describe('Confidence score 0.0–1.0 (default: 0.8)'),
|
||
},
|
||
async ({ source_bank, source_memory, target_bank, target_memory, relation, confidence }) => {
|
||
const body = {
|
||
source_bank,
|
||
source_memory,
|
||
target_bank,
|
||
target_memory,
|
||
relation,
|
||
};
|
||
if (confidence !== undefined) body.confidence = confidence;
|
||
|
||
const result = await hindsightRequest('POST', `/${source_bank}/tunnels`, body);
|
||
|
||
return {
|
||
content: [{
|
||
type: 'text',
|
||
text: JSON.stringify({
|
||
success: true,
|
||
tunnel: result.tunnel || result,
|
||
}, null, 2),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
// --- Resources ---
|
||
|
||
server.resource(
|
||
'memory-config',
|
||
'rcll://config',
|
||
async () => ({
|
||
contents: [{
|
||
uri: 'rcll://config',
|
||
mimeType: 'application/json',
|
||
text: JSON.stringify({
|
||
rcll_url: RCLL_URL,
|
||
hindsight_url: RCLL_URL, // legacy alias, drop after 60d
|
||
default_bank: DEFAULT_BANK,
|
||
rooms: ['auth', 'pipeline', 'schema', 'infrastructure', 'ui', 'api', 'deployment', 'monitoring', 'agent', 'general'],
|
||
halls: ['fact', 'event', 'decision', 'preference', 'discovery', 'procedure', 'warning'],
|
||
layers: {
|
||
L0: 'Identity — always loaded',
|
||
L1: 'Critical facts — per-space',
|
||
L2: 'Session context — per-conversation (default)',
|
||
L3: 'Deep memory — full search only',
|
||
},
|
||
}, null, 2),
|
||
}],
|
||
})
|
||
);
|
||
|
||
// --- Start ---
|
||
|
||
async function main() {
|
||
const transport = new StdioServerTransport();
|
||
await server.connect(transport);
|
||
if (!process.env.RCLL_BANK && !process.env.MEMPALACE_BANK) {
|
||
console.error(
|
||
`rcll-mcp: no bank configured — falling back to the pre-rebrand default '${DEFAULT_BANK}'. ` +
|
||
'Set RCLL_BANK explicitly; this fallback goes away in a future major.'
|
||
);
|
||
}
|
||
console.error(`rcll-mcp running (backend: ${RCLL_URL}, bank: ${DEFAULT_BANK})`);
|
||
}
|
||
|
||
main().catch(err => {
|
||
console.error('Fatal:', err);
|
||
process.exit(1);
|
||
});
|