godcrm/backend/services/agent-tools/memory-tools.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

236 lines
8.2 KiB
JavaScript

/**
* Memory Tool Handlers — Hindsight integration
*
* Handles: memory_retain, memory_recall, memory_reflect, memory_bridge
* Proxies to Hindsight API at localhost:5100
*/
import { aiLogger } from '../../utils/logger.js';
import { routeMemoryScope, ROUTE_CONFIDENCE_FLOOR } from '../dcd/scopeRouter.js';
// Origin is env-overridable so the same code works bare-metal (default
// localhost:5100) and in containers/compose (e.g. HINDSIGHT_URL=http://hindsight:5100).
const HINDSIGHT_ORIGIN = (process.env.HINDSIGHT_URL || 'http://127.0.0.1:5100').replace(/\/+$/, '');
const HINDSIGHT_BASE = `${HINDSIGHT_ORIGIN}/v1/default/banks`;
const DEFAULT_BANK = 'godcrm-main';
/**
* Make a request to Hindsight API
*/
async function hindsightRequest(method, path, body = null) {
const url = `${HINDSIGHT_BASE}${path}`;
const opts = {
method,
headers: { 'Content-Type': 'application/json' },
};
if (body) opts.body = JSON.stringify(body);
const res = await fetch(url, opts);
// Body may not be JSON (e.g. a 500/502 HTML page from the docker proxy or a
// crashed worker). Parse defensively so a non-JSON error body doesn't surface
// as an opaque SyntaxError.
const raw = await res.text();
let data;
try {
data = raw ? JSON.parse(raw) : {};
} catch {
data = { detail: raw };
}
if (!res.ok) {
throw new Error(`Hindsight API ${res.status}: ${formatHindsightDetail(data, res.status)}`);
}
return data;
}
/**
* Turn a Hindsight/FastAPI error body into a human-readable string.
* FastAPI returns 422 validation errors as `detail: [{loc, msg, type}, ...]`,
* which naively stringify to "[object Object]" — the bug this guards against.
*/
function formatHindsightDetail(data, status) {
const detail = data?.detail;
if (typeof detail === 'string' && detail) return detail;
if (Array.isArray(detail)) {
return detail
.map((e) => {
const loc = Array.isArray(e?.loc) ? e.loc.join('.') : '';
const msg = e?.msg || e?.type || JSON.stringify(e);
return loc ? `${loc}: ${msg}` : msg;
})
.join('; ');
}
if (detail && typeof detail === 'object') return JSON.stringify(detail);
if (typeof data?.error === 'string' && data.error) return data.error;
return `request failed (HTTP ${status})`;
}
export const memoryToolHandlers = {
/**
* Save facts/observations to long-term memory.
*/
async memory_retain({ text, content, bank_id, context: ctx, document_id, tags, room, hall, layer }, userId, context = {}) {
const bankId = bank_id || DEFAULT_BANK;
// The memorized string. Hindsight's own field is `content`, so agents
// routinely pass it under that name instead of the tool's `text` — that
// mismatch shipped an empty item and 422'd as `body.items.0.content:
// Field required`. Accept either, and fail fast locally on empty so we
// never round-trip an empty item to the API.
const memorized = (text ?? content ?? '').toString().trim();
if (!memorized) {
throw new Error('memory_retain: `text` is required and must be a non-empty string (the fact/observation to memorize).');
}
const item = { content: memorized };
if (ctx) item.context = ctx;
if (document_id) item.document_id = document_id;
if (tags && Array.isArray(tags)) item.tags = tags;
if (room) item.room = room;
if (hall) item.hall = hall;
if (layer) item.layer = layer;
aiLogger.info({ bankId, textLen: memorized.length, room, hall, layer, agent: context.agentName }, 'memory_retain');
const result = await hindsightRequest('POST', `/${bankId}/memories`, {
items: [item],
});
const storedIds = (result.items || []).map(i => i.id || i.uuid).filter(Boolean);
return {
success: true,
bank_id: bankId,
items_stored: result.items_count || 1,
ids: storedIds.length ? storedIds : null,
usage: result.usage || null,
};
},
/**
* Search long-term memory for relevant facts.
*/
async memory_recall({ query, bank_id, limit, room, hall, max_layer, auto_scope }, userId, context = {}) {
const bankId = bank_id || DEFAULT_BANK;
// ADR-157 DCD scope-router (Collection step). Opt-in via `auto_scope`, and
// ONLY when the caller did not already scope the call — an explicit `room`
// is always honoured (skip-router for already-scoped retrieval). A confident
// route narrows BEFORE the search (+34% precision); a low-confidence route
// widens to the domain (no room) rather than risk mis-scoping.
let routedRoom = room;
let routed = null;
if (auto_scope && !room && query) {
routed = routeMemoryScope(query);
if (routed.confidence >= ROUTE_CONFIDENCE_FLOOR) routedRoom = routed.room;
aiLogger.info(
{ bankId, query, routed, applied: routedRoom || null, agent: context.agentName },
'memory_recall scope-router'
);
}
const recall = async (roomArg) => {
aiLogger.info({ bankId, query, room: roomArg, hall, max_layer, agent: context.agentName }, 'memory_recall');
const body = { query, limit: limit || 10 };
if (roomArg) body.room = Array.isArray(roomArg) ? roomArg : [roomArg];
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);
return (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,
}));
};
let memories = await recall(routedRoom);
// Widen-by-one-level fallback (ADR-157): a routed room that returns nothing
// drops the Collection predicate and retries at the domain level, so the
// router can never bury results that an unscoped search would have found.
if (routed && routedRoom && memories.length === 0) {
aiLogger.info({ bankId, room: routedRoom, agent: context.agentName }, 'memory_recall widen: routed room empty → drop room');
memories = await recall(undefined);
}
return {
success: true,
bank_id: bankId,
count: memories.length,
memories,
};
},
/**
* Create a cross-bank memory bridge (tunnel) between two related memories.
*/
async memory_bridge({ source_bank, source_memory, target_bank, target_memory, relation, confidence }, userId, context = {}) {
aiLogger.info({ source_bank, target_bank, relation, agent: context.agentName }, 'memory_bridge');
const body = {
source_bank,
source_memory,
target_bank,
target_memory,
relation,
};
if (confidence !== undefined) body.confidence = confidence;
if (context.agentName) body.created_by = context.agentName;
const result = await hindsightRequest('POST', `/${source_bank}/tunnels`, body);
return {
success: true,
tunnel: result.tunnel,
};
},
/**
* Deep reasoning over memory — synthesize patterns and insights.
*/
async memory_reflect({ query, bank_id }, userId, context = {}) {
const bankId = bank_id || DEFAULT_BANK;
aiLogger.info({ bankId, query, agent: context.agentName }, 'memory_reflect');
const result = await hindsightRequest('POST', `/${bankId}/reflect`, {
query,
});
return {
success: true,
bank_id: bankId,
answer: result.answer || result.response || result.text || JSON.stringify(result),
citations: result.based_on || result.citations || [],
};
},
/**
* Create compressed memory summaries (closets) from stored facts.
*/
async memory_compress({ bank_id, room, hall, min_sources, query }, userId, context = {}) {
const bankId = bank_id || DEFAULT_BANK;
aiLogger.info({ bankId, room, hall, min_sources, agent: context.agentName }, 'memory_compress');
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 {
success: true,
bank_id: bankId,
closets_created: result.closets_created || 0,
closets: result.closets || [],
};
},
};