Appearance
Memory
Alfred's Memory v2 system gives the agent a persistent, curated fact store that survives across runs. Facts live on disk under .alfred/memory/, are indexed by SQLite FTS5 for full-text search, and are surfaced to the model through a four-stage lifecycle. The model can read, write, and delete facts through three dedicated tools.
Source files: src/memory/types.ts, src/memory/localFile.ts, src/memory/episodes.ts, src/tools/memoryTool.ts.
Design: ADR 0001 §4.
On-disk layout
Every workspace gets its own isolated store rooted at ${workingDir}/.alfred/memory/:
text
.alfred/memory/
USER.md # Core: stable user prefs and conventions (hand-edited or model-written)
MEMORY.md # Core: auto-generated one-line index of every stored fact
facts/
<slug>.md # Recall: one fact per file, YAML frontmatter + body
episodes/
<id>.json # Episodic: one record per completed agent run
archive/ # Aged-out or deduped facts (annotated and moved here by extract())
index.db # SQLite FTS5 index over facts (unicode61 tokeniser)The LocalFileProvider class in src/memory/localFile.ts implements this layout. It is constructed with a root path and opens the SQLite database lazily on first use (with PRAGMA journal_mode=WAL).
Tiers
Core (token-budgeted)
The Core tier consists of USER.md and MEMORY.md. It is injected into the system prompt once per session via inject(). The combined text is capped at 1,500 estimated tokens (Math.ceil(chars / 4)).
When the combined text exceeds the budget, USER.md content is kept whole (it is small by design) and MEMORY.md is truncated with a "… (truncated to fit token budget)" suffix. The MemoryBlock return value carries an estimatedTokens count and a truncated: boolean flag.
MEMORY.md is automatically rebuilt by rebuildMemoryIndex() after every write: it contains one line per stored fact in the form:
- [<slug>] (<type>): <first 80 chars of content>Recall (per-fact files)
Each fact lives in facts/<slug>.md with a minimal YAML frontmatter block:
---
type: user
ts: 2026-06-06T10:00:00.000Z
scope: src/query
ttl: 2026-12-31
---
User prefers Bun over Node for all scripts.Facts are looked up by slug, searched via FTS5, and hydrated into the Fact interface on read.
Archival
extract() scans every file in facts/ for staleness and exact-duplicate content. Stale facts are annotated with an HTML comment (<!-- archived: <reason> at <iso-date> -->) and moved to archive/. The FTS5 index is updated to remove archived slugs.
Episodic
Episodes (see Episodes) live separately in episodes/<id>.json and are not included in the Core token budget.
Fact and Episode shapes
Fact
ts
interface Fact {
readonly id: string; // === slug, e.g. "user-prefers-bun"
readonly slug: string;
readonly type: FactType; // "user" | "feedback" | "project" | "reference"
readonly scope?: string; // workspace-relative path (optional)
readonly content: string; // body text (no frontmatter)
readonly ts: string; // ISO-8601 creation/update timestamp
readonly ttl?: string; // ISO-8601 expiry date (optional)
}FactFrontmatter carries the same fields except id, slug, and content; it is the subset serialised to the YAML block.
Episode
ts
interface Episode {
readonly id: string; // ISO timestamp + 6-char random hex
readonly goal: string;
readonly approach: string;
readonly worked: readonly string[];
readonly failed: readonly string[];
readonly verifyExit?: string;
readonly gitSha?: string;
readonly cost?: number; // USD
readonly ts: string; // ISO-8601
}MemoryProvider interface
src/memory/types.ts defines the single abstract surface that the engine and tools use. No implementation detail crosses this boundary.
ts
interface MemoryProvider {
inject(): Promise<MemoryBlock>;
prefetch(query: string, k: number): Promise<readonly Fact[]>;
sync(candidate: Omit<Fact, "id">): Promise<void>;
extract(): Promise<void>;
search(query: string): Promise<readonly Fact[]>;
upsert(fact: Omit<Fact, "id">): Promise<Fact>;
get(id: string): Promise<Fact | null>;
forget(id: string): Promise<void>;
contradict(fact: Omit<Fact, "id">): Promise<readonly Fact[]>;
}The 4-stage lifecycle
Stage 1 — inject
inject() is called once per run when building the system prompt. It reads USER.md and MEMORY.md, applies the 1,500-token budget, and returns a MemoryBlock. The result is spliced into the stable prefix of the system prompt, before any volatile environment section, so it lands in the provider's prompt cache.
Stage 2 — prefetch
prefetch(query, k) is called at turn-start, before the model sees the user message. It delegates to search(query) and returns up to k relevant Fact objects. These can be rendered into the context message for the upcoming turn.
LocalFileProvider.prefetch is a thin wrapper: search(query).then(results => results.slice(0, k)).
Stage 3 — sync
sync(candidate) is called post-turn. It decides whether to persist a candidate fact:
- Calls
contradict(candidate)to find existing facts with the same slug, same type+scope, or similar content terms (via FTS5). - If contradicting facts exist, it overwrites the first one (update-don't-duplicate policy), preserving the existing slug.
- If none exist, it calls
upsert(candidate). - Always rebuilds
MEMORY.mdafter writing.
Stage 4 — extract
extract() is called when the agent run finishes. It:
- TTL sweep: reads every
facts/<slug>.md, parses thettlfield, and archives any fact whose expiry date is before now. - Scope sweep: if a fact has a
scopepath, checks whether that path exists on disk (Bun.file(scope).exists()). Missing scope → stale. - Dedup: after archiving, scans remaining facts for identical
type + contentpairs. Keeps the newer one (byts), archives the older. - Rebuilds
MEMORY.md.
All three sub-phases run in the same extract() call. Archive operations run in parallel (Promise.all).
SQLite FTS5 search
LocalFileProvider opens index.db with a single virtual table:
sql
CREATE VIRTUAL TABLE IF NOT EXISTS facts_fts
USING fts5(slug, type, scope, content, tokenize='unicode61')search(query) runs:
sql
SELECT slug FROM facts_fts WHERE facts_fts MATCH ? ORDER BY rank LIMIT 20indexFact deletes then re-inserts the row for that slug (upsert via DELETE + INSERT). deindexFact issues a bare DELETE. All database access is through Bun's bun:sqlite bindings.
Episodes
EpisodeStore in src/memory/episodes.ts manages episodes/<id>.json files. IDs are <ISO-timestamp-sanitised>-<6-hex-char> to guarantee uniqueness for sub-millisecond writes. Episode files are validated on read with a Zod schema; corrupt files are silently skipped.
Key operations:
write(data)— assignsidandts, writes<id>.json.list(limit?)— reads all episode files sorted lexicographically (ISO timestamps sort chronologically), returned newest-first.query(terms, k)— case-insensitive substring match acrossgoal + approach + worked + failed; returns up toknewest matches.delete(id)— unlinks the file; no-op if absent.
Model-facing tools
Three tools in src/tools/memoryTool.ts expose the memory store to the model. Each tool is constructed with buildTool and rooted at ${ctx.workingDir}/.alfred/memory. A module-level Map<string, LocalFileProvider> caches one provider instance per working directory.
memory_search
Full-text search over stored facts. Read-only and concurrency-safe — runs in parallel with other read-only tools.
Input: { query: string, k?: number (1–50, default 10) }
Output: One fact per section, formatted as:
[slug] type=<type> [scope=<scope>] ts=<ts>
<content>memory_upsert
Insert or update a fact. Not read-only; runs serially.
Input: { slug: string, type: FactType, content: string, scope?: string, ttl?: string }
Output: "Stored fact [<slug>] (<type>) at <ts>"The slug must be lowercase alphanumeric + hyphens (/^[a-z0-9-]+$/). The tool stamps ts as new Date().toISOString() at call time and delegates to provider.upsert().
Update-don't-duplicate policy
The tool description instructs the model: "Check for contradictions with memory_search before writing." Upserting an existing slug overwrites it cleanly.
memory_forget
Permanently delete a fact by slug. Not read-only; runs serially.
Input: { slug: string }
Output: "Deleted fact [<slug>] (no-op if it did not exist)"Delegates to provider.forget(), which unlinks the file, removes the FTS5 row, and rebuilds MEMORY.md.
See also
- Agent Loop — where
inject()feeds the system prompt andextract()is called on thedoneevent - Orchestrator — memory providers can be wired through
RuntimeOptions - ADR 0001 §4 — full design rationale and tier definitions