fix(knowledge): scope pins to sessions
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user