fix(knowledge): scope pins to sessions

This commit is contained in:
Bohdan Triapitsyn
2026-08-19 00:07:57 +03:00
parent a66903c73c
commit f9d1ded81a
16 changed files with 340 additions and 106 deletions
@@ -69,7 +69,13 @@ export const createContextObligatoryRuntime = ({
*/
const knowledge = sessionKnowledgeRuntime
? await sessionKnowledgeRuntime
.resolvePending(directory, sessionKnowledgeRuntime.readDeliveredSignature(session))
.resolvePending(
directory,
// Compaction removed the previously delivered block, so its stored
// signature is no longer evidence that the session still carries it.
'',
sessionKnowledgeRuntime.readPins(session),
)
.catch(() => ({ text: '', signature: '' }))
: { text: '', signature: '' };
@@ -73,7 +73,10 @@ describe('context obligatory runtime', () => {
const url = new URL(typeof input === 'string' ? input : input.url);
requests.push({ path: url.pathname, method: init.method ?? 'GET', body: init.body });
if (url.pathname === '/session/ses_1' && init.method === 'PATCH') return json({});
if (url.pathname === '/session/ses_1') return json({ id: 'ses_1', metadata: {} });
if (url.pathname === '/session/ses_1') return json({
id: 'ses_1',
metadata: { openchamber: { knowledge_context_delivered: 'sig-before-compaction' } },
});
if (url.pathname === '/session/ses_1/message') return json([
{ info: { id: 'msg_agent', role: 'assistant', providerID: 'provider', modelID: 'model', agent: 'build' } },
{ info: { id: 'msg_summary', role: 'assistant', summary: true, time: { completed: 30 } } },
@@ -81,18 +84,30 @@ describe('context obligatory runtime', () => {
if (url.pathname === '/session/ses_1/prompt_async') return json({});
throw new Error(`Unexpected ${url.pathname}`);
}));
const resolvePending = vi.fn(async () => ({
text: '## Pinned notes\n\n- Remember this.',
signature: 'sig-1',
}));
const runtime = createContextObligatoryRuntime({
buildOpenCodeUrl: (path) => `http://opencode.test${path}`,
getOpenCodeAuthHeaders: () => ({}),
sessionKnowledgeRuntime: {
metadataKey: 'knowledge_context_delivered',
readDeliveredSignature: () => '',
resolvePending: async () => ({ text: '## Pinned notes\n\n- Remember this.', signature: 'sig-1' }),
readPins: () => ({ notes: ['n1'], plans: [] }),
resolvePending,
},
});
await runtime.processPayload({ type: 'session.compacted', properties: { sessionID: 'ses_1' } });
await runtime.processPayload({
type: 'session.compacted',
properties: { sessionID: 'ses_1', directory: '/work/project' },
});
expect(resolvePending).toHaveBeenCalledWith(
'/work/project',
'',
{ notes: ['n1'], plans: [] },
);
const prompt = requests.find((request) => request.path.endsWith('/prompt_async'));
expect(JSON.parse(prompt.body).parts[0].text).toContain('Remember this.');
const patch = requests.find((request) => request.method === 'PATCH');
@@ -123,7 +138,7 @@ describe('context obligatory runtime', () => {
getOpenCodeAuthHeaders: () => ({}),
sessionKnowledgeRuntime: {
metadataKey: 'knowledge_context_delivered',
readDeliveredSignature: () => '',
readPins: () => ({ notes: ['n1'], plans: [] }),
resolvePending: async () => ({ text: 'Pinned notes block', signature: 'sig-1' }),
},
});
@@ -152,7 +167,7 @@ describe('context obligatory runtime', () => {
getOpenCodeAuthHeaders: () => ({}),
sessionKnowledgeRuntime: {
metadataKey: 'knowledge_context_delivered',
readDeliveredSignature: () => 'sig-1',
readPins: () => ({ notes: [], plans: [] }),
resolvePending: async () => ({ text: '', signature: 'sig-1' }),
},
});
@@ -27,11 +27,10 @@ Nothing outside this module may write `context.json` or the `plans` directory.
"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 }]
"plans": [{ "id": "", "file": "1700000000-title.md", "title": "", "createdAt": 0 }]
}
```
@@ -39,6 +38,8 @@ 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.
Legacy `pinned` fields may remain in existing files but are ignored; attachment
ownership lives in each session's metadata.
`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
@@ -62,9 +63,9 @@ the two ever disagree.
| 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 |
| PATCH | `/api/project-context/:projectId/notes/:noteId` | patches `body`; legacy `pinned` input is ignored by session knowledge; `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 |
| PATCH | `/api/project-context/:projectId/plans/:planId` | legacy project pin state only; session attachment uses session knowledge; `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 |
@@ -1,6 +1,6 @@
# Session Knowledge
What a session must be told about the project — the user's pinned notes and
What a session must be told about the project — that session's pinned notes and
plans, and the index of what the agent has remembered — and whether it has been
told yet.
@@ -19,6 +19,11 @@ on believing it does and never sends it again.
## The contract
`session.metadata.openchamber.project_context_pins` owns the note and plan ids
attached to that session. Pins never come from project-wide note or plan state.
A new-session draft passes its pins into this metadata when its first message
creates the session.
`session.metadata.openchamber.knowledge_context_delivered` holds the signature
of what the session is carrying. It lives with the session, so it survives the
tab closing and is visible to every sender, including the ones with no tab.
@@ -78,5 +83,5 @@ no session index, no settings row and no panel tab — absent rather than switch
off, which would invite turning on something never announced. The setting itself
also defaults to off, so setting the variable does not enable memory by itself.
Pinned notes and plans are unaffected: they ship as normal and travel with every
message whether or not memory exists.
Pinned notes and plans are unaffected by the memory switch and remain scoped to
the session that pinned them.
@@ -55,14 +55,34 @@ export const registerSessionKnowledgeRoutes = (app, dependencies) => {
if (!directory) {
return res.json({ notes: [], plans: [], memory: { global: 0, project: 0 } });
}
const sessionId = asNonEmptyString(req.query.sessionId);
try {
return res.json(await sessionKnowledgeRuntime.collectSummary(directory));
return res.json(sessionId
? await sessionKnowledgeRuntime.collectSummaryForSession(sessionId, directory)
: await sessionKnowledgeRuntime.collectSummary(directory));
} catch {
// A panel that cannot read this shows nothing rather than an error.
return res.json({ notes: [], plans: [], memory: { global: 0, project: 0 } });
}
});
app.post('/api/session-knowledge/pin', parseJsonBody, async (req, res) => {
const body = req.body;
if (!isRecord(body)) return res.status(400).json({ error: 'Body must be an object' });
const sessionId = asNonEmptyString(body.sessionId);
const directory = asNonEmptyString(body.directory);
const id = asNonEmptyString(body.id);
const kind = body.kind === 'note' || body.kind === 'plan' ? body.kind : '';
if (!sessionId || !directory || !id || !kind || typeof body.pinned !== 'boolean') {
return res.status(400).json({ error: 'sessionId, directory, kind, id and pinned are required' });
}
try {
return res.json({ pins: await sessionKnowledgeRuntime.setPin(sessionId, directory, kind, id, body.pinned) });
} catch (error) {
return res.status(500).json({ error: error?.message ?? 'Unable to update pin' });
}
});
app.post('/api/session-knowledge/delivered', parseJsonBody, async (req, res) => {
const body = req.body;
if (!isRecord(body)) {
@@ -16,6 +16,7 @@
*/
const KNOWLEDGE_METADATA_KEY = 'knowledge_context_delivered';
const PINS_METADATA_KEY = 'project_context_pins';
/** Total budget for the assembled block; anything past it is cut, loudly. */
const KNOWLEDGE_MAX_LENGTH = 8000;
@@ -119,7 +120,17 @@ export const createSessionKnowledgeRuntime = (dependencies) => {
* source never blanks the rest: a memory store that will not load must not
* take the user's pinned notes down with it.
*/
const collect = async (directory) => {
const readPins = (session) => {
const metadata = isRecord(session?.metadata) ? session.metadata : {};
const openchamber = isRecord(metadata.openchamber) ? metadata.openchamber : {};
const pins = isRecord(openchamber[PINS_METADATA_KEY]) ? openchamber[PINS_METADATA_KEY] : {};
const strings = (value) => Array.isArray(value)
? [...new Set(value.filter((entry) => typeof entry === 'string' && entry.trim()).map((entry) => entry.trim()))]
: [];
return { notes: strings(pins.notes), plans: strings(pins.plans) };
};
const collect = async (directory, pins = { notes: [], plans: [] }) => {
const projectId = directory ? await resolveProjectId(directory) : '';
let notes = [];
@@ -127,8 +138,10 @@ export const createSessionKnowledgeRuntime = (dependencies) => {
if (projectId) {
try {
const context = await projectContextRuntime.readContext(projectId);
notes = (context.notes || []).filter((note) => note.pinned);
const pinnedPlans = (context.plans || []).filter((plan) => plan.pinned);
const noteIds = new Set(pins.notes);
const planIds = new Set(pins.plans);
notes = (context.notes || []).filter((note) => noteIds.has(note.id));
const pinnedPlans = (context.plans || []).filter((plan) => planIds.has(plan.id));
plans = await Promise.all(pinnedPlans.map(async (plan) => {
try {
const content = await projectContextRuntime.readPlan(projectId, plan.id);
@@ -175,7 +188,7 @@ export const createSessionKnowledgeRuntime = (dependencies) => {
* off disk to show a number would make opening a panel cost what sending a
* message costs.
*/
const collectSummary = async (directory) => {
const collectSummary = async (directory, pins = { notes: [], plans: [] }) => {
const projectId = directory ? await resolveProjectId(directory) : '';
const empty = { notes: [], plans: [], memory: { global: 0, project: 0 } };
if (!projectId) return empty;
@@ -184,9 +197,11 @@ export const createSessionKnowledgeRuntime = (dependencies) => {
let plans = [];
try {
const context = await projectContextRuntime.readContext(projectId);
notes = (context.notes || []).filter((note) => note.pinned)
const noteIds = new Set(pins.notes);
const planIds = new Set(pins.plans);
notes = (context.notes || []).filter((note) => noteIds.has(note.id))
.map((note) => ({ id: note.id, body: note.body }));
plans = (context.plans || []).filter((plan) => plan.pinned)
plans = (context.plans || []).filter((plan) => planIds.has(plan.id))
.map((plan) => ({ id: plan.id, title: plan.title }));
} catch {
notes = [];
@@ -223,8 +238,8 @@ export const createSessionKnowledgeRuntime = (dependencies) => {
* The text this session still owes, or an empty string when it is already
* carrying it. `deliveredSignature` comes from the session's metadata.
*/
const resolvePending = async (directory, deliveredSignature) => {
const collected = await collect(directory);
const resolvePending = async (directory, deliveredSignature, pins = { notes: [], plans: [] }) => {
const collected = await collect(directory, pins);
const signature = buildKnowledgeSignature(collected);
if (!signature || signature === deliveredSignature) {
return { text: '', signature };
@@ -241,7 +256,38 @@ export const createSessionKnowledgeRuntime = (dependencies) => {
*/
const resolvePendingForSession = async (sessionId, directory) => {
const session = await readSession(sessionId, directory).catch(() => null);
return resolvePending(directory, readDeliveredSignature(session));
return resolvePending(directory, readDeliveredSignature(session), readPins(session));
};
const collectSummaryForSession = async (sessionId, directory) => {
const session = await readSession(sessionId, directory).catch(() => null);
return collectSummary(directory, readPins(session));
};
const setPin = async (sessionId, directory, kind, id, pinned) => {
const fresh = await readSession(sessionId, directory);
const metadata = isRecord(fresh?.metadata) ? fresh.metadata : {};
const openchamber = isRecord(metadata.openchamber) ? metadata.openchamber : {};
const pins = readPins(fresh);
const key = kind === 'note' ? 'notes' : 'plans';
const next = new Set(pins[key]);
if (pinned) next.add(id);
else next.delete(id);
await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, {
directory,
method: 'PATCH',
body: {
metadata: {
...metadata,
openchamber: {
...openchamber,
[PINS_METADATA_KEY]: { ...pins, [key]: [...next] },
[KNOWLEDGE_METADATA_KEY]: '',
},
},
},
});
return { ...pins, [key]: [...next] };
};
/**
@@ -272,10 +318,14 @@ export const createSessionKnowledgeRuntime = (dependencies) => {
return {
collect,
collectSummary,
collectSummaryForSession,
resolvePending,
resolvePendingForSession,
recordDelivered,
readDeliveredSignature,
readPins,
setPin,
metadataKey: KNOWLEDGE_METADATA_KEY,
pinsMetadataKey: PINS_METADATA_KEY,
};
};
@@ -4,6 +4,7 @@ import { buildKnowledgeSignature, buildKnowledgeText, createSessionKnowledgeRunt
const DIRECTORY = '/work/project';
const PROJECT_ID = 'path_project';
const PINS = { notes: ['n1'], plans: ['p1'] };
const note = (overrides = {}) => ({
id: 'n1', body: 'Pinned note body.', createdAt: 1, updatedAt: 1, pinned: true, source: 'manual', ...overrides,
@@ -26,12 +27,13 @@ const createRuntime = (overrides = {}) => createSessionKnowledgeRuntime({
readAll: async () => ({ global: [memory()], project: [], globalFailed: false, projectFailed: false }),
...overrides.agentMemoryRuntime,
},
...('openCodeFetch' in overrides ? { openCodeFetch: overrides.openCodeFetch } : {}),
...('isAgentMemoryEnabled' in overrides ? { isAgentMemoryEnabled: overrides.isAgentMemoryEnabled } : {}),
});
describe('what the session is owed', () => {
test('carries pinned notes, pinned plan bodies, and the memory index', async () => {
const { text } = await createRuntime().resolvePending(DIRECTORY, '');
const { text } = await createRuntime().resolvePending(DIRECTORY, '', PINS);
expect(text).toContain('Pinned note body.');
expect(text).toContain('Migration plan');
@@ -52,7 +54,7 @@ describe('what the session is owed', () => {
},
});
const { text } = await runtime.resolvePending(DIRECTORY, '');
const { text } = await runtime.resolvePending(DIRECTORY, '', { notes: [], plans: [] });
expect(text).not.toContain('Pinned note body.');
});
@@ -123,7 +125,7 @@ describe('when a source will not load', () => {
agentMemoryRuntime: { readAll: async () => { throw new Error('unreadable'); } },
});
const { text } = await runtime.resolvePending(DIRECTORY, '');
const { text } = await runtime.resolvePending(DIRECTORY, '', PINS);
expect(text).toContain('Pinned note body.');
});
@@ -135,7 +137,7 @@ describe('when a source will not load', () => {
},
});
const { text } = await runtime.resolvePending(DIRECTORY, '');
const { text } = await runtime.resolvePending(DIRECTORY, '', PINS);
expect(text).not.toContain('Uses bun');
});
@@ -148,7 +150,7 @@ describe('when a source will not load', () => {
},
});
const { text } = await runtime.resolvePending(DIRECTORY, '');
const { text } = await runtime.resolvePending(DIRECTORY, '', PINS);
expect(text).toContain('Migration plan');
expect(text).toContain('plan content unavailable');
@@ -159,7 +161,7 @@ describe('when a source will not load', () => {
projectContextRuntime: { readContext: async () => { throw new Error('unreadable'); } },
});
const { text } = await runtime.resolvePending(DIRECTORY, '');
const { text } = await runtime.resolvePending(DIRECTORY, '', PINS);
expect(text).toContain('Uses bun');
});
@@ -169,7 +171,7 @@ describe('the memory switch', () => {
test('memory is left out entirely while the feature is off', async () => {
const runtime = createRuntime({ isAgentMemoryEnabled: async () => false });
const { text } = await runtime.resolvePending(DIRECTORY, '');
const { text } = await runtime.resolvePending(DIRECTORY, '', PINS);
expect(text).not.toContain('Uses bun');
expect(text).toContain('Pinned note body.');
@@ -187,6 +189,39 @@ describe('the memory switch', () => {
});
describe('reading what a session was told', () => {
test('project context pins are isolated in each session metadata record', () => {
const runtime = createRuntime();
expect(runtime.readPins({
metadata: { openchamber: { project_context_pins: { notes: ['n1'], plans: [] } } },
})).toEqual({ notes: ['n1'], plans: [] });
expect(runtime.readPins({
metadata: { openchamber: { project_context_pins: { notes: [], plans: ['p1'] } } },
})).toEqual({ notes: [], plans: ['p1'] });
expect(runtime.readPins({})).toEqual({ notes: [], plans: [] });
});
test('pinning updates only the target session and invalidates its delivered signature', async () => {
const requests = [];
const runtime = createRuntime({
openCodeFetch: async (path, options = {}) => {
requests.push({ path, options });
if (options.method === 'PATCH') return {};
return {
metadata: { openchamber: { project_context_pins: { notes: [], plans: [] }, knowledge_context_delivered: 'old' } },
};
},
});
await runtime.setPin('ses_a', DIRECTORY, 'note', 'n1', true);
expect(requests.map((request) => request.path)).toEqual(['/session/ses_a', '/session/ses_a']);
expect(requests[1].options.body.metadata.openchamber).toEqual({
project_context_pins: { notes: ['n1'], plans: [] },
knowledge_context_delivered: '',
});
});
test('finds the signature stored on the session', () => {
const runtime = createRuntime();