AstraMemory Local
by astragenie
README.md
# AstraMemory Local
[](https://www.npmjs.com/package/astramem-local)
[](https://opensource.org/licenses/MIT)
[](https://github.com/astragenie/astramem-local/actions/workflows/test.yml)
Local-first memory daemon for AI coding agents — wire-compatible with [astramem-plugin](https://github.com/astragenie/astramem-plugin) (v0.5.2+). See the npm badge above or [CHANGELOG.md](CHANGELOG.md) for the current release — this README doesn't hardcode a version number, it goes stale every release.
## Try it in 60 seconds
No install beyond npm, no Ollama, nothing written to disk:
```bash
npm install -g astramem-local
astramem-local demo
```
This boots an in-memory daemon, seeds 5 example memories (decision, fact, lesson, todo,
preference) through a deterministic mock embed/LLM path, runs one sample recall against them, and
keeps serving in the foreground until you hit Ctrl+C. When you're ready for persistent storage,
run `astramem-local init` to install a real daemon.
---
## Why it exists
Claude Code sessions compact and terminate, taking context with them. AstraMemory Local captures
every session transcript, distills typed memories (decisions, facts, lessons, commands, todos),
and serves them back via hybrid search (BM25 + vector + importance + freshness). It runs entirely
on your workstation — no cloud account, no data leaves your machine. The plugin's hooks post to
the local daemon instead of the SaaS endpoint through a single environment variable swap.
---
## Quick start
**npm (recommended):**
```bash
npm install -g astramem-local
astramem-local init
# follow the wizard — picks embedding (ONNX in-process, default) and LLM (codex-subscription
# default, or Ollama/Azure/Anthropic/OpenAI-compatible) providers, writes config.yaml + secrets.env
astramem-local service install
export MEMORY_API_URL=http://127.0.0.1:7777
export MEMORY_BEARER=$(astramem-local token print)
```
Requires Node.js >= 20. Native deps (`better-sqlite3-multiple-ciphers`, `@napi-rs/keyring`) use
prebuilt binaries where available, falling back to a from-source compile otherwise.
**One-liner install scripts** — no npm needed first, the script does the Node check + npm install
for you, then hands off to the wizard. Fastest path if you don't already have a Node toolchain
open:
```bash
# Linux / macOS
curl -fsSL https://raw.githubusercontent.com/astragenie/astramem-local/main/install.sh | bash
```
```powershell
# Windows (PowerShell 5.1+ or 7+)
irm https://raw.githubusercontent.com/astragenie/astramem-local/main/install.ps1 | iex
```
If the script's own output is piped through a non-interactive shell, it stops after installing
and tells you to run `astramem-local init` yourself — the wizard needs a real terminal to prompt
for provider choice.
**Build from a git clone:**
```bash
git clone https://github.com/astragenie/astramem-local.git
cd astramem-local
bun install
bun run build
node dist/cli/index.js init
# follow the wizard — picks embedding (ONNX in-process, default) and LLM (codex-subscription
# default, or Ollama/Azure/Anthropic/OpenAI-compatible) providers, writes config.yaml + secrets.env
node dist/cli/index.js service install
export MEMORY_API_URL=http://127.0.0.1:7777
export MEMORY_BEARER=$(node dist/cli/index.js token print)
```
> **If you pick the Ollama LLM tier, first run can take 10-30 minutes** if Ollama isn't installed
> yet. Since v0.18.0 the wizard's actual default LLM provider is `codex-subscription` (reuses an
> existing `codex login` ChatGPT session — no local model download at all); Ollama is offered as
> the free, fully-local alternative and becomes the pre-selected default when no Codex login is
> detected. The Ollama LLM tier pulls `qwen2.5:7b` (~4.7 GB — the tested/recommended local
> extraction model; the previously-tried `qwen3:8b` returned empty extraction output under
> thinking mode and was dropped) plus `mxbai-embed-large` (~670 MB) if you also pick Ollama for
> embedding — about **~5.4 GB** total download, with no separate confirmation prompt. Embedding
> itself defaults to **ONNX** (bundled, in-process, no Ollama server required) regardless of
> which LLM provider you choose. See [docs/providers.md](docs/providers.md) for the full
> provider breakdown and RAM guidance.
Restart Claude Code. All plugin hooks (PreCompact, SessionEnd, SubagentStop) now post to the
local daemon. No other plugin changes needed.
Prefer to try before installing anything? See [Try it in 60 seconds](#try-it-in-60-seconds) at the
top of this README — `astramem-local demo` needs nothing from the rest of this section.
---
## Wire compatibility
The daemon's ingest endpoint now speaks the same wire protocol as the SaaS backend. Both old and new clients work:
**Legacy plugin (v0.1.x):**
```json
POST /ingest/transcript
Content-Type: application/json
Authorization: Bearer <token>
{
"session_id": "claude-20260630-abc123",
"source": "precompact",
"content": "[Assistant]: Distilled 3 facts..."
}
```
**SaaS canonical (v0.2.0+):**
```json
POST /ingest/transcript
Content-Type: application/json
Authorization: Bearer <token>
{
"event": "pre_compact",
"session_id": "claude-20260630-abc123",
"turns": [{"role": "user", "content": "..."}, ...],
"wire_version": "v1.0",
"captured_at": "2026-06-30T12:34:56Z",
"client_version": "0.5.0",
"project_id": "my-project",
"cwd": "/home/user/src"
}
```
Both shapes are accepted — no migration needed. See [contracts/wire.ts](contracts/wire.ts) for the complete schema definition.
---
## Two ways to run this: full daemon vs. MCP-only stdio
Everything above (`service install`, `serve`) runs `astramem-local` as a long-lived HTTP
daemon. There is a second, much lighter mode if all you want is the MCP tool surface inside
Claude Code (or another MCP host) and you don't need the LLM-proxy auto-capture path,
background distillation, or sync:
```bash
astramem-local mcp-stdio
```
This spawns **one process per MCP client session** over the stdio transport
([src/cli/mcp-stdio.ts](src/cli/mcp-stdio.ts)) — same tool set and same on-disk SQLite store as
the daemon's `POST /mcp`, but no instance lock, no worker loop, no timers, and no service
lifecycle at all. It exits automatically when the MCP host closes stdin. Point an MCP host at
it directly instead of running `service install`:
```json
{
"mcpServers": {
"astramem": { "command": "astramem-local", "args": ["mcp-stdio"] }
}
}
```
**What you don't get in this mode:** `remember` (direct insert) still works, but transcript
capture from the plugin's hooks (`POST /ingest/transcript`) and the LLM-proxy auto-capture path
both require an HTTP listener — `proxy/server.ts` binds one — so both structurally need the
long-lived daemon. So does background distillation of anything that would otherwise sit in the
worker's job queue, and `sync`. `mcp-stdio` is a real way to use the MCP tools against your
local store with no daemon install; it is not a replacement for the daemon if you rely on
automatic capture.
One more thing worth knowing: the CLI's own code comment currently labels this "test-enabling
infrastructure" and calls the daemon's `POST /mcp` "the supported production surface"
([src/cli/index.ts](src/cli/index.ts), the `mcp-stdio` case) — it works today, but the project
doesn't yet treat it as the primary supported path the way this README treats `service install`.
---
## Architecture
```
memory-plugin hooks
|
| POST /ingest/transcript (v0.2.0+ — SaaS-canonical envelope)
| Authorization: Bearer <token>
v
+------------------+ SQLite (memory.sqlite)
| HTTP daemon | ---> +-------------------+
| Fastify | | sessions |
| 127.0.0.1:7777 | | messages |
| (v0.2.0+) | | transcripts |
+------------------+ | ingest_idempotency|
| jobs (queue) |
| memories |
| memories_fts (FTS5)|
| memories_vec (vec0)|
| entities |
| memory_entities |
| entity_relations |
| budget_spend |
+-------------------+
|
in-process worker loop
|
8-stage distillation
(cleanup -> normalize ->
chunk -> compact ->
extract -> reduce ->
memory-normalize ->
embed + index)
|
+--------------+--------------+
| | |
memories FTS5 index sqlite-vec
(rows) (BM25 search) (cosine ANN)
| | |
+--------------+--------------+
|
hybrid score fusion
a*BM25 + b*cosine +
c*importance + d*freshness
|
GET /search POST /recall
|
/recall in plugin slash commands
```
Single Node process. Workers run in-process on a polling loop. SQLite is the source of truth.
Everything derived (vectors, FTS rows, compactions) can be rebuilt by replaying the jobs table.
`entities` + `memory_entities` (FEAT-402/FEAT-453) tag named things (people, projects, tools,
decisions, ...) mentioned in a memory, with optional fact-level bitemporal validity hints.
`entity_relations` (FEAT-481) is one join further: entity-to-entity relation triples
(`subject_entity_id`, `predicate`, `object_entity_id`) extracted alongside the same atom's
`entities[]` list, retrievable via the `get_entity_related` / `get_entity_timeline` MCP tools
(bitemporal `as_of` aware — a relation sourced from an invalidated/superseded memory stops
reading as valid once `as_of` passes that memory's own `valid_to`).
---
## Memory types
Canonical ~10-value `type` registry (U4 contract-unification, ADR D4),
single-sourced in `@astragenie/astramem-contracts`
(`contracts/schemas/atom.v1.schema.json`) and mirrored in
`src/contracts/memory.ts` `MEMORY_TYPES`. astramem-local writes the first 7
today; the last 3 are cloud-authored types local can read (e.g. via sync)
but does not yet produce.
| Type | Description | Example | Ships from |
|---------------|---------------------------------------------------------|-----------------------------------------------|------------------------|
| `decision` | Architectural or design choice made during a session | "Use sqlite-vec for v1 vector storage" | local |
| `fact` | Objective project fact, configuration detail | "Port 7777 is the default daemon port" | local |
| `lesson` | Something that went wrong and how it was resolved | "sqlite-vec rowid must match memories rowid" | local |
| `command` | CLI command or script worth remembering | "bun run build && bun run test -- migrate" | local |
| `todo` | Outstanding work item surfaced in conversation | "Add reembed job when provider changes" | local |
| `note` | Freeform observation not fitting another type | "Team prefers PRs under 400 lines" | local |
| `event` | Something that happened at a point in time | "Deployed v0.8.1 to prod" | local |
| `preference` | User/agent preference atom | "User prefers terse commit messages" | cloud only (today) |
| `task_result` | Outcome of a completed task | "Migration 014 applied cleanly" | cloud only (today) |
| `summary` | Session/thread summary atom | "Summary of the FEAT-424 filter-unification session" | cloud only (today) |
---
## Provider matrix
| Concern | Codex subscription (cloud, **shipped default**) | Ollama (local, free) | ONNX (local, in-process) | Azure OpenAI (cloud) |
|------------------|--------------------------------------------------|---------------------------------|--------------------------------------|----------------------------------------------|
| LLM compaction | gpt-5.4-mini | qwen2.5:7b (default tier) | — | gpt-4.1 or any deployment |
| LLM extraction | gpt-5.4-mini | qwen2.5:7b (default tier) | — | gpt-4.1 or any deployment |
| Embedding | — (no embeddings endpoint) | mxbai-embed-large (1024-dim) | mxbai-embed-large (1024-dim, **shipped default**) | text-embedding-3-small (1024 via dimensions) |
| Cost | ChatGPT subscription quota (not tracked by `budget.daily_usd`) | $0 (local inference) | $0 (local inference) | ~$0.02/1K tokens + $0.0001/1K embed tokens |
| Setup | `codex login` | `ollama serve` + `ollama pull <model>` | none — bundled, no server needed | Azure portal + endpoint + deployment name |
Providers are configurable independently per stage: LLM compaction/extraction is one axis,
embedding is another. The `init` wizard defaults to `codex-subscription` for LLM (falling back to
Ollama's `qwen2.5:7b` tier when no Codex login is detected) and `onnx` for embedding, but any
combination is valid. Embedding provider is system-wide — switching requires
`astramem-local rebuild --reembed` to re-index all memories in the new model's vector space.
See [docs/providers.md](docs/providers.md) for full setup instructions.
---
## Public endpoints
All endpoints require Bearer token authentication except `GET /version` and `GET /health`.
`GET /dashboard` additionally accepts an `HttpOnly` session cookie, bootstrapped via a one-time
`?token=` visit (see [Dashboard](#dashboard) below).
| Endpoint | Auth | Description |
|---|---|---|
| `GET /health` | — | Daemon health probe: `{ ok, version, wire_versions_supported, schema_version }` |
| `GET /version` | — | Version discovery: `{ name, version, wire_versions_supported, schema_version, ts }` |
| `POST /ingest/transcript` | Bearer | Capture protocol endpoint — accepts `transcript` and `events` kinds; idempotency via `Idempotency-Key` header. See [docs/capture-protocol.md](docs/capture-protocol.md). |
| `GET /search` | Bearer | Hybrid search with type/repo/project/since filters + named `preset` routing. See [docs/retrieval-presets.md](docs/retrieval-presets.md). |
| `POST /recall` | Bearer | Top-K semantic recall (alias: `search` with k=5) + named `preset` routing |
| `POST /recall/pack` | Bearer | Token-budgeted memory pack for a repo/project/branch — powers the SessionStart hook |
| `POST /remember` | Bearer | Direct memory insert, bypasses distillation |
| `GET /memory/:id` | Bearer | Single memory lookup |
| `GET /memory/:id/why` | Bearer | Provenance receipt — extraction evidence + confidence for a memory |
| `GET /memory/:id/history` | Bearer | Full `memory_events` log for a memory (invalidate/supersede/promote chain) |
| `POST /memory/:id/invalidate` | Bearer | Soft-delete a memory (lifecycle op) |
| `POST /memory/:id/supersede` | Bearer | Replace a memory with a newer one, linked via `memory_events` |
| `POST /memory/:id/promote` | Bearer | Promote a memory's scope: personal → team → org |
| `POST /memory/:id/used` | Bearer | Record an explicit recall-usefulness signal for a memory |
| `GET /sessions/:id/digest` | Bearer | Session summary digest |
| `GET /dashboard` | Bearer, cookie, or one-time `?token=` bootstrap | Read-only HTML metrics dashboard, auto-refreshing every 5s |
| `POST /mcp` | Bearer | Model Context Protocol endpoint (auto-discovered tools, see below) |
## MCP tools (Claude Code auto-discovery)
The daemon exposes a **Model Context Protocol** (Streamable HTTP) endpoint at `POST /mcp`.
Claude Code discovers and calls the tools below automatically when configured in `.mcp.json`.
| Tool | Description | Maps to |
|---|---|---|
| `search_memory` | Hybrid FTS + vector search with optional type/repo/project/since filters + named `preset` routing | `GET /search` |
| `recall_memory` | Top-K semantic recall (default k=5) + named `preset` routing | `POST /recall` |
| `remember` | Direct memory insert, bypasses distillation | `POST /remember` |
| `get_health` | Daemon health probe: `{ ok, version, wire_versions_supported, schema_version }` | `GET /health` |
| `why_memory` | Provenance receipt — extraction evidence + confidence for a memory | `GET /memory/:id/why` |
| `session_digest` | Session summary digest | `GET /sessions/:id/digest` |
| `agent_profile` | Read-time per-agent "what has this agent learned" profile — top lessons, recent decisions, prior corrections | `GET /agents/:agent/profile` |
| `user_profile` | Read-time daemon-wide profile (no agent filter) — standing facts, preferences, recurring lessons, recent decisions, known entities. Optional `?scope=team\|org\|all` (defaults to `private`) | `GET /profile` |
| `get_entity_related` | Entity-to-entity relation triples (`entity_relations` table, FEAT-481) for one entity — bitemporal `as_of` aware, optional `direction` (`subject`\|`object`\|`both`) | (MCP-only, no REST route) |
| `get_entity_timeline` | Memories mentioning an entity over time, newest-first, optional `since` lower bound | `GET /entities/:id/memories` |
| `invalidate_memory` | Soft-delete a memory (lifecycle op) | `POST /memory/:id/invalidate` |
| `supersede_memory` | Replace an old memory with a new one, linked via `memory_events` | `POST /memory/:id/supersede` |
| `promote_memory` | Promote a memory's scope: personal → team → org | `POST /memory/:id/promote` |
| `restore_memory` | Un-archive a memory previously archived by the W4.4 TTL policy or a manual archive event | `POST /memory/:id/restore` |
| `memory_history` | Full `memory_events` log for a memory | `GET /memory/:id/history` |
| `submit_feedback` | Explicit recall-usefulness signal — "this memory mattered" (U5 canonical name, shared with cloud) | `POST /memory/:id/used` |
| `mark_memory_used` | **DEPRECATED** one-release alias for `submit_feedback` (same handler) — migrate now | `POST /memory/:id/used` |
| `erase_memory` | Erasure v1 (ADR-006): permanently hard-delete a memory, leaving only a tombstone event | `DELETE /memory/:id` |
| `list_consolidation_proposals` | Pending (or resolved) stage-9 consolidation proposals awaiting a merge/keep decision (ADR-004) | `GET /consolidation/proposals` |
| `resolve_consolidation_proposal` | Accept or reject a pending consolidation proposal | `POST /consolidation/proposals/:id/accept` / `.../reject` |
**Plugin `.mcp.json` wiring:**
```json
{
"mcpServers": {
"astramem": {
"type": "http",
"url": "${MEMORY_API_URL}/mcp",
"headers": { "Authorization": "Bearer ${MEMORY_BEARER}" }
}
}
}
```
Set `MEMORY_API_URL=http://127.0.0.1:7777` and `MEMORY_BEARER` to your token
(printed by `astramem-local token print`).
This daemon reads and writes only `MEMORY_API_URL` (`src/cli/init.ts`) — `/mcp` is appended to it
the same way `/health` or `/search` would be. If you've seen `MEMORY_MCP_URL` referenced
elsewhere, that's a separate variable from the companion plugin's docs for an older two-port
SaaS-style deployment (distinct API and MCP servers); it doesn't apply to this single-port daemon.
**Automated wiring:** `astramem-local mcp install --client <name>` writes the above for you —
resolves the daemon's URL/port and Bearer token the same way `serve` does, and updates the
target client's config in place (idempotent, backs up the existing file to `.bak` first).
| `--client` | Target file | Written automatically? |
|---|---|---|
| `claude-code` | `.mcp.json` (project root) | Yes |
| `cursor` | `~/.cursor/mcp.json` | Yes |
| `claude` | Claude Desktop's `claude_desktop_config.json` (per-OS path) | Yes |
| `windsurf` | `~/.codeium/windsurf/mcp_config.json` | No — prints a manual snippet |
| `vscode` | `.vscode/mcp.json` | No — prints a manual snippet |
```bash
astramem-local mcp install --client claude-code # writes .mcp.json in the current project
astramem-local mcp install --client cursor --dry-run # preview only, no write
astramem-local mcp install --client claude --url http://127.0.0.1:7777
```
---
## Budget cap
The daily LLM spend cap (default: **$10 USD**) is enforced before each LLM call.
- Ollama always reports `$0` cost — the cap only applies to Azure usage.
- When the cap is reached, pending distillation jobs move to `paused` state. Ingest continues
to accept transcripts (no data loss). Distillation resumes the next UTC day automatically.
- Override: `astramem-local budget --reset` (logged).
- Check current spend: `astramem-local budget`.
---
## Security
### Encryption at rest
`memory.sqlite` is encrypted by default using `better-sqlite3-multiple-ciphers` (SQLCipher-compatible
cipher driver). The 32-byte key is resolved through a provider chain:
1. **OS credential store** — Windows Credential Manager / macOS Keychain / Linux libsecret, via
`@napi-rs/keyring`.
2. **Key-file fallback** — `<configDir>/db.key` (mode `0600`) with a WARN log, used only when the
credential store throws (e.g. headless Linux with no secret-service session).
A pre-existing plaintext `memory.sqlite` (from a version predating encryption) is **auto-migrated**
transparently on daemon startup: the file is checkpointed, re-keyed via `PRAGMA rekey`, and
verified (row-count match) before the encrypted copy replaces the original. The pre-migration
plaintext file is preserved at `memory.sqlite.pre-encryption.bak` — nothing is deleted. Migration
is idempotent; an already-encrypted file is a no-op.
Disabling encryption (`security.encryption.enabled: false`) is a deliberate trust trade-off — the
daemon logs a prominent WARN at startup, and `astramem-local doctor` reports
`encryption: OFF — memory.sqlite is stored in PLAINTEXT`.
### Stage-0 secret redaction
Every transcript turn and manual `/remember` write passes through a redaction choke point *before*
it is persisted — downstream pipeline stages only ever see already-redacted text. Detection runs in
three passes:
1. **PEM private-key blocks** (multiline, whole block).
2. **Vendor/pattern detectors** — AWS access keys, GitHub tokens, Azure storage keys/SAS tokens, GCP
API keys, Slack tokens, JWTs, generic `key=value` credentials, connection-string userinfo — plus
any org-specific regexes from `security.redaction.customPatterns`.
3. **Shannon-entropy fallback** — flags high-entropy strings (default threshold 4.0 bits/char) that
pattern detectors missed.
Matches are replaced with a placeholder — `[REDACTED:<type>:<hash8>]`, where `hash8` is the first 8
hex chars of `SHA-256(secret value)` — so the same secret always redacts to the same placeholder
(dedup-safe) while the raw value is **never stored or logged**. Only counts are persisted, in the
`redaction_log` table (`type`, `count`, `session_id`, `created_at`) — `astramem-local doctor` surfaces
a 7-day breakdown, e.g. `redaction: on — 12 secrets redacted (3 aws_access_key, 9 generic_credential) in last 7d`.
Toggle with `security.redaction.enabled` (default `true`).
**Redaction is secrets-only, regardless of extraction profile.** `distill.extractProfile:
conversational` (see `docs/configuration.md#distillextractprofile`) extracts far more non-secret
personal detail (names, relationships, life events, preferences) than the default `engineering`
profile — that is its entire purpose. This redaction pass is completely unchanged either way: it
never redacts non-secret personal content under any profile, and everything it does not redact is
written to the local SQLite store and (if a cloud LLM provider is configured for extraction) sent
to that provider like any other transcript content. `astramem-local doctor` reports the active
profile and this same disclosure in its "Extraction profile" row.
### Bearer token storage
The daemon's Bearer token is stored the same way as the DB encryption key: OS credential store
first, `secrets.env` (mode `0600`) only as a fallback when the credential store is unavailable. A
token found only in `secrets.env` is opportunistically promoted into the credential store the next
time the daemon resolves it — `secrets.env` itself is never rewritten or deleted as part of that
promotion.
### Dev no-auth mode
Set `auth.devNoAuth: true` in `config.yaml` to hit the API — and `/dashboard` (see
[Dashboard](#dashboard)) — from local tools (curl, a browser, a script) without supplying
`Authorization: Bearer <token>` at all. Dashboard keeps its own independent Bearer/cookie/`?token=`
auth only while this flag is off; with it on, the dashboard is token-free too, subject to the same
loopback Host-header guard as everything else. Config-file only — there is no env var for this, so
it's always an explicit, visible line in config.yaml, never a stray shell variable someone forgot
was set.
What it protects against: the Bearer token doubles as a DNS-rebinding defense — without it, a
malicious web page the operator's browser visits could `fetch('http://127.0.0.1:7777/...')` and
read or write the whole memory store. `devNoAuth` keeps that defense by requiring every request's
`Host` header to resolve to `127.0.0.1`, `localhost`, or `::1` (with or without a port) — anything
else gets a `403`, not a silent pass-through.
What it does **not** protect against: with `devNoAuth` on, **every other local process** — any
script, any browser tab, any other application on the machine — can read and write your memories
with no credential at all. That's the entire trade being made; only enable it on a machine you
trust completely, and never alongside `sync` pairing to a prod workspace (`doctor` WARNs on this
every run while it's on).
It only ever activates on a loopback bind. If `network.host` is set to anything non-loopback (e.g.
`0.0.0.0`), the daemon refuses to skip auth — it logs a loud warning and keeps Bearer enforcement in
place on every route, rather than either exposing the whole LAN with no credential or refusing to
start at all.
---
## Capture protocol
`astramem-local` accepts session capture from any tool that can speak one small HTTP contract —
`POST /ingest/transcript` with an `astramem-capture@1` envelope. Two kinds are supported:
- **`transcript`** (default) — raw turns, run through the full 8-stage distillation pipeline.
- **`events`** — pre-typed atom candidates (decision/fact/lesson/command/todo/note/event) that skip
the raw-text/LLM stages and enter directly at the reduce stage. Built for sources that already
know their own semantics (e.g. `runner-plugin` slice grades and lessons).
Both kinds pass through the same stage-0 redaction choke point described above. Writing a new tool
integration is a small translator — capture at the tool surface, shape into one envelope per session
boundary, POST it. See [docs/capture-protocol.md](docs/capture-protocol.md) for the full contract,
field reference, and a curl example.
---
## Memory lifecycle
Every memory has an append-only history in the `memory_events` log. Lifecycle operations never
delete a row — they append an event and update derived state:
| Operation | Effect |
|---|---|
| **Invalidate** | Soft-deletes a memory (optionally with a reason) — it stops surfacing in search/recall. |
| **Supersede** | Replaces an old memory with a new one; the two are linked via the event log. |
| **Promote** | Widens a memory's scope: `personal` → `team` → `org` (downward/same-scope transitions are rejected). |
`GET /memory/:id/history` (and the `memory_history` MCP tool) return the full event chain for a
memory — the complete invalidate/supersede/promote provenance trail.
**`why_memory` receipts** (`GET /memory/:id/why`, MCP `why_memory`) answer "why does the daemon
believe this?" — they return the extraction evidence and confidence that produced the memory, so a
recalled fact or decision can be traced back to its source.
---
## User profile
`GET /profile` (and the `user_profile` MCP tool) return a read-time, daemon-wide "what does this
owner keep coming back to" profile, rolled up from the existing atom kinds — zero new schema, zero
persisted document, no rebuild step. Every call is a fresh SQL read against current state, so there
is no staleness window.
| Profile section | Rolled up from |
|---|---|
| `standing_facts` | `fact` atoms |
| `preferences` | `note` atoms (the closest existing semantic match — see FEAT-451 design doc) |
| `recurring_lessons` | `lesson` atoms |
| `recent_decisions` | `decision` atoms, newest first |
| `known_entities` | linked entities of kind `actor`/`tech`/`product`/`tool`, ranked by mention count |
`standing_facts`/`preferences`/`recurring_lessons` are ranked by the same ADR-010 usefulness signal
`agent_profile`'s `top_lessons` uses, then importance, then memory id (a total tie-break, required
for deterministic output). Only currently-active memories count (`valid_to IS NULL AND archived =
0`) — a superseded or invalidated atom drops out on the next call, no extra event needed.
Defaults to `scope=private` (ADR-009's default); pass `?scope=team|org|all` to widen it. This
daemon has a single global Bearer token and no per-caller scope authorization, so widening scope is
the authenticated caller's own choice, not a privilege escalation — see the FEAT-451 design doc §3
for the full reasoning.
---
## Usefulness metric
The daemon tracks a **recall-usefulness rate**: of the memories served by a search/recall/pack call,
how many were later marked as actually used (`POST /memory/:id/used`, MCP `submit_feedback` — or its
deprecated one-release alias `mark_memory_used` — or the REST twin). The rate is
`distinct atoms used / distinct atoms served` in a given time window, computed per memory type and
per surface (`mcp` / `rest` / `cli`).
This is a **v1 measure-only signal** — it does not yet feed ranking (see ADR-010). Query text is
never stored; only a truncated SHA-256 digest of the query is kept alongside the served/used events,
themselves appended to the same `memory_events` log lifecycle operations use.
---
## Dashboard
`GET /dashboard` serves a single-file, auto-refreshing (every 5s) HTML metrics page — no
JavaScript, no CDN, no external assets, dark mode by default. It shows memory counts by type,
recent captures, job-queue state, distill throughput, provider health, today/MTD budget spend vs
cap, and the pending-capture queue depth.
Auth accepts either the usual `Authorization: Bearer <token>` header, or an `HttpOnly` session
cookie. To open the dashboard directly in a browser (which can't set an `Authorization` header),
visit it once with `?token=<bearer>` — the daemon exchanges that for the cookie via a 302 redirect
to the clean URL, so the bearer never persists in browser history or gets re-sent by the
`<meta refresh>` poll. A missing or wrong credential returns a plain-text 401, never HTML, and the
query string is stripped from the log line so a wrong `?token=` guess doesn't persist a candidate
secret. With [Dev no-auth mode](#dev-no-auth-mode) active, none of the above is required at
all — the dashboard opens with no token or cookie, gated only by the same loopback Host-header
check every other route gets under that mode.
---
## Commands reference
| Command | What it does |
|--------------------------------------------|----------------------------------------------------|
| `astramem-local init [--no-hook]` | Interactive wizard — writes config + secrets, runs migrations, installs service, offers the SessionStart memory-pack hook |
| `astramem-local serve [--port N]` | Start daemon in foreground (dev/debug) |
| `astramem-local service install` | Register daemon as a user-scope OS service |
| `astramem-local service status` | Show service state |
| `astramem-local service start` | Start the service |
| `astramem-local service stop` | Stop the service |
| `astramem-local service uninstall` | Remove the service unit |
| `astramem-local doctor` | Run all health checks, print table |
| `astramem-local doctor --json` | Machine-readable health check output |
| `astramem-local search "<query>"` | Hybrid search, print results table |
| `astramem-local search "<query>" --type decision` | Filter by memory type |
| `astramem-local recall "<question>"` | Top-5 semantic recall (alias for search k=5) |
| `astramem-local remember "<text>" [--type]` | Direct insert, bypasses distillation pipeline |
| `astramem-local queue` | Show pending/failed jobs |
| `astramem-local queue --state failed` | Show only failed jobs |
| `astramem-local rebuild [--reembed]` | Rebuild derived indexes; --reembed re-vectors all |
| `astramem-local providers list` | List configured providers and their health |
| `astramem-local providers test [name]` | Ping provider, print latency + dim |
| `astramem-local budget` | Show today and month spend vs cap |
| `astramem-local budget --reset` | Clear today's spend counter (override, logged) |
| `astramem-local token print` | Print the current Bearer token |
| `astramem-local token rotate` | Generate new token, invalidate the old one |
| `astramem-local mcp install --client <name>` | Write/update an MCP client's config to point at this daemon's `POST /mcp` (see [MCP tools](#mcp-tools-claude-code-auto-discovery)) |
| `astramem-local mcp-stdio` | Run the MCP tool surface over stdio, one process per client session — no daemon, no service install (see [Two ways to run this](#two-ways-to-run-this-full-daemon-vs-mcp-only-stdio)) |
| `astramem-local demo` | 30s zero-dependency trial: in-memory daemon, seeded example memories, one sample recall, no Ollama required |
---
## Further reading
- [docs/retrieval-presets.md](docs/retrieval-presets.md) — named retrieval presets (`preset=default|recent|exact|entity|temporal`)
- [docs/migration-from-saas.md](docs/migration-from-saas.md) — switch the plugin from remote SaaS to local daemon
- [docs/configuration.md](docs/configuration.md) — full config.yaml reference
- [docs/providers.md](docs/providers.md) — Ollama and Azure OpenAI setup
- [docs/capture-protocol.md](docs/capture-protocol.md) — `astramem-capture@1` wire contract (`transcript` + `events` kinds)
- [docs/hooks/memory-pack.md](docs/hooks/memory-pack.md) — SessionStart memory-pack hook (auto-installed by `init`)
- [docs/troubleshooting.md](docs/troubleshooting.md) — common issues and fixes
- [docs/contracts.md](docs/contracts.md) — frozen type interfaces (for contributors)
- [CHANGELOG.md](CHANGELOG.md) — release history
---
## Retrieval quality gates
Every push and pull request to `main` runs the ADR-005 retrieval eval harness
(`tests/eval/retrieval-eval.test.ts`) as part of `bun run test` — it is a regular test, not an
optional or manually-triggered check. It runs a fixture corpus of graded queries through the
real hybrid search path (BM25 + vector + importance + freshness fusion) with a deterministic
fake embedder, and fails the build if either regression floor is missed: **recall@10 ≥ 0.9** or
**NDCG@10 ≥ 0.7**.
These are enforced floors, not a self-reported benchmark leaderboard: the numbers are computed
by CI from a fixture set checked into the repo (`contracts/fixtures/eval/`), on every change, by
anyone — not hand-picked and reported after the fact. A PR that regresses ranking behavior fails
the same test suite as any other broken behavior; there is no separate step to remember to run.
No external benchmark figure (e.g. LoCoMo) is published in this README. The only LoCoMo run to
date used a 10% sample — one conversation, n=199 — which is not comparable to the full
~1986-question set that published competitor baselines run on, so we're not quoting a number
here until a full-sample rerun lands. The CI-gated floor above is what's real and checkable
today.
See [docs/eval/methodology.md](docs/eval/methodology.md) for what each of the project's eval
harnesses measures (including the manual extraction-quality benchmark and the LongMemEval
adapter's current status) and how to reproduce them.
---
## Known limitations
Documenting these here so they're a five-minute read instead of a surprise:
- **Temporal queries are weak, and the gap is asymmetric.** Bitemporal storage and `as_of`
point-in-time filtering are correct end-to-end (`migrations/006-atom-v3.sql`,
`validityFilterClause()` in `src/search/search.ts`) — the schema isn't the problem, and it isn't
simply unpopulated either. `AtomSchema` (`src/distill/prompts/extract.ts`) has both `valid_at`
and `invalid_at`, but extraction only fills in one of them in practice: a 2499-atom measurement
found 423 distinct `valid_from` values (the `valid_at` half works) against zero non-null
`valid_to` (the `invalid_at` half has never fired). Whether that's a bug or just reflects that
conversations rarely state an explicit invalidation is unmeasured. Treat point-in-time recall as
schema-ready with one-sided extraction coverage, not fully extraction-populated.
- **Contradicting memories can both be returned by recall.** Contradiction detection ships
`mode: 'enabled'` by default (`src/config/config.ts`) but only *proposes* — it doesn't
auto-resolve. Until a consolidation proposal is accepted or rejected
(`list_consolidation_proposals` / `resolve_consolidation_proposal`), the original memory and
the one that contradicts it are both live and both eligible to surface in the same
search/recall call.
- **Local Ollama models default to non-thinking mode — opt-in, not name-based detection.**
Thinking-mode local models can silently return an empty `{"atoms":[]}` extraction — a
well-formed, successful-looking response — if their reasoning trace consumes the whole turn
before `format:json` output is produced. Earlier versions tried to catch this with a model-name
regex, which was incomplete by construction. `OllamaLLMConfig.think`
(`src/providers/llm/ollama.ts`) now defaults to `false` for every model regardless of name;
reasoning mode requires an explicit `think: true` override, so the class of bug is closed for
the default path rather than patched per model name. `doctor`'s `llmChatProbe`
(`src/doctor/probes/llm-chat-probe.ts`) sends a real `format:json` request and requires a
parseable, non-empty response, so a broken provider/model combination shows red instead of a
false-green plain-chat probe. **Stick to the tested extraction model** (`qwen2.5:7b` — F1 67%,
precision 99% in the 2026-07-08 extraction-bench A/B; `qwen3:8b` was tried as the default and
dropped after it returned empty extraction output); if you switch to another local reasoning
model with `think: true`, verify with a real extraction run, not just `doctor`.
---
## Development
This project uses [Bun](https://bun.sh/) as the package manager and script runner.
```bash
bun install # install dependencies
bun run build # compile TypeScript → dist/
bun run test # run the vitest suite
```
See [CONTRIBUTING.md](CONTRIBUTING.md) for build/test/PR conventions, including the maintainer
publishing flow.
---
## Status
Local-first memory daemon with 32 migrations, an [MCP surface](#mcp-tools-claude-code-auto-discovery)
whose tool count is CI-enforced against `contracts/manifests/mcp-tools.v1.json`, hybrid
BM25+vector+importance+freshness search, event-sourced memory lifecycle (invalidate/supersede/
promote) with full provenance (`why_memory`), consolidation proposals, entity-tagged memory
with point-in-time (`as_of`) filtering, usefulness-ranked user/agent profiles (live-computed,
zero persisted state), reversible soft-delete for batch purges, and local↔SaaS sync
(shipper/puller/conflict-resolve). CI-gated retrieval eval (ADR-005) runs recall@10 ≥ 0.9 /
NDCG@10 ≥ 0.7 floors on every push.
Companion plugin: [github.com/astragenie/astramem-plugin](https://github.com/astragenie/astramem-plugin)
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessUnresponsive