Files
openchamber/packages/web/server/lib/agent-memory/routes.js
T
Bohdan Triapitsyn 34e8a24b20 feat(knowledge): rebuild the project notes panel as Project knowledge (#2973)
The panel stored notes, todos and plans inside one shared JSON file that
six unrelated domains also wrote to, synchronised itself through window
CustomEvents, and could only read plans. It is now Project knowledge:
server-owned storage with explicit routes, a store with rollback, a
section sidebar, plans that open and edit in place, and search across
all of it.

Notes and plans the user pins travel with every message sent in that
project. Pinning is project state, not an attachment to one message, so
it holds until unpinned and the work status panel names what is riding
along and can detach it.

Agent memory is added alongside, in two scopes: what is true about the
user, and what is true about this codebase. The split is not cosmetic —
a wrong project fact costs one project and is noticed, while a wrong
global fact quietly shapes every session everywhere and the user has no
code to check it against. It stays separate from notes so an agent
mistake cannot land in what the user wrote. Sessions receive an index of
titles only; bodies are read on demand, because an index carrying full
text grows until it crowds out the conversation.

Deciding what a session must be told, and whether it has been told, now
lives on the server. The client owned it before, which meant sessions
started without a UI — scheduled tasks, sessions the agent dispatches —
received nothing at all, and a tab's record of what it had sent outlived
the conversation: after compaction the agent no longer held the block
while the tab went on believing it did. What was delivered is recorded
in the session's own metadata, and compaction restores it through the
runtime that already restores pinned messages, in the same turn.

Agent memory ships dark behind OPENCHAMBER_MEMORY_ENABLE: unset, there
is no tool, no routes, no session index, no settings row and no panel
tab. Absent rather than switched off, so nothing invites turning on a
feature that has not been announced. Pinned notes and plans are
unaffected and ship as normal.
2026-08-18 02:59:04 +03:00

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');
}
});
};