Skip to content

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:

  1. Calls contradict(candidate) to find existing facts with the same slug, same type+scope, or similar content terms (via FTS5).
  2. If contradicting facts exist, it overwrites the first one (update-don't-duplicate policy), preserving the existing slug.
  3. If none exist, it calls upsert(candidate).
  4. Always rebuilds MEMORY.md after writing.

Stage 4 — extract ​

extract() is called when the agent run finishes. It:

  1. TTL sweep: reads every facts/<slug>.md, parses the ttl field, and archives any fact whose expiry date is before now.
  2. Scope sweep: if a fact has a scope path, checks whether that path exists on disk (Bun.file(scope).exists()). Missing scope → stale.
  3. Dedup: after archiving, scans remaining facts for identical type + content pairs. Keeps the newer one (by ts), archives the older.
  4. Rebuilds MEMORY.md.

All three sub-phases run in the same extract() call. Archive operations run in parallel (Promise.all).


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 20

indexFact 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) — assigns id and ts, 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 across goal + approach + worked + failed; returns up to k newest 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.

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 and extract() is called on the done event
  • Orchestrator — memory providers can be wired through RuntimeOptions
  • ADR 0001 §4 — full design rationale and tier definitions

MIT Licensed.