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

200 lines
6.7 KiB
TypeScript
Raw Normal View History

/**
* Client for the OpenChamber agent memory routes.
*
* The store is owned by the server (`packages/web/server/lib/agent-memory`).
* This module only speaks HTTP and resolves no storage paths.
*
* Every function throws on failure. An authoritative read must never resolve to
* an empty list a caller could mistake for "the agent remembers nothing" — that
* reading is exactly what would make the user think memory had been lost.
*
* A 404 is the one exception, and it means the feature is switched off rather
* than that the entry is missing: the server disables the whole surface, so
* callers translate it into `disabled` instead of an error.
*/
import { createProjectIdFromPath } from './projectId';
import { runtimeFetch } from './runtime-fetch';
export type AgentMemoryType = 'fact' | 'preference' | 'reference';
export type AgentMemoryScope = 'global' | 'project';
export interface AgentMemoryEntry {
id: string;
title: string;
body: string;
type: AgentMemoryType;
createdAt: number;
updatedAt: number;
/**
* Reads as an instruction to the model rather than a fact. Kept in the store
* and shown here, but withheld from what sessions are told.
*/
flagged?: boolean;
/** The session this was learned in, when the agent recorded one. */
sessionId?: string;
}
interface AgentMemorySnapshot {
global: AgentMemoryEntry[];
project: AgentMemoryEntry[];
/**
* A scope that failed to load. Kept separate from an empty list so the panel
* can say "could not load" rather than showing an empty tab that reads as
* "the agent has forgotten everything".
*/
globalFailed: boolean;
projectFailed: boolean;
}
/** Mirrors the server's clamps, so the editor stops where storage would cut. */
export const AGENT_MEMORY_TITLE_MAX_LENGTH = 120;
export const AGENT_MEMORY_BODY_MAX_LENGTH = 2000;
/** Raised when the server reports the whole memory surface as switched off. */
export class AgentMemoryDisabledError extends Error {
constructor() {
super('Agent memory is disabled');
this.name = 'AgentMemoryDisabledError';
}
}
const BASE_PATH = '/api/agent-memory';
/**
* Mirrors the server: the storage id comes from the project path, not from
* `project.id`, because the path-derived id is what names the file on disk.
*/
const resolveMemoryProjectId = (projectPath: string | null | undefined): string => {
const trimmed = typeof projectPath === 'string' ? projectPath.trim() : '';
return trimmed ? createProjectIdFromPath(trimmed) : '';
};
const scopeQuery = (scope: AgentMemoryScope, projectId: string): string => {
if (scope === 'global') {
return 'scope=global';
}
if (!projectId) {
throw new Error('Project memory needs a resolvable project path');
}
return `scope=project&projectId=${encodeURIComponent(projectId)}`;
};
interface ErrorPayload {
error?: unknown;
disabled?: unknown;
}
/**
* A 404 alone does not mean the feature is off — a deleted entry answers 404
* too. Only the server's explicit `disabled` flag distinguishes them.
*/
const failed = async (response: Response, fallback: string): Promise<never> => {
let payload: ErrorPayload | null = null;
try {
payload = await response.json() as ErrorPayload | null;
} catch {
// Fall through to the generic message.
}
if (response.status === 404 && payload?.disabled === true) {
throw new AgentMemoryDisabledError();
}
const message = typeof payload?.error === 'string' && payload.error.trim()
? payload.error
: `${fallback} (${response.status})`;
throw new Error(message);
};
const parseEntry = (value: unknown): AgentMemoryEntry | null => {
const record = value as Partial<AgentMemoryEntry> | null;
if (!record || typeof record !== 'object') {
return null;
}
if (typeof record.id !== 'string' || typeof record.title !== 'string' || typeof record.body !== 'string') {
return null;
}
return {
id: record.id,
title: record.title,
body: record.body,
type: record.type === 'preference' || record.type === 'reference' ? record.type : 'fact',
createdAt: typeof record.createdAt === 'number' ? record.createdAt : 0,
updatedAt: typeof record.updatedAt === 'number' ? record.updatedAt : 0,
...(record.flagged === true ? { flagged: true } : {}),
...(typeof record.sessionId === 'string' ? { sessionId: record.sessionId } : {}),
};
};
const parseEntries = (value: unknown): AgentMemoryEntry[] => (
Array.isArray(value) ? value.map(parseEntry).filter((entry): entry is AgentMemoryEntry => entry !== null) : []
);
/**
* Both scopes in one request. Two requests would let one scope render while the
* other is still in flight, which reads as memory that has gone missing.
*/
export const fetchAgentMemory = async (
projectPath: string | null,
options: { signal?: AbortSignal } = {},
): Promise<AgentMemorySnapshot> => {
const projectId = resolveMemoryProjectId(projectPath);
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : '';
const response = await runtimeFetch(`${BASE_PATH}/all${query}`, {
cache: 'no-store',
signal: options.signal,
});
if (!response.ok) {
return failed(response, 'Failed to load agent memory');
}
const payload = await response.json() as Record<string, unknown> | null;
if (!payload || typeof payload !== 'object') {
throw new Error('Malformed agent memory response');
}
return {
global: parseEntries(payload.global),
project: parseEntries(payload.project),
globalFailed: payload.globalFailed === true,
projectFailed: payload.projectFailed === true,
};
};
/** A user correction from the panel; the agent rewrites by saving again. */
export const updateAgentMemory = async (
scope: AgentMemoryScope,
projectPath: string | null,
memoryId: string,
patch: { title?: string; body?: string; type?: AgentMemoryType },
): Promise<AgentMemoryEntry> => {
const query = scopeQuery(scope, resolveMemoryProjectId(projectPath));
const response = await runtimeFetch(`${BASE_PATH}/${encodeURIComponent(memoryId)}?${query}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
if (!response.ok) {
return failed(response, 'Failed to save memory');
}
const payload = await response.json() as { entry?: unknown } | null;
const entry = parseEntry(payload?.entry);
if (!entry) {
throw new Error('Malformed agent memory response');
}
return entry;
};
export const deleteAgentMemory = async (
scope: AgentMemoryScope,
projectPath: string | null,
memoryId: string,
): Promise<void> => {
const query = scopeQuery(scope, resolveMemoryProjectId(projectPath));
const response = await runtimeFetch(`${BASE_PATH}/${encodeURIComponent(memoryId)}?${query}`, {
method: 'DELETE',
});
if (!response.ok) {
await failed(response, 'Failed to delete memory');
}
};