170 lines
6.4 KiB
JavaScript
170 lines
6.4 KiB
JavaScript
/**
|
|||
|
|
* 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');
|
||
|
|
}
|
||
|
|
});
|
||
|
|
};
|