Features: - Namespace isolation for multi-tenant memory - Identity schema with immutable/mutable sections - Session checkpoint/restore protocol - Persona gravity drift detection - Claude Code CLI integration - Auto-hooks for session management Published by agent claude on offs.run
448 lines
16 KiB
JavaScript
448 lines
16 KiB
JavaScript
'use strict';
|
|
|
|
// Load persisted config from ~/.memory-bridge/.env (populated by auth.init on first run)
|
|
const path = require('path');
|
|
const os = require('os');
|
|
require('dotenv').config({ path: path.join(os.homedir(), '.memory-bridge', '.env') });
|
|
|
|
const express = require('express');
|
|
const auth = require('./auth');
|
|
const embedder = require('./embed');
|
|
const store = require('./store');
|
|
const identity = require('./identity');
|
|
const checkpoint = require('./checkpoint');
|
|
const gravity = require('./gravity');
|
|
|
|
const PORT = Number(process.env.MEMORY_BRIDGE_PORT) || 3722;
|
|
const VERSION = '2.0.0'; // Major version bump for PAIF multi-tenant support
|
|
|
|
// Validate that agent request matches authenticated context
|
|
function validateAgentAccess(req, res, requestedAgentId) {
|
|
const { agentId, isMaster } = req.agentContext;
|
|
|
|
// Master token can access any agent
|
|
if (isMaster) {
|
|
return true;
|
|
}
|
|
|
|
// Agent token can only access their own namespace
|
|
if (agentId !== requestedAgentId) {
|
|
res.status(403).json({
|
|
error: 'Access denied',
|
|
message: `Token for agent '${agentId}' cannot access namespace '${requestedAgentId}'`
|
|
});
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
async function main() {
|
|
auth.init();
|
|
await store.init();
|
|
const embeddingMode = await embedder.init();
|
|
|
|
const app = express();
|
|
app.use(express.json({ limit: '10mb' }));
|
|
|
|
// ── GET /health (public) ──────────────────────────────────────────────────
|
|
app.get('/health', async (_req, res) => {
|
|
try {
|
|
const memoriesCount = await store.count();
|
|
const stats = await store.stats();
|
|
res.json({
|
|
status: 'ok',
|
|
version: VERSION,
|
|
embeddingMode,
|
|
memoriesCount,
|
|
registeredAgents: stats.totalAgents,
|
|
paifMode: true
|
|
});
|
|
} catch (err) {
|
|
res.status(500).json({ status: 'error', error: err.message });
|
|
}
|
|
});
|
|
|
|
// ── POST /register-agent (master only) ──────────────────────────────────────
|
|
app.post('/register-agent', auth.middleware, (req, res) => {
|
|
if (!req.agentContext.isMaster) {
|
|
return res.status(403).json({ error: 'Only master token can register agents' });
|
|
}
|
|
|
|
const { agent_id } = req.body || {};
|
|
if (!agent_id || typeof agent_id !== 'string') {
|
|
return res.status(400).json({ error: 'agent_id is required' });
|
|
}
|
|
|
|
try {
|
|
const token = auth.registerAgent(agent_id);
|
|
res.status(201).json({
|
|
agent_id,
|
|
token,
|
|
message: `Agent '${agent_id}' registered successfully`
|
|
});
|
|
} catch (err) {
|
|
res.status(400).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ── GET /agents (master only) ───────────────────────────────────────────────
|
|
app.get('/agents', auth.middleware, (req, res) => {
|
|
if (!req.agentContext.isMaster) {
|
|
return res.status(403).json({ error: 'Only master token can list agents' });
|
|
}
|
|
res.json({ agents: auth.listAgents() });
|
|
});
|
|
|
|
// ── POST /identity (master only - create identity) ──────────────────────────
|
|
app.post('/identity', auth.middleware, (req, res) => {
|
|
if (!req.agentContext.isMaster) {
|
|
return res.status(403).json({ error: 'Only master token can create identities' });
|
|
}
|
|
|
|
const { agent_id, origin, purpose, values, voice, lineage } = req.body || {};
|
|
|
|
if (!agent_id) {
|
|
return res.status(400).json({ error: 'agent_id is required' });
|
|
}
|
|
|
|
try {
|
|
const newIdentity = identity.createIdentity(agent_id, {
|
|
createdBy: origin?.created_by || 'unknown',
|
|
purpose,
|
|
values,
|
|
voice,
|
|
lineage
|
|
});
|
|
res.status(201).json({
|
|
agent_id,
|
|
identity: newIdentity,
|
|
message: `Identity created for agent '${agent_id}'`
|
|
});
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ── GET /identity/:agent_id ─────────────────────────────────────────────────
|
|
app.get('/identity/:agent_id', auth.middleware, (req, res) => {
|
|
const { agent_id } = req.params;
|
|
|
|
if (!validateAgentAccess(req, res, agent_id)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const id = identity.loadIdentity(agent_id);
|
|
if (!id) {
|
|
return res.status(404).json({ error: `Identity not found for agent '${agent_id}'` });
|
|
}
|
|
res.json({ agent_id, identity: id });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ── POST /identity/:agent_id/validate ─────────────────────────────────────────
|
|
app.post('/identity/:agent_id/validate', auth.middleware, (req, res) => {
|
|
const { agent_id } = req.params;
|
|
const { text } = req.body || {};
|
|
|
|
if (!validateAgentAccess(req, res, agent_id)) {
|
|
return;
|
|
}
|
|
|
|
if (!text) {
|
|
return res.status(400).json({ error: 'text is required for validation' });
|
|
}
|
|
|
|
try {
|
|
const result = identity.validateAlignment(agent_id, text);
|
|
res.json({ agent_id, validation: result });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ── POST /identity/:agent_id/update ─────────────────────────────────────────
|
|
// Update mutable state (master only or own agent)
|
|
app.post('/identity/:agent_id/update', auth.middleware, (req, res) => {
|
|
const { agent_id } = req.params;
|
|
const { updates } = req.body || {};
|
|
|
|
if (!validateAgentAccess(req, res, agent_id)) {
|
|
return;
|
|
}
|
|
|
|
if (!updates) {
|
|
return res.status(400).json({ error: 'updates object is required' });
|
|
}
|
|
|
|
try {
|
|
const updated = identity.updateMutableState(agent_id, updates);
|
|
res.json({ agent_id, identity: updated, message: 'Mutable state updated' });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ── POST /checkpoint/:agent_id ───────────────────────────────────────────────
|
|
app.post('/checkpoint/:agent_id', auth.middleware, (req, res) => {
|
|
const { agent_id } = req.params;
|
|
const sessionData = req.body || {};
|
|
|
|
if (!validateAgentAccess(req, res, agent_id)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const cp = checkpoint.createCheckpoint(agent_id, sessionData);
|
|
res.status(201).json({
|
|
agent_id,
|
|
session_id: cp.metadata.session_id,
|
|
checkpointed_at: cp.metadata.ended_at,
|
|
message: 'Session checkpointed successfully'
|
|
});
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ── GET /checkpoint/:agent_id/latest ──────────────────────────────────────────
|
|
app.get('/checkpoint/:agent_id/latest', auth.middleware, (req, res) => {
|
|
const { agent_id } = req.params;
|
|
|
|
if (!validateAgentAccess(req, res, agent_id)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const cp = checkpoint.getLatestCheckpoint(agent_id);
|
|
if (!cp) {
|
|
return res.status(404).json({ error: `No checkpoint found for agent '${agent_id}'` });
|
|
}
|
|
res.json({ agent_id, checkpoint: cp });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ── POST /restore/:agent_id ──────────────────────────────────────────────────
|
|
app.post('/restore/:agent_id', auth.middleware, (req, res) => {
|
|
const { agent_id } = req.params;
|
|
|
|
if (!validateAgentAccess(req, res, agent_id)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = checkpoint.restoreSession(agent_id);
|
|
res.json({
|
|
agent_id,
|
|
restored: result.restored,
|
|
checkpoint_found: !!result.checkpoint,
|
|
identity_updated: result.identity_updated,
|
|
message: result.restored ? 'Session restored from checkpoint' : 'No checkpoint found - fresh session started'
|
|
});
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ── GET /sessions/:agent_id ───────────────────────────────────────────────────
|
|
app.get('/sessions/:agent_id', auth.middleware, (req, res) => {
|
|
const { agent_id } = req.params;
|
|
|
|
if (!validateAgentAccess(req, res, agent_id)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const checkpoints = checkpoint.listCheckpoints(agent_id);
|
|
const stats = checkpoint.getSessionStats(agent_id);
|
|
res.json({ agent_id, checkpoints, stats });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ── POST /gravity-check/:agent_id ───────────────────────────────────────────
|
|
app.post('/gravity-check/:agent_id', auth.middleware, (req, res) => {
|
|
const { agent_id } = req.params;
|
|
const { text, auto_realign = false } = req.body || {};
|
|
|
|
if (!validateAgentAccess(req, res, agent_id)) {
|
|
return;
|
|
}
|
|
|
|
if (!text) {
|
|
return res.status(400).json({ error: 'text is required for gravity check' });
|
|
}
|
|
|
|
try {
|
|
const result = gravity.gravityCheck(agent_id, text, {
|
|
autoRealign: auto_realign,
|
|
updateStats: true
|
|
});
|
|
res.json(result);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ── GET /gravity-check/:agent_id/quick ────────────────────────────────────────
|
|
app.get('/gravity-check/:agent_id/quick', auth.middleware, (req, res) => {
|
|
const { agent_id } = req.params;
|
|
const { text } = req.query;
|
|
|
|
if (!validateAgentAccess(req, res, agent_id)) {
|
|
return;
|
|
}
|
|
|
|
if (!text) {
|
|
return res.status(400).json({ error: 'text query param is required' });
|
|
}
|
|
|
|
try {
|
|
const result = gravity.quickCheck(text, agent_id);
|
|
res.json({ agent_id, ...result });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ── GET /gravity-patterns (public) ─────────────────────────────────────────────
|
|
app.get('/gravity-patterns', (_req, res) => {
|
|
res.json({
|
|
patterns: Object.keys(gravity.DRIFT_PATTERNS),
|
|
description: 'Categories of drift patterns detected by persona gravity system'
|
|
});
|
|
});
|
|
|
|
// ── Auth guard for all remaining endpoints ─────────────────────────────────
|
|
app.use(auth.middleware);
|
|
|
|
// ── POST /store ────────────────────────────────────────────────────────────
|
|
app.post('/store', async (req, res) => {
|
|
const { text, tags = [], source = '', agent_id } = req.body || {};
|
|
|
|
if (!text || typeof text !== 'string' || !text.trim()) {
|
|
return res.status(400).json({ error: '`text` is required' });
|
|
}
|
|
|
|
if (!agent_id || typeof agent_id !== 'string') {
|
|
return res.status(400).json({ error: '`agent_id` is required' });
|
|
}
|
|
|
|
if (!validateAgentAccess(req, res, agent_id)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const vector = await embedder.embed(text.trim());
|
|
const result = await store.add(agent_id, { text: text.trim(), tags, source, vector });
|
|
res.status(201).json(result);
|
|
} catch (err) {
|
|
console.error('POST /store:', err.message);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ── POST /recall ────────────────────────────────────────────────────────────
|
|
app.post('/recall', async (req, res) => {
|
|
const { query, limit = 10, tags = [], agent_id } = req.body || {};
|
|
|
|
if (!query || typeof query !== 'string' || !query.trim()) {
|
|
return res.status(400).json({ error: '`query` is required' });
|
|
}
|
|
|
|
if (!agent_id || typeof agent_id !== 'string') {
|
|
return res.status(400).json({ error: '`agent_id` is required' });
|
|
}
|
|
|
|
if (!validateAgentAccess(req, res, agent_id)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const vector = await embedder.embed(query.trim());
|
|
const memories = await store.query(agent_id, {
|
|
vector,
|
|
limit: Math.min(Number(limit) || 10, 100),
|
|
tags,
|
|
});
|
|
res.json({ memories });
|
|
} catch (err) {
|
|
console.error('POST /recall:', err.message);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ── POST /forget ──────────────────────────────────────────────────────────
|
|
app.post('/forget', async (req, res) => {
|
|
const { id, agent_id } = req.body || {};
|
|
|
|
if (!id) return res.status(400).json({ error: '`id` is required' });
|
|
if (!agent_id) return res.status(400).json({ error: '`agent_id` is required' });
|
|
|
|
if (!validateAgentAccess(req, res, agent_id)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await store.remove(agent_id, id);
|
|
res.json({ message: 'forgotten', id });
|
|
} catch (err) {
|
|
console.error('POST /forget:', err.message);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ── GET /list ──────────────────────────────────────────────────────────────
|
|
app.get('/list', async (req, res) => {
|
|
const { agent_id, page = 1, pageSize = 50 } = req.query;
|
|
|
|
if (!agent_id) return res.status(400).json({ error: '`agent_id` query param is required' });
|
|
|
|
if (!validateAgentAccess(req, res, agent_id)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await store.list(agent_id, {
|
|
page: Math.max(1, parseInt(page) || 1),
|
|
pageSize: Math.min(100, Math.max(1, parseInt(pageSize) || 50))
|
|
});
|
|
res.json(result);
|
|
} catch (err) {
|
|
console.error('GET /list:', err.message);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ── GET /stats (master only) ────────────────────────────────────────────────
|
|
app.get('/stats', auth.middleware, async (req, res) => {
|
|
if (!req.agentContext.isMaster) {
|
|
return res.status(403).json({ error: 'Only master token can view stats' });
|
|
}
|
|
try {
|
|
const stats = await store.stats();
|
|
res.json(stats);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
app.listen(PORT, () => {
|
|
console.log(`\nmemory-bridge v${VERSION} listening on http://localhost:${PORT}`);
|
|
console.log(`Embedding : ${embeddingMode}`);
|
|
console.log(`Storage : ${path.join(os.homedir(), '.memory-bridge')}`);
|
|
console.log(`PAIF Mode : Multi-tenant agent isolation enabled\n`);
|
|
});
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error('Failed to start memory-bridge:', err);
|
|
process.exit(1);
|
|
});
|