Files
memind/mcp-result-compactor.mjs
T
john 60a428612c
Memind CI / Test, build, and release guards (push) Has been cancelled
feat(context): add MCP result compactor for tool output shadow path
Introduce off/shadow/active compaction for large tkmind read/research and
excel report payloads with optional Redis-backed ctx_fetch handles, keeping
fail-open behavior when storage is unavailable.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 22:05:02 +08:00

274 lines
7.8 KiB
JavaScript

import crypto from 'node:crypto';
export const MCP_COMPACT_MODES = Object.freeze(['off', 'shadow', 'active']);
export const DEFAULT_MCP_COMPACT_BUDGET_CHARS = 4000;
export const DEFAULT_MCP_COMPACT_TTL_SECONDS = 3600;
export const MCP_COMPACT_HANDLE_PREFIX = 'mcpctx';
const memoryEntries = new Map();
function normalizeMode(value, fallback = 'off') {
const raw = String(value ?? fallback).trim().toLowerCase();
return MCP_COMPACT_MODES.includes(raw) ? raw : fallback;
}
export function resolveMcpCompactMode(env = process.env) {
return normalizeMode(env.MEMIND_MCP_COMPACT_MODE, 'off');
}
export function isMcpCompactActive(env = process.env) {
return resolveMcpCompactMode(env) === 'active';
}
export function resolveMcpCompactBudgetChars(env = process.env) {
const raw = Number(env.MEMIND_MCP_COMPACT_BUDGET_CHARS ?? DEFAULT_MCP_COMPACT_BUDGET_CHARS);
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_MCP_COMPACT_BUDGET_CHARS;
}
export function resolveMcpCompactTtlSeconds(env = process.env) {
const raw = Number(env.MEMIND_MCP_COMPACT_TTL_SECONDS ?? DEFAULT_MCP_COMPACT_TTL_SECONDS);
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_MCP_COMPACT_TTL_SECONDS;
}
function byteLength(text) {
return Buffer.byteLength(String(text ?? ''), 'utf8');
}
function normalizePayload(payload) {
if (payload == null) return '';
if (typeof payload === 'string') return payload;
try {
return JSON.stringify(payload);
} catch {
return String(payload);
}
}
export function buildCompactSummary(text, budgetChars) {
const raw = String(text ?? '');
const budget = Math.max(200, Number(budgetChars) || DEFAULT_MCP_COMPACT_BUDGET_CHARS);
if (raw.length <= budget) return raw;
const markerReserve = 96;
const bodyBudget = Math.max(160, budget - markerReserve);
const headLen = Math.floor(bodyBudget * 0.62);
const tailLen = Math.max(48, bodyBudget - headLen);
const omitted = Math.max(0, raw.length - headLen - tailLen);
return [
raw.slice(0, headLen),
'',
`...[memind compact omitted ${omitted} chars]...`,
'',
raw.slice(-tailLen),
].join('\n');
}
function formatCompactEnvelope({ summary, handle, stats }) {
const savedPct = Math.round((stats.savedRatio ?? 0) * 100);
return [
summary,
'',
`[memind_ctx handle=${handle} saved=${savedPct}% raw_bytes=${stats.rawBytes}; use ctx_fetch to retrieve full payload]`,
].join('\n');
}
export function createMemoryCompactStore({ now = Date.now } = {}) {
return {
kind: 'memory',
async put(text, { kind = 'generic', ttlSeconds = DEFAULT_MCP_COMPACT_TTL_SECONDS } = {}) {
const handle = `${MCP_COMPACT_HANDLE_PREFIX}_${crypto.randomBytes(8).toString('hex')}`;
const expiresAt = now() + ttlSeconds * 1000;
memoryEntries.set(handle, {
text: String(text ?? ''),
kind,
expiresAt,
});
return handle;
},
async get(handle) {
const entry = memoryEntries.get(String(handle ?? '').trim());
if (!entry) return null;
if (entry.expiresAt <= now()) {
memoryEntries.delete(String(handle ?? '').trim());
return null;
}
return entry.text;
},
async clear() {
memoryEntries.clear();
},
};
}
let defaultStore = createMemoryCompactStore();
let redisStorePromise = null;
export function resetMcpCompactStoresForTests() {
memoryEntries.clear();
defaultStore = createMemoryCompactStore();
redisStorePromise = null;
}
export function setDefaultMcpCompactStore(store) {
defaultStore = store ?? createMemoryCompactStore();
}
async function resolveDefaultStore(env = process.env) {
const redisUrl = String(env.MEMIND_RUNTIME_REDIS_URL ?? env.REDIS_URL ?? '').trim();
if (!redisUrl) return defaultStore;
if (!redisStorePromise) {
redisStorePromise = createRedisCompactStore({ url: redisUrl, env }).catch(() => defaultStore);
}
return redisStorePromise;
}
export async function createRedisCompactStore({
url,
env = process.env,
keyPrefix = 'memind:mcp-compact:',
} = {}) {
const { createClient } = await import('redis');
const client = createClient({ url });
client.on('error', () => {});
await client.connect();
const ttlSeconds = resolveMcpCompactTtlSeconds(env);
return {
kind: 'redis',
async put(text, { kind = 'generic', ttlSeconds: itemTtl = ttlSeconds } = {}) {
const handle = `${MCP_COMPACT_HANDLE_PREFIX}_${crypto.randomBytes(8).toString('hex')}`;
const key = `${keyPrefix}${handle}`;
await client.set(key, JSON.stringify({ text: String(text ?? ''), kind }), {
EX: itemTtl,
});
return handle;
},
async get(handle) {
const key = `${keyPrefix}${String(handle ?? '').trim()}`;
const raw = await client.get(key);
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
return parsed?.text ?? null;
} catch {
return null;
}
},
};
}
export async function compact(payload, {
kind = 'generic',
budget,
mode,
env = process.env,
store,
} = {}) {
const resolvedMode = normalizeMode(mode ?? resolveMcpCompactMode(env), 'off');
const resolvedBudget = budget ?? resolveMcpCompactBudgetChars(env);
const text = normalizePayload(payload);
const rawBytes = byteLength(text);
if (resolvedMode === 'off' || text.length <= resolvedBudget) {
return {
text,
compacted: false,
mode: resolvedMode,
stats: {
rawBytes,
sentBytes: rawBytes,
savedRatio: 0,
kind,
},
};
}
const summary = buildCompactSummary(text, resolvedBudget);
const summaryBytes = byteLength(summary);
const stats = {
rawBytes,
sentBytes: summaryBytes,
savedRatio: rawBytes > 0 ? Number(((rawBytes - summaryBytes) / rawBytes).toFixed(4)) : 0,
kind,
mode: resolvedMode,
};
if (resolvedMode === 'shadow') {
return {
text,
compacted: false,
mode: 'shadow',
stats,
shadowSummary: summary,
};
}
const resolvedStore = store ?? await resolveDefaultStore(env);
let handle = null;
try {
handle = await resolvedStore.put(text, {
kind,
ttlSeconds: resolveMcpCompactTtlSeconds(env),
});
} catch {
handle = null;
}
if (!handle) {
return {
text,
compacted: false,
mode: 'active',
stats,
fallback: 'store_unavailable',
};
}
const compactText = formatCompactEnvelope({ summary, handle, stats });
return {
text: compactText,
compacted: true,
mode: 'active',
handle,
stats: {
...stats,
sentBytes: byteLength(compactText),
savedRatio: rawBytes > 0
? Number(((rawBytes - byteLength(compactText)) / rawBytes).toFixed(4))
: 0,
},
};
}
export async function applyMcpCompaction(payload, { kind = 'generic', env = process.env, store } = {}) {
return compact(payload, { kind, env, store });
}
export async function fetchCompactPayload(handle, { env = process.env, store } = {}) {
const normalized = String(handle ?? '').trim();
if (!normalized.startsWith(`${MCP_COMPACT_HANDLE_PREFIX}_`)) {
return { ok: false, message: 'invalid handle' };
}
const resolvedStore = store ?? await resolveDefaultStore(env);
try {
const text = await resolvedStore.get(normalized);
if (!text) return { ok: false, message: 'handle expired or not found' };
return { ok: true, text, handle: normalized };
} catch (err) {
return {
ok: false,
message: err instanceof Error ? err.message : String(err),
};
}
}
export const ctxFetchToolDefinition = {
name: 'ctx_fetch',
description: 'Retrieve the full MCP tool payload previously compacted by Memind context runtime.',
inputSchema: {
type: 'object',
properties: {
handle: { type: 'string', description: 'Handle emitted by memind_ctx compact envelope.' },
},
required: ['handle'],
},
};