/** * Autonomous memory — canonical path whitelist + tier utilities. * * Single source of truth for the 8 stable memory documents. Every write * goes through {@link assertWritablePath}; every reader can ask * {@link getTierForPath} what a row belongs to. Adding or removing a * path means editing this file — and exactly this file. * * ## Why a whitelist? * * Earlier upstream-faithful designs used date-keyed files * (`memory/YYYY-MM-DD.md`), which have three problems for a persistent * multi-user agent: * * 1. **Unbounded growth** — one row per day per agent per user, forever. * 2. **LLM routing ambiguity** — "which date file do I read?" has no * good answer, so the model reads (and ranks against) all of them. * 3. **No natural deduplication** — the same fact written across three * days returns three near-identical hits. * * With 8 stable canonical documents the LLM knows *exactly* where to * write ("is this a preference? → `memory/user/preferences.md`") and * each row accumulates via UPSERT instead of piling up new rows. * * ## Two tiers * * - **Agent tier** (`memory/agent/*`) — operational knowledge that * every caller of the agent benefits from. Stored with `user_id = NULL`. * Shared across all users of a collaborative agent; the only tier that * exists for isolated/autonomous agents with no invoker. * * - **User tier** (`memory/user/*`) — per-invoker personalization. * Stored with `user_id = `. Read path filters so User A never * sees User B's rows, even for the same agent. Not writable unless the * flush scope carries a non-empty `userId`. * * The tier of a path is a function of its prefix (`memory/agent/` vs * `memory/user/`), so adding a new document is one line here + the * constant array entry; no store or route code has to change. */ import { MEMORY_PATH_PREFIX } from './constants'; import type { MemoryScope } from './types'; /** Tier discriminator — the two kinds of memory a row can belong to. */ export type MemoryTier = 'agent' | 'user'; /** Every canonical document the flush phase is allowed to write. */ export interface MemoryPathDescriptor { /** Full path including the `memory/` prefix. */ path: string; /** Which tier the path lives under. */ tier: MemoryTier; /** Short label shown in UI tier badges and the flush prompt rubric. */ label: string; /** One-line description — fed to the LLM to make routing unambiguous. */ description: string; } /** Agent-tier documents — shared across all users of the agent. */ export const MEMORY_AGENT_PATHS: readonly MemoryPathDescriptor[] = Object.freeze([ Object.freeze({ path: 'memory/agent/playbook.md', tier: 'agent' as const, label: 'Playbook', description: 'Successful task patterns and workflows that have worked for this agent — ' + 'reusable recipes, known-good tool sequences, approaches that consistently ' + 'produce the right result.', }), Object.freeze({ path: 'memory/agent/pitfalls.md', tier: 'agent' as const, label: 'Pitfalls', description: 'Tool failures, error recoveries, schema mistakes, and invalid argument ' + 'shapes the agent has seen — so the same mistake is not repeated on future ' + 'turns. One note per distinct failure mode.', }), Object.freeze({ path: 'memory/agent/domain.md', tier: 'agent' as const, label: 'Domain', description: 'Durable facts about the systems, APIs, data models, and business rules ' + 'this agent operates on — things that are true independent of any one user ' + 'and that the agent repeatedly needs to know.', }), Object.freeze({ path: 'memory/agent/style.md', tier: 'agent' as const, label: 'Style', description: 'Tone, formatting, and response-shape conventions the agent has converged ' + 'on across the whole user base. Do NOT write user-specific preferences here ' + '(those belong under the user tier).', }), ]); /** User-tier documents — per-invoker, never shared across users. */ export const MEMORY_USER_PATHS: readonly MemoryPathDescriptor[] = Object.freeze( [ Object.freeze({ path: 'memory/user/profile.md', tier: 'user' as const, label: 'Profile', description: "This specific user's identity — name, role, team, sign-off name, " + 'responsibilities, stable facts about who they are. Only facts ' + 'volunteered by the user themselves.', }), Object.freeze({ path: 'memory/user/preferences.md', tier: 'user' as const, label: 'Preferences', description: 'How THIS user wants things done — preferred formats, verbosity, tone, ' + 'cadence, output length, language conventions. Explicit corrections they ' + 'have given count as preferences.', }), Object.freeze({ path: 'memory/user/projects.md', tier: 'user' as const, label: 'Projects', description: "This user's current initiatives, ongoing work, deadlines, and active " + 'project context. Include dates in the prose when relevant. Retire entries ' + 'by overwriting them on future flushes when the user indicates a project is done.', }), Object.freeze({ path: 'memory/user/references.md', tier: 'user' as const, label: 'References', description: 'External systems, dashboards, accounts, links, channels, and resources ' + "THIS user has pointed the agent at — the 'where to look' pointers that " + 'make later turns more efficient.', }), ] ); /** All 8 canonical documents in display order: agent tier first, user tier second. */ export const MEMORY_ALL_PATHS: readonly MemoryPathDescriptor[] = Object.freeze([ ...MEMORY_AGENT_PATHS, ...MEMORY_USER_PATHS, ]); /** Fast O(1) lookup map: path → descriptor. */ const PATH_INDEX: ReadonlyMap = new Map( MEMORY_ALL_PATHS.map((p) => [p.path, p]) ); /** Set of all whitelisted paths — for `has()` lookups. */ export const MEMORY_WRITABLE_PATHS: ReadonlySet = new Set( MEMORY_ALL_PATHS.map((p) => p.path) ); /** Returns the descriptor for a canonical path, or `undefined` if not on the whitelist. */ export function getPathDescriptor( path: string ): MemoryPathDescriptor | undefined { return PATH_INDEX.get(path); } /** * Returns the tier a given path belongs to. * * For unknown paths (e.g. legacy date-keyed rows that predate this schema), * falls back to inspecting the `memory/agent/` or `memory/user/` prefix. * Anything that matches neither is classified as `agent` — it will surface * in the admin UI under the agent-tier header and stay read-only there. */ export function getTierForPath(path: string): MemoryTier { const descriptor = PATH_INDEX.get(path); if (descriptor) return descriptor.tier; if (path.startsWith('memory/user/')) return 'user'; return 'agent'; } /** * Paths that are legal to write **for this caller's scope**. * * If `scope.userId` is absent (isolated/autonomous agent), the user-tier * paths are dropped. This is the list the flush prompt renders into its * rubric — the LLM never sees paths it cannot write to. */ export function getWritablePathsForScope( scope: Pick ): readonly MemoryPathDescriptor[] { if (scope.userId == null || scope.userId === '') { return MEMORY_AGENT_PATHS; } return MEMORY_ALL_PATHS; } /** * Validates a write path against the whitelist AND the caller's scope. * * Throws with an actionable message when: * - the path is missing the `memory/` prefix * - the path is not on the 8-doc whitelist * - the path is user-tier but `scope.userId` is missing * * The store's append() is the single call site; other callers should * never hit this directly. Kept as a standalone function for testability. */ export function assertWritablePath( path: string, scope: Pick ): MemoryPathDescriptor { if (!path || !path.startsWith(MEMORY_PATH_PREFIX)) { throw new Error( `memory_append path must start with "${MEMORY_PATH_PREFIX}" (received "${path}")` ); } const descriptor = PATH_INDEX.get(path); if (!descriptor) { throw new Error( `memory_append path "${path}" is not on the canonical whitelist. ` + `Allowed: ${MEMORY_ALL_PATHS.map((p) => p.path).join(', ')}` ); } if ( descriptor.tier === 'user' && (scope.userId == null || scope.userId === '') ) { throw new Error( `memory_append to user-tier path "${path}" requires a caller userId in scope, ` + `but scope.userId is missing. This path is private to a specific user and ` + `cannot be written from an isolated/autonomous context.` ); } return descriptor; } /** * Renders the whitelist as a compact bullet list, used inside the flush * prompt so the LLM sees the authoritative rubric at inference time. * * Output shape: * - memory/agent/playbook.md [Playbook, shared] — * - memory/agent/pitfalls.md [Pitfalls, shared] — * ... * * The `[Label, shared|private]` tag tells the model whether the doc is * visible to all users (agent tier) or only to the caller (user tier). * Filtered to only the paths writable for the given scope. */ export function renderPathsRubric(scope: Pick): string { const writable = getWritablePathsForScope(scope); return writable .map((p) => { const visibility = p.tier === 'agent' ? 'shared' : 'private'; return `- ${p.path} [${p.label}, ${visibility}] — ${p.description}`; }) .join('\n'); }