Files
openchamber/packages/web/server/lib/agent-memory/runtime.js
T
Bohdan Triapitsyn 34e8a24b20 feat(knowledge): rebuild the project notes panel as Project knowledge (#2973)
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.
2026-08-18 02:59:04 +03:00

428 lines
16 KiB
JavaScript

/**
* Agent memory storage.
*
* What the agent has learned and chose to keep, in two scopes:
*
* - **project** — `<projectsDir>/<projectId>/memory.json`. How this codebase
* works, what was decided, where things live.
* - **global** — `<userConfigRoot>/memory.json`. Who the user is and how they
* want to be worked with. It belongs to no project, so it cannot live under
* one.
*
* The split is not cosmetic. A wrong project fact costs one project and is
* noticed quickly; a wrong global fact quietly shapes every session in every
* project, and the user has no code to check it against. Global memory is
* therefore deliberately narrower: fewer entries, and only the types that
* genuinely have no other home.
*
* This is NOT the notes surface. Notes are what the user writes for themselves
* and hands to the agent by pinning; memory is what the agent writes for
* itself. Keeping them apart keeps an agent mistake out of the user's notes.
*
* Because the agent writes here unprompted, two invariants guard the store:
*
* - **Restatements replace.** A memory the agent phrases differently the second
* time supersedes the first rather than sitting beside it, so the store
* cannot fill with variants of one fact that later disagree.
* - **Timestamps are the record of change.** The panel derives "new" and
* "changed" from `createdAt` and `updatedAt` against when the user last
* looked, so what the agent stored without asking stays visible without the
* store carrying any review state of its own.
*/
const MEMORY_VERSION = 1;
/**
* Titles are what every session carries, so their combined length is the
* standing cost of memory. Short enough to keep a full store's index modest,
* long enough to say what an entry is about.
*/
const MEMORY_TITLE_MAX_LENGTH = 60;
const MEMORY_BODY_MAX_LENGTH = 2000;
/** Global memory stays small on purpose: it is the highest-blast-radius store. */
const GLOBAL_MEMORY_MAX_ITEMS = 60;
const PROJECT_MEMORY_MAX_ITEMS = 200;
/**
* `fact` — something true about the project or the user.
* `preference` — how the user wants work done.
* `reference` — a pointer to a resource that is hard to rediscover.
*/
const MEMORY_TYPES = new Set(['fact', 'preference', 'reference']);
import { findThreatPattern } from './threat-patterns.js';
const PROJECT_ID_PATTERN = /^[a-zA-Z0-9._:-]+$/;
/**
* Two entries are the same memory when this much of the incoming one is already
* in the stored one. Set high on purpose: merging two genuinely different
* memories destroys one of them silently, which is far worse than keeping a
* near-duplicate the user can see and delete.
*/
const DUPLICATE_OVERLAP_THRESHOLD = 0.75;
/**
* Below this many meaningful words, overlap is noise — "use bun" and "use npm"
* share half their tokens. Short entries fall back to exact-title matching.
*/
const DUPLICATE_MIN_TOKENS = 4;
/**
* Words carried by almost every sentence, so their overlap says nothing about
* whether two memories mean the same thing.
*/
const STOP_WORDS = new Set([
'a', 'an', 'and', 'are', 'as', 'at', 'be', 'but', 'by', 'for', 'from', 'has',
'have', 'in', 'into', 'is', 'it', 'its', 'not', 'of', 'on', 'or', 'that',
'the', 'their', 'them', 'they', 'this', 'to', 'was', 'were', 'when', 'with',
]);
const tokenize = (value) => {
const tokens = new Set();
for (const raw of String(value).toLowerCase().split(/[^\p{L}\p{N}]+/u)) {
if (raw.length < 3 || STOP_WORDS.has(raw)) continue;
tokens.add(raw);
}
return tokens;
};
/** How much of `incoming` is already present in `existing`, in `[0, 1]`. */
const overlapFraction = (incoming, existing) => {
if (incoming.size === 0) return 0;
let shared = 0;
for (const token of incoming) {
if (existing.has(token)) shared += 1;
}
return shared / incoming.size;
};
/**
* The stored entry a new one should replace, or null for a genuinely new
* memory.
*
* Exact title match alone is not enough: an agent that re-learns the same fact
* phrases it differently each time ("run UI tests per file" / "UI tests must be
* run one file at a time"), and storing both leaves the two free to drift apart
* until they contradict each other. Comparing the wording catches the restated
* duplicate that the title check misses.
*/
const findSupersededEntry = (entries, title, body) => {
const lowerTitle = title.toLowerCase();
const exact = entries.find((entry) => entry.title.toLowerCase() === lowerTitle);
if (exact) return exact;
const incoming = tokenize(`${title} ${body}`);
if (incoming.size < DUPLICATE_MIN_TOKENS) return null;
let best = null;
let bestScore = 0;
for (const entry of entries) {
const score = overlapFraction(incoming, tokenize(`${entry.title} ${entry.body}`));
if (score >= DUPLICATE_OVERLAP_THRESHOLD && score > bestScore) {
best = entry;
bestScore = score;
}
}
return best;
};
const asNonEmptyString = (value) => {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
};
const clampLength = (value, maxLength) => {
if (typeof value !== 'string') return '';
return value.length > maxLength ? value.slice(0, maxLength) : value;
};
const isObjectRecord = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const limitForScope = (scope) => (scope === 'global' ? GLOBAL_MEMORY_MAX_ITEMS : PROJECT_MEMORY_MAX_ITEMS);
const sanitizeEntries = (value, now, scope) => {
if (!Array.isArray(value)) return [];
const result = [];
const seen = new Set();
for (const entry of value) {
if (result.length >= limitForScope(scope)) break;
if (!isObjectRecord(entry)) continue;
const id = asNonEmptyString(entry.id);
const title = clampLength(asNonEmptyString(entry.title) || '', MEMORY_TITLE_MAX_LENGTH);
const body = clampLength(typeof entry.body === 'string' ? entry.body : '', MEMORY_BODY_MAX_LENGTH).trim();
if (!id || !title || !body || seen.has(id)) continue;
seen.add(id);
const createdAt = Number.isFinite(entry.createdAt) && entry.createdAt >= 0 ? entry.createdAt : now;
const sessionId = asNonEmptyString(entry.sessionId);
result.push({
id,
title,
body,
type: MEMORY_TYPES.has(entry.type) ? entry.type : 'fact',
createdAt,
updatedAt: Number.isFinite(entry.updatedAt) && entry.updatedAt >= 0 ? entry.updatedAt : createdAt,
// Re-checked on every read, not trusted from the file: an entry written
// before a pattern existed, or edited on disk since, is judged now.
...(findThreatPattern(`${title}\n${body}`) ? { flagged: true } : {}),
...(sessionId ? { sessionId } : {}),
});
}
return result.sort((a, b) => b.updatedAt - a.updatedAt);
};
const createEmptyMemory = () => ({ version: MEMORY_VERSION, entries: [] });
export const createAgentMemoryRuntime = (deps) => {
const { fsPromises, path, projectsDirPath, userConfigRoot, createId } = deps;
const idFactory = typeof createId === 'function'
? createId
: () => (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `mem_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`);
const writeLocks = new Map();
const sanitizeProjectId = (projectId) => {
const value = asNonEmptyString(projectId);
if (!value) {
throw new Error('projectId is required');
}
if (!PROJECT_ID_PATTERN.test(value)) {
throw new Error('projectId contains unsupported characters');
}
return value;
};
/** `target` is `{ scope: 'global' }` or `{ scope: 'project', projectId }`. */
const resolveTarget = (target) => {
if (target?.scope === 'global') {
return { scope: 'global', key: 'global', filePath: path.join(userConfigRoot, 'memory.json') };
}
if (target?.scope === 'project') {
const projectId = sanitizeProjectId(target.projectId);
return {
scope: 'project',
key: `project:${projectId}`,
filePath: path.join(projectsDirPath, projectId, 'memory.json'),
};
}
throw new Error('scope is required');
};
const readJson = async (filePath) => {
let raw;
try {
raw = await fsPromises.readFile(filePath, 'utf8');
} catch (error) {
if (error && error.code === 'ENOENT') return { missing: true, value: null };
throw error;
}
try {
const parsed = JSON.parse(raw);
return { missing: false, value: isObjectRecord(parsed) ? parsed : null };
} catch {
return { missing: false, value: null };
}
};
const writeJsonAtomic = async (filePath, value) => {
const temporaryPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
await fsPromises.mkdir(path.dirname(filePath), { recursive: true });
await fsPromises.writeFile(temporaryPath, JSON.stringify(value, null, 2), 'utf8');
await fsPromises.rename(temporaryPath, filePath);
};
const withWriteLock = async (key, mutate) => {
const previous = writeLocks.get(key) || Promise.resolve();
let release;
const next = new Promise((resolve) => { release = resolve; });
const chained = previous.finally(() => next);
writeLocks.set(key, chained);
await previous;
try {
return await mutate();
} finally {
release();
if (writeLocks.get(key) === chained) {
writeLocks.delete(key);
}
}
};
/**
* Missing is authoritative empty; malformed is a failure. An agent that reads
* "no memory" from a corrupt file would cheerfully rewrite everything it
* thought it had lost.
*/
const read = async (target) => {
const resolved = resolveTarget(target);
const stored = await readJson(resolved.filePath);
if (!stored.missing && !stored.value) {
throw new Error('Stored agent memory is malformed');
}
if (stored.missing) {
return createEmptyMemory();
}
return {
version: MEMORY_VERSION,
entries: sanitizeEntries(stored.value.entries, Date.now(), resolved.scope),
};
};
const write = async (resolved, entries) => {
await writeJsonAtomic(resolved.filePath, { version: MEMORY_VERSION, entries });
};
const create = async (target, value) => {
const resolved = resolveTarget(target);
const title = clampLength(asNonEmptyString(value?.title) || '', MEMORY_TITLE_MAX_LENGTH);
const body = clampLength(typeof value?.body === 'string' ? value.body : '', MEMORY_BODY_MAX_LENGTH).trim();
if (!title) throw new Error('title is required');
if (!body) throw new Error('body is required');
return withWriteLock(resolved.key, async () => {
const now = Date.now();
const current = await read(target);
// A restatement of something already stored is an update, not a second
// copy: an agent re-learning a fact each session would otherwise fill the
// store with near-duplicates and contradict itself.
//
// Checked before the capacity limit, because replacing an entry does not
// grow the store — a full store must still be able to correct itself.
const existing = findSupersededEntry(current.entries, title, body);
if (existing) {
const updated = {
...existing,
title,
body,
updatedAt: now,
...(MEMORY_TYPES.has(value?.type) ? { type: value.type } : {}),
};
const entries = current.entries.map((entry) => (entry.id === existing.id ? updated : entry));
await write(resolved, entries);
return { entry: updated, entries, replaced: true };
}
const limit = limitForScope(resolved.scope);
if (current.entries.length >= limit) {
// Handed its own titles and told what to do with them. A bare "full"
// leaves the agent with a dead end, when the useful move — merge the
// overlapping entries, drop the stale ones, then retry — is something
// only it can judge.
const titles = current.entries.map((entry) => `- ${entry.title}`).join('\n');
throw new Error(
`${resolved.scope} memory is full (${current.entries.length}/${limit} entries). `
+ 'Consolidate before saving anything else: merge overlapping entries by saving one '
+ 'under an existing title, and delete what is stale or wrong. Then retry this save, '
+ `all in this turn. Current entries:\n${titles}`,
);
}
const sessionId = asNonEmptyString(value?.sessionId);
const entry = {
id: idFactory(),
title,
body,
type: MEMORY_TYPES.has(value?.type) ? value.type : 'fact',
createdAt: now,
updatedAt: now,
...(findThreatPattern(`${title}\n${body}`) ? { flagged: true } : {}),
...(sessionId ? { sessionId } : {}),
};
const entries = [entry, ...current.entries];
await write(resolved, entries);
return { entry, entries, replaced: false };
});
};
/**
* A user correction. The agent rewrites by saving the same memory again, so
* this exists for the panel: a memory worded badly enough to mislead should
* be fixable where it is read, not only deletable.
*/
const update = async (target, memoryId, patch) => {
const resolved = resolveTarget(target);
const id = asNonEmptyString(memoryId);
if (!id) throw new Error('memoryId is required');
const hasTitle = typeof patch?.title === 'string';
const hasBody = typeof patch?.body === 'string';
const hasType = MEMORY_TYPES.has(patch?.type);
if (!hasTitle && !hasBody && !hasType) {
throw new Error('title, body or type is required');
}
const title = hasTitle ? clampLength(patch.title, MEMORY_TITLE_MAX_LENGTH).trim() : null;
const body = hasBody ? clampLength(patch.body, MEMORY_BODY_MAX_LENGTH).trim() : null;
if (hasTitle && !title) throw new Error('title is required');
if (hasBody && !body) throw new Error('body is required');
return withWriteLock(resolved.key, async () => {
const current = await read(target);
const existing = current.entries.find((entry) => entry.id === id);
if (!existing) {
return null;
}
const updated = {
...existing,
...(hasTitle ? { title } : {}),
...(hasBody ? { body } : {}),
...(hasType ? { type: patch.type } : {}),
updatedAt: Date.now(),
};
const entries = current.entries.map((entry) => (entry.id === id ? updated : entry));
await write(resolved, entries);
return { entry: updated, entries };
});
};
const remove = async (target, memoryId) => {
const resolved = resolveTarget(target);
const id = asNonEmptyString(memoryId);
if (!id) throw new Error('memoryId is required');
return withWriteLock(resolved.key, async () => {
const current = await read(target);
if (!current.entries.some((entry) => entry.id === id)) {
return { deleted: false, entries: current.entries };
}
const entries = current.entries.filter((entry) => entry.id !== id);
await write(resolved, entries);
return { deleted: true, entries };
});
};
/**
* Both scopes at once, for the session index. A failure in one scope must not
* hide the other: losing the project half should not also erase what the
* agent knows about the user.
*/
const readAll = async (projectId) => {
const settled = await Promise.allSettled([
read({ scope: 'global' }),
projectId ? read({ scope: 'project', projectId }) : Promise.resolve(createEmptyMemory()),
]);
return {
global: settled[0].status === 'fulfilled' ? settled[0].value.entries : [],
project: settled[1].status === 'fulfilled' ? settled[1].value.entries : [],
globalFailed: settled[0].status === 'rejected',
projectFailed: settled[1].status === 'rejected',
};
};
return { read, readAll, create, update, remove, resolveTarget };
};