Memory
Dawn has three coexisting memory mechanisms; pick by scope and shape. Two are flat profile text a human edits (workspace/AGENTS.md and a route-local memory.md); the third is a typed, discrete collection the agent writes to and recalls across sessions (memory.ts). All three are opt-in by presence — no registration call in your route — and all three compose into the same system prompt.
workspace/AGENTS.md (L1) | memory.md (L2) | memory.ts (L3) | |
|---|---|---|---|
| Scope | Whole app (every route) | One route | Namespaced per declared scope |
| Format | Free-form Markdown | Free-form Markdown | Typed records (Zod schema) |
| Who writes | The agent (via writeFile) or you | You (human) | The agent (via remember) |
| Persistence | A file, re-read every turn | A file, re-read every turn | SQLite store, queried per turn |
| When to use | Stable facts shared across all routes | Stable facts for one route | Discrete facts the agent accumulates over time |
Workspace profile (AGENTS.md)
If workspace/AGENTS.md exists and has content, Dawn injects it into the agent system prompt on every model turn under a # Memory heading. Use it for stable, app-level context that should follow all routes: repository conventions, product facts, account assumptions for a local workspace.
# Workspace Memory
- Use pnpm for package commands.
- Prefer short, direct customer replies.
- Escalate billing exceptions to the finance queue.You do not import anything to enable this — the presence of the file is the opt-in. Behavior:
- The path is
workspace/AGENTS.mdunder the app root (context.appRoot), which equals the project directory underdawn dev. - The capability is
createAgentsMdMarker(); it always participates for agent routes but renders an empty fragment unless the file exists and has content. - The file is re-read when the fragment renders, so a later turn sees an edited file without rebuilding the route.
- Empty files and unreadable files are skipped. Files larger than 64 KiB are not loaded; the prompt notes the file exceeded the limit.
Because AGENTS.md is workspace-level, it is shared by every agent route in the same running app — it is not route-local.
Updating it
When a workspace/ directory exists, the workspace tools capability provides a path-jailed writeFile tool. Dawn's injected instruction already tells the agent to update its memory by calling writeFile({ path: "AGENTS.md", content: "..." }) — no extra tooling required. Because the update affects every route in the app, keep write access intentional and the content concise.
Route memory (memory.md)
When you want stable context that applies to one route only, add a memory.md next to that route's index.ts:
src/app/research/
index.ts
memory.mdDawn injects its trimmed contents under a # Route Memory heading via createMemoryMdMarker(), placed after the workspace AGENTS.md fragment, so the route file refines — never replaces — the workspace-wide facts.
# Research Route Memory
- Every claim in a report must carry an inline citation to a source you read.
- Prefer primary sources; flag anything you could not verify.Like AGENTS.md, it is opt-in by presence and re-read on every turn. It is a prompt fragment only — not a store, and nothing writes it but you. Empty files contribute nothing, and files larger than 32 KiB are skipped (the prompt notes the file exceeded the limit).
Long-term collection (memory.ts)
AGENTS.md and memory.md are flat text. Long-term memory is a typed, discrete collection the agent writes to and reads back across sessions, backed by @dawn-ai/memory on node:sqlite. Declare it with a memory.ts next to a route's index.ts:
import { defineMemory } from "@dawn-ai/sdk"
import { z } from "zod"
export default defineMemory({
kind: "semantic",
scope: ["workspace", "route"],
schema: z.object({
subject: z.string(),
predicate: z.string(),
value: z.string(),
}),
})defineMemory({ kind, scope, schema, identity? }):
kind—"semantic" | "episodic" | "procedural" | "reflection". Only"semantic"is wired end-to-end today; the other three are typed but deferred.scope— a subset of["workspace", "route", "tenant", "user", "agent"]. The dimensions a memory is partitioned by;["workspace", "route"]namespaces records to the app and the specific route, so two routes never read each other's memories.schema— a Zod schema describing your fact shape.identity?— the keys used for write reconciliation (inautoandaskmodes). Defaults to["subject", "predicate"].
Generated tools
Typegen emits two typed tools for any route with a memory.ts into .dawn/dawn.generated.d.ts:
recall— fetch in-scope memories by keyword, kind, or tags.remember— store a typed fact (omitted entirely whenwritesis"off").
recall({ query?, kind?, tags?, limit? }) searches the store. It defaults status to "active" and limit to 8, and returns one id: content line per match, or (no memories found) when empty.
remember({ data, content?, tags?, confidence? }) validates data against the schema, then writes a record. The id is data-derived — memory_ + the first 16 hex chars of sha1(namespace | JSON(data)) — so a contradicting value (same identity, different data) gets a distinct id and can coexist with the old row. content defaults to JSON.stringify(data), confidence to 1, tags to [].
How recall ranks
Ranked recall (any call with a query) orders results by a weighted blend:
| Signal | Default weight | Meaning |
|---|---|---|
| Relevance | 0.6 | IDF-weighted overlap — matching rare, specific words counts far more than ubiquitous ones |
| Recency | 0.3 | Exponential decay; the boost halves every 14 days |
| Confidence | 0.1 | The confidence stored with the memory |
Ties break by updatedAt (newest first), then id. Query-less searches (like
the injected index) keep pure recency order, and dawn memory list (which
lists candidates, not active memories) is likewise unaffected.
Tune it in dawn.config.ts (all fields optional and defaulted):
export default {
memory: {
recall: {
weights: { relevance: 0.6, recency: 0.3, confidence: 0.1 },
recencyHalfLifeMs: 14 * 24 * 60 * 60 * 1000,
candidatePool: 256, // ranked searches score at most this many newest token-matches
},
},
} satisfies import("@dawn-ai/core").DawnConfigSemantic recall (opt-in)
Keyword recall can't match a paraphrase — "expedite delivery" shares no tokens with a memory that says "faster shipping preferred", so it never surfaces. Opt into semantic recall to add a vector signal that catches meaning across different wording. Enable it by supplying an embedder in dawn.config.ts:
import { openaiEmbedder } from "@dawn-ai/langchain"
export default {
memory: {
vector: { embedder: openaiEmbedder() }, // presence of embedder = enabled
},
} satisfies import("@dawn-ai/core").DawnConfigWith an embedder configured, ranked recall becomes hybrid: a keyword (IDF) candidate list and a vector (cosine) candidate list, fused co-equally by Reciprocal Rank Fusion (RRF), followed by the same bounded recency/confidence second stage as keyword recall. Keyword recall is never dropped. Dense retrieval is weak on exact IDs, codes, and proper names — a query for order ALPHA-111 needs the literal token match — so both signals are kept side by side rather than replaced.
The Embedder interface is pluggable. openaiEmbedder() (from @dawn-ai/langchain) ships in the box and calls OpenAI's embeddings endpoint over the shared OPENAI_BASE_URL seam (so aimock can intercept it). Any object matching the Embedder shape works, so you can bring a custom embedder:
interface Embedder {
readonly id: string // model tag stored with each vector
readonly dims: number
embed(texts: readonly string[]): Promise<Float32Array[]>
}For tests, @dawn-ai/testing exports fakeEmbedder() — a deterministic, network-free bag-of-token-hash embedder — so vector recall stays offline and reproducible in your suite.
Embeddings are model-tagged, so switching embedders is safe. Each stored vector records the embedder id that produced it; recall only vector-compares rows whose tag matches the active embedder. Change the embedder or model and the old vectors simply stop participating — recall degrades gracefully to keyword-only for those rows until they're re-embedded, rather than comparing across incompatible vector spaces.
Vector recall is a second stage on top of keyword recall; leaving vector unset keeps the default keyword-only path exactly as before. When enabled, the only change is one opt-in network embed of the query at recall time (and of the content at write time). Under a fixed embedder — or aimock replay — recall stays deterministic.
Tuning lives alongside the embedder in memory.vector (all optional and defaulted):
memory: {
vector: {
embedder: openaiEmbedder(),
weights: { keyword: 1, vector: 1 }, // per-list weight in the RRF (co-equal by default)
rrfK: 60, // RRF rank constant — smaller separates top ranks more
vectorK: 64, // how many nearest vectors to fuse in
recencyWeight: 0.3, // second-stage recency boost
confidenceWeight: 0.1, // second-stage confidence boost
},
}Postgres backend (pgvector)
The default SQLite store is local-first: one file, no service, brute-force cosine in JS. For a production or multi-instance deployment — many app instances sharing one memory store, or a corpus too large for linear cosine — Dawn ships a Postgres + pgvector backend as @dawn-ai/memory-pgvector. Point memory.store at it in dawn.config.ts:
import { openaiEmbedder } from "@dawn-ai/langchain"
import { pgvectorMemoryStore } from "@dawn-ai/memory-pgvector"
export default {
memory: {
store: pgvectorMemoryStore({
connectionString: process.env.DATABASE_URL,
dimensions: 1536, // must match your embedder's output dims
}),
vector: { embedder: openaiEmbedder() },
},
} satisfies import("@dawn-ai/core").DawnConfigIt requires a running Postgres with the vector extension available (the store runs CREATE EXTENSION IF NOT EXISTS vector on first use). For local development, the official image ships the extension pre-installed:
docker run --rm -e POSTGRES_PASSWORD=postgres -p 5432:5432 pgvector/pgvector:pg16Retrieval runs in Postgres: an HNSW index over the embedding column with cosine distance (vector_cosine_ops) for the vector list, plus a token index for the keyword list. The dimensions you pass selects the column type — dimensions ≤ 2000 use pgvector's vector type, and larger dimensions (e.g. text-embedding-3-large's 3072) use halfvec, up to a 4000 ceiling.
SQLite stays the local-first default — reach for pgvector when you need a shared production store, multiple app instances, or indexed vector retrieval over a larger corpus. If you only need single-process local persistence, SQLite is simpler and keeps the no-service path. See the runnable examples/memory app, which switches to pgvector automatically when DATABASE_URL is set and falls back to SQLite otherwise.
The pgvector package is also smoke-tested as a published tarball outside the Dawn monorepo against real Postgres + pgvector and real OpenAI embeddings. That check asserts openaiEmbedder() returns a 1536-dimension Float32Array for the default model and that a zero-shared-token paraphrase is recalled through the hybrid vector path, guarding the encodingFormat: "float" embedder contract.
The injected index
Dawn also injects an index of the in-scope memories the agent can recall, so it knows what is available without a blind recall. It lists up to indexMaxEntries (default 20) active in-scope records, each truncated to 80 chars, under a # Long-Term Memory heading placed after the user prompt:
# Long-Term Memory
These memories are available — call `recall({ query })` to load full details before relying on them.
- memory_a1b2c3d4e5f6a7b8: acme prefers invoices net-30
- memory_0f1e2d3c4b5a6978: primary contact is jordan@acme.testThe default store lives at <appRoot>/.dawn/memory.sqlite.
Write governance
The memory.writes mode in dawn.config.ts controls what remember does. It defaults to "candidate".
| Mode | remember tool | Where writes land | Reconciliation |
|---|---|---|---|
off | Not generated (recall-only) | — | — |
candidate (default) | Generated | candidate — hidden from recall until approved | None |
auto | Generated | active immediately | Inline (see below) |
ask | Generated | active immediately | Inline — supersedes prompt first |
In auto mode each write reconciles inline against existing active records with the same identity key:
- Same identity + same data → idempotent
UPDATE(refreshes content/tags/timestamp). - Same identity, different value → supersede: a new active row is written and the old row flips to
superseded(history preserved, not deleted). - No matching identity → add a new active row.
ask mode
ask shares auto's write semantics exactly — same reconciliation, same statuses — with one difference: a SUPERSEDE (same identity, different value) asks a human first. The prompt shows the old and new values with Once / Always / Deny; Always persists a rule for the whole route, so future overwrites in that route proceed silently. ADDs and idempotent updates never prompt.
- Deny keeps the old record active; nothing is written; the agent is told which memory was kept.
- Headless (non-interactive mode, CI, evals, deployed servers):
askbehaves exactly asauto— the supersede proceeds.askis a supervision affordance for interactive development, not a security boundary. Explicitdenyrules in the permissions config are still honored headless. - Decisions persist under the
memorykey in.dawn/permissions.jsonas namespace prefixes, e.g.{ "allow": { "memory": ["workspace=app|route=/support|"] } }(keep the trailing|— it prevents sibling-route prefix collisions).
Reviewing candidates
In the default candidate mode, the agent's writes queue up for review. Manage them with the dawn memory CLI (--cwd <path> points at the app root; defaults to the current directory):
dawn memory listRecords print as ${id} [${status}] ${namespace} — ${content}. The subcommands:
list— every candidate across all namespaces.search <q>— candidates whose content or namespace contains<q>.inspect <id>— the full record as JSON.approve <id>— flip a candidate toactive. (Errors if the record is not a candidate.)reject <id>— hard-delete the record.forget <id>— also a hard delete; it differs fromrejectonly in the printed message.
Configuration
The memory block in dawn.config.ts configures L3 across the app:
export default {
memory: {
// store? — defaults to a SQLite store at <appRoot>/.dawn/memory.sqlite
writes: "candidate", // "off" | "candidate" | "auto" | "ask" (default "candidate")
indexMaxEntries: 20, // index size cap (default 20)
// Supply tenant/user/agent dimensions at runtime for namespacing.
resolveScope: ({ routePath, appRoot }) => ({
tenant: process.env.TENANT_ID ?? "default",
user: process.env.USER_ID ?? "anon",
}),
},
} satisfies import("@dawn-ai/core").DawnConfigresolveScope runs per request and fills in the tenant / user / agent dimensions a route declared in its scope. The workspace and route dimensions are derived automatically (from the app-root basename and the route path); only dimensions a route lists in scope end up in its namespace. Any string is a safe scope value — the delimiters | and = are percent-encoded when the namespace is serialized, so a free-text tenant/user id can't corrupt or collide across namespaces.
Testing
Seed memory rows directly in tests with seedMemory from @dawn-ai/testing. Pass a store instance or a { path }, plus partial records (sensible defaults fill the rest):
import { seedMemory } from "@dawn-ai/testing"
const store = await seedMemory(
{ path: ":memory:" },
[
{
id: "memory_seed1",
namespace: "workspace=app|route=/research",
content: "acme prefers invoices net-30",
status: "active",
},
],
)Verifying against a real model
For deterministic coverage the aimock harness scripts exact tool arguments — which means it cannot tell you whether a real model can drive the generated tools from their schemas. A gated live-smoke suite (packages/testing/test/memory-live.smoke.test.ts) closes that gap: it exercises remember, recall, supersession, namespace isolation, the injected index, and the candidate-approval flow against a real model. It runs only when OPENAI_API_KEY is set and is skipped in CI. For hands-on behavioral checks — eyeballing the composed prompt and walking the dawn memory governance flow by hand — the repo also keeps a manual runbook under docs/superpowers/runbooks/.
What's deferred
Long-term memory ships with the semantic kind, deterministic ranked keyword recall (IDF-weighted relevance blended with recency and confidence), and opt-in hybrid vector recall. The following are typed or designed but not implemented:
- The
episodic,procedural, andreflectionkinds. - Full BM25 (term-frequency / length-normalized) ranking via FTS5, and a memory graph.
- A dev-server Memory Inspector UI.
The SQLite (local-first, default) and Postgres/pgvector (production/multi-instance) store backends both ship today.
For anything beyond the semantic collection, use memory.md for stable profile text and memory.ts for facts the agent should accumulate over time.