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.
This commit is contained in:
Bohdan Triapitsyn
2026-08-18 02:59:04 +03:00
committed by GitHub
parent 7611076436
commit 34e8a24b20
102 changed files with 10640 additions and 1630 deletions
@@ -0,0 +1,157 @@
# Project Context
Server-owned storage for the Project Notes surface: free-form notes, todos, and
plan markdown files.
## Ownership
| Path | Owner | Contents |
|---|---|---|
| `<projectsDir>/<projectId>.json` | shared UI (`packages/ui/src/lib/openchamberConfig.ts`), plus server-owned `version` / `scheduledTasks` | worktree setup, draft starters, project actions |
| `<projectsDir>/<projectId>/context.json` | **this module, exclusively** | notes, todos, plan manifest |
| `<projectsDir>/<projectId>/plans/*.md` | **this module, exclusively** | plan bodies |
The split is the point. Both files were previously one, written by the client
with a whole-file read-modify-write. Adding a server writer to that file would
have made unrelated features (project actions, draft starters) clobber notes
across processes, with no lock able to span both sides. Separate files remove
the shared resource instead of trying to coordinate access to it.
Nothing outside this module may write `context.json` or the `plans` directory.
## Storage format
```json
{
"version": 2,
"notes": [{
"id": "", "body": "", "createdAt": 0, "updatedAt": 0,
"source": "manual | selection | agent",
"pinned": false,
"origin": { "sessionId": "", "messageId": "" }
}],
"todos": [{ "id": "", "text": "", "completed": false, "createdAt": 0 }],
"plans": [{ "id": "", "file": "1700000000-title.md", "title": "", "createdAt": 0, "pinned": false }]
}
```
Notes are entries, not one blob. Version 1 stored a single string; it converts
to a single `manual` note on read (an empty string converts to no notes at
all). The conversion lives in the read path rather than a separate migration
pass so that every reader — including one racing a writer — sees one shape.
`source` records where a note came from, and `origin` links it back to the
message it was distilled from, so a note taken off a chat selection can be
traced to its conversation.
Notes and todos are written through separate routes. That split is what stops a
todo toggle from persisting half-typed notes alongside it, and stops an
agent-authored note from clobbering a concurrent todo change.
Plan links store a **base name**, never a path. The file always lives in
`<projectId>/plans/`, so moving the project storage directory cannot invalidate
a reference and a caller can never address a file outside it. `title` is
denormalized into the manifest so listing plans costs one read rather than one
read per plan; `readPlan` returns the title parsed from the file, which wins if
the two ever disagree.
## Routes
| Method | Route | Notes |
|---|---|---|
| GET | `/api/project-context/:projectId` | full context; missing file is `200` empty |
| PUT | `/api/project-context/:projectId/todos` | replaces the whole list; returns committed context |
| POST | `/api/project-context/:projectId/notes` | `201`; takes `{body, source?, origin?}` |
| PATCH | `/api/project-context/:projectId/notes/:noteId` | patches `body` and/or `pinned`; `404` when unknown |
| DELETE | `/api/project-context/:projectId/notes/:noteId` | `404` when unknown |
| PATCH | `/api/project-context/:projectId/plans/:planId` | pin state only; `404` when unknown |
| GET | `/api/project-context/:projectId/plans/:planId` | `404` when the link or its markdown is gone |
| POST | `/api/project-context/:projectId/plans` | `201`; takes `{title, body}`, never a path |
| PUT | `/api/project-context/:projectId/plans/:planId` | takes the whole `{raw}` document; `404` when the link or its markdown is gone |
| DELETE | `/api/project-context/:projectId/plans/:planId` | `404` when unknown |
**Body parsing is attached per route.** This server has no global JSON parser:
`core-routes` parses only an allowlist of `/api` path prefixes so the generic
OpenCode proxy keeps an unread request stream, and every other `/api` request
passes through untouched. A write route that forgets `express.json()` therefore
sees `req.body` as `undefined` and rejects every request as a malformed body —
which is exactly how this shipped once. `routes.http.test.js` mounts the routes
on a bare express app so that failure mode fails the suite instead of the user.
`projectId` is validated against `/^[a-zA-Z0-9._:-]+$/`, which rejects
separators and traversal. Validation failures are `400`; malformed stored data
and I/O failures are `500`.
## Invariants
- **Missing is not malformed.** A missing `context.json` is authoritative empty
data. Unparseable JSON is a failure that propagates as `500`, so the client
preserves what it already has instead of rendering an empty panel over intact
data on disk.
- **Writes are serialized per project** through an in-process lock, and land via
write-to-temp + rename so a crash cannot leave a half-written file.
- **`readContext` never takes the lock.** Every mutator calls it while already
holding the lock, so locking there would deadlock. The legacy migration it can
trigger is safe unlocked: both writes are atomic renames of identical content.
- **Plan create writes markdown before the manifest entry**; delete removes the
manifest entry before the file. Either partial failure leaves an unreferenced
markdown file, which is inert. The reverse order would leave a manifest entry
that renders as a plan and fails to open.
- **Plan update takes the raw document, not title + body.** The editor owns
the file verbatim; reassembling it from parsed parts would rewrite the
heading and reformat what the user typed. The manifest title is re-derived
from the saved content, and the file name never changes with the title — it
is the stable identity behind the link.
- **Plan update refuses to recreate a deleted file.** If the markdown vanished
underneath an open editor the link is already dead; writing would resurrect
content the user believes was discarded, so it returns `404` instead.
- **A note patch touches only the fields it names.** Pinning sends `pinned`
alone, so it cannot roll back an edit that landed between the two requests,
and editing does not reset a pin. Editing bumps `updatedAt`; pinning does not,
because a pin is not a change to what the note says.
- **A note body can be clamped but never blanked.** An empty body is rejected
rather than stored, since a note with nothing in it is indistinguishable from
a delete the user did not ask for.
- **Notes are capped at 200 per project.** Past that, creation fails loudly
instead of silently evicting the oldest entry.
- **Per-entry sanitization never fails the whole read.** A malformed todo or
plan link is dropped; the rest of the context still loads.
## Legacy migration
`projectNotes`, `projectTodos`, and `projectPlanFiles` originally lived in
`<projectId>.json`. On the first read with no `context.json`, those three keys
are moved out and deleted from the client-owned file; every other key is
preserved untouched.
Plan links carried absolute paths. Migration converts each to a base name. A
file already in the plans directory is used in place; one referenced from
elsewhere — a stale path left by an earlier project id — is copied in rather
than dropped. A link whose markdown cannot be found at all is discarded, since
it could not have been opened either way.
The legacy keys are removed only after `context.json` is durably written, so any
failure simply leaves the migration to run again on the next read. Repeat and
concurrent reads converge on identical content.
## Cross-module contract
`packages/web/server/lib/opencode/settings-runtime.js` merges project storage
when a project id changes. Its `mergeProjectContextFiles` step must run before
`moveDirectoryContents`, because that mover only renames into a free
destination and would otherwise discard the old `context.json` whenever the
destination already had one.
`mergeProjectContextFiles` merges every list by identity and deliberately does
not convert a version 1 string note: this module owns that conversion, and
doing it in two places would mean two definitions of the same migration.
`mergeProjectConfigData` still merges the legacy `projectNotes` /
`projectTodos` / `projectPlanFiles` keys. That is deliberate: a project whose
context has not been migrated yet keeps its data in `<projectId>.json`, and the
migration picks it up from the merged destination afterwards.
## Tests
- `runtime.test.js` — storage, sanitization, migration, locking, plan lifecycle.
- `routes.test.js` — status-code mapping, payload validation, failure surfacing.
@@ -0,0 +1,264 @@
import express from 'express';
import request from 'supertest';
import { describe, expect, it } from 'vitest';
import { registerProjectContextRoutes } from './routes.js';
/**
* End-to-end route tests over real HTTP.
*
* An earlier unit test invoked the handlers directly. That covered status-code
* mapping but could not see middleware, and the blind spot shipped a real bug:
* the
* server has no global JSON parser — `core-routes` parses only an allowlist of
* path prefixes so the OpenCode proxy keeps an unread stream — so every write
* here arrived with `req.body` undefined and was rejected as malformed.
*
* These tests mount the routes on a bare express app, exactly as production
* does, so a missing body parser fails the suite instead of the user.
*/
const emptyContext = { version: 2, notes: [], todos: [], plans: [] };
const createApp = (overrides = {}) => {
const received = {};
const runtime = {
readContext: async () => emptyContext,
saveTodos: async (_projectId, todos) => {
received.todos = todos;
return { ...emptyContext, todos };
},
createNote: async (_projectId, value) => {
received.note = value;
return {
note: { id: 'n1', body: value.body, createdAt: 1, updatedAt: 1, source: value.source ?? 'manual', pinned: false },
context: emptyContext,
};
},
updateNote: async (_projectId, _noteId, patch) => {
received.notePatch = patch;
return {
note: { id: 'n1', body: 'x', createdAt: 1, updatedAt: 2, source: 'manual', pinned: patch.pinned === true },
context: emptyContext,
};
},
deleteNote: async () => ({ deleted: true, context: emptyContext }),
readPlan: async () => null,
createPlan: async (_projectId, value) => {
received.plan = value;
return { plan: { id: 'p1', file: 'a.md', title: value.title, createdAt: 1, pinned: false }, context: emptyContext };
},
updatePlan: async (_projectId, _planId, value) => {
received.planRaw = value;
return { plan: { id: 'p1', file: 'a.md', title: 'A', createdAt: 1, pinned: false }, context: emptyContext, title: 'A', body: 'x', raw: value.raw };
},
setPlanPinned: async (_projectId, _planId, pinned) => ({
plan: { id: 'p1', file: 'a.md', title: 'A', createdAt: 1, pinned },
context: emptyContext,
}),
deletePlan: async () => ({ deleted: true, context: emptyContext }),
...overrides,
};
const app = express();
// Deliberately NO app.use(express.json()): production does not have one on
// this path, so adding it here would hide the very defect these tests exist
// to catch.
registerProjectContextRoutes(app, { projectContextRuntime: runtime });
return { app, received };
};
const BASE = '/api/project-context/path_dGVzdA';
describe('project context routes over HTTP', () => {
it('reads the context', async () => {
const { app } = createApp();
const res = await request(app).get(BASE).expect(200);
expect(res.body).toEqual(emptyContext);
});
it('accepts a todos write with a JSON body', async () => {
const { app, received } = createApp();
await request(app)
.put(`${BASE}/todos`)
.send({ todos: [{ id: 't1', text: 'one' }] })
.expect(200);
expect(received.todos).toEqual([{ id: 't1', text: 'one' }]);
});
it('accepts a note create with a JSON body', async () => {
const { app, received } = createApp();
const res = await request(app)
.post(`${BASE}/notes`)
.send({ body: 'hello', source: 'selection', origin: { sessionId: 'ses_1' } })
.expect(201);
expect(received.note.body).toBe('hello');
expect(received.note.source).toBe('selection');
expect(res.body.note.id).toBe('n1');
});
it('accepts a note pin patch with a JSON body', async () => {
const { app, received } = createApp();
const res = await request(app)
.patch(`${BASE}/notes/n1`)
.send({ pinned: true })
.expect(200);
expect(received.notePatch).toEqual({ pinned: true });
expect(res.body.note.pinned).toBe(true);
});
it('accepts a note body patch with a JSON body', async () => {
const { app, received } = createApp();
await request(app)
.patch(`${BASE}/notes/n1`)
.send({ body: 'edited' })
.expect(200);
expect(received.notePatch).toEqual({ body: 'edited' });
});
it('accepts a plan pin patch with a JSON body', async () => {
const { app } = createApp();
const res = await request(app)
.patch(`${BASE}/plans/p1`)
.send({ pinned: true })
.expect(200);
expect(res.body.plan.pinned).toBe(true);
});
it('accepts a plan create with a JSON body', async () => {
const { app, received } = createApp();
await request(app)
.post(`${BASE}/plans`)
.send({ title: 'A', body: 'text' })
.expect(201);
expect(received.plan).toEqual({ title: 'A', body: 'text' });
});
it('accepts a plan save with a JSON body', async () => {
const { app, received } = createApp();
await request(app)
.put(`${BASE}/plans/p1`)
.send({ raw: '# A\n\nx' })
.expect(200);
expect(received.planRaw).toEqual({ raw: '# A\n\nx' });
});
it('deletes a note', async () => {
const { app } = createApp();
await request(app).delete(`${BASE}/notes/n1`).expect(200);
});
it('deletes a plan', async () => {
const { app } = createApp();
await request(app).delete(`${BASE}/plans/p1`).expect(200);
});
it('still rejects a genuinely malformed body', async () => {
const { app } = createApp();
await request(app)
.post(`${BASE}/notes`)
.send({ notBody: 'nope' })
.expect(400);
});
it('rejects malformed todo shapes', async () => {
const { app } = createApp();
await request(app)
.put(`${BASE}/todos`)
.send({ todos: [{ id: 1, text: 'bad id type' }] })
.expect(400);
});
it('rejects an unknown note source', async () => {
const { app } = createApp();
await request(app)
.post(`${BASE}/notes`)
.send({ body: 'hello', source: 'somewhere-else' })
.expect(400);
});
it('rejects a plan pin patch without a boolean', async () => {
const { app } = createApp();
await request(app)
.patch(`${BASE}/plans/p1`)
.send({ pinned: 'yes' })
.expect(400);
});
it('rejects a plan save without raw content', async () => {
const { app } = createApp();
await request(app)
.put(`${BASE}/plans/p1`)
.send({ body: 'wrong field' })
.expect(400);
});
it('returns 404 for an unknown plan', async () => {
const { app } = createApp();
await request(app).get(`${BASE}/plans/nope`).expect(404);
});
it('returns 404 when patching a note that does not exist', async () => {
const { app } = createApp({ updateNote: async () => null });
await request(app).patch(`${BASE}/notes/nope`).send({ body: 'x' }).expect(404);
});
it('returns 404 when deleting a note that does not exist', async () => {
const { app } = createApp({ deleteNote: async () => ({ deleted: false, context: emptyContext }) });
await request(app).delete(`${BASE}/notes/nope`).expect(404);
});
it('returns 404 when deleting a plan that does not exist', async () => {
const { app } = createApp({ deletePlan: async () => ({ deleted: false, context: emptyContext }) });
await request(app).delete(`${BASE}/plans/nope`).expect(404);
});
it('returns 404 when saving a plan whose markdown is gone', async () => {
const { app } = createApp({ updatePlan: async () => null });
await request(app).put(`${BASE}/plans/p1`).send({ raw: '# B' }).expect(404);
});
it('surfaces malformed stored context as a server error, not empty data', async () => {
const { app } = createApp({
readContext: async () => {
throw new Error('Stored project context is malformed');
},
});
const res = await request(app).get(BASE).expect(500);
expect(res.body).toEqual({ error: 'Stored project context is malformed' });
});
it('rejects a traversal projectId as a client error', async () => {
const { app } = createApp({
readContext: async () => {
throw new Error('projectId contains unsupported characters');
},
});
await request(app).get('/api/project-context/..%2Fescape').expect(400);
});
});
@@ -0,0 +1,237 @@
/**
* 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');
}
});
};
@@ -0,0 +1,667 @@
/**
* Project context storage: notes, todos, and plan files.
*
* The server is the sole writer of `<projectsDir>/<projectId>/context.json`.
* The sibling `<projectsDir>/<projectId>.json` stays client-owned (worktree
* setup, draft starters, project actions) and server-owned only for
* `version`/`scheduledTasks`; keeping the two apart is what removes the
* cross-process read-modify-write race that a shared file would create.
*
* Plan bodies live as markdown at `<projectsDir>/<projectId>/plans/<file>.md`
* and are referenced by base name only, so moving the project storage
* directory never invalidates a reference.
*/
const PROJECT_CONTEXT_VERSION = 2;
const PROJECT_NOTE_BODY_MAX_LENGTH = 3000;
const PROJECT_NOTE_MAX_ITEMS = 200;
const PROJECT_TODO_TEXT_MAX_LENGTH = 120;
const PROJECT_PLAN_TITLE_MAX_LENGTH = 160;
const PROJECT_PLAN_BODY_MAX_LENGTH = 200_000;
const PROJECT_TODO_MAX_ITEMS = 500;
const PROJECT_PLAN_MAX_ITEMS = 500;
const PROJECT_ID_PATTERN = /^[a-zA-Z0-9._:-]+$/;
const PLAN_FILE_PATTERN = /^[a-zA-Z0-9._-]+\.md$/;
const asNonEmptyString = (value) => {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
};
const clampLength = (value, maxLength) => {
if (typeof value !== 'string') return '';
return value.length > maxLength ? value.slice(0, maxLength) : value;
};
const isObjectRecord = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const NOTE_SOURCES = new Set(['manual', 'selection', 'agent']);
const sanitizeNoteOrigin = (value) => {
if (!isObjectRecord(value)) return null;
const sessionId = asNonEmptyString(value.sessionId);
const messageId = asNonEmptyString(value.messageId);
if (!sessionId) return null;
return messageId ? { sessionId, messageId } : { sessionId };
};
/**
* Notes are a list of entries.
*
* Version 1 stored a single string. It is converted here rather than in a
* separate migration pass so that any read — including one that races another
* writer — sees the same shape.
*/
const sanitizeNotes = (value, now) => {
if (typeof value === 'string') {
const body = clampLength(value, PROJECT_NOTE_BODY_MAX_LENGTH).trim();
if (!body) return [];
return [{
id: `note_legacy_${now}`,
body,
createdAt: now,
updatedAt: now,
source: 'manual',
pinned: false,
}];
}
if (!Array.isArray(value)) return [];
const result = [];
const seen = new Set();
for (const entry of value) {
if (result.length >= PROJECT_NOTE_MAX_ITEMS) break;
if (!isObjectRecord(entry)) continue;
const id = asNonEmptyString(entry.id);
const body = clampLength(typeof entry.body === 'string' ? entry.body : '', PROJECT_NOTE_BODY_MAX_LENGTH).trim();
if (!id || !body || seen.has(id)) continue;
seen.add(id);
const createdAt = Number.isFinite(entry.createdAt) && entry.createdAt >= 0 ? entry.createdAt : now;
const origin = sanitizeNoteOrigin(entry.origin);
result.push({
id,
body,
createdAt,
updatedAt: Number.isFinite(entry.updatedAt) && entry.updatedAt >= 0 ? entry.updatedAt : createdAt,
source: NOTE_SOURCES.has(entry.source) ? entry.source : 'manual',
pinned: entry.pinned === true,
...(origin ? { origin } : {}),
});
}
return result.sort((a, b) => b.createdAt - a.createdAt);
};
const sanitizeTodos = (value, now) => {
if (!Array.isArray(value)) return [];
const result = [];
const seen = new Set();
for (const entry of value) {
if (result.length >= PROJECT_TODO_MAX_ITEMS) break;
if (!isObjectRecord(entry)) continue;
const id = asNonEmptyString(entry.id);
const text = clampLength(asNonEmptyString(entry.text) || '', PROJECT_TODO_TEXT_MAX_LENGTH);
if (!id || !text || seen.has(id)) continue;
seen.add(id);
result.push({
id,
text,
completed: entry.completed === true,
createdAt: Number.isFinite(entry.createdAt) && entry.createdAt >= 0 ? entry.createdAt : now,
});
}
return result;
};
const sanitizePlanTitle = (value) => clampLength(asNonEmptyString(value) || '', PROJECT_PLAN_TITLE_MAX_LENGTH);
export const parsePlanMarkdown = (raw) => {
const normalized = (typeof raw === 'string' ? raw : '').replace(/\r\n?/g, '\n');
const match = normalized.match(/^\s*#\s+(.+?)\s*(?:\n+|$)/);
if (match) {
return {
title: sanitizePlanTitle(match[1]) || 'Plan',
body: normalized.slice(match[0].length).replace(/^\n+/, ''),
};
}
const firstLine = normalized.split('\n').map((line) => line.trim()).find(Boolean) || 'Plan';
return {
title: sanitizePlanTitle(firstLine.replace(/^#+\s*/, '')) || 'Plan',
body: normalized.trim(),
};
};
const formatPlanMarkdown = (title, body) => {
const normalizedTitle = sanitizePlanTitle(title) || 'Plan';
const normalizedBody = typeof body === 'string' ? body.trim() : '';
return normalizedBody ? `# ${normalizedTitle}\n\n${normalizedBody}` : `# ${normalizedTitle}\n`;
};
const slugifyPlanTitle = (value) => {
const normalized = value
.trim()
.toLowerCase()
.replace(/[`*_#>[\](){}.!?,:;"']/g, '')
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '');
return normalized || 'plan';
};
const sanitizePlanLinks = (value, now) => {
if (!Array.isArray(value)) return [];
const result = [];
const seenIds = new Set();
const seenFiles = new Set();
for (const entry of value) {
if (result.length >= PROJECT_PLAN_MAX_ITEMS) break;
if (!isObjectRecord(entry)) continue;
const id = asNonEmptyString(entry.id);
const file = asNonEmptyString(entry.file);
if (!id || !file || !PLAN_FILE_PATTERN.test(file)) continue;
if (seenIds.has(id) || seenFiles.has(file)) continue;
seenIds.add(id);
seenFiles.add(file);
result.push({
id,
file,
title: sanitizePlanTitle(entry.title) || 'Plan',
createdAt: Number.isFinite(entry.createdAt) && entry.createdAt >= 0 ? entry.createdAt : now,
pinned: entry.pinned === true,
});
}
return result.sort((a, b) => b.createdAt - a.createdAt);
};
const createEmptyContext = () => ({
version: PROJECT_CONTEXT_VERSION,
notes: [],
todos: [],
plans: [],
});
export const createProjectContextRuntime = (deps) => {
const { fsPromises, path, projectsDirPath, createId } = deps;
const idFactory = typeof createId === 'function'
? createId
: () => (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `plan_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`);
const writeLocks = new Map();
const sanitizeProjectId = (projectId) => {
const value = asNonEmptyString(projectId);
if (!value) {
throw new Error('projectId is required');
}
if (!PROJECT_ID_PATTERN.test(value)) {
throw new Error('projectId contains unsupported characters');
}
return value;
};
const storageDirFor = (projectId) => path.join(projectsDirPath, sanitizeProjectId(projectId));
const contextPathFor = (projectId) => path.join(storageDirFor(projectId), 'context.json');
const plansDirFor = (projectId) => path.join(storageDirFor(projectId), 'plans');
const legacyConfigPathFor = (projectId) => path.join(projectsDirPath, `${sanitizeProjectId(projectId)}.json`);
const readJson = async (filePath) => {
let raw;
try {
raw = await fsPromises.readFile(filePath, 'utf8');
} catch (error) {
if (error && error.code === 'ENOENT') return { missing: true, value: null };
throw error;
}
try {
const parsed = JSON.parse(raw);
return { missing: false, value: isObjectRecord(parsed) ? parsed : null };
} catch {
return { missing: false, value: null };
}
};
const writeJsonAtomic = async (filePath, value) => {
const temporaryPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
await fsPromises.mkdir(path.dirname(filePath), { recursive: true });
await fsPromises.writeFile(temporaryPath, JSON.stringify(value, null, 2), 'utf8');
await fsPromises.rename(temporaryPath, filePath);
};
const withWriteLock = async (projectId, mutate) => {
const key = sanitizeProjectId(projectId);
const previous = writeLocks.get(key) || Promise.resolve();
let release;
const next = new Promise((resolve) => { release = resolve; });
const chained = previous.finally(() => next);
writeLocks.set(key, chained);
await previous;
try {
return await mutate();
} finally {
release();
if (writeLocks.get(key) === chained) {
writeLocks.delete(key);
}
}
};
/**
* One-time migration of `projectNotes` / `projectTodos` / `projectPlanFiles`
* out of the client-owned `<projectId>.json`.
*
* Plan links carried absolute paths; those are converted to base names. A
* referenced file that is not already inside the plans directory is moved
* there so a stale absolute path from an earlier project id is recovered
* rather than dropped. A link whose file cannot be located at all is kept
* out of the result — the markdown is gone, so the link is dead either way.
*
* The legacy keys are removed only after `context.json` is durably written.
* A failure at any point leaves the legacy keys in place, so the migration
* simply runs again on the next read.
*/
const migrateFromLegacyConfig = async (projectId, now) => {
const legacyPath = legacyConfigPathFor(projectId);
const legacy = await readJson(legacyPath);
if (!legacy.value) {
return null;
}
const hasLegacyKeys = legacy.value.projectNotes !== undefined
|| legacy.value.projectTodos !== undefined
|| legacy.value.projectPlanFiles !== undefined;
if (!hasLegacyKeys) {
return null;
}
const plansDir = plansDirFor(projectId);
const links = [];
const rawLinks = Array.isArray(legacy.value.projectPlanFiles) ? legacy.value.projectPlanFiles : [];
for (const entry of rawLinks) {
if (!isObjectRecord(entry)) continue;
const id = asNonEmptyString(entry.id);
const absolutePath = asNonEmptyString(entry.path);
if (!id || !absolutePath) continue;
const file = path.basename(absolutePath);
if (!PLAN_FILE_PATTERN.test(file)) continue;
const targetPath = path.join(plansDir, file);
let raw = null;
try {
raw = await fsPromises.readFile(targetPath, 'utf8');
} catch (error) {
if (!error || error.code !== 'ENOENT') throw error;
// Not in the plans directory yet — recover it from the recorded path.
try {
raw = await fsPromises.readFile(absolutePath, 'utf8');
} catch (recoverError) {
if (!recoverError || recoverError.code !== 'ENOENT') throw recoverError;
continue;
}
await fsPromises.mkdir(plansDir, { recursive: true });
await fsPromises.writeFile(targetPath, raw, 'utf8');
}
links.push({
id,
file,
title: parsePlanMarkdown(raw).title,
createdAt: Number.isFinite(entry.createdAt) && entry.createdAt >= 0 ? entry.createdAt : now,
});
}
const migrated = {
version: PROJECT_CONTEXT_VERSION,
notes: sanitizeNotes(legacy.value.projectNotes, now),
todos: sanitizeTodos(legacy.value.projectTodos, now),
plans: sanitizePlanLinks(links, now),
};
await writeJsonAtomic(contextPathFor(projectId), migrated);
const remaining = { ...legacy.value };
delete remaining.projectNotes;
delete remaining.projectTodos;
delete remaining.projectPlanFiles;
await writeJsonAtomic(legacyPath, remaining);
return migrated;
};
/**
* Read the stored context.
*
* Distinguishes the three states the caller must not conflate: a missing
* file is authoritative empty, malformed JSON is a failure, and an I/O
* error propagates. Never returns an empty context to paper over a read
* that did not succeed.
*
* Deliberately does NOT take the write lock: every mutator calls this while
* already holding it, so locking here would deadlock. The legacy migration
* it can trigger is safe unlocked — both of its writes are atomic renames
* of identical content, so concurrent migrations converge instead of
* interleaving.
*/
const readContext = async (projectId) => {
const now = Date.now();
const stored = await readJson(contextPathFor(projectId));
if (!stored.missing && !stored.value) {
throw new Error('Stored project context is malformed');
}
if (stored.missing) {
const migrated = await migrateFromLegacyConfig(projectId, now);
if (migrated) {
return {
version: PROJECT_CONTEXT_VERSION,
notes: sanitizeNotes(migrated.notes, now),
todos: sanitizeTodos(migrated.todos, now),
plans: sanitizePlanLinks(migrated.plans, now),
};
}
return createEmptyContext();
}
return {
version: PROJECT_CONTEXT_VERSION,
notes: sanitizeNotes(stored.value.notes, now),
todos: sanitizeTodos(stored.value.todos, now),
plans: sanitizePlanLinks(stored.value.plans, now),
};
};
const writeContext = async (projectId, context) => {
await writeJsonAtomic(contextPathFor(projectId), {
version: PROJECT_CONTEXT_VERSION,
notes: context.notes,
todos: context.todos,
plans: context.plans,
});
};
const saveTodos = async (projectId, todos) => {
return withWriteLock(projectId, async () => {
const now = Date.now();
const current = await readContext(projectId);
const next = { ...current, todos: sanitizeTodos(todos, now) };
await writeContext(projectId, next);
return next;
});
};
/**
* Notes are addressed individually.
*
* Splitting them from todos is what lets the panel stop writing both fields
* on every keystroke-driven save: a todo toggle can no longer clobber notes
* the user is still typing, and an agent-authored note can no longer lose a
* concurrent todo change.
*/
const createNote = async (projectId, value) => {
const body = clampLength(typeof value?.body === 'string' ? value.body : '', PROJECT_NOTE_BODY_MAX_LENGTH).trim();
if (!body) {
throw new Error('body is required');
}
return withWriteLock(projectId, async () => {
const now = Date.now();
const current = await readContext(projectId);
if (current.notes.length >= PROJECT_NOTE_MAX_ITEMS) {
throw new Error(`A project can hold at most ${PROJECT_NOTE_MAX_ITEMS} notes`);
}
const note = {
id: idFactory(),
body,
createdAt: now,
updatedAt: now,
source: NOTE_SOURCES.has(value?.source) ? value.source : 'manual',
pinned: false,
...(sanitizeNoteOrigin(value?.origin) ? { origin: sanitizeNoteOrigin(value.origin) } : {}),
};
const next = { ...current, notes: [note, ...current.notes] };
await writeContext(projectId, next);
return { note, context: next };
});
};
/**
* Patch one note. Omitted fields are left alone, so pinning a note cannot
* roll back an edit that landed between the two requests.
*/
const updateNote = async (projectId, noteId, patch) => {
const id = asNonEmptyString(noteId);
if (!id) {
throw new Error('noteId is required');
}
const hasBody = typeof patch?.body === 'string';
const hasPinned = typeof patch?.pinned === 'boolean';
if (!hasBody && !hasPinned) {
throw new Error('body or pinned is required');
}
const body = hasBody ? clampLength(patch.body, PROJECT_NOTE_BODY_MAX_LENGTH).trim() : null;
if (hasBody && !body) {
throw new Error('body is required');
}
return withWriteLock(projectId, async () => {
const now = Date.now();
const current = await readContext(projectId);
const existing = current.notes.find((note) => note.id === id);
if (!existing) {
return null;
}
const note = {
...existing,
...(hasBody ? { body, updatedAt: now } : {}),
...(hasPinned ? { pinned: patch.pinned } : {}),
};
const next = { ...current, notes: current.notes.map((entry) => (entry.id === id ? note : entry)) };
await writeContext(projectId, next);
return { note, context: next };
});
};
const deleteNote = async (projectId, noteId) => {
const id = asNonEmptyString(noteId);
if (!id) {
throw new Error('noteId is required');
}
return withWriteLock(projectId, async () => {
const current = await readContext(projectId);
if (!current.notes.some((note) => note.id === id)) {
return { deleted: false, context: current };
}
const next = { ...current, notes: current.notes.filter((note) => note.id !== id) };
await writeContext(projectId, next);
return { deleted: true, context: next };
});
};
const readPlan = async (projectId, planId) => {
const id = asNonEmptyString(planId);
if (!id) {
throw new Error('planId is required');
}
const context = await readContext(projectId);
const link = context.plans.find((entry) => entry.id === id);
if (!link) {
return null;
}
let raw;
try {
raw = await fsPromises.readFile(path.join(plansDirFor(projectId), link.file), 'utf8');
} catch (error) {
if (error && error.code === 'ENOENT') return null;
throw error;
}
const parsed = parsePlanMarkdown(raw);
return { id: link.id, file: link.file, createdAt: link.createdAt, title: parsed.title, body: parsed.body, raw };
};
/**
* Overwrite a plan's markdown in place.
*
* Takes the whole raw document, because the editor surface owns the file
* verbatim — round-tripping through title + body would rewrite the heading
* and silently reformat what the user typed. The manifest title is
* re-derived from the saved content so the list never drifts from the file.
*
* The file name is deliberately not regenerated on a title change: it is the
* stable identity behind the link, and renaming it would strand the markdown
* if the manifest write failed afterwards.
*/
const updatePlan = async (projectId, planId, value) => {
const id = asNonEmptyString(planId);
if (!id) {
throw new Error('planId is required');
}
if (typeof value?.raw !== 'string') {
throw new Error('raw is required');
}
const raw = clampLength(value.raw, PROJECT_PLAN_BODY_MAX_LENGTH);
return withWriteLock(projectId, async () => {
const current = await readContext(projectId);
const link = current.plans.find((entry) => entry.id === id);
if (!link) {
return null;
}
const filePath = path.join(plansDirFor(projectId), link.file);
// Refuse to recreate a file that was deleted underneath us: the link is
// already dead, and writing here would resurrect it with editor content
// the user believed was discarded.
try {
await fsPromises.access(filePath);
} catch (error) {
if (error && error.code === 'ENOENT') return null;
throw error;
}
await fsPromises.writeFile(filePath, raw, 'utf8');
const parsed = parsePlanMarkdown(raw);
const nextLink = { ...link, title: parsed.title };
const next = {
...current,
plans: current.plans.map((entry) => (entry.id === id ? nextLink : entry)),
};
await writeContext(projectId, next);
return { plan: nextLink, context: next, title: parsed.title, body: parsed.body, raw };
});
};
/**
* Create a plan from title + body.
*
* The markdown file is written before the manifest entry. A failure after
* the file write leaves an unreferenced markdown file rather than a
* manifest entry pointing at nothing — the orphan is inert, a dangling
* entry would surface as a broken row in the UI.
*/
const createPlan = async (projectId, value) => {
const title = sanitizePlanTitle(value?.title) || 'Plan';
const body = clampLength(typeof value?.body === 'string' ? value.body : '', PROJECT_PLAN_BODY_MAX_LENGTH);
return withWriteLock(projectId, async () => {
const current = await readContext(projectId);
const createdAt = Date.now();
const plansDir = plansDirFor(projectId);
await fsPromises.mkdir(plansDir, { recursive: true });
const baseName = `${createdAt}-${slugifyPlanTitle(title)}`;
let file = `${baseName}.md`;
let attempt = 1;
while (current.plans.some((entry) => entry.file === file)) {
file = `${baseName}-${attempt}.md`;
attempt += 1;
}
await fsPromises.writeFile(path.join(plansDir, file), formatPlanMarkdown(title, body), 'utf8');
const link = { id: idFactory(), file, title, createdAt, pinned: false };
const next = { ...current, plans: [link, ...current.plans] };
await writeContext(projectId, next);
return { plan: link, context: next };
});
};
/**
* Delete a plan.
*
* The manifest entry is removed first so a failed file unlink cannot leave
* the UI showing a plan that no longer opens. The leftover markdown is
* unreferenced and harmless.
*/
/** Pin state is patched on its own so it cannot roll back a concurrent edit. */
const setPlanPinned = async (projectId, planId, pinned) => {
const id = asNonEmptyString(planId);
if (!id) {
throw new Error('planId is required');
}
return withWriteLock(projectId, async () => {
const current = await readContext(projectId);
const existing = current.plans.find((entry) => entry.id === id);
if (!existing) {
return null;
}
const plan = { ...existing, pinned: pinned === true };
const next = { ...current, plans: current.plans.map((entry) => (entry.id === id ? plan : entry)) };
await writeContext(projectId, next);
return { plan, context: next };
});
};
const deletePlan = async (projectId, planId) => {
const id = asNonEmptyString(planId);
if (!id) {
throw new Error('planId is required');
}
return withWriteLock(projectId, async () => {
const current = await readContext(projectId);
const link = current.plans.find((entry) => entry.id === id);
if (!link) {
return { deleted: false, context: current };
}
const next = { ...current, plans: current.plans.filter((entry) => entry.id !== id) };
await writeContext(projectId, next);
await fsPromises.rm(path.join(plansDirFor(projectId), link.file), { force: true });
return { deleted: true, context: next };
});
};
return {
readContext,
saveTodos,
createNote,
updateNote,
deleteNote,
readPlan,
updatePlan,
createPlan,
setPlanPinned,
deletePlan,
contextPathFor,
plansDirFor,
};
};
@@ -0,0 +1,498 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import fsPromises from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { createProjectContextRuntime, parsePlanMarkdown } from './runtime.js';
const PROJECT_ID = 'path_dGVzdA';
let projectsDirPath;
let runtime;
let idCounter;
const legacyConfigPath = () => path.join(projectsDirPath, `${PROJECT_ID}.json`);
const contextPath = () => path.join(projectsDirPath, PROJECT_ID, 'context.json');
const plansDir = () => path.join(projectsDirPath, PROJECT_ID, 'plans');
const writeJson = async (filePath, value) => {
await fsPromises.mkdir(path.dirname(filePath), { recursive: true });
await fsPromises.writeFile(filePath, JSON.stringify(value, null, 2), 'utf8');
};
const readJson = async (filePath) => JSON.parse(await fsPromises.readFile(filePath, 'utf8'));
beforeEach(async () => {
projectsDirPath = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-project-context-'));
idCounter = 0;
runtime = createProjectContextRuntime({
fsPromises,
path,
projectsDirPath,
createId: () => `plan-${++idCounter}`,
});
});
afterEach(async () => {
await fsPromises.rm(projectsDirPath, { recursive: true, force: true });
});
describe('projectId validation', () => {
test('rejects traversal and empty ids', async () => {
await expect(runtime.readContext('../escape')).rejects.toThrow('unsupported characters');
await expect(runtime.readContext('a/b')).rejects.toThrow('unsupported characters');
await expect(runtime.readContext('')).rejects.toThrow('projectId is required');
});
});
describe('readContext', () => {
test('missing file is authoritative empty', async () => {
expect(await runtime.readContext(PROJECT_ID)).toEqual({
version: 2,
notes: [],
todos: [],
plans: [],
});
});
test('malformed stored context fails instead of reading as empty', async () => {
await fsPromises.mkdir(path.dirname(contextPath()), { recursive: true });
await fsPromises.writeFile(contextPath(), '{ not json', 'utf8');
await expect(runtime.readContext(PROJECT_ID)).rejects.toThrow('malformed');
});
test('drops malformed todo and plan entries without failing the read', async () => {
await writeJson(contextPath(), {
version: 2,
notes: [{ id: 'n1', body: 'kept', createdAt: 1, updatedAt: 1, source: 'manual' }],
todos: [{ id: 'a', text: 'ok', completed: false, createdAt: 1 }, { id: '', text: 'no id' }, { text: 'no id' }],
plans: [
{ id: 'p1', file: 'a.md', title: 'A', createdAt: 2 },
{ id: 'p2', file: '../escape.md', title: 'Bad', createdAt: 3 },
{ id: 'p3', file: 'no-extension', createdAt: 4 },
],
});
const context = await runtime.readContext(PROJECT_ID);
expect(context.notes.map((note) => note.body)).toEqual(['kept']);
expect(context.todos.map((todo) => todo.id)).toEqual(['a']);
expect(context.plans.map((plan) => plan.id)).toEqual(['p1']);
});
test('clamps a note body to the maximum length', async () => {
await writeJson(contextPath(), {
version: 2,
notes: [{ id: 'n1', body: 'x'.repeat(5000), createdAt: 1, updatedAt: 1 }],
todos: [],
plans: [],
});
expect((await runtime.readContext(PROJECT_ID)).notes[0].body).toHaveLength(3000);
});
test('converts a version 1 string note into a single entry', async () => {
await writeJson(contextPath(), { version: 1, notes: 'legacy blob', todos: [], plans: [] });
const notes = (await runtime.readContext(PROJECT_ID)).notes;
expect(notes).toHaveLength(1);
expect(notes[0].body).toBe('legacy blob');
expect(notes[0].source).toBe('manual');
expect(notes[0].pinned).toBe(false);
});
test('an empty version 1 string converts to no notes at all', async () => {
await writeJson(contextPath(), { version: 1, notes: ' ', todos: [], plans: [] });
expect((await runtime.readContext(PROJECT_ID)).notes).toEqual([]);
});
test('newest note is listed first', async () => {
await writeJson(contextPath(), {
version: 2,
notes: [
{ id: 'old', body: 'old', createdAt: 1, updatedAt: 1 },
{ id: 'new', body: 'new', createdAt: 9, updatedAt: 9 },
],
todos: [],
plans: [],
});
expect((await runtime.readContext(PROJECT_ID)).notes.map((note) => note.id)).toEqual(['new', 'old']);
});
});
describe('legacy migration', () => {
test('moves the three keys out of the client-owned config and preserves the rest', async () => {
await fsPromises.mkdir(plansDir(), { recursive: true });
await fsPromises.writeFile(path.join(plansDir(), '10-old.md'), '# Old plan\n\nbody here', 'utf8');
await writeJson(legacyConfigPath(), {
projectPath: '/tmp/test',
'setup-worktree': ['bun install'],
projectActions: [{ id: 'a', name: 'Dev', command: 'bun dev' }],
projectNotes: 'legacy notes',
projectTodos: [{ id: 't1', text: 'legacy todo', completed: true, createdAt: 5 }],
projectPlanFiles: [{ id: 'p1', path: path.join(plansDir(), '10-old.md'), createdAt: 10 }],
});
const context = await runtime.readContext(PROJECT_ID);
expect(context.notes.map((note) => note.body)).toEqual(['legacy notes']);
expect(context.todos).toEqual([{ id: 't1', text: 'legacy todo', completed: true, createdAt: 5 }]);
expect(context.plans).toEqual([{ id: 'p1', file: '10-old.md', title: 'Old plan', createdAt: 10, pinned: false }]);
const remaining = await readJson(legacyConfigPath());
expect(remaining).toEqual({
projectPath: '/tmp/test',
'setup-worktree': ['bun install'],
projectActions: [{ id: 'a', name: 'Dev', command: 'bun dev' }],
});
});
test('recovers a plan whose recorded path points outside the plans directory', async () => {
const strayPath = path.join(projectsDirPath, 'stray.md');
await fsPromises.writeFile(strayPath, '# Stray\n\nrecovered', 'utf8');
await writeJson(legacyConfigPath(), {
projectPlanFiles: [{ id: 'p1', path: strayPath, createdAt: 10 }],
});
const context = await runtime.readContext(PROJECT_ID);
expect(context.plans).toEqual([{ id: 'p1', file: 'stray.md', title: 'Stray', createdAt: 10, pinned: false }]);
expect(await fsPromises.readFile(path.join(plansDir(), 'stray.md'), 'utf8')).toContain('recovered');
});
test('drops a link whose markdown no longer exists anywhere', async () => {
await writeJson(legacyConfigPath(), {
projectNotes: 'kept',
projectPlanFiles: [{ id: 'gone', path: path.join(plansDir(), 'missing.md'), createdAt: 10 }],
});
const context = await runtime.readContext(PROJECT_ID);
expect(context.notes.map((note) => note.body)).toEqual(['kept']);
expect(context.plans).toEqual([]);
});
test('does not run when the legacy config holds no context keys', async () => {
await writeJson(legacyConfigPath(), { 'setup-worktree': ['bun install'] });
expect(await runtime.readContext(PROJECT_ID)).toEqual({ version: 2, notes: [], todos: [], plans: [] });
await expect(fsPromises.access(contextPath())).rejects.toThrow();
expect(await readJson(legacyConfigPath())).toEqual({ 'setup-worktree': ['bun install'] });
});
test('is idempotent across repeated reads', async () => {
await writeJson(legacyConfigPath(), { projectNotes: 'once', projectTodos: [] });
const first = await runtime.readContext(PROJECT_ID);
const second = await runtime.readContext(PROJECT_ID);
expect(second).toEqual(first);
expect(await readJson(legacyConfigPath())).toEqual({});
});
test('concurrent reads converge on the same migrated content', async () => {
await writeJson(legacyConfigPath(), { projectNotes: 'concurrent', projectTodos: [] });
const results = await Promise.all([
runtime.readContext(PROJECT_ID),
runtime.readContext(PROJECT_ID),
runtime.readContext(PROJECT_ID),
]);
for (const result of results) {
expect(result.notes.map((note) => note.body)).toEqual(['concurrent']);
}
expect((await readJson(contextPath())).notes[0].body).toBe('concurrent');
});
});
describe('todos', () => {
test('round-trips through disk', async () => {
await runtime.saveTodos(PROJECT_ID, [{ id: 't1', text: 'do it', completed: false, createdAt: 1 }]);
expect((await runtime.readContext(PROJECT_ID)).todos).toEqual([
{ id: 't1', text: 'do it', completed: false, createdAt: 1 },
]);
});
test('preserves notes and plans it does not write', async () => {
const { note } = await runtime.createNote(PROJECT_ID, { body: 'keep me' });
const { plan } = await runtime.createPlan(PROJECT_ID, { title: 'Keep me', body: 'x' });
await runtime.saveTodos(PROJECT_ID, [{ id: 't1', text: 'todo', createdAt: 1 }]);
const context = await runtime.readContext(PROJECT_ID);
expect(context.notes.map((entry) => entry.id)).toEqual([note.id]);
expect(context.plans.map((entry) => entry.id)).toEqual([plan.id]);
});
test('serializes concurrent writes without losing one', async () => {
await Promise.all([
runtime.saveTodos(PROJECT_ID, [{ id: '1', text: 'one', createdAt: 1 }]),
runtime.saveTodos(PROJECT_ID, [{ id: '2', text: 'two', createdAt: 2 }]),
]);
expect((await runtime.readContext(PROJECT_ID)).todos).toHaveLength(1);
});
test('clamps oversized todo text', async () => {
await runtime.saveTodos(PROJECT_ID, [{ id: 't1', text: 'z'.repeat(300), createdAt: 1 }]);
expect((await runtime.readContext(PROJECT_ID)).todos[0].text).toHaveLength(120);
});
});
describe('notes', () => {
test('create returns the stored note and prepends it', async () => {
const first = await runtime.createNote(PROJECT_ID, { body: 'first' });
const second = await runtime.createNote(PROJECT_ID, { body: 'second' });
expect(first.note.source).toBe('manual');
expect(first.note.pinned).toBe(false);
expect(second.context.notes.map((note) => note.body)).toEqual(['second', 'first']);
});
test('create records provenance for a note distilled from a chat selection', async () => {
const { note } = await runtime.createNote(PROJECT_ID, {
body: 'insight',
source: 'selection',
origin: { sessionId: 'ses_1', messageId: 'msg_1' },
});
expect(note.source).toBe('selection');
expect(note.origin).toEqual({ sessionId: 'ses_1', messageId: 'msg_1' });
});
test('create drops an origin with no session', async () => {
const { note } = await runtime.createNote(PROJECT_ID, { body: 'x', origin: { messageId: 'msg_1' } });
expect(note.origin).toBeUndefined();
});
test('create rejects an empty body', async () => {
await expect(runtime.createNote(PROJECT_ID, { body: ' ' })).rejects.toThrow('body is required');
});
test('create clamps an oversized body', async () => {
const { note } = await runtime.createNote(PROJECT_ID, { body: 'y'.repeat(4000) });
expect(note.body).toHaveLength(3000);
});
test('update patches the body and bumps updatedAt without touching createdAt', async () => {
const { note } = await runtime.createNote(PROJECT_ID, { body: 'before' });
await new Promise((resolve) => setTimeout(resolve, 2));
const result = await runtime.updateNote(PROJECT_ID, note.id, { body: 'after' });
expect(result.note.body).toBe('after');
expect(result.note.createdAt).toBe(note.createdAt);
expect(result.note.updatedAt).toBeGreaterThan(note.updatedAt);
});
test('pinning alone leaves the body and updatedAt untouched', async () => {
const { note } = await runtime.createNote(PROJECT_ID, { body: 'body' });
const result = await runtime.updateNote(PROJECT_ID, note.id, { pinned: true });
expect(result.note.pinned).toBe(true);
expect(result.note.body).toBe('body');
expect(result.note.updatedAt).toBe(note.updatedAt);
});
test('update rejects an empty patch', async () => {
const { note } = await runtime.createNote(PROJECT_ID, { body: 'body' });
await expect(runtime.updateNote(PROJECT_ID, note.id, {})).rejects.toThrow('body or pinned is required');
});
test('update rejects blanking the body', async () => {
const { note } = await runtime.createNote(PROJECT_ID, { body: 'body' });
await expect(runtime.updateNote(PROJECT_ID, note.id, { body: ' ' })).rejects.toThrow('body is required');
});
test('update returns null for an unknown note', async () => {
expect(await runtime.updateNote(PROJECT_ID, 'missing', { body: 'x' })).toBeNull();
});
test('delete removes only the requested note', async () => {
const keep = await runtime.createNote(PROJECT_ID, { body: 'keep' });
const drop = await runtime.createNote(PROJECT_ID, { body: 'drop' });
const result = await runtime.deleteNote(PROJECT_ID, drop.note.id);
expect(result.deleted).toBe(true);
expect(result.context.notes.map((note) => note.id)).toEqual([keep.note.id]);
});
test('deleting an unknown note reports no deletion', async () => {
const result = await runtime.deleteNote(PROJECT_ID, 'missing');
expect(result.deleted).toBe(false);
});
test('refuses to grow past the note limit', async () => {
const notes = Array.from({ length: 200 }, (_unused, index) => ({
id: `n${index}`,
body: `note ${index}`,
createdAt: index,
updatedAt: index,
}));
await writeJson(contextPath(), { version: 2, notes, todos: [], plans: [] });
await expect(runtime.createNote(PROJECT_ID, { body: 'one too many' })).rejects.toThrow('at most 200 notes');
});
test('concurrent creates all survive', async () => {
await Promise.all([
runtime.createNote(PROJECT_ID, { body: 'a' }),
runtime.createNote(PROJECT_ID, { body: 'b' }),
runtime.createNote(PROJECT_ID, { body: 'c' }),
]);
const bodies = (await runtime.readContext(PROJECT_ID)).notes.map((note) => note.body);
expect(bodies.sort()).toEqual(['a', 'b', 'c']);
});
});
describe('plans', () => {
test('create writes markdown and returns a readable plan', async () => {
const { plan } = await runtime.createPlan(PROJECT_ID, { title: 'My Plan', body: 'step one' });
expect(plan.file).toMatch(/^\d+-my-plan\.md$/);
const read = await runtime.readPlan(PROJECT_ID, plan.id);
expect(read.title).toBe('My Plan');
expect(read.body).toBe('step one');
expect(read.raw).toBe('# My Plan\n\nstep one');
});
test('newest plan is listed first', async () => {
const first = await runtime.createPlan(PROJECT_ID, { title: 'First', body: 'a' });
await new Promise((resolve) => setTimeout(resolve, 2));
const second = await runtime.createPlan(PROJECT_ID, { title: 'Second', body: 'b' });
const context = await runtime.readContext(PROJECT_ID);
expect(context.plans.map((entry) => entry.id)).toEqual([second.plan.id, first.plan.id]);
});
test('reading an unknown plan returns null rather than throwing', async () => {
expect(await runtime.readPlan(PROJECT_ID, 'nope')).toBeNull();
});
test('reading a plan whose markdown was deleted returns null', async () => {
const { plan } = await runtime.createPlan(PROJECT_ID, { title: 'Doomed', body: 'x' });
await fsPromises.rm(path.join(plansDir(), plan.file));
expect(await runtime.readPlan(PROJECT_ID, plan.id)).toBeNull();
});
test('delete removes both the entry and the markdown', async () => {
const { plan } = await runtime.createPlan(PROJECT_ID, { title: 'Bye', body: 'x' });
const result = await runtime.deletePlan(PROJECT_ID, plan.id);
expect(result.deleted).toBe(true);
expect(result.context.plans).toEqual([]);
await expect(fsPromises.access(path.join(plansDir(), plan.file))).rejects.toThrow();
});
test('deleting an unknown plan reports no deletion and keeps state', async () => {
const { plan } = await runtime.createPlan(PROJECT_ID, { title: 'Stay', body: 'x' });
const result = await runtime.deletePlan(PROJECT_ID, 'missing');
expect(result.deleted).toBe(false);
expect(result.context.plans.map((entry) => entry.id)).toEqual([plan.id]);
});
test('plans created in the same millisecond do not collide on a file name', async () => {
const created = await Promise.all([
runtime.createPlan(PROJECT_ID, { title: 'Same', body: 'a' }),
runtime.createPlan(PROJECT_ID, { title: 'Same', body: 'b' }),
]);
const files = new Set(created.map((entry) => entry.plan.file));
expect(files.size).toBe(2);
const context = await runtime.readContext(PROJECT_ID);
expect(context.plans).toHaveLength(2);
});
test('update rewrites the markdown verbatim and re-derives the manifest title', async () => {
const { plan } = await runtime.createPlan(PROJECT_ID, { title: 'Old', body: 'first' });
const result = await runtime.updatePlan(PROJECT_ID, plan.id, { raw: '# New title\n\n- step\n- step two\n' });
expect(result.plan.title).toBe('New title');
expect(result.plan.file).toBe(plan.file);
expect(await fsPromises.readFile(path.join(plansDir(), plan.file), 'utf8')).toBe('# New title\n\n- step\n- step two\n');
expect((await runtime.readContext(PROJECT_ID)).plans[0].title).toBe('New title');
});
test('update keeps the file name when the title changes', async () => {
const { plan } = await runtime.createPlan(PROJECT_ID, { title: 'Original', body: 'x' });
await runtime.updatePlan(PROJECT_ID, plan.id, { raw: '# Totally different\n\nx' });
const context = await runtime.readContext(PROJECT_ID);
expect(context.plans[0].file).toBe(plan.file);
expect(context.plans).toHaveLength(1);
});
test('update returns null for an unknown plan without writing anything', async () => {
expect(await runtime.updatePlan(PROJECT_ID, 'missing', { raw: '# X' })).toBeNull();
await expect(fsPromises.readdir(plansDir())).rejects.toThrow();
});
test('update refuses to recreate markdown deleted underneath it', async () => {
const { plan } = await runtime.createPlan(PROJECT_ID, { title: 'Gone', body: 'x' });
await fsPromises.rm(path.join(plansDir(), plan.file));
expect(await runtime.updatePlan(PROJECT_ID, plan.id, { raw: '# Resurrected' })).toBeNull();
await expect(fsPromises.access(path.join(plansDir(), plan.file))).rejects.toThrow();
});
test('update rejects a non-string payload', async () => {
const { plan } = await runtime.createPlan(PROJECT_ID, { title: 'A', body: 'x' });
await expect(runtime.updatePlan(PROJECT_ID, plan.id, {})).rejects.toThrow('raw is required');
});
test('update does not disturb notes or todos', async () => {
await runtime.createNote(PROJECT_ID, { body: 'keep me' });
await runtime.saveTodos(PROJECT_ID, [{ id: 't1', text: 'keep', completed: false, createdAt: 1 }]);
const { plan } = await runtime.createPlan(PROJECT_ID, { title: 'A', body: 'x' });
await runtime.updatePlan(PROJECT_ID, plan.id, { raw: '# B\n\ny' });
const context = await runtime.readContext(PROJECT_ID);
expect(context.notes.map((note) => note.body)).toEqual(['keep me']);
expect(context.todos).toHaveLength(1);
});
test('pinning a plan leaves its title and file alone', async () => {
const { plan } = await runtime.createPlan(PROJECT_ID, { title: 'Pin me', body: 'x' });
const result = await runtime.setPlanPinned(PROJECT_ID, plan.id, true);
expect(result.plan).toEqual({ ...plan, pinned: true });
expect((await runtime.readContext(PROJECT_ID)).plans[0].pinned).toBe(true);
});
test('pinning an unknown plan returns null', async () => {
expect(await runtime.setPlanPinned(PROJECT_ID, 'missing', true)).toBeNull();
});
test('editing a plan preserves its pin state', async () => {
const { plan } = await runtime.createPlan(PROJECT_ID, { title: 'A', body: 'x' });
await runtime.setPlanPinned(PROJECT_ID, plan.id, true);
const result = await runtime.updatePlan(PROJECT_ID, plan.id, { raw: '# B\n\ny' });
expect(result.plan.pinned).toBe(true);
});
test('an untitled body still produces a titled markdown file', async () => {
const { plan } = await runtime.createPlan(PROJECT_ID, { title: '', body: '' });
const read = await runtime.readPlan(PROJECT_ID, plan.id);
expect(read.title).toBe('Plan');
expect(read.body).toBe('');
});
});
describe('parsePlanMarkdown', () => {
test('reads the leading heading as the title', () => {
expect(parsePlanMarkdown('# Title\n\nbody')).toEqual({ title: 'Title', body: 'body' });
});
test('falls back to the first non-empty line', () => {
expect(parsePlanMarkdown('\n\njust text\nmore')).toEqual({ title: 'just text', body: 'just text\nmore' });
});
test('normalizes CRLF input', () => {
expect(parsePlanMarkdown('# Title\r\n\r\nbody')).toEqual({ title: 'Title', body: 'body' });
});
test('empty input yields the default title', () => {
expect(parsePlanMarkdown('')).toEqual({ title: 'Plan', body: '' });
});
});