433 lines
16 KiB
JavaScript
433 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 });
|
|
try {
|
|
await fsPromises.writeFile(temporaryPath, JSON.stringify(value, null, 2), 'utf8');
|
|
await fsPromises.rename(temporaryPath, filePath);
|
|
} catch (error) {
|
|
await fsPromises.rm(temporaryPath, { force: true }).catch(() => {});
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
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 };
|
|
};
|