Files

244 lines
9.6 KiB
JavaScript
Raw Permalink Normal View History

/**
* Dispatch for the `memory.*` actions the `openchamber_memory` tool calls.
*
* Kept beside the store rather than inside the control service, because the
* control service already owns sessions, schedules and the browser; memory
* shares none of that machinery and only needs the same envelope.
*
* Project scope is derived from the session's directory, never from the model.
* Letting the agent name a project id would let a memory learned in one
* checkout be filed against another, which the user would have no way to
* notice.
*
* The directory is resolved to the project first. A session running in a
* worktree has the worktree's own path, and keying memory by that path filed it
* under a project the panel never looks at — the memory was written, stored,
* and invisible. Every worktree of a repository shares one project memory,
* which is also what the user means by "this project".
*/
const MEMORY_TYPES = new Set(['fact', 'preference', 'reference']);
const asNonEmptyString = (value) => {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
};
/** Everything the agent is told about an entry it has not opened yet. */
const toSummary = (entry, scope) => ({
memoryId: entry.id,
title: entry.title,
type: entry.type,
scope,
});
const toFullEntry = (entry, scope) => ({ ...toSummary(entry, scope), body: entry.body });
export const createAgentMemoryActions = (dependencies) => {
const {
agentMemoryRuntime,
createError,
onMemoryChanged,
resolveProjectId: resolveProjectIdForDirectory,
isAgentMemoryEnabled,
} = dependencies;
/**
* Announce a write so an open panel shows it without being reopened. The
* agent writes here on its own initiative, so without this the user only
* learns what was stored the next time something else happens to reload.
*
* Never allowed to fail the action: the memory is already on disk, and a
* broken notification must not report the write as failed.
*/
const announce = (scope, projectId) => {
if (typeof onMemoryChanged !== 'function') return;
try {
onMemoryChanged({ scope, ...(projectId ? { projectId } : {}) });
} catch {
// A listener that throws must not take the write down with it.
}
};
const fail = (message, status = 400) => {
throw createError(message, status);
};
const resolveProjectId = async (contextDirectory) => {
const directory = asNonEmptyString(contextDirectory);
const projectId = directory ? await resolveProjectIdForDirectory(directory) : '';
if (!projectId) {
fail('Project memory needs a session directory, and this session has none', 400);
}
return projectId;
};
const resolveTarget = async (input, contextDirectory) => {
const scope = asNonEmptyString(input.scope);
if (scope === 'global') return { scope: 'global' };
if (scope === 'project') {
return { scope: 'project', projectId: await resolveProjectId(contextDirectory) };
}
return fail('scope must be global or project', 400);
};
const listBothScopes = async (contextDirectory) => {
const directory = asNonEmptyString(contextDirectory);
const projectId = directory ? await resolveProjectIdForDirectory(directory) : null;
const result = await agentMemoryRuntime.readAll(projectId);
// A scope that failed to load is reported, never rendered as empty: an
// agent told it has no memories will happily store them all again.
return {
memories: [
...result.global.map((entry) => toSummary(entry, 'global')),
...result.project.map((entry) => toSummary(entry, 'project')),
],
...(result.globalFailed ? { globalUnavailable: true } : {}),
...(result.projectFailed ? { projectUnavailable: true } : {}),
};
};
const list = async (input, contextDirectory) => {
const scope = asNonEmptyString(input.scope);
if (!scope || scope === 'both') {
return listBothScopes(contextDirectory);
}
const target = await resolveTarget(input, contextDirectory);
const { entries } = await agentMemoryRuntime.read(target);
return { memories: entries.map((entry) => toSummary(entry, target.scope)) };
};
/**
* Reading by title as well as by id is deliberate: the session index lists
* titles only, so requiring an id would force a list call before every read
* just to translate what the agent can already see.
*
* Scope is optional here. It decides everything for a write — a fact filed
* globally reaches every project — but for a read it is only which drawer to
* open, and demanding it turned a legible request into an error the model had
* to recover from. Omitted, both stores are searched.
*/
const read = async (input, contextDirectory) => {
const memoryId = asNonEmptyString(input.memoryId);
const title = asNonEmptyString(input.title);
if (!memoryId && !title) {
fail('memory.read requires memoryId or title', 400);
}
const matches = (entry) => (memoryId
? entry.id === memoryId
: entry.title.toLowerCase() === title.toLowerCase());
const requestedScope = asNonEmptyString(input.scope);
if (requestedScope === 'global' || requestedScope === 'project') {
const target = await resolveTarget(input, contextDirectory);
const { entries } = await agentMemoryRuntime.read(target);
const found = entries.find(matches);
if (!found) {
fail('No memory matches that id or title in this scope', 404);
}
return { memory: toFullEntry(found, target.scope) };
}
const directory = asNonEmptyString(contextDirectory);
const projectId = directory ? await resolveProjectIdForDirectory(directory) : null;
const result = await agentMemoryRuntime.readAll(projectId);
const projectMatch = result.project.find(matches);
if (projectMatch) {
// Project first: when both stores hold the same title, the one about this
// codebase is the one being asked about.
return { memory: toFullEntry(projectMatch, 'project') };
}
const globalMatch = result.global.find(matches);
if (globalMatch) {
return { memory: toFullEntry(globalMatch, 'global') };
}
if (result.globalFailed || result.projectFailed) {
// Never reported as "no such memory": a store that failed to load may well
// hold it, and the agent would go on to store it a second time.
fail('Stored memory could not be read; try again before assuming it is absent', 503);
}
fail('No memory matches that id or title', 404);
};
const save = async (input, contextDirectory) => {
const target = await resolveTarget(input, contextDirectory);
const title = asNonEmptyString(input.title);
const body = asNonEmptyString(input.body);
if (!title) fail('title is required for memory.save', 400);
if (!body) fail('body is required for memory.save', 400);
if (input.type !== undefined && !MEMORY_TYPES.has(input.type)) {
fail('type must be fact, preference, or reference', 400);
}
const result = await agentMemoryRuntime.create(target, {
title,
body,
type: input.type,
sessionId: asNonEmptyString(input.sessionId),
});
announce(target.scope, target.projectId);
// Deliberately does not echo the text back. Handing the model what it just
// wrote invites it to find something to improve and re-save, and the store
// is not the place to discover that a save worked — the confirmation is.
return {
saved: true,
memory: toSummary(result.entry, target.scope),
// Told plainly so the agent does not report storing a second memory when
// it actually corrected one it had already written.
replaced: result.replaced,
...(result.entry.flagged
? { warning: 'Stored, but held back from future sessions: this text reads as an instruction to the model rather than a fact. The user can see it in the Memory panel.' }
: {}),
};
};
const remove = async (input, contextDirectory) => {
const target = await resolveTarget(input, contextDirectory);
const memoryId = asNonEmptyString(input.memoryId);
if (!memoryId) fail('memoryId is required for memory.delete', 400);
const result = await agentMemoryRuntime.remove(target, memoryId);
if (!result.deleted) {
fail('No memory has that id in this scope', 404);
}
announce(target.scope, target.projectId);
return { deleted: true, memoryId };
};
const execute = async (action, input = {}, contextDirectory) => {
/**
* The tool lives in the managed OpenCode child and only disappears when
* that child restarts, so between switching memory off and restarting it
* the agent can still call this. Ungated, those writes would land on disk
* while the panel that shows them is hidden and the index that carries
* them is suppressed — memory accumulating where nobody can see it.
*/
if (typeof isAgentMemoryEnabled === 'function') {
let enabled = false;
try {
enabled = await isAgentMemoryEnabled();
} catch {
// An unreadable setting closes the surface rather than opening it.
enabled = false;
}
if (!enabled) {
return fail('Agent memory is switched off in OpenChamber settings', 403);
}
}
switch (action) {
case 'memory.list': return list(input, contextDirectory);
case 'memory.read': return read(input, contextDirectory);
case 'memory.save': return save(input, contextDirectory);
case 'memory.delete': return remove(input, contextDirectory);
default: return fail(`Unsupported memory action: ${action || 'missing'}`, 400);
}
};
return { execute };
};