The panel stored notes, todos and plans inside one shared JSON file that six unrelated domains also wrote to, synchronised itself through window CustomEvents, and could only read plans. It is now Project knowledge: server-owned storage with explicit routes, a store with rollback, a section sidebar, plans that open and edit in place, and search across all of it. Notes and plans the user pins travel with every message sent in that project. Pinning is project state, not an attachment to one message, so it holds until unpinned and the work status panel names what is riding along and can detach it. Agent memory is added alongside, in two scopes: what is true about the user, and what is true about this codebase. The split is not cosmetic — a wrong project fact costs one project and is noticed, while a wrong global fact quietly shapes every session everywhere and the user has no code to check it against. It stays separate from notes so an agent mistake cannot land in what the user wrote. Sessions receive an index of titles only; bodies are read on demand, because an index carrying full text grows until it crowds out the conversation. Deciding what a session must be told, and whether it has been told, now lives on the server. The client owned it before, which meant sessions started without a UI — scheduled tasks, sessions the agent dispatches — received nothing at all, and a tab's record of what it had sent outlived the conversation: after compaction the agent no longer held the block while the tab went on believing it did. What was delivered is recorded in the session's own metadata, and compaction restores it through the runtime that already restores pinned messages, in the same turn. Agent memory ships dark behind OPENCHAMBER_MEMORY_ENABLE: unset, there is no tool, no routes, no session index, no settings row and no panel tab. Absent rather than switched off, so nothing invites turning on a feature that has not been announced. Pinned notes and plans are unaffected and ship as normal.
200 lines
6.7 KiB
TypeScript
200 lines
6.7 KiB
TypeScript
/**
|
|
* 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');
|
|
}
|
|
};
|