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.
238 lines
8.3 KiB
JavaScript
238 lines
8.3 KiB
JavaScript
/**
|
|
* OpenChamber project context routes: notes, todos, and plan files.
|
|
*
|
|
* These replace the shared UI's direct `/api/fs/*` access to
|
|
* `~/.config/openchamber/projects/*`. The client no longer resolves the home
|
|
* directory or composes storage paths, and plan markdown is addressed by id
|
|
* rather than by an absolute path supplied by the caller.
|
|
*
|
|
* Body parsing is attached per route. There is no global JSON parser: the
|
|
* generic OpenCode proxy needs an unread request stream, so `core-routes`
|
|
* parses only an explicit allowlist of path prefixes and leaves every other
|
|
* `/api` request untouched. 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 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');
|
|
};
|
|
|
|
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 });
|
|
};
|
|
|
|
const isValidNoteSource = (value) => value === 'manual' || value === 'selection' || value === 'agent';
|
|
|
|
const hasValidTodosShape = (value) => (
|
|
Array.isArray(value)
|
|
&& value.every((todo) => (
|
|
isObjectRecord(todo)
|
|
&& typeof todo.id === 'string'
|
|
&& typeof todo.text === 'string'
|
|
&& (todo.completed === undefined || typeof todo.completed === 'boolean')
|
|
&& (todo.createdAt === undefined || (typeof todo.createdAt === 'number' && Number.isFinite(todo.createdAt)))
|
|
))
|
|
);
|
|
|
|
export const registerProjectContextRoutes = (app, dependencies) => {
|
|
const { projectContextRuntime } = dependencies;
|
|
|
|
app.get('/api/project-context/:projectId', async (req, res) => {
|
|
try {
|
|
return res.json(await projectContextRuntime.readContext(req.params.projectId));
|
|
} catch (error) {
|
|
return respondWithError(res, error, 'Failed to read project context');
|
|
}
|
|
});
|
|
|
|
app.put('/api/project-context/:projectId/todos', parseJsonBody, async (req, res) => {
|
|
const body = req.body;
|
|
if (!isObjectRecord(body)) {
|
|
return res.status(400).json({ error: 'Body must be an object' });
|
|
}
|
|
if (!hasValidTodosShape(body.todos)) {
|
|
return res.status(400).json({ error: 'todos must be an array of todo items' });
|
|
}
|
|
|
|
try {
|
|
return res.json(await projectContextRuntime.saveTodos(req.params.projectId, body.todos));
|
|
} catch (error) {
|
|
return respondWithError(res, error, 'Failed to save project todos');
|
|
}
|
|
});
|
|
|
|
app.post('/api/project-context/:projectId/notes', parseJsonBody, async (req, res) => {
|
|
const body = req.body;
|
|
if (!isObjectRecord(body)) {
|
|
return res.status(400).json({ error: 'Body must be an object' });
|
|
}
|
|
if (typeof body.body !== 'string') {
|
|
return res.status(400).json({ error: 'body must be a string' });
|
|
}
|
|
if (body.source !== undefined && !isValidNoteSource(body.source)) {
|
|
return res.status(400).json({ error: 'source must be manual, selection, or agent' });
|
|
}
|
|
if (body.origin !== undefined && !isObjectRecord(body.origin)) {
|
|
return res.status(400).json({ error: 'origin must be an object' });
|
|
}
|
|
|
|
try {
|
|
const { note, context } = await projectContextRuntime.createNote(req.params.projectId, {
|
|
body: body.body,
|
|
source: body.source,
|
|
origin: body.origin,
|
|
});
|
|
return res.status(201).json({ note, context });
|
|
} catch (error) {
|
|
return respondWithError(res, error, 'Failed to create note');
|
|
}
|
|
});
|
|
|
|
app.patch('/api/project-context/:projectId/notes/:noteId', parseJsonBody, async (req, res) => {
|
|
const body = req.body;
|
|
if (!isObjectRecord(body)) {
|
|
return res.status(400).json({ error: 'Body must be an object' });
|
|
}
|
|
if (body.body !== undefined && typeof body.body !== 'string') {
|
|
return res.status(400).json({ error: 'body must be a string' });
|
|
}
|
|
if (body.pinned !== undefined && typeof body.pinned !== 'boolean') {
|
|
return res.status(400).json({ error: 'pinned must be a boolean' });
|
|
}
|
|
|
|
try {
|
|
const result = await projectContextRuntime.updateNote(req.params.projectId, req.params.noteId, {
|
|
...(body.body !== undefined ? { body: body.body } : {}),
|
|
...(body.pinned !== undefined ? { pinned: body.pinned } : {}),
|
|
});
|
|
if (!result) {
|
|
return res.status(404).json({ error: 'Note not found' });
|
|
}
|
|
return res.json(result);
|
|
} catch (error) {
|
|
return respondWithError(res, error, 'Failed to save note');
|
|
}
|
|
});
|
|
|
|
app.delete('/api/project-context/:projectId/notes/:noteId', async (req, res) => {
|
|
try {
|
|
const { deleted, context } = await projectContextRuntime.deleteNote(
|
|
req.params.projectId,
|
|
req.params.noteId,
|
|
);
|
|
if (!deleted) {
|
|
return res.status(404).json({ error: 'Note not found' });
|
|
}
|
|
return res.json(context);
|
|
} catch (error) {
|
|
return respondWithError(res, error, 'Failed to delete note');
|
|
}
|
|
});
|
|
|
|
app.patch('/api/project-context/:projectId/plans/:planId', parseJsonBody, async (req, res) => {
|
|
const body = req.body;
|
|
if (!isObjectRecord(body) || typeof body.pinned !== 'boolean') {
|
|
return res.status(400).json({ error: 'pinned must be a boolean' });
|
|
}
|
|
|
|
try {
|
|
const result = await projectContextRuntime.setPlanPinned(
|
|
req.params.projectId,
|
|
req.params.planId,
|
|
body.pinned,
|
|
);
|
|
if (!result) {
|
|
return res.status(404).json({ error: 'Plan not found' });
|
|
}
|
|
return res.json(result);
|
|
} catch (error) {
|
|
return respondWithError(res, error, 'Failed to update plan');
|
|
}
|
|
});
|
|
|
|
app.get('/api/project-context/:projectId/plans/:planId', async (req, res) => {
|
|
try {
|
|
const plan = await projectContextRuntime.readPlan(req.params.projectId, req.params.planId);
|
|
if (!plan) {
|
|
return res.status(404).json({ error: 'Plan not found' });
|
|
}
|
|
return res.json(plan);
|
|
} catch (error) {
|
|
return respondWithError(res, error, 'Failed to read plan');
|
|
}
|
|
});
|
|
|
|
app.put('/api/project-context/:projectId/plans/:planId', parseJsonBody, async (req, res) => {
|
|
const body = req.body;
|
|
if (!isObjectRecord(body)) {
|
|
return res.status(400).json({ error: 'Body must be an object' });
|
|
}
|
|
if (typeof body.raw !== 'string') {
|
|
return res.status(400).json({ error: 'raw must be a string' });
|
|
}
|
|
|
|
try {
|
|
const result = await projectContextRuntime.updatePlan(
|
|
req.params.projectId,
|
|
req.params.planId,
|
|
{ raw: body.raw },
|
|
);
|
|
if (!result) {
|
|
return res.status(404).json({ error: 'Plan not found' });
|
|
}
|
|
return res.json(result);
|
|
} catch (error) {
|
|
return respondWithError(res, error, 'Failed to save plan');
|
|
}
|
|
});
|
|
|
|
app.post('/api/project-context/:projectId/plans', parseJsonBody, async (req, res) => {
|
|
const body = req.body;
|
|
if (!isObjectRecord(body)) {
|
|
return res.status(400).json({ error: 'Body must be an object' });
|
|
}
|
|
if (typeof body.body !== 'string') {
|
|
return res.status(400).json({ error: 'body must be a string' });
|
|
}
|
|
if (body.title !== undefined && typeof body.title !== 'string') {
|
|
return res.status(400).json({ error: 'title must be a string' });
|
|
}
|
|
|
|
try {
|
|
const { plan, context } = await projectContextRuntime.createPlan(req.params.projectId, {
|
|
title: body.title ?? '',
|
|
body: body.body,
|
|
});
|
|
return res.status(201).json({ plan, context });
|
|
} catch (error) {
|
|
return respondWithError(res, error, 'Failed to create plan');
|
|
}
|
|
});
|
|
|
|
app.delete('/api/project-context/:projectId/plans/:planId', async (req, res) => {
|
|
try {
|
|
const { deleted, context } = await projectContextRuntime.deletePlan(
|
|
req.params.projectId,
|
|
req.params.planId,
|
|
);
|
|
if (!deleted) {
|
|
return res.status(404).json({ error: 'Plan not found' });
|
|
}
|
|
return res.json(context);
|
|
} catch (error) {
|
|
return respondWithError(res, error, 'Failed to delete plan');
|
|
}
|
|
});
|
|
};
|