MEMORY / COGNITIVE MEMORY
Remembered, not assumed.
CognitiveMemory learns durable facts from your turns and puts the relevant ones in front of the model. It is deterministic where it can be: what gets stored, what gets pre-staged, and what a recall returns do not depend on which model you configured. The CLI wires a model around that core — the page says which rules are the engine's and which are the CLI's.
Looking for conversation history and session persistence? See Conversation state. Cognitive memory is a separate, optional layer for durable context across sessions.
Cognitive Memory, on its own
cogmem.astracollab.app
The tiers on this page are one implementation of Cognitive Memory — the one NAH runs in-process, against a SQLite file under ~/.nah. It is also a hosted service with its own client: cogmemory serves the same four tiers, recall, tensions and the self-model over HTTP, with a key and a scope per project, and its runTurn helper will not let a memory failure take down a turn that already produced an answer. The official site covers that, and the design behind it, in more depth than a CLI page can.
Four tiers, one prompt block
L0RegistersUnresolved contradictions, per-domain reliability, and the fast-gate notice. Bodies only.L1Hot cacheThe only tier rendered into the prompt. One gist line each, plus a body where a trigger earns it. Newly taught facts land here.L2Warm storeOff the index. Up to 20 candidates go to the arbiter each turn, and the best three above the relevance floor are promoted back to L1.L3Cold archiveSearchable and promotable, but nothing writes here on its own. Eviction demotes to L2, so L3 fills only through addMemory(item, "L3").L1 is the only memory tier that reaches the prompt as an index. L2 and L3 stay invisible to the model until something promotes them or recall pulls them, which is what keeps the index a fixed size rather than a function of how long you have worked in a directory.
Nothing here is required for the agent loop to run — memory is an additive layer you construct yourself. It is off by default: the runtime does not inject anything until you pass a memory option. The tiers above are one implementation of the framework’s NahMemory interface; swap in a vector DB or Mastra and the runtime calls the same hooks without changing.
What gets learned, and what does not
Naive memory only catches phrasings like 'always use kebab-case' and silently drops everything else. These rules exist because that made memory look broken: a stated fact simply never reached the tiers.
Deterministic first
Pattern matching runs before any model call, so a plainly-stated fact is captured even when the model refuses, hedges, or returns nothing.
Then the model
The same model that runs the turn extracts facts, preferences, conventions and tensions in the user's own words.
Never from questions
A lookup is not a lesson. A message containing a question mark is not extracted from at all — this one is CLI wiring, not engine behaviour.
Interaction-scoped text is dropped
"Do not verify this against the repo" and "just remember this" describe how to behave right now, so they never become project facts.
Too short or too long is dropped
Statements under 8 characters or over 600 are discarded before they reach a tier, and the deterministic pass returns at most 6.
Paraphrases collapse
A byte-identical restatement always folds into the one you hold. Anything else is put to a reconcile function, which may merge, replace, or reject it.
A newly taught memory lands in L1 immediately. Waiting for the arbiter to promote it added a turn of latency, so a fact you taught on one turn was still missing from the next prompt.
What the CLI adds to the engine
The engine owns the tiers, the budget, the ranking and the merge rules. Everything model-shaped is wiring, and the CLI supplies four pieces. Build your own and the question filter becomes yours too — the engine has no opinion about questions.
extractcreateTurnExtractor(model)
The same model that runs the turn. Skips questions, and layers deterministic matches under whatever the model returns.
reconcilecreateMemoryReconciler(model)
One batched add/merge/replace/reject call per turn over restatements. An unusable answer keeps both statements.
onPersistMemoryStore
Projects the snapshot onto SQLite rows under ~/.nah/memory after every turn, and restores them on the next run.
recallcreateRecallTool
search() exposed to the model as a read-only tool, so pre-staging is never the only way in.
Index by default, bodies on demand
Always injecting every memory body is expensive and injects distractors by construction. Instead each turn gets a one-line index of the hot cache, and a full body is spent only where a deterministic signal earns it.
The trigger is the same idea Aider uses for its repo map: pull identifiers out of your message — URLs, hostnames, paths, SCREAMING_SNAKE, alphanumeric codes, camelCase, long kebab-case, hex-ish codes — and keep only those absent from the visible transcript. If you name something concrete the model cannot already see, and a memory mentions it, that memory's body is included. No model decision is involved, and hostnames are in the set because a host like internal-hbr-2291.example is exactly the value a memory holds and a dotted-name pattern used to miss.
indexOne gist line. The default, and what nearly everything costs.triggerFull body — you named an identifier absent from the visible transcript and a memory matched it.tensionFull body. Unresolved contradictions earn the tokens.guardrailFull body. A domain this agent is unreliable in.## Cognitive Memory State
### Memory index — established earlier in this project
One line per remembered item. Use `recall` to pull a full item, then treat it as true.
- staging build ID (deployment)
- file naming: kebab-case (naming)
- internal staging host is internal-hbr-2291.example (infra)maxTotalTokens caps everything injected in one prompt, index and bodies together, and when the cap bites the report is flagged as truncated instead of quietly losing the tail.
Two things move an entry out of the index, and neither deletes it. An L1 item the turn never mentions is demoted to L2 once the cache holds more than five, and if L1 outgrows maxL1Tokens the least recently used are demoted the same way. A demoted memory still answers recall and can be promoted back.
The recall tool
Pre-staging is a best-effort optimisation: a model may not read the index, and a question sharing no words with a memory will not have it staged. The recall tool lets the agent ask directly. Ranking runs in-process, scoring content-word overlap normalised by the shorter side so the score is comparable across memories of different lengths, and the same scoring promotes to L1 — so recall quality does not depend on model quality.
recall { query: 'staging build id', limit: 6 }
// Remembered (1 match):
// - The staging build ID is ZQ7X4M2K. (deployment) [L1, relevance 0.75]Results that are the same fact restated collapse to the best-scoring one, so a paraphrase cannot take two slots in a single answer. search defaults to 8; the tool asks for 6 and caps the request at 20. When nothing matches it says so, and tells the model to say it was not told rather than guessing. It is read-only, so it is not approval-gated, and the CLI registers it automatically alongside the coding tools.
Seeing what it cost
There is no published benchmark for pre-inject versus on-demand retrieval for coding-agent project memory, so the only way to tune the tradeoff is to watch it. Every injection is recorded with its reason, its tier and its token cost, for the last 20 turns.
/memory
Cognitive Memory Cache State
Turns processed: 7
Active tensions: 0
L1 Hot cache items: 9
L2 Warm store items: 2
L3 Cold archive items: 0
Prompt injection log (what memory added, and why)
7 turn(s) · avg 58 tokens · max 83
index=25
turn 7: 58 tokens
index [index/L1] staging build ID (deployment)
body [trigger/L1] internal staging host is internal-hbr-2291.exampleUse it to decide whether the budget is too tight, whether the trigger is firing when it should, and whether an index line is enough or the model keeps reaching for recall. The cost is ceil(chars / 4) over the gist, the tags and any body — cheap enough to compute on the critical path and good enough for relative tuning, but it is an estimate rather than a provider token count.
Promotion and the arbiter
The arbiter runs at the top of postTurnAsync, over warm candidates left by earlier turns. Anything learned in the current turn has already gone to L1, so a fact you teach is indexed from the very next prompt and is first judged for promotion on the turn after that.
With no arbiter configured, promotion is deterministic: warm candidates are scored against the current turn by content-word overlap and the best three above the floor are promoted. Scoring used to compare a single domain tag against the turn text with a substring match, which almost never fired — a memory tagged naming-conventions cannot match a question that says 'naming'. Promotion was effectively random.
Supply arbiter to decide tiers with a model instead, or take createModelArbiter, which is exported from the package root and lives in its own entry point so a consumer who only wants the deterministic core does not have to install a model SDK. An arbiter receives the turn text, the assistant reply, the L0 prompt, the L1 summaries and the L2 candidates, and returns a structured ArbiterEvaluationResult.
import { CognitiveMemory, createModelArbiter } from 'not-another-harness';
const memory = new CognitiveMemory({
extract: myExtractor, // omit to use only the built-in patterns
reconcile: myReconciler, // omit to collapse exact matches only
arbiter: createModelArbiter({ model: arbiterModel }),
maxTotalTokens: 2000,
onPersist: (snapshot) => db.save(snapshot),
});Self-model and knowledge tensions
The self-model tracks per-domain reliability as a moving average, plus known failure patterns and recommended strategies. A domain in the active list below 75% reliability is rendered into L0 as a guardrail on every prompt, so weak areas get explicit attention instead of confident guesses.
Nothing populates it by itself. A guardrail needs the domain in activeDomains — from initialSelfModel, or by passing detectedDomains to postTurnAsync — and at least one recorded outcome. recordDomainOutcome is the only writer, and the CLI never calls it, so a fresh install shows an empty self-model and injects no guardrails until you wire it yourself.
Active tensions are pinned into every prompt block until resolved, each paired with an actionable question so the agent asks rather than guesses. This is the one part of L0 the CLI fills on its own, because the turn extractor may report a contradiction it saw. addTension and resolveTension drive it from outside; /tensions resolve <id> closes one in the CLI.
Persistence
The CLI stores memory per working directory at ~/.nah/memory/<cwd-hash>.sqlite — twelve hex characters of a SHA-1 over the resolved directory path, so two projects never share a store and a path with spaces in it is still a filename — and reloads it on the next run, so a fact you taught yesterday is available in a session that starts today. It uses node:sqlite, so there is no native module to install, and a memory file from an earlier version is imported once and kept as .json.imported rather than deleted.
Memory is stored independently of the transcript, so a turn can be persisted while its memory is not and the other way round. Three switches control it:
--no-sessionKeeps the session and its memory in-process for the life of the run.NAH_MEMORY_NOPERSIST=1Disables the store on its own, for the SDK path as well as the CLI. Memory still works within the process.NAH_MEMORY_DEBUG=1Traces deterministic matches, model extraction, arbiter scores and persistence per turn.Memory is entirely in-process plus that database; the agent is never asked to author or maintain memory files by hand.
Local or hosted, your choice
Everything above runs in-process against a SQLite file under ~/.nah. The same cognitive layer is also available as a hosted service, and /cogmem switches between them without changing what the model sees — same tiers, same index/body split, same deterministic recall. Only where the memories live changes.
> /cogmem setup
Service URL [https://cogmem.astracollab.app]:
Key: 1) device credential store 2) paste here 3) use COGNITIVE_MEMORY_KEY [1]:
service ok · version 1.4.0 · extractor rules+model
memory → hosted · https://cogmem.astracollab.app
> /cogmem
Cognitive Memory
backend hosted · https://cogmem.astracollab.app
key cmk_…1f (40 chars) from the credential store
memories 14 L1 · 2 L2 · 0 L3
> /cogmem local
memory → local · everything you already learned is still thereTwo things are deliberately not done for you. The local store is never touched when you connect, so /cogmem local brings everything back and nothing is copied anywhere until /cogmem import is confirmed. And a service that is unreachable never fails a turn: the prompt block is skipped, the reason is shown in /cogmem and /memory, and the same is true at startup — hosted memory with no usable key falls back to local and says why, because a silent fallback is indistinguishable from working memory until a fact fails to arrive.
The key comes from COGNITIVE_MEMORY_KEY if that is set, and from the platform credential store otherwise, in the same place provider keys live. COGNITIVE_MEMORY_URL overrides the service URL, which is what makes a self-hosted deployment one variable away. In hosted mode the service does the extraction, so the turns you have are sent to it to learn from — the trade for not spending a model call per turn on your own provider.
API surface
Methods
planInjection({ userMessage?, forceFull? })Returns text, entries, totalTokens and truncated. The primary entry point.getPromptContext(message?)Prompt text only. Prefer planInjection when you want to know what was included.search(query, limit = 8)Deterministic ranked lookup across all three memory tiers. Backs the recall tool.postTurnAsync({ userMessage, assistantResponse, detectedDomains? })Arbiter, extraction, budgets, persistence. Fire and forget; it runs after the response streams.addMemory(item, targetTier = "L2")Adds or promotes a MemoryItem into a tier.addTension(tension) / resolveTension(id, { resolvedBy, pattern })Registers or closes a contradiction pinned in L0.recordDomainOutcome(domain, success, failurePattern?)The only writer of the self-model. Moving average, weight 1 / min(samples, 10).getSnapshot() / loadSnapshot(snapshot)Serialize or restore the full L0–L3 state and stats.Constructor options
extractModel-backed turn extractor. Without one, the built-in regex only recognises "always/never/must/should/make sure to/remember to" and learns nothing else.reconcileAdjudicates a new statement against ones you already hold. Omit it and only byte-identical restatements collapse.arbiterDecide tiers with a model. Omit it and promotion is deterministic content-word overlap.maxTotalTokensCeiling on everything injected into one prompt, index and bodies together. Default 2000.maxL1TokensBudget for the hot cache. Default 8000; the least recently used are demoted to L2, never dropped.onPersistCalled with the snapshot after every turn. This is the only persistence hook there is.initialSelfModelSeed reliability scores and activeDomains. The guardrail block renders from here.maxL0TokensAccepted but not read. L0 is bounded by maxTotalTokens; no tiering decision consults it.Types for every item above — MemoryItem, MemoryInjectionReport, MemoryInclusionReason, MemoryReconciliation, KnowledgeTension, CognitiveMemoryOptions — are exported from the package root alongside the class, as are the deterministic primitives it ranks with: extractDeterministic, extractIdentifiers, isInteractionScoped, runFastGate, relevanceTokens and overlapScore.
There is also packages/nah/eval-memory.mts, which teaches facts containing unguessable tokens, wipes the transcript, and asks again — so anything the model can still answer came out of memory rather than context.