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
+2 -2
View File
@@ -5,9 +5,9 @@ All notable changes to this project will be documented in this file.
## [Unreleased] ## [Unreleased]
- **Settings/Integrations:** a new Integrations settings page lists Claude Code, Command Code, and Cursor plugins with install, update, setup, and remove actions, plus Discord and Telegram Coming soon placeholders. - **Settings/Integrations:** a new Integrations settings page lists Claude Code, Command Code, and Cursor plugins with install, update, setup, and remove actions, plus Discord and Telegram Coming soon placeholders.
- **Project knowledge:** the Project notes panel is now Project knowledge, with notes, todos, plans and their search in a resizable sidebar. Notes are cards you expand by clicking anywhere on them, plans open and edit in the panel itself instead of a separate tab, and notes and plans can be pinned as project context. - **Project knowledge:** the Project notes panel is now Project knowledge, with notes, todos, plans and their search in a resizable sidebar. Notes are cards you expand by clicking anywhere on them, plans open and edit in the panel itself instead of a separate tab, and notes and plans can be attached to the current session or its new-session draft without affecting other sessions.
- **Files:** drag files onto the Files sidebar to upload them into the project or a specific folder; existing files require confirmation before replacement, and open previews refresh after an upload (thanks to @makeittech, @alanzchen). - **Files:** drag files onto the Files sidebar to upload them into the project or a specific folder; existing files require confirmation before replacement, and open previews refresh after an upload (thanks to @makeittech, @alanzchen).
- Work status: the Context sources section now names each pinned note and plan available as standing project context, and its pin button unpins them from there. - Work status: the Context sources section now names each note and plan attached to that session, and its pin button detaches them from there.
- Settings: OpenChamber no longer replaces a full OpenCode config with an empty `$schema`-only stub when the file uses JSON5-style unquoted keys; Settings changes now fail instead of wiping plugins, MCP servers, and providers (thanks to @makeittech). - Settings: OpenChamber no longer replaces a full OpenCode config with an empty `$schema`-only stub when the file uses JSON5-style unquoted keys; Settings changes now fail instead of wiping plugins, MCP servers, and providers (thanks to @makeittech).
- Chat: an open conversation no longer keeps re-coloring the same code blocks in the background, so browsing files with a chat open stops pinning a CPU core and spinning up the fans (thanks to @makeittech). - Chat: an open conversation no longer keeps re-coloring the same code blocks in the background, so browsing files with a chat open stops pinning a CPU core and spinning up the fans (thanks to @makeittech).
- Stability/Proxy: the local server now reuses its connection to OpenCode instead of opening a new one for every API request. Under sustained traffic the old behavior could use up every outgoing network port on the machine, at which point nothing on the computer could open a new connection until the traffic stopped and the ports were released (thanks to @alohaninja). - Stability/Proxy: the local server now reuses its connection to OpenCode instead of opening a new one for every API request. Under sustained traffic the old behavior could use up every outgoing network port on the machine, at which point nothing on the computer could open a new connection until the traffic stopped and the ports were released (thanks to @alohaninja).
@@ -5,12 +5,9 @@ import { useSkillsStore } from '@/stores/useSkillsStore';
import { useMcpStore } from '@/stores/useMcpStore'; import { useMcpStore } from '@/stores/useMcpStore';
import { useSession } from '@/sync/sync-context'; import { useSession } from '@/sync/sync-context';
import { getLinkedIssues } from '@/lib/linkedIssues'; import { getLinkedIssues } from '@/lib/linkedIssues';
import { fetchSessionKnowledgeSummary, type SessionKnowledgeSummary } from '@/lib/sessionKnowledgeApi'; import { fetchSessionKnowledgeSummary, setSessionProjectContextPin, type SessionKnowledgeSummary } from '@/lib/sessionKnowledgeApi';
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
import { useProjectContextStore } from '@/stores/useProjectContextStore'; import { useProjectContextStore } from '@/stores/useProjectContextStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore'; import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { WorkStatusCollapsibleSection, WorkStatusRow, WorkStatusValue } from './WorkStatusPrimitives'; import { WorkStatusCollapsibleSection, WorkStatusRow, WorkStatusValue } from './WorkStatusPrimitives';
import { useReportWorkStatusPresence } from './presenceContext'; import { useReportWorkStatusPresence } from './presenceContext';
@@ -50,7 +47,7 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
}, [directory, loadSkills]); }, [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 * rather than from the notes panel's store, because this must be right
* whether or not that panel has ever been opened. * 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 } }, { notes: [], plans: [], memory: { global: 0, project: 0 } },
); );
// Re-read whenever the stores that own pins or memory change, not only when // Re-read when source content or memory changes, not only when the session does.
// 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.
const contextEntries = useProjectContextStore((state) => state.entries); const contextEntries = useProjectContextStore((state) => state.entries);
const memoryProject = useAgentMemoryStore((state) => state.project); const memoryProject = useAgentMemoryStore((state) => state.project);
const memoryGlobal = useAgentMemoryStore((state) => state.global); const memoryGlobal = useAgentMemoryStore((state) => state.global);
React.useEffect(() => { React.useEffect(() => {
let cancelled = false; let cancelled = false;
void fetchSessionKnowledgeSummary(directory).then((summary) => { void fetchSessionKnowledgeSummary(directory, sessionId).then((summary) => {
if (!cancelled) setKnowledge(summary); if (!cancelled) setKnowledge(summary);
}); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [directory, contextEntries, memoryProject, memoryGlobal]); }, [directory, sessionId, session, 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]);
// Unpinning from here, like the pinned-messages section: a panel that says // 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 // what is attached should be able to detach it, or the user has to go find
// the surface that can. // the surface that can.
const unpinNote = React.useCallback((noteId: string) => { const unpinNote = React.useCallback((noteId: string) => {
if (projectRef) void setNotePinned(projectRef, noteId, false); if (!directory || !sessionId) return;
}, [projectRef, setNotePinned]); 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) => { const unpinPlan = React.useCallback((planId: string) => {
if (projectRef) void setPlanPinned(projectRef, planId, false); if (!directory || !sessionId) return;
}, [projectRef, setPlanPinned]); 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 memoryCount = knowledge.memory.global + knowledge.memory.project;
const pinnedCount = knowledge.notes.length + knowledge.plans.length; 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.prCountSingle', { count: prCount })
: t('chat.workStatus.breakdown.prCountPlural', { count: prCount })); : t('chat.workStatus.breakdown.prCountPlural', { count: prCount }));
} }
// Pinned knowledge outranks the ambient counts in the summary: it is // Pinned knowledge outranks ambient counts because the user chose it for this session.
// something the user chose for this project, not something that happens to
// be installed.
if (summaryParts.length === 0 && pinnedCount > 0) { if (summaryParts.length === 0 && pinnedCount > 0) {
summaryParts.push(pinnedCount === 1 summaryParts.push(pinnedCount === 1
? t('chat.workStatus.breakdown.pinnedKnowledgeSingle', { count: pinnedCount }) ? 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 {/* Named individually: a count alone would not identify this session's context. */}
is riding along with every message they send. */}
{/* The pin is the control, exactly as in the pinned-messages section {/* The pin is the control, exactly as in the pinned-messages section
above: same icon, same placement, same behaviour. Two pins that look above: same icon, same placement, same behaviour. Two pins that look
different in one panel would read as two different things. */} different in one panel would read as two different things. */}
@@ -194,7 +181,7 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
leading={( leading={(
<button <button
type="button" type="button"
disabled={!projectRef} disabled={!sessionId || !directory}
aria-label={t('chat.workStatus.breakdown.unpin')} aria-label={t('chat.workStatus.breakdown.unpin')}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
@@ -216,7 +203,7 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
leading={( leading={(
<button <button
type="button" type="button"
disabled={!projectRef} disabled={!sessionId || !directory}
aria-label={t('chat.workStatus.breakdown.unpin')} aria-label={t('chat.workStatus.breakdown.unpin')}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); 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) list it no longer matches. Hosts that own a fullscreen plan surface (mobile)
still pass `onOpenPlan` and keep theirs. 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 Notes and plans are project data, but attaching one writes its id to the current
across every session in that project until it is unpinned. The composer once session metadata. Other sessions in the project do not inherit it. A pin made
carried a chip for it, from when pinned context was a one-shot attachment to the while a new-session draft is open lives on that draft and transfers only to the
next message; standing state shown permanently above the input reads as session created by its first message. Work status lists and detaches the pins of
something being attached to what you are typing, which it is not. What is the session it describes.
attached, and the control to detach it, live in the work status panel instead.
## Memory is not a fifth kind of note ## 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 ## Pinned context
The pin toggle on a note or plan marks it as standing context for the agent. The pin toggle on a note or plan attaches it to the current session or draft.
Assembly and delivery live in `packages/ui/src/lib/projectContextPinning.ts`; Assembly and delivery live in `packages/web/server/lib/session-knowledge`.
this surface only owns the toggle. `ComposerPinnedContextChip` shows the user
what is riding along.
## Related ## Related
@@ -23,12 +23,13 @@ const NOTE_SAVE_DEBOUNCE_MS = 400;
*/ */
const NoteRow: React.FC<{ const NoteRow: React.FC<{
note: ProjectNote; note: ProjectNote;
pinned: boolean;
expanded: boolean; expanded: boolean;
onToggleExpanded: () => void; onToggleExpanded: () => void;
onSaveBody: (body: string) => void; onSaveBody: (body: string) => void;
onTogglePinned: () => void; onTogglePinned: () => void;
onDelete: () => void; onDelete: () => void;
}> = ({ note, expanded, onToggleExpanded, onSaveBody, onTogglePinned, onDelete }) => { }> = ({ note, pinned, expanded, onToggleExpanded, onSaveBody, onTogglePinned, onDelete }) => {
const { t } = useI18n(); const { t } = useI18n();
const [draft, setDraft] = React.useState(note.body); const [draft, setDraft] = React.useState(note.body);
const lastSavedRef = React.useRef(note.body); const lastSavedRef = React.useRef(note.body);
@@ -107,19 +108,19 @@ const NoteRow: React.FC<{
onClick={onTogglePinned} onClick={onTogglePinned}
className={cn( 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', '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-pressed={pinned}
aria-label={note.pinned aria-label={pinned
? t('rightSidebar.contextNotesTodo.notes.actions.unpin') ? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
: t('rightSidebar.contextNotesTodo.notes.actions.pin')} : t('rightSidebar.contextNotesTodo.notes.actions.pin')}
title={note.pinned title={pinned
? t('rightSidebar.contextNotesTodo.notes.actions.unpin') ? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
: t('rightSidebar.contextNotesTodo.notes.actions.pin')} : t('rightSidebar.contextNotesTodo.notes.actions.pin')}
> >
{/* Filled means pinned, outline means "pin this" — the same {/* Filled means pinned, outline means "pin this" — the same
language the work status panel uses. */} 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>
<button <button
type="button" type="button"
@@ -163,7 +164,9 @@ export const NotesSection: React.FC<{
notes: ProjectNote[]; notes: ProjectNote[];
disabled: boolean; disabled: boolean;
query: string; 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 { t } = useI18n();
const [composerText, setComposerText] = React.useState(''); const [composerText, setComposerText] = React.useState('');
// One at a time on purpose: notes can run to 3000 characters each, and // 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 setNotesPanelHeight = useUIStore((state) => state.setNotesPanelHeight);
const createNote = useProjectContextStore((state) => state.createNote); const createNote = useProjectContextStore((state) => state.createNote);
const saveNoteBody = useProjectContextStore((state) => state.saveNoteBody); const saveNoteBody = useProjectContextStore((state) => state.saveNoteBody);
const setNotePinned = useProjectContextStore((state) => state.setNotePinned);
const deleteNote = useProjectContextStore((state) => state.deleteNote); const deleteNote = useProjectContextStore((state) => state.deleteNote);
const visibleNotes = React.useMemo(() => { const visibleNotes = React.useMemo(() => {
@@ -214,12 +216,12 @@ export const NotesSection: React.FC<{
const handleTogglePinned = React.useCallback( const handleTogglePinned = React.useCallback(
async (noteId: string, pinned: boolean) => { async (noteId: string, pinned: boolean) => {
const ok = await setNotePinned(projectRef, noteId, pinned); const ok = await onTogglePinned(noteId, pinned);
if (!ok) { if (!ok) {
reportFailure(t('rightSidebar.contextNotesTodo.toast.saveNotesFailed')); reportFailure(t('rightSidebar.contextNotesTodo.toast.saveNotesFailed'));
} }
}, },
[projectRef, reportFailure, setNotePinned, t] [onTogglePinned, reportFailure, t]
); );
const handleSaveBody = React.useCallback( const handleSaveBody = React.useCallback(
@@ -281,10 +283,11 @@ export const NotesSection: React.FC<{
<NoteRow <NoteRow
key={note.id} key={note.id}
note={note} note={note}
pinned={pinnedNoteIds.has(note.id)}
expanded={expandedNoteId === note.id} expanded={expandedNoteId === note.id}
onToggleExpanded={() => setExpandedNoteId((current) => (current === note.id ? null : note.id))} onToggleExpanded={() => setExpandedNoteId((current) => (current === note.id ? null : note.id))}
onSaveBody={(body) => handleSaveBody(note.id, body)} 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)} onDelete={() => void handleDelete(note.id)}
/> />
))} ))}
@@ -24,14 +24,15 @@ export const PlansSection: React.FC<{
query: string; query: string;
/** Hosts without a ContextPanel (mobile) render their own plan viewer. */ /** Hosts without a ContextPanel (mobile) render their own plan viewer. */
onOpenPlan?: (plan: { id: string; title: string }) => void; 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 { t } = useI18n();
const fileInputRef = React.useRef<HTMLInputElement | null>(null); const fileInputRef = React.useRef<HTMLInputElement | null>(null);
const [isImporting, setIsImporting] = React.useState(false); const [isImporting, setIsImporting] = React.useState(false);
const [deletingPlanId, setDeletingPlanId] = React.useState<string | null>(null); const [deletingPlanId, setDeletingPlanId] = React.useState<string | null>(null);
const createPlan = useProjectContextStore((state) => state.createPlan); const createPlan = useProjectContextStore((state) => state.createPlan);
const removePlan = useProjectContextStore((state) => state.deletePlan); const removePlan = useProjectContextStore((state) => state.deletePlan);
const setPlanPinned = useProjectContextStore((state) => state.setPlanPinned);
const currentDirectory = useDirectoryStore((state) => state.currentDirectory); const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab); const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
@@ -136,13 +137,13 @@ export const PlansSection: React.FC<{
const handleTogglePinned = React.useCallback( const handleTogglePinned = React.useCallback(
async (planId: string, pinned: boolean) => { async (planId: string, pinned: boolean) => {
const ok = await setPlanPinned(projectRef, planId, pinned); const ok = await onTogglePinned(planId, pinned);
if (!ok) { if (!ok) {
const detail = useProjectContextStore.getState().getEntry(projectRef).error; const detail = useProjectContextStore.getState().getEntry(projectRef).error;
toast.error(t('rightSidebar.contextNotesTodo.toast.updatePlanFailed'), detail ? { description: detail } : undefined); toast.error(t('rightSidebar.contextNotesTodo.toast.updatePlanFailed'), detail ? { description: detail } : undefined);
} }
}, },
[projectRef, setPlanPinned, t] [onTogglePinned, projectRef, t]
); );
const visiblePlans = React.useMemo(() => { const visiblePlans = React.useMemo(() => {
@@ -220,16 +221,16 @@ export const PlansSection: React.FC<{
</button> </button>
<button <button
type="button" type="button"
onClick={() => void handleTogglePinned(plan.id, !plan.pinned)} onClick={() => void handleTogglePinned(plan.id, !pinnedPlanIds.has(plan.id))}
className={cn( 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', '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-pressed={pinnedPlanIds.has(plan.id)}
aria-label={plan.pinned aria-label={pinnedPlanIds.has(plan.id)
? t('rightSidebar.contextNotesTodo.notes.actions.unpin') ? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
: t('rightSidebar.contextNotesTodo.notes.actions.pin')} : 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.unpin')
: t('rightSidebar.contextNotesTodo.notes.actions.pin')} : t('rightSidebar.contextNotesTodo.notes.actions.pin')}
> >
@@ -17,6 +17,8 @@ import { NotesSection } from './NotesSection';
import { PlansSection } from './PlansSection'; import { PlansSection } from './PlansSection';
import { TodosSection } from './TodosSection'; import { TodosSection } from './TodosSection';
import { useProjectTodoSend } from './useProjectTodoSend'; 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. */ /** 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 }))); 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 loadProjectContext = useProjectContextStore((state) => state.load);
const saveTodos = useProjectContextStore((state) => state.saveTodos); 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 // 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. // 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} notes={contextEntry.notes}
disabled={isLoading} disabled={isLoading}
query={query} query={query}
pinnedNoteIds={pinnedNoteIds}
onTogglePinned={(noteId, pinned) => toggleSessionPin('note', noteId, pinned)}
/> />
) : null} ) : null}
@@ -413,6 +454,8 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
projectRef={projectRef} projectRef={projectRef}
plans={contextEntry.plans} plans={contextEntry.plans}
query={query} query={query}
pinnedPlanIds={pinnedPlanIds}
onTogglePinned={(planId, pinned) => toggleSessionPin('plan', planId, pinned)}
// Hosts that own a fullscreen plan surface (mobile) keep it; on the // Hosts that own a fullscreen plan surface (mobile) keep it; on the
// desktop panel the plan opens here, in place of the list. // desktop panel the plan opens here, in place of the list.
onOpenPlan={onOpenPlan ?? setOpenPlan} onOpenPlan={onOpenPlan ?? setOpenPlan}
+31 -1
View File
@@ -12,6 +12,7 @@
*/ */
import { runtimeFetch } from './runtime-fetch'; import { runtimeFetch } from './runtime-fetch';
import { z } from 'zod';
interface SessionKnowledge { interface SessionKnowledge {
/** Empty when the session already carries what it needs. */ /** 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. */ /** What the session is carrying, for display. Never throws; shows nothing instead. */
export const fetchSessionKnowledgeSummary = async ( export const fetchSessionKnowledgeSummary = async (
directory: string | null, directory: string | null,
sessionId?: string | null,
): Promise<SessionKnowledgeSummary> => { ): Promise<SessionKnowledgeSummary> => {
if (!directory) { if (!directory) {
return EMPTY_SUMMARY; return EMPTY_SUMMARY;
} }
try { try {
const params = new URLSearchParams({ directory });
if (sessionId) params.set('sessionId', sessionId);
const response = await runtimeFetch( const response = await runtimeFetch(
`/api/session-knowledge/summary?${new URLSearchParams({ directory }).toString()}`, `/api/session-knowledge/summary?${params.toString()}`,
{ cache: 'no-store' }, { cache: 'no-store' },
); );
if (!response.ok) { if (!response.ok) {
@@ -112,3 +116,29 @@ export const fetchSessionKnowledgeSummary = async (
return EMPTY_SUMMARY; 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) 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 () => { test("uses the server-authoritative directory after worktree session creation", async () => {
createdSessionDirectory = "/canonical/worktree" createdSessionDirectory = "/canonical/worktree"
useSessionUIStore.getState().openNewSessionDraft({ useSessionUIStore.getState().openNewSessionDraft({
+29 -9
View File
@@ -270,6 +270,7 @@ export type NewSessionDraftState = {
initialPrompt?: string initialPrompt?: string
syntheticParts?: SyntheticContextPart[] syntheticParts?: SyntheticContextPart[]
targetFolderId?: string targetFolderId?: string
projectContextPins?: { notes: string[]; plans: string[] }
} }
export type ViewportAnchor = { 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 setNewSessionDraftTarget: (target: { projectId?: string | null; selectedProjectId?: string | null; directoryOverride?: string | null }, options?: { force?: boolean }) => void
setDraftPreserveDirectoryOverride: (value: boolean) => void setDraftPreserveDirectoryOverride: (value: boolean) => void
setDraftPermissionAutoAcceptEnabled: (enabled: boolean) => void setDraftPermissionAutoAcceptEnabled: (enabled: boolean) => void
setDraftProjectContextPin: (kind: "note" | "plan", id: string, pinned: boolean) => void
acknowledgeSessionAbort: (sessionId: string) => void acknowledgeSessionAbort: (sessionId: string) => void
clearAbortPrompt: () => void clearAbortPrompt: () => void
armAbortPrompt: (durationMs?: number) => number | null armAbortPrompt: (durationMs?: number) => number | null
@@ -726,7 +728,15 @@ export async function materializeOpenDraftSession(selection: {
await waitForWorktreeBootstrapIfConfigured(draftDirectoryOverride, draftProjectId) 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") if (!created?.id) throw new Error("Failed to create session")
// The server response is authoritative. It may canonicalize a requested // The server response is authoritative. It may canonicalize a requested
@@ -1026,6 +1036,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
initialPrompt: options?.initialPrompt, initialPrompt: options?.initialPrompt,
syntheticParts: options?.syntheticParts, syntheticParts: options?.syntheticParts,
targetFolderId: options?.targetFolderId, targetFolderId: options?.targetFolderId,
projectContextPins: options?.projectContextPins,
} }
set({ set({
@@ -1138,6 +1149,22 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
return { newSessionDraft: { ...s.newSessionDraft, permissionAutoAcceptEnabled: enabled } } 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) => acknowledgeSessionAbort: (sessionId) =>
set((s) => { set((s) => {
const flags = new Map(s.sessionAbortFlags) 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 — calls SDK, SSE event updates child store
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
deleteSession: async (id, options) => { deleteSession: async (id, options) => deleteSessionAction(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
},
deleteSessions: async (ids, options) => { deleteSessions: async (ids, options) => {
const result = await deleteSessionsAction(ids, options) const result = await deleteSessionsAction(ids, options)
@@ -69,7 +69,13 @@ export const createContextObligatoryRuntime = ({
*/ */
const knowledge = sessionKnowledgeRuntime const knowledge = sessionKnowledgeRuntime
? await 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: '' })) .catch(() => ({ text: '', signature: '' }))
: { text: '', signature: '' }; : { text: '', signature: '' };
@@ -73,7 +73,10 @@ describe('context obligatory runtime', () => {
const url = new URL(typeof input === 'string' ? input : input.url); const url = new URL(typeof input === 'string' ? input : input.url);
requests.push({ path: url.pathname, method: init.method ?? 'GET', body: init.body }); 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' && 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([ if (url.pathname === '/session/ses_1/message') return json([
{ info: { id: 'msg_agent', role: 'assistant', providerID: 'provider', modelID: 'model', agent: 'build' } }, { info: { id: 'msg_agent', role: 'assistant', providerID: 'provider', modelID: 'model', agent: 'build' } },
{ info: { id: 'msg_summary', role: 'assistant', summary: true, time: { completed: 30 } } }, { 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({}); if (url.pathname === '/session/ses_1/prompt_async') return json({});
throw new Error(`Unexpected ${url.pathname}`); throw new Error(`Unexpected ${url.pathname}`);
})); }));
const resolvePending = vi.fn(async () => ({
text: '## Pinned notes\n\n- Remember this.',
signature: 'sig-1',
}));
const runtime = createContextObligatoryRuntime({ const runtime = createContextObligatoryRuntime({
buildOpenCodeUrl: (path) => `http://opencode.test${path}`, buildOpenCodeUrl: (path) => `http://opencode.test${path}`,
getOpenCodeAuthHeaders: () => ({}), getOpenCodeAuthHeaders: () => ({}),
sessionKnowledgeRuntime: { sessionKnowledgeRuntime: {
metadataKey: 'knowledge_context_delivered', metadataKey: 'knowledge_context_delivered',
readDeliveredSignature: () => '', readPins: () => ({ notes: ['n1'], plans: [] }),
resolvePending: async () => ({ text: '## Pinned notes\n\n- Remember this.', signature: 'sig-1' }), 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')); const prompt = requests.find((request) => request.path.endsWith('/prompt_async'));
expect(JSON.parse(prompt.body).parts[0].text).toContain('Remember this.'); expect(JSON.parse(prompt.body).parts[0].text).toContain('Remember this.');
const patch = requests.find((request) => request.method === 'PATCH'); const patch = requests.find((request) => request.method === 'PATCH');
@@ -123,7 +138,7 @@ describe('context obligatory runtime', () => {
getOpenCodeAuthHeaders: () => ({}), getOpenCodeAuthHeaders: () => ({}),
sessionKnowledgeRuntime: { sessionKnowledgeRuntime: {
metadataKey: 'knowledge_context_delivered', metadataKey: 'knowledge_context_delivered',
readDeliveredSignature: () => '', readPins: () => ({ notes: ['n1'], plans: [] }),
resolvePending: async () => ({ text: 'Pinned notes block', signature: 'sig-1' }), resolvePending: async () => ({ text: 'Pinned notes block', signature: 'sig-1' }),
}, },
}); });
@@ -152,7 +167,7 @@ describe('context obligatory runtime', () => {
getOpenCodeAuthHeaders: () => ({}), getOpenCodeAuthHeaders: () => ({}),
sessionKnowledgeRuntime: { sessionKnowledgeRuntime: {
metadataKey: 'knowledge_context_delivered', metadataKey: 'knowledge_context_delivered',
readDeliveredSignature: () => 'sig-1', readPins: () => ({ notes: [], plans: [] }),
resolvePending: async () => ({ text: '', signature: 'sig-1' }), resolvePending: async () => ({ text: '', signature: 'sig-1' }),
}, },
}); });
@@ -27,11 +27,10 @@ Nothing outside this module may write `context.json` or the `plans` directory.
"notes": [{ "notes": [{
"id": "", "body": "", "createdAt": 0, "updatedAt": 0, "id": "", "body": "", "createdAt": 0, "updatedAt": 0,
"source": "manual | selection | agent", "source": "manual | selection | agent",
"pinned": false,
"origin": { "sessionId": "", "messageId": "" } "origin": { "sessionId": "", "messageId": "" }
}], }],
"todos": [{ "id": "", "text": "", "completed": false, "createdAt": 0 }], "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 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 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. 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 `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 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 | | 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 | | PUT | `/api/project-context/:projectId/todos` | replaces the whole list; returns committed context |
| POST | `/api/project-context/:projectId/notes` | `201`; takes `{body, source?, origin?}` | | 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 | | 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 | | 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 | | 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 | | 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 # 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 plans, and the index of what the agent has remembered — and whether it has been
told yet. told yet.
@@ -19,6 +19,11 @@ on believing it does and never sends it again.
## The contract ## 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 `session.metadata.openchamber.knowledge_context_delivered` holds the signature
of what the session is carrying. It lives with the session, so it survives the 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. 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 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. 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 Pinned notes and plans are unaffected by the memory switch and remain scoped to
message whether or not memory exists. the session that pinned them.
@@ -55,14 +55,34 @@ export const registerSessionKnowledgeRoutes = (app, dependencies) => {
if (!directory) { if (!directory) {
return res.json({ notes: [], plans: [], memory: { global: 0, project: 0 } }); return res.json({ notes: [], plans: [], memory: { global: 0, project: 0 } });
} }
const sessionId = asNonEmptyString(req.query.sessionId);
try { try {
return res.json(await sessionKnowledgeRuntime.collectSummary(directory)); return res.json(sessionId
? await sessionKnowledgeRuntime.collectSummaryForSession(sessionId, directory)
: await sessionKnowledgeRuntime.collectSummary(directory));
} catch { } catch {
// A panel that cannot read this shows nothing rather than an error. // A panel that cannot read this shows nothing rather than an error.
return res.json({ notes: [], plans: [], memory: { global: 0, project: 0 } }); 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) => { app.post('/api/session-knowledge/delivered', parseJsonBody, async (req, res) => {
const body = req.body; const body = req.body;
if (!isRecord(body)) { if (!isRecord(body)) {
@@ -16,6 +16,7 @@
*/ */
const KNOWLEDGE_METADATA_KEY = 'knowledge_context_delivered'; 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. */ /** Total budget for the assembled block; anything past it is cut, loudly. */
const KNOWLEDGE_MAX_LENGTH = 8000; 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 * source never blanks the rest: a memory store that will not load must not
* take the user's pinned notes down with it. * 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) : ''; const projectId = directory ? await resolveProjectId(directory) : '';
let notes = []; let notes = [];
@@ -127,8 +138,10 @@ export const createSessionKnowledgeRuntime = (dependencies) => {
if (projectId) { if (projectId) {
try { try {
const context = await projectContextRuntime.readContext(projectId); const context = await projectContextRuntime.readContext(projectId);
notes = (context.notes || []).filter((note) => note.pinned); const noteIds = new Set(pins.notes);
const pinnedPlans = (context.plans || []).filter((plan) => plan.pinned); 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) => { plans = await Promise.all(pinnedPlans.map(async (plan) => {
try { try {
const content = await projectContextRuntime.readPlan(projectId, plan.id); 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 * off disk to show a number would make opening a panel cost what sending a
* message costs. * message costs.
*/ */
const collectSummary = async (directory) => { const collectSummary = async (directory, pins = { notes: [], plans: [] }) => {
const projectId = directory ? await resolveProjectId(directory) : ''; const projectId = directory ? await resolveProjectId(directory) : '';
const empty = { notes: [], plans: [], memory: { global: 0, project: 0 } }; const empty = { notes: [], plans: [], memory: { global: 0, project: 0 } };
if (!projectId) return empty; if (!projectId) return empty;
@@ -184,9 +197,11 @@ export const createSessionKnowledgeRuntime = (dependencies) => {
let plans = []; let plans = [];
try { try {
const context = await projectContextRuntime.readContext(projectId); 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 })); .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 })); .map((plan) => ({ id: plan.id, title: plan.title }));
} catch { } catch {
notes = []; notes = [];
@@ -223,8 +238,8 @@ export const createSessionKnowledgeRuntime = (dependencies) => {
* The text this session still owes, or an empty string when it is already * The text this session still owes, or an empty string when it is already
* carrying it. `deliveredSignature` comes from the session's metadata. * carrying it. `deliveredSignature` comes from the session's metadata.
*/ */
const resolvePending = async (directory, deliveredSignature) => { const resolvePending = async (directory, deliveredSignature, pins = { notes: [], plans: [] }) => {
const collected = await collect(directory); const collected = await collect(directory, pins);
const signature = buildKnowledgeSignature(collected); const signature = buildKnowledgeSignature(collected);
if (!signature || signature === deliveredSignature) { if (!signature || signature === deliveredSignature) {
return { text: '', signature }; return { text: '', signature };
@@ -241,7 +256,38 @@ export const createSessionKnowledgeRuntime = (dependencies) => {
*/ */
const resolvePendingForSession = async (sessionId, directory) => { const resolvePendingForSession = async (sessionId, directory) => {
const session = await readSession(sessionId, directory).catch(() => null); 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 { return {
collect, collect,
collectSummary, collectSummary,
collectSummaryForSession,
resolvePending, resolvePending,
resolvePendingForSession, resolvePendingForSession,
recordDelivered, recordDelivered,
readDeliveredSignature, readDeliveredSignature,
readPins,
setPin,
metadataKey: KNOWLEDGE_METADATA_KEY, metadataKey: KNOWLEDGE_METADATA_KEY,
pinsMetadataKey: PINS_METADATA_KEY,
}; };
}; };
@@ -4,6 +4,7 @@ import { buildKnowledgeSignature, buildKnowledgeText, createSessionKnowledgeRunt
const DIRECTORY = '/work/project'; const DIRECTORY = '/work/project';
const PROJECT_ID = 'path_project'; const PROJECT_ID = 'path_project';
const PINS = { notes: ['n1'], plans: ['p1'] };
const note = (overrides = {}) => ({ const note = (overrides = {}) => ({
id: 'n1', body: 'Pinned note body.', createdAt: 1, updatedAt: 1, pinned: true, source: 'manual', ...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 }), readAll: async () => ({ global: [memory()], project: [], globalFailed: false, projectFailed: false }),
...overrides.agentMemoryRuntime, ...overrides.agentMemoryRuntime,
}, },
...('openCodeFetch' in overrides ? { openCodeFetch: overrides.openCodeFetch } : {}),
...('isAgentMemoryEnabled' in overrides ? { isAgentMemoryEnabled: overrides.isAgentMemoryEnabled } : {}), ...('isAgentMemoryEnabled' in overrides ? { isAgentMemoryEnabled: overrides.isAgentMemoryEnabled } : {}),
}); });
describe('what the session is owed', () => { describe('what the session is owed', () => {
test('carries pinned notes, pinned plan bodies, and the memory index', async () => { 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('Pinned note body.');
expect(text).toContain('Migration plan'); 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.'); 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'); } }, 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.'); 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'); 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('Migration plan');
expect(text).toContain('plan content unavailable'); expect(text).toContain('plan content unavailable');
@@ -159,7 +161,7 @@ describe('when a source will not load', () => {
projectContextRuntime: { readContext: async () => { throw new Error('unreadable'); } }, 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'); 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 () => { test('memory is left out entirely while the feature is off', async () => {
const runtime = createRuntime({ isAgentMemoryEnabled: async () => false }); 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).not.toContain('Uses bun');
expect(text).toContain('Pinned note body.'); expect(text).toContain('Pinned note body.');
@@ -187,6 +189,39 @@ describe('the memory switch', () => {
}); });
describe('reading what a session was told', () => { 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', () => { test('finds the signature stored on the session', () => {
const runtime = createRuntime(); const runtime = createRuntime();