Files

170 lines
6.4 KiB
JavaScript
Raw Permalink Normal View History

/**
* OpenChamber agent memory routes.
*
* The scope is a query parameter rather than part of the path, because global
* and project memory are the same resource with two homes: one set of handlers
* that resolve `?scope=global` or `?scope=project&projectId=...`. Getting the
* scope wrong must fail loudly, never silently write the user's global memory
* from a project-scoped call.
*
* Memory is created by the agent through the `openchamber_memory` tool, so
* there is no create route here; the panel reads, corrects, and deletes.
*
* The body parser is attached per route rather than globally: the generic
* OpenCode proxy needs an unread request stream, so `core-routes` parses only
* an explicit allowlist of path prefixes. A route that forgets this sees
* `req.body` as undefined and rejects every write as a malformed body.
*/
import express from 'express';
const parseJsonBody = express.json({ limit: '1mb' });
const MEMORY_TYPES = new Set(['fact', 'preference', 'reference']);
const isObjectRecord = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const isValidationError = (error) => {
const message = error instanceof Error ? error.message : '';
return message.includes('is required')
|| message.includes('unsupported characters')
|| message.includes('holds at most');
};
const respondWithError = (res, error, fallbackMessage) => {
const message = error instanceof Error ? error.message : fallbackMessage;
if (isValidationError(error)) {
return res.status(400).json({ error: message });
}
return res.status(500).json({ error: message || fallbackMessage });
};
/**
* Resolves the target scope, or returns the reason it could not be resolved.
* A project request without an id is rejected here rather than quietly falling
* back to global, which would write project facts into every other project.
*/
const resolveScope = (query) => {
if (query.scope === 'global') {
return { target: { scope: 'global' } };
}
if (query.scope === 'project') {
if (typeof query.projectId !== 'string' || query.projectId.trim().length === 0) {
return { error: 'projectId is required for project scope' };
}
return { target: { scope: 'project', projectId: query.projectId } };
}
return { error: 'scope must be global or project' };
};
export const registerAgentMemoryRoutes = (app, dependencies) => {
const { agentMemoryRuntime, isAgentMemoryEnabled } = dependencies;
/**
* One gate for the whole surface. The settings toggle disables the feature,
* not just its UI: with memory off, these routes must not read or write the
* store at all, or a stale client would keep editing memory the user believes
* is turned off.
*/
const requireEnabled = async (_req, res, next) => {
if (!isAgentMemoryEnabled) {
return next();
}
try {
// Awaited: the setting is read from disk, and testing the returned
// promise for truthiness would leave the gate permanently open.
if (!(await isAgentMemoryEnabled())) {
// Flagged, not merely 404: a missing entry answers 404 too, and a
// client that could not tell them apart would report a deleted memory
// as the whole feature being switched off.
return res.status(404).json({ error: 'Agent memory is disabled', disabled: true });
}
} catch {
// An unreadable settings file must not silently expose a surface the
// user may have turned off.
return res.status(503).json({ error: 'Agent memory availability is unknown' });
}
return next();
};
app.get('/api/agent-memory', requireEnabled, async (req, res) => {
const { target, error } = resolveScope(req.query);
if (error) {
return res.status(400).json({ error });
}
try {
return res.json(await agentMemoryRuntime.read(target));
} catch (caught) {
return respondWithError(res, caught, 'Failed to read agent memory');
}
});
/**
* Both scopes in one response. The panel always shows them together, and two
* separate requests would let one scope render while the other is still
* loading, which reads as memory that has gone missing.
*/
app.get('/api/agent-memory/all', requireEnabled, async (req, res) => {
const projectId = typeof req.query.projectId === 'string' && req.query.projectId.trim().length > 0
? req.query.projectId
: null;
try {
return res.json(await agentMemoryRuntime.readAll(projectId));
} catch (caught) {
return respondWithError(res, caught, 'Failed to read agent memory');
}
});
app.patch('/api/agent-memory/:memoryId', requireEnabled, parseJsonBody, async (req, res) => {
const { target, error } = resolveScope(req.query);
if (error) {
return res.status(400).json({ error });
}
const body = req.body;
if (!isObjectRecord(body)) {
return res.status(400).json({ error: 'Body must be an object' });
}
if (body.title !== undefined && typeof body.title !== 'string') {
return res.status(400).json({ error: 'title must be a string' });
}
if (body.body !== undefined && typeof body.body !== 'string') {
return res.status(400).json({ error: 'body must be a string' });
}
if (body.type !== undefined && !MEMORY_TYPES.has(body.type)) {
return res.status(400).json({ error: 'type must be fact, preference, or reference' });
}
try {
const result = await agentMemoryRuntime.update(target, req.params.memoryId, {
...(body.title !== undefined ? { title: body.title } : {}),
...(body.body !== undefined ? { body: body.body } : {}),
...(body.type !== undefined ? { type: body.type } : {}),
});
if (!result) {
return res.status(404).json({ error: 'Memory not found' });
}
return res.json(result);
} catch (caught) {
return respondWithError(res, caught, 'Failed to save memory');
}
});
app.delete('/api/agent-memory/:memoryId', requireEnabled, async (req, res) => {
const { target, error } = resolveScope(req.query);
if (error) {
return res.status(400).json({ error });
}
try {
const result = await agentMemoryRuntime.remove(target, req.params.memoryId);
if (!result.deleted) {
return res.status(404).json({ error: 'Memory not found' });
}
return res.json(result);
} catch (caught) {
return respondWithError(res, caught, 'Failed to delete memory');
}
});
};