# Jarvis Memory Service — the shared brain (Railway)

A fast graph memory. The **recall** path has no model on the server — embeddings
run locally on CPU (FastEmbed / ONNX) and search is pure vector + keyword. The
intelligence that decides *what* to remember lives on each device (Jarvis's
Claude on Mac/Windows, a local model on the phone, future daemons).

The **write** path has an OPTIONAL server-side layer: if `DEEPSEEK_API_KEY` is
set, `/remember` makes ONE cheap DeepSeek call to dedup / supersede / clean the
fact and extract its event date + entities + relations (Mem0-style). With no key
it degrades gracefully to a plain add (with cosine≥0.95 duplicate suppression).
**An LLM failure never loses a memory** — it falls back to a plain add.

```
Mac Jarvis (Claude) ─┐
Win Jarvis (Claude) ─┤
phone (local model) ─┼─HTTPS(save/search)─▶  this API ──▶ FalkorDB (the graph)
screenshot-watcher  ─┤              (local multilingual embeddings; DeepSeek
transcriber         ─┘               only on writes, and only if keyed)
```

Embeddings are **multilingual** by default (Ahmed speaks Arabic) —
`sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2`, 384-dim (same dim
as the old English-only `bge-small`, so the vector indexes don't need rebuilding).

## Deploy on Railway (one project, two services)

**1. FalkorDB (the graph store)** — *done if you followed along.*
- Deploy Docker image `falkordb/falkordb:latest`, attach a **Volume at `/data`**.
- Note its private name (e.g. `falkordb.railway.internal`), port `6379`.

**2. This memory API**
- Same project → *Deploy from GitHub repo* → root directory
  `claude-voice/memory-service` (has the Dockerfile).
- Railway gives it a public URL → that's `MEMORY_API_URL` for the devices.

**3. Point each device at it** — in `claude-voice/.env` on Mac AND Windows:
```
MEMORY_API_URL=https://<your-app>.up.railway.app
MEMORY_API_KEY=<the same key>
```

## Environment variables

| Var | Default | Purpose |
|---|---|---|
| `FALKOR_HOST` / `FALKOR_PORT` | `localhost` / `6379` | FalkorDB address |
| `FALKOR_PASSWORD` | *(none)* | FalkorDB auth, if set |
| `MEMORY_API_KEY` | *(none)* | Bearer token the devices send. Empty = open |
| `MEMORY_GROUP` | `ahmed` | Default namespace |
| `MEMORY_GRAPH` | `jarvis` | FalkorDB graph name |
| `EMBED_MODEL` | `sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2` | Embedding model (must be a fastembed-supported model) |
| `EMBED_DIM` | derived from model (fallback `384`) | Vector dim — **auto-derived from the model at startup**; the env value is only a hint |
| `DEEPSEEK_API_KEY` | *(none)* | Enables the LLM write pipeline **and the `/dossier` composer**. Empty = plain-add degrade; `/dossier` returns `{composed:null}` |
| `WRITE_MODEL` | `deepseek-v4-flash` | Model for the write-pipeline call (cheap/fast) |
| `DOSSIER_MODEL` | `deepseek-v4-pro` | Model that COMPOSES `/dossier` cards (richer; one call, temp 0.3, max_tokens 2500) |
| `DEEPSEEK_URL` | `https://api.deepseek.com` | DeepSeek-compatible base URL |
| `AUTOLINK` / `AUTOLINK_THRESHOLD` / `AUTOLINK_MAX` | `1` / `0.60` / `5` | Layer-1 auto-linking (0.60 is calibrated for the multilingual model's TRUE cosine scale; the old 0.85 was for bge-small's inflated scores) |
| `INSIGHT_DEDUP` | `0.78` | Cosine above which a new insight counts as a repeat of an existing one (same true-cosine calibration) |

## Deploy-day migration order (IMPORTANT)

The default embed model changed from English-only `bge-small` to the multilingual
model. Its vectors differ, so **existing embeddings must be recomputed** or search
quality degrades. On the deploy that ships this change:

1. **Deploy** the new service (Railway redeploys on push).
2. **`POST /reembed`** — re-embeds every Memory + Insight + Entity with the current
   model and recreates the vector indexes at the current dim (drop+create, so an
   `EMBED_MODEL` swap to a different dimension is handled). Idempotent.
   ```bash
   curl -X POST $MEMORY_API_URL/reembed -H "Authorization: Bearer $MEMORY_API_KEY" \
     -H 'Content-Type: application/json' -d '{"batch":200}'
   ```
3. **Verify `/health`** reports the expected `embed_model` and `dim`:
   ```bash
   curl $MEMORY_API_URL/health
   # {"ok":true,"memory_count":…,"entity_count":…,"reflection_last_run":"…",
   #  "embed_model":"sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2","dim":384,"memories":…}
   ```

(If you keep the default model, dim stays 384 and the vector indexes are unchanged
— `/reembed` still needs to run once so the stored vectors match the new model.)

## Verify
```bash
curl $MEMORY_API_URL/health
curl -X POST $MEMORY_API_URL/remember -H "Authorization: Bearer $MEMORY_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"text":"Ahmed has a 9pm meeting with client Ali about a bulk furniture order.","when":"2026-07-20","importance":7}'
curl "$MEMORY_API_URL/search?q=research%20for%20my%20meeting" \
  -H "Authorization: Bearer $MEMORY_API_KEY"
```

## API

### Memories
- `POST /remember {text, kind?, when?, importance?, raw?, source?, group?, links?, entities?}`
  → `{id, op, auto_linked, entities}`. Runs the write pipeline: dedup / supersede /
  cleanup. `when` = ISO event date the fact refers to; `importance` 1-10 (default 5);
  `raw=true` = a raw utterance to clean into an atomic fact.
- `GET /search?q=&k=&group=&hops=&include_stale=&as_of=&when_from=&when_to=`
  → `{facts:[{id,fact,kind,score,confidence,importance,linked,…}]}`. Hybrid vector +
  keyword recall, salience-ranked, reinforced on read, with 1-hop linked context.
  `as_of` = bi-temporal "what was true then"; `when_from`/`when_to` window the event date.
- `GET /memories?group=&limit=&offset=&kind=` → newest-first list (HUD viewer).
- `POST /supersede {old_id, text, …}` — retire old, add new (bi-temporal).
- `POST /retire {id}` — soft-retire (stale but kept). `POST /forget {id}` — hard delete.

### Graph (links + entities + relations)
- `GET /graph?group=` → `{nodes, edges}` — Memory + Entity nodes, LINK + MENTIONS + REL edges.
- `POST /link {from_id, to, type}` · `POST /unlink {from_id, to}`
- `POST /backfill_links {dry_run?, threshold?, max_links?}` — Layer-1 auto-link every memory.
- `POST /attach_entities {memory_id, entities}` — attach entities to an existing memory.
- `GET /entity?name=` → entities (facts + summary + read + **relations**) — resolves by key/alias/substring.
- `GET /profile?name=&group=` → **fast raw one-entity aggregate for a HUD card** (the HUD's
  quick-render — this response SHAPE is stable):
  `{entity{key,name,etype,aliases,created_at}, summary, read, quick{phones,emails,urls,amounts},
  relations[{other,otype,rtype,direction,other_summary}], timeline[{id,date,kind,fact,tone}] (≤60,
  newest first), facts_by_kind{preference|plan|pattern|synthesis|convention:[…]}, tasks[{id,text,due}]
  (open tasks naming the entity), counts{facts,relations,last_touch}, also_matched[…], has_composed}`.
  **Resolution is fuzzy-tolerant:** exact key → alias → substring → **difflib token-sort ratio over
  every same-group entity's name+aliases** — a full-name ratio ≥0.85 beats a short exact-substring hit
  (so voice-mangled `"Saud Altamimi"→"saad altamimi"` resolves to the client, not intern `saad`). The
  best score wins; the rest go to `also_matched`. Facts are merged only from the chosen entity + its
  **true name-variants** (never a different person who merely shared the query), and the never-linked
  text sweep ignores names inside email local-parts (`saad@…` ≠ a mention of Saad). `quick` is
  regex-harvested from the entity's facts (Saudi phones normalized to `05XXXXXXXX`). Empty optional
  sections are **omitted**, so any etype degrades cleanly. **`has_composed`** (bool, additive) is true
  when a fresh LLM-composed dossier is cached and instantly available from `POST /dossier`.
- `POST /dossier {name, group?, force?}` → **COMPOSED, adaptive dossier card**. An LLM (`DOSSIER_MODEL`)
  READS the full `/profile` aggregate and returns an organized profile whose sections fit **what the
  entity IS TO AHMED** (client vs intern vs concept vs supplier …) — money, dates, promises and
  follow-ups surfaced first, 4-7 sections, never raw memory text. Response:
  `{composed: <card | null>, cached: bool}`. **`composed:null` means fall back to the raw `/profile`
  card** (no DeepSeek key, or any LLM/network failure — graceful, never fatal).
  - **Card schema** (validated + normalized in code — unknown widget `type`s and chip `kind`s are
    DROPPED, sections capped 8, rows 12, chips 8, badges 3):
    ```
    {"header": {"role_line": str, "badges": [str]},
     "sections": [
       {"title": str, "type": "text",  "body": str} |
       {"title": str, "type": "kv",    "rows": [{"k": str, "v": str}]} |
       {"title": str, "type": "chips", "chips": [{"kind": "phone|email|link|map|whatsapp", "label": str, "value": str}]} |
       {"title": str, "type": "list",  "rows": [{"icon": "person|company|event|task|money|note", "title": str, "sub": str|null, "date": str|null, "entity": str|null}]}
     ]}
    ```
    Chip `value`s are actionable: phone→`tel:+9665…`, whatsapp→`https://wa.me/9665…`, email→`mailto:…`,
    map→`https://maps.apple.com/?q=<escaped>`, link→raw URL. A `list` row with `entity` set is a
    tap-through to that entity's card (drill-down).
  - **Cache**: the composed JSON is stored on the entity node (`dossier_json`/`dossier_at`/
    `dossier_nfacts`). A cache hit — **same fact count**, **< 24h old**, and `force` not set — returns
    instantly (`cached:true`). **Invalidation:** any new/changed fact about the entity moves the fact
    count so the next call recomposes; a dossier also expires after 24h; `force:true` always recomposes.
- `POST /entity/update {key, group?, etype?, name?}` — correct an entity's **type** or **display name**
  (aliases + canonical key preserved, so its facts stay attached). `etype` ∈
  person/company/place/product/amount/thing/concept.
- `POST /summary {name, text}` · `POST /read {name, text}` — entity current-state + theory-of-mind.
- `POST /relation {from, to, rtype}` — entity↔entity `(:Entity)-[:REL]->(:Entity)` (both ends resolved).
- `POST /entities/merge {keep_key, merge_keys[]}` — fold duplicates into one canonical entity.
- `POST /entities/dedup_backfill {dry_run}` — scan lookalikes; dry lists pairs+scores, real run
  auto-merges ≥0.95 and returns the 0.80–0.95 band as `ambiguous`.

### Reflection, profile, ops
- `POST /insight {text, title?, itype?}` · `GET /insights` · `GET /insights/search?q=` · `POST /insight/seen {id}`
- `POST /values {text}` · `GET /values` — Ahmed's value model (singleton).
- `POST /core {text}` · `GET /core_block` — Ahmed's core context block (singleton).
- `POST /reflection_ran {status, note}` — reflection engine heartbeat (surfaces in `/health`).
- `POST /reembed {batch}` — re-embed all nodes + recreate vector indexes (deploy-day migration).
- `GET /export?group=` — complete dump (memories incl. stale, entities, links, mentions, relations,
  insights, tasks, profile, core, meta, exported_at) for nightly backups.
- `GET /health` → `{ok, memory_count, entity_count, reflection_last_run, embed_model, dim, memories}`.

### Tasks & reminders
- `POST /task {text, due?}` · `GET /tasks?include_done=` · `POST /task/done|reopen|delete {id}` · `GET /reminders/due`

### MCP (for Claude Code / claude.ai)
A streamable-HTTP MCP endpoint is mounted at `/mcp` (bearer header) and `/mcp/<token>`
(path secret). Tools: `memory_search` (with `when_from`/`when_to`/`as_of`),
`memory_remember` (with `when`/`importance`), `project_checkpoint`, `entity_lookup`,
`entity_profile` (full one-entity dossier — the `/profile` card).

Any future "brain" just `POST /remember`s with its own `source`, and it all lands
in the same connected graph.
