Files
openchamber/packages/ui/src/lib/sessionKnowledgeApi.ts
T

145 lines
4.4 KiB
TypeScript

/**
* Project knowledge a session still owes, as decided by the server.
*
* The client neither assembles this text nor tracks what it has sent. It used
* to do both, which meant a session started without a UI got nothing, and a
* conversation that was compacted kept a tab-local belief that the agent still
* had context the summary had just removed.
*
* Nothing here throws. A message must go out even when its background cannot
* be fetched: sending without the block costs the agent some context, failing
* the send costs the user their message.
*/
import { runtimeFetch } from './runtime-fetch';
import { z } from 'zod';
interface SessionKnowledge {
/** Empty when the session already carries what it needs. */
text: string;
/** Reported back once the message carrying the text has actually gone out. */
signature: string;
}
const EMPTY: SessionKnowledge = { text: '', signature: '' };
export const fetchSessionKnowledge = async (
directory: string | null,
sessionId: string | null,
): Promise<SessionKnowledge> => {
if (!directory) {
return EMPTY;
}
try {
const params = new URLSearchParams({ directory });
if (sessionId) {
params.set('sessionId', sessionId);
}
const response = await runtimeFetch(`/api/session-knowledge?${params.toString()}`, {
cache: 'no-store',
});
if (!response.ok) {
return EMPTY;
}
const payload = await response.json() as Partial<SessionKnowledge> | null;
return {
text: typeof payload?.text === 'string' ? payload.text : '',
signature: typeof payload?.signature === 'string' ? payload.signature : '',
};
} catch {
return EMPTY;
}
};
/**
* Recorded after the send resolves, never before: a failed send must carry the
* block again rather than assume the agent already saw it.
*/
export const reportSessionKnowledgeDelivered = async (
directory: string | null,
sessionId: string | null,
signature: string,
): Promise<void> => {
if (!directory || !sessionId || !signature) {
return;
}
try {
await runtimeFetch('/api/session-knowledge/delivered', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ directory, sessionId, signature }),
});
} catch {
// Only means the block may be sent once more.
}
};
export interface SessionKnowledgeSummary {
notes: Array<{ id: string; body: string }>;
plans: Array<{ id: string; title: string }>;
memory: { global: number; project: number };
}
const EMPTY_SUMMARY: SessionKnowledgeSummary = { notes: [], plans: [], memory: { global: 0, project: 0 } };
/** 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?${params.toString()}`,
{ cache: 'no-store' },
);
if (!response.ok) {
return EMPTY_SUMMARY;
}
const payload = await response.json() as Partial<SessionKnowledgeSummary> | null;
return {
notes: Array.isArray(payload?.notes) ? payload.notes : [],
plans: Array.isArray(payload?.plans) ? payload.plans : [],
memory: {
global: typeof payload?.memory?.global === 'number' ? payload.memory.global : 0,
project: typeof payload?.memory?.project === 'number' ? payload.memory.project : 0,
},
};
} catch {
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;
}
};