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
@@ -5,12 +5,9 @@ import { useSkillsStore } from '@/stores/useSkillsStore';
import { useMcpStore } from '@/stores/useMcpStore';
import { useSession } from '@/sync/sync-context';
import { getLinkedIssues } from '@/lib/linkedIssues';
import { fetchSessionKnowledgeSummary, type SessionKnowledgeSummary } from '@/lib/sessionKnowledgeApi';
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
import { fetchSessionKnowledgeSummary, setSessionProjectContextPin, type SessionKnowledgeSummary } from '@/lib/sessionKnowledgeApi';
import { useProjectContextStore } from '@/stores/useProjectContextStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { WorkStatusCollapsibleSection, WorkStatusRow, WorkStatusValue } from './WorkStatusPrimitives';
import { useReportWorkStatusPresence } from './presenceContext';
@@ -50,7 +47,7 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
}, [directory, loadSkills]);
/**
* What the project sends along with every message. Read from the server
* What this session carries. Read from the server
* rather than from the notes panel's store, because this must be right
* whether or not that panel has ever been opened.
*/
@@ -58,41 +55,34 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
{ notes: [], plans: [], memory: { global: 0, project: 0 } },
);
// Re-read whenever the stores that own pins or memory change, not only when
// the directory does. Unpinning is a write those stores make, and a panel
// that keeps listing what was just unpinned tells the user it is still going
// to the agent when it is not.
// Re-read when source content or memory changes, not only when the session does.
const contextEntries = useProjectContextStore((state) => state.entries);
const memoryProject = useAgentMemoryStore((state) => state.project);
const memoryGlobal = useAgentMemoryStore((state) => state.global);
React.useEffect(() => {
let cancelled = false;
void fetchSessionKnowledgeSummary(directory).then((summary) => {
void fetchSessionKnowledgeSummary(directory, sessionId).then((summary) => {
if (!cancelled) setKnowledge(summary);
});
return () => { cancelled = true; };
}, [directory, contextEntries, memoryProject, memoryGlobal]);
const projects = useProjectsStore((state) => state.projects);
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
const setNotePinned = useProjectContextStore((state) => state.setNotePinned);
const setPlanPinned = useProjectContextStore((state) => state.setPlanPinned);
const projectRef = React.useMemo(() => {
const resolved = resolveProjectForSessionDirectory(projects, availableWorktreesByProject, directory ?? '');
return resolved ? { id: resolved.id, path: resolved.path } : null;
}, [availableWorktreesByProject, directory, projects]);
}, [directory, sessionId, session, contextEntries, memoryProject, memoryGlobal]);
// Unpinning from here, like the pinned-messages section: a panel that says
// what is attached should be able to detach it, or the user has to go find
// the surface that can.
const unpinNote = React.useCallback((noteId: string) => {
if (projectRef) void setNotePinned(projectRef, noteId, false);
}, [projectRef, setNotePinned]);
if (!directory || !sessionId) return;
void setSessionProjectContextPin(directory, sessionId, 'note', noteId, false).then((pins) => {
if (pins) setKnowledge((current) => ({ ...current, notes: current.notes.filter((note) => note.id !== noteId) }));
});
}, [directory, sessionId]);
const unpinPlan = React.useCallback((planId: string) => {
if (projectRef) void setPlanPinned(projectRef, planId, false);
}, [projectRef, setPlanPinned]);
if (!directory || !sessionId) return;
void setSessionProjectContextPin(directory, sessionId, 'plan', planId, false).then((pins) => {
if (pins) setKnowledge((current) => ({ ...current, plans: current.plans.filter((plan) => plan.id !== planId) }));
});
}, [directory, sessionId]);
const memoryCount = knowledge.memory.global + knowledge.memory.project;
const pinnedCount = knowledge.notes.length + knowledge.plans.length;
@@ -131,9 +121,7 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
? t('chat.workStatus.breakdown.prCountSingle', { count: prCount })
: t('chat.workStatus.breakdown.prCountPlural', { count: prCount }));
}
// Pinned knowledge outranks the ambient counts in the summary: it is
// something the user chose for this project, not something that happens to
// be installed.
// Pinned knowledge outranks ambient counts because the user chose it for this session.
if (summaryParts.length === 0 && pinnedCount > 0) {
summaryParts.push(pinnedCount === 1
? t('chat.workStatus.breakdown.pinnedKnowledgeSingle', { count: pinnedCount })
@@ -182,8 +170,7 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
/>
))}
{/* Named individually: a count alone would not tell the user which note
is riding along with every message they send. */}
{/* Named individually: a count alone would not identify this session's context. */}
{/* The pin is the control, exactly as in the pinned-messages section
above: same icon, same placement, same behaviour. Two pins that look
different in one panel would read as two different things. */}
@@ -194,7 +181,7 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
leading={(
<button
type="button"
disabled={!projectRef}
disabled={!sessionId || !directory}
aria-label={t('chat.workStatus.breakdown.unpin')}
onClick={(event) => {
event.stopPropagation();
@@ -216,7 +203,7 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
leading={(
<button
type="button"
disabled={!projectRef}
disabled={!sessionId || !directory}
aria-label={t('chat.workStatus.breakdown.unpin')}
onClick={(event) => {
event.stopPropagation();
@@ -60,14 +60,13 @@ Leaving the section or the project closes it, so its editor never sits over a
list it no longer matches. Hosts that own a fullscreen plan surface (mobile)
still pass `onOpenPlan` and keep theirs.
## Pins are project state, not a message attachment
## Pins belong to one session
Pinning a note or plan writes to the project, not to the session, so it holds
across every session in that project until it is unpinned. The composer once
carried a chip for it, from when pinned context was a one-shot attachment to the
next message; standing state shown permanently above the input reads as
something being attached to what you are typing, which it is not. What is
attached, and the control to detach it, live in the work status panel instead.
Notes and plans are project data, but attaching one writes its id to the current
session metadata. Other sessions in the project do not inherit it. A pin made
while a new-session draft is open lives on that draft and transfers only to the
session created by its first message. Work status lists and detaches the pins of
the session it describes.
## Memory is not a fifth kind of note
@@ -211,10 +210,8 @@ matched the old project would silently hide everything in the new one.
## Pinned context
The pin toggle on a note or plan marks it as standing context for the agent.
Assembly and delivery live in `packages/ui/src/lib/projectContextPinning.ts`;
this surface only owns the toggle. `ComposerPinnedContextChip` shows the user
what is riding along.
The pin toggle on a note or plan attaches it to the current session or draft.
Assembly and delivery live in `packages/web/server/lib/session-knowledge`.
## Related
@@ -23,12 +23,13 @@ const NOTE_SAVE_DEBOUNCE_MS = 400;
*/
const NoteRow: React.FC<{
note: ProjectNote;
pinned: boolean;
expanded: boolean;
onToggleExpanded: () => void;
onSaveBody: (body: string) => void;
onTogglePinned: () => void;
onDelete: () => void;
}> = ({ note, expanded, onToggleExpanded, onSaveBody, onTogglePinned, onDelete }) => {
}> = ({ note, pinned, expanded, onToggleExpanded, onSaveBody, onTogglePinned, onDelete }) => {
const { t } = useI18n();
const [draft, setDraft] = React.useState(note.body);
const lastSavedRef = React.useRef(note.body);
@@ -107,19 +108,19 @@ const NoteRow: React.FC<{
onClick={onTogglePinned}
className={cn(
'inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
note.pinned ? 'text-primary' : 'text-muted-foreground hover:text-foreground'
pinned ? 'text-primary' : 'text-muted-foreground hover:text-foreground'
)}
aria-pressed={note.pinned}
aria-label={note.pinned
aria-pressed={pinned}
aria-label={pinned
? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
: t('rightSidebar.contextNotesTodo.notes.actions.pin')}
title={note.pinned
title={pinned
? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
: t('rightSidebar.contextNotesTodo.notes.actions.pin')}
>
{/* Filled means pinned, outline means "pin this" — the same
language the work status panel uses. */}
<Icon name={note.pinned ? 'pushpin-2-fill' : 'pushpin'} className="h-3.5 w-3.5" />
<Icon name={pinned ? 'pushpin-2-fill' : 'pushpin'} className="h-3.5 w-3.5" />
</button>
<button
type="button"
@@ -163,7 +164,9 @@ export const NotesSection: React.FC<{
notes: ProjectNote[];
disabled: boolean;
query: string;
}> = ({ projectRef, notes, disabled, query }) => {
pinnedNoteIds: ReadonlySet<string>;
onTogglePinned: (noteId: string, pinned: boolean) => Promise<boolean>;
}> = ({ projectRef, notes, disabled, query, pinnedNoteIds, onTogglePinned }) => {
const { t } = useI18n();
const [composerText, setComposerText] = React.useState('');
// One at a time on purpose: notes can run to 3000 characters each, and
@@ -173,7 +176,6 @@ export const NotesSection: React.FC<{
const setNotesPanelHeight = useUIStore((state) => state.setNotesPanelHeight);
const createNote = useProjectContextStore((state) => state.createNote);
const saveNoteBody = useProjectContextStore((state) => state.saveNoteBody);
const setNotePinned = useProjectContextStore((state) => state.setNotePinned);
const deleteNote = useProjectContextStore((state) => state.deleteNote);
const visibleNotes = React.useMemo(() => {
@@ -214,12 +216,12 @@ export const NotesSection: React.FC<{
const handleTogglePinned = React.useCallback(
async (noteId: string, pinned: boolean) => {
const ok = await setNotePinned(projectRef, noteId, pinned);
const ok = await onTogglePinned(noteId, pinned);
if (!ok) {
reportFailure(t('rightSidebar.contextNotesTodo.toast.saveNotesFailed'));
}
},
[projectRef, reportFailure, setNotePinned, t]
[onTogglePinned, reportFailure, t]
);
const handleSaveBody = React.useCallback(
@@ -281,10 +283,11 @@ export const NotesSection: React.FC<{
<NoteRow
key={note.id}
note={note}
pinned={pinnedNoteIds.has(note.id)}
expanded={expandedNoteId === note.id}
onToggleExpanded={() => setExpandedNoteId((current) => (current === note.id ? null : note.id))}
onSaveBody={(body) => handleSaveBody(note.id, body)}
onTogglePinned={() => void handleTogglePinned(note.id, !note.pinned)}
onTogglePinned={() => void handleTogglePinned(note.id, !pinnedNoteIds.has(note.id))}
onDelete={() => void handleDelete(note.id)}
/>
))}
@@ -24,14 +24,15 @@ export const PlansSection: React.FC<{
query: string;
/** Hosts without a ContextPanel (mobile) render their own plan viewer. */
onOpenPlan?: (plan: { id: string; title: string }) => void;
}> = ({ projectRef, plans, query, onOpenPlan }) => {
pinnedPlanIds: ReadonlySet<string>;
onTogglePinned: (planId: string, pinned: boolean) => Promise<boolean>;
}> = ({ projectRef, plans, query, onOpenPlan, pinnedPlanIds, onTogglePinned }) => {
const { t } = useI18n();
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
const [isImporting, setIsImporting] = React.useState(false);
const [deletingPlanId, setDeletingPlanId] = React.useState<string | null>(null);
const createPlan = useProjectContextStore((state) => state.createPlan);
const removePlan = useProjectContextStore((state) => state.deletePlan);
const setPlanPinned = useProjectContextStore((state) => state.setPlanPinned);
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
@@ -136,13 +137,13 @@ export const PlansSection: React.FC<{
const handleTogglePinned = React.useCallback(
async (planId: string, pinned: boolean) => {
const ok = await setPlanPinned(projectRef, planId, pinned);
const ok = await onTogglePinned(planId, pinned);
if (!ok) {
const detail = useProjectContextStore.getState().getEntry(projectRef).error;
toast.error(t('rightSidebar.contextNotesTodo.toast.updatePlanFailed'), detail ? { description: detail } : undefined);
}
},
[projectRef, setPlanPinned, t]
[onTogglePinned, projectRef, t]
);
const visiblePlans = React.useMemo(() => {
@@ -220,16 +221,16 @@ export const PlansSection: React.FC<{
</button>
<button
type="button"
onClick={() => void handleTogglePinned(plan.id, !plan.pinned)}
onClick={() => void handleTogglePinned(plan.id, !pinnedPlanIds.has(plan.id))}
className={cn(
'inline-flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-md hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
plan.pinned ? 'text-primary' : 'text-muted-foreground hover:text-foreground'
pinnedPlanIds.has(plan.id) ? 'text-primary' : 'text-muted-foreground hover:text-foreground'
)}
aria-pressed={plan.pinned}
aria-label={plan.pinned
aria-pressed={pinnedPlanIds.has(plan.id)}
aria-label={pinnedPlanIds.has(plan.id)
? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
: t('rightSidebar.contextNotesTodo.notes.actions.pin')}
title={plan.pinned
title={pinnedPlanIds.has(plan.id)
? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
: t('rightSidebar.contextNotesTodo.notes.actions.pin')}
>
@@ -17,6 +17,8 @@ import { NotesSection } from './NotesSection';
import { PlansSection } from './PlansSection';
import { TodosSection } from './TodosSection';
import { useProjectTodoSend } from './useProjectTodoSend';
import { fetchSessionKnowledgeSummary, setSessionProjectContextPin, type SessionProjectContextPins } from '@/lib/sessionKnowledgeApi';
import { useSessionUIStore } from '@/sync/session-ui-store';
/** Lazy: the plan editor is a large view, and most panel visits never open it. */
const PlanView = React.lazy(() => import('@/components/views/PlanView').then((module) => ({ default: module.PlanView })));
@@ -85,6 +87,43 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
);
const loadProjectContext = useProjectContextStore((state) => state.load);
const saveTodos = useProjectContextStore((state) => state.saveTodos);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const currentSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory);
const newSessionDraft = useSessionUIStore((state) => state.newSessionDraft);
const setDraftProjectContextPin = useSessionUIStore((state) => state.setDraftProjectContextPin);
const [sessionPins, setSessionPins] = React.useState<SessionProjectContextPins>({ notes: [], plans: [] });
React.useEffect(() => {
if (newSessionDraft.open) {
setSessionPins(newSessionDraft.projectContextPins ?? { notes: [], plans: [] });
return;
}
let cancelled = false;
void fetchSessionKnowledgeSummary(currentSessionDirectory, currentSessionId).then((summary) => {
if (!cancelled) {
setSessionPins({
notes: summary.notes.map((note) => note.id),
plans: summary.plans.map((plan) => plan.id),
});
}
});
return () => { cancelled = true; };
}, [currentSessionDirectory, currentSessionId, newSessionDraft.open, newSessionDraft.projectContextPins]);
const toggleSessionPin = React.useCallback(async (kind: 'note' | 'plan', id: string, pinned: boolean) => {
if (newSessionDraft.open) {
setDraftProjectContextPin(kind, id, pinned);
return true;
}
if (!currentSessionId || !currentSessionDirectory) return false;
const next = await setSessionProjectContextPin(currentSessionDirectory, currentSessionId, kind, id, pinned);
if (!next) return false;
setSessionPins(next);
return true;
}, [currentSessionDirectory, currentSessionId, newSessionDraft.open, setDraftProjectContextPin]);
const pinnedNoteIds = React.useMemo(() => new Set(sessionPins.notes), [sessionPins.notes]);
const pinnedPlanIds = React.useMemo(() => new Set(sessionPins.plans), [sessionPins.plans]);
// The whole feature is one switch: with memory off there is nothing for the
// agent to manage, so showing the user what is stored would be pointless.
@@ -387,6 +426,8 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
notes={contextEntry.notes}
disabled={isLoading}
query={query}
pinnedNoteIds={pinnedNoteIds}
onTogglePinned={(noteId, pinned) => toggleSessionPin('note', noteId, pinned)}
/>
) : null}
@@ -413,6 +454,8 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
projectRef={projectRef}
plans={contextEntry.plans}
query={query}
pinnedPlanIds={pinnedPlanIds}
onTogglePinned={(planId, pinned) => toggleSessionPin('plan', planId, pinned)}
// Hosts that own a fullscreen plan surface (mobile) keep it; on the
// desktop panel the plan opens here, in place of the list.
onOpenPlan={onOpenPlan ?? setOpenPlan}
+31 -1
View File
@@ -12,6 +12,7 @@
*/
import { runtimeFetch } from './runtime-fetch';
import { z } from 'zod';
interface SessionKnowledge {
/** Empty when the session already carries what it needs. */
@@ -86,14 +87,17 @@ const EMPTY_SUMMARY: SessionKnowledgeSummary = { notes: [], plans: [], memory: {
/** What the session is carrying, for display. Never throws; shows nothing instead. */
export const fetchSessionKnowledgeSummary = async (
directory: string | null,
sessionId?: string | null,
): Promise<SessionKnowledgeSummary> => {
if (!directory) {
return EMPTY_SUMMARY;
}
try {
const params = new URLSearchParams({ directory });
if (sessionId) params.set('sessionId', sessionId);
const response = await runtimeFetch(
`/api/session-knowledge/summary?${new URLSearchParams({ directory }).toString()}`,
`/api/session-knowledge/summary?${params.toString()}`,
{ cache: 'no-store' },
);
if (!response.ok) {
@@ -112,3 +116,29 @@ export const fetchSessionKnowledgeSummary = async (
return EMPTY_SUMMARY;
}
};
export type SessionProjectContextPins = { notes: string[]; plans: string[] };
const sessionProjectContextPinsResponseSchema = z.object({
pins: z.object({ notes: z.array(z.string()), plans: z.array(z.string()) }),
});
export const setSessionProjectContextPin = async (
directory: string,
sessionId: string,
kind: 'note' | 'plan',
id: string,
pinned: boolean,
): Promise<SessionProjectContextPins | null> => {
try {
const response = await runtimeFetch('/api/session-knowledge/pin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ directory, sessionId, kind, id, pinned }),
});
if (!response.ok) return null;
return sessionProjectContextPinsResponseSchema.parse(await response.json()).pins;
} catch {
return null;
}
};
@@ -373,6 +373,27 @@ describe("issue 2039 draft auto-accept", () => {
expect(permissionAutoAcceptCalls).toHaveLength(0)
})
test("transfers draft project context pins only to the session it creates", async () => {
useSessionUIStore.getState().openNewSessionDraft({
projectContextPins: { notes: ["note-a"], plans: [] },
})
useSessionUIStore.getState().setDraftProjectContextPin("plan", "plan-a", true)
await materializeOpenDraftSession({ providerID: "provider", modelID: "model" })
expect(createSessionCalls[0]?.metadata).toEqual({
openchamber: {
project_context_pins: { notes: ["note-a"], plans: ["plan-a"] },
},
})
expect(useSessionUIStore.getState().newSessionDraft.projectContextPins).toBe(undefined)
useSessionUIStore.getState().openNewSessionDraft()
await materializeOpenDraftSession({ providerID: "provider", modelID: "model" })
expect(createSessionCalls[1]?.metadata).toBe(undefined)
})
test("uses the server-authoritative directory after worktree session creation", async () => {
createdSessionDirectory = "/canonical/worktree"
useSessionUIStore.getState().openNewSessionDraft({
+29 -9
View File
@@ -270,6 +270,7 @@ export type NewSessionDraftState = {
initialPrompt?: string
syntheticParts?: SyntheticContextPart[]
targetFolderId?: string
projectContextPins?: { notes: string[]; plans: string[] }
}
export type ViewportAnchor = {
@@ -319,6 +320,7 @@ export type SessionUIState = {
setNewSessionDraftTarget: (target: { projectId?: string | null; selectedProjectId?: string | null; directoryOverride?: string | null }, options?: { force?: boolean }) => void
setDraftPreserveDirectoryOverride: (value: boolean) => void
setDraftPermissionAutoAcceptEnabled: (enabled: boolean) => void
setDraftProjectContextPin: (kind: "note" | "plan", id: string, pinned: boolean) => void
acknowledgeSessionAbort: (sessionId: string) => void
clearAbortPrompt: () => void
armAbortPrompt: (durationMs?: number) => number | null
@@ -726,7 +728,15 @@ export async function materializeOpenDraftSession(selection: {
await waitForWorktreeBootstrapIfConfigured(draftDirectoryOverride, draftProjectId)
const created = await store.createSession(draft.title, draftDirectoryOverride, draft.parentID ?? null)
const draftPins = draft.projectContextPins ?? { notes: [], plans: [] }
const created = await store.createSession(
draft.title,
draftDirectoryOverride,
draft.parentID ?? null,
draftPins.notes.length > 0 || draftPins.plans.length > 0
? { openchamber: { project_context_pins: draftPins } }
: undefined,
)
if (!created?.id) throw new Error("Failed to create session")
// The server response is authoritative. It may canonicalize a requested
@@ -1026,6 +1036,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
initialPrompt: options?.initialPrompt,
syntheticParts: options?.syntheticParts,
targetFolderId: options?.targetFolderId,
projectContextPins: options?.projectContextPins,
}
set({
@@ -1138,6 +1149,22 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
return { newSessionDraft: { ...s.newSessionDraft, permissionAutoAcceptEnabled: enabled } }
}),
setDraftProjectContextPin: (kind, id, pinned) =>
set((s) => {
if (!s.newSessionDraft?.open) return s
const pins = s.newSessionDraft.projectContextPins ?? { notes: [], plans: [] }
const key = kind === "note" ? "notes" : "plans"
const next = new Set(pins[key])
if (pinned) next.add(id)
else next.delete(id)
return {
newSessionDraft: {
...s.newSessionDraft,
projectContextPins: { ...pins, [key]: [...next] },
},
}
}),
acknowledgeSessionAbort: (sessionId) =>
set((s) => {
const flags = new Map(s.sessionAbortFlags)
@@ -1578,14 +1605,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
// ---------------------------------------------------------------------------
// deleteSession — calls SDK, SSE event updates child store
// ---------------------------------------------------------------------------
deleteSession: async (id, options) => {
const deleted = await deleteSessionAction(id, options)
if (deleted) {
// Nothing to forget here any more: what a session was told lives in its
// own metadata and goes with it.
}
return deleted
},
deleteSession: async (id, options) => deleteSessionAction(id, options),
deleteSessions: async (ids, options) => {
const result = await deleteSessionsAction(ids, options)
@@ -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();