Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

*(formerly Open Brain Local)*

[![CI](https://github.com/MSIH/life-context/actions/workflows/ci.yml/badge.svg?branch=2.0)](https://github.com/MSIH/life-context/actions/workflows/ci.yml)
[![CI](https://github.com/MSIH/life-context/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/MSIH/life-context/actions/workflows/ci.yml)

A local-first, self-owned memory layer that any AI tool can plug into — one database, one gateway, running entirely on your own machine. LifeContext stores your notes, contacts, and (soon) emails, photos, and location history as a unified memory your AI assistants can actually recall from: by meaning, by person, by place, and by time.

Expand Down Expand Up @@ -48,7 +48,7 @@ npm start # REST + MCP on http://localhost:3000

`npm run setup` prints the generated `LIFECONTEXT_API_KEY` once — **save it**; it's the `x-api-key` header for every call. Prefer to do it by hand? The manual equivalent (pull `qwen3-embedding:0.6b` + `qwen2.5:3b`, `cp .env.example .env`, set a key) is in [`docs/local-llm-setup-guide.md`](docs/local-llm-setup-guide.md).

**Tests & CI.** `npm test` (`node --test test/*.test.mjs`) runs the full suite — no Ollama needed (the embedder/planner-offline paths degrade gracefully). The `test/*.test.mjs` glob is expanded by the shell, so on **Node ≤ 20** run it from a shell that globs (any Unix shell, or **Git Bash** on Windows — not `cmd.exe`/PowerShell); Node 21+ expands it natively. `npm run check:boundary` asserts connectors never import `src/`, and `npm run test:connectors` runs each connector's own suite (skipping any whose deps aren't installed). The [CI workflow](.github/workflows/ci.yml) runs the suite (via bash, so the glob expands everywhere) + boundary check on Ubuntu and Windows (Node 20 & 22) for every PR and push to `2.0`. Recommended: enable branch protection on `2.0` requiring the CI checks (each `test (<os>, <node>)` matrix cell) to pass before merge.
**Tests & CI.** `npm test` (`node --test test/*.test.mjs`) runs the full suite — no Ollama needed (the embedder/planner-offline paths degrade gracefully). The `test/*.test.mjs` glob is expanded by the shell, so on **Node ≤ 20** run it from a shell that globs (any Unix shell, or **Git Bash** on Windows — not `cmd.exe`/PowerShell); Node 21+ expands it natively. `npm run check:boundary` asserts connectors never import `src/`, and `npm run test:connectors` runs each connector's own suite (skipping any whose deps aren't installed). The [CI workflow](.github/workflows/ci.yml) runs the suite (via bash, so the glob expands everywhere) + boundary check on Ubuntu and Windows (Node 20 & 22) for every PR and push to `main`. Recommended: enable branch protection on `main` requiring the CI checks (each `test (<os>, <node>)` matrix cell) to pass before merge.

Upgrading from an earlier version? Migrate your existing memories into the artifact store once (back up your DB file first — `life-context.db` by default, or whatever `DB_PATH` points to — it's idempotent and safe to re-run). It reuses the stored vectors as-is, so it's only valid while the embedding model and `VECTOR_DIMENSION` are unchanged:

Expand Down Expand Up @@ -105,7 +105,7 @@ Recall returns the stored memory with a similarity score. For AI clients, point
Every endpoint/tool requires the key, sent as the `x-api-key` header (or `Authorization: Bearer`, or — for clients that can't set headers — an `?api_key=` query param; header preferred, see the caveat above). REST and MCP share one store.

- **REST** — `POST /api/remember`, `POST /api/recall`, `POST /api/search`, `POST /api/timeline`, `POST /api/about_entity`, `GET /api/artifact/:id`
- **Entity curation** (`/api/v1/entities`, core-owned — never via a connector, see [`docs/03-ob2-design.md §7`](docs/03-ob2-design.md)) — `GET /api/v1/entities/duplicates` (rank likely-duplicate person entities by shared phone/email + name similarity; read-only), `POST /api/v1/entities/merge` (`{keep_id, absorb_id}` — tombstones the absorbed entity, never deletes it, and re-points its aliases/links/relations to the survivor), plus the contacts-UI CRUD surface — list/get, create, `PATCH` fields, add/remove aliases & relationships, and photo upload/download (all under `/api/v1/entities`, see [`docs/09-contacts-ui.md`](docs/09-contacts-ui.md)). A browser UI over these lives at `/<token>/ui/contacts.html` (token-only, #169 — see the curation note above).
- **Entity curation** (`/api/v1/entities`, core-owned — never via a connector, see [`docs/03-ob2-design.md §7`](docs/03-ob2-design.md)) — `GET /api/v1/entities/duplicates` (rank likely-duplicate person entities by shared phone/email + name similarity; read-only), `POST /api/v1/entities/merge` (`{keep_id, absorb_id}` — tombstones the absorbed entity, never deletes it, and re-points its aliases/links/relations to the survivor), plus the contacts-UI CRUD surface — list/get, create, `PATCH` fields, add/remove aliases & relationships, and photo upload/download (all under `/api/v1/entities`, see [`docs/09-contacts-ui.md`](docs/09-contacts-ui.md)). The **proposed-entity review queue** (the approval gate for *suggested* entities — a connector/agent suggestion is never minted unapproved; trusted direct creation via `POST /api/v1/entities` is the separate curation path): `GET /api/v1/entities/proposed`, `POST /api/v1/entities/proposed/:id/approve`, `POST …/proposed/:id/reject`, `POST …/proposed/stage-from-directory` (#119/#154), plus `GET /api/v1/entities/photos` (the face-match photo source, #112). A browser UI over these lives at `/<token>/ui/contacts.html` (token-only, #169 — see the curation note above).
- **Connector ingest** (`/api/v1`, see [`docs/04-connector-contract.md`](docs/04-connector-contract.md)) — `POST /api/v1/ingest` (submit one artifact; upsert on `(source, source_id)` — 201 create / 200 update, non-destructive issues accepted with a `warnings` array, 256 KB body cap), `POST /api/v1/ingest/batch` (submit 1–100 artifacts in one call; 200 with index-aligned per-item results + a `summary`, per-item isolation — one bad item is reported at its index, never poisons the rest), `GET /api/v1/ingest/types` (the machine-readable type registry, plus `extension_types` — the `x-` types actually observed in the store, with counts, §6). The payload's JSON Schema is published at [`schemas/ingest.v1.json`](schemas/ingest.v1.json) (generated from the zod schema via `npm run schema:ingest`) so connector authors can validate offline, without a live server:

```bash
Expand Down Expand Up @@ -146,6 +146,8 @@ Feeding the brain is connector-driven: an HTTP ingest contract so anything — a

Location is resolved server-side: a connector submitting raw `latitude`/`longitude` gets a `place_label` filled in automatically, offline, against a bundled dataset (`src/geodata/places.json`, ~135k places) derived from [GeoNames](https://www.geonames.org/), licensed [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). US labels store the full state name (`Austin, Texas`), so a query like "in texas" matches directly. Regenerate it with `npm run geocode:build -- <path-to-cities1000.txt>` (the `--` forwards the path to the script) after downloading GeoNames' `cities1000.txt` dump (population ≥ 1,000) from <https://download.geonames.org/export/dump/cities1000.zip> — the raw dump isn't committed, only the derived file and `scripts/build-places.js` are. Upgrading an existing DB from the old code-form labels (`Austin, TX`): back up the `.db`, then run `npm run backfill:geo` once (idempotent; re-derives `place_label` from stored coordinates, leaving connector-supplied labels untouched).

Beyond people, the entity graph also has **place** (#137) and **event** (#138) kinds — recurring locations and multi-artifact occasions you can pivot recall around (`about_entity('Deer Valley')`, spend-by-place, "everything from the Tahoe trip"). Both can be minted directly (the `/api/v1/entities` CRUD surface) or discovered bottom-up from photo GPS/time and staged for approval: `npm run places:cluster` grids GPS-bearing artifacts into candidate places, `npm run events:cluster` groups contiguous-day bursts away from home into candidate events — each stages a proposed entity you approve. See [`docs/03-ob2-design.md §2.2`](docs/03-ob2-design.md).

## Design documents

| Doc | What it covers |
Expand Down
8 changes: 7 additions & 1 deletion docs/03-ob2-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ CREATE TABLE entity_relations (
);
```

> **Schema drift note (current tables).** The blocks above are the original design sketch; the live schema in `src/db.js` has grown since — the authoritative reference is [`.claude/rules/data-model.md`](../.claude/rules/data-model.md). Added since: `entities.merged_into` (merge tombstones, #75); `NOT NULL` + enforced foreign keys on `entity_aliases`/`entity_links`/`unresolved_aliases` (#110); and the tables `alias_tombstones` (#111), `unresolved_aliases` (connector hint staging), `proposed_entities` (the human-approval queue, #119), and `contact_directory` (side handle→name lookup, #154).

**Person↔person relationships (issue #37).** A contact's `relatedNames[] {type, name}` (parsed above) become `entity_relations` edges. Because every source expresses a relationship as a related **name string + a type** (no foreign key), this is a name→entity resolution problem, so it reuses the same machinery as alias hints: for each related name, `resolveEntityIds(name)` — a hit inserts the edge now; a miss is **staged** on the owner's contact artifact in `unresolved_aliases` (`alias_type='relation'`, `role` = the raw label) and `resolveRelationHints` forms the edge when that person is later imported (so either import order converges). The raw label is canonicalized (`RELATION_TYPE_MAP`) to a fixed vocabulary — `spouse, partner, domesticPartner, child, parent, mother, father, sibling, brother, sister, friend, relative, assistant, manager, referredBy, worksAt, custom` — with the original preserved in `raw_label`. Edges are append-only and idempotent (`UNIQUE(from, to, relation_type)` + `OR IGNORE`); `ingest_log` records `relation_added` / `relation_resolved`. `about_entity` returns each entity's outgoing edges as `relations: [{ entity_id, name, relation_type, raw_label, confidence }]` and its incoming edges as `relations_in` (same shape, the `from` side) (`raw_label` carries the original label, most useful when `relation_type` is `custom`).

**Business contacts + employment (issue #88).** A contact flagged as a company (`X-ABSHOWAS:COMPANY` / vCard 4.0 `KIND:org` → `isCompany`) is created as a `kind='org'` entity rather than `kind='person'`, filling the existing schema slot instead of polluting the person graph (`isCompany` stays in `attrs_json` as the raw signal; `kind` is the derived classification — deterministic flags only, no fuzzy heuristics). A person's `ORG` name seeds a structured `worksAt` edge (person→org) through the same relation-staging machinery: the org **name** (`ORG` component `parts[0]`, not the joined `org, department` display string) becomes a synthetic `worksAt` hint that forms an edge to the org entity when a matching org contact exists, in **either import order**; a name that matches no imported org contact is staged (never fabricated into an org entity — a free-text org string has no dedup key to assert identity by, the same exact-name-match limitation as person relations). A startup data migration promotes any pre-existing `person`+`isCompany` entity to `org` (idempotent; logged `schema_migration` only when rows change; no DDL — `kind` and `entity_relations` already exist). Person-only surfaces (`listProbableDuplicates` dedup, `listContactPhotos` reference faces) already filter `kind='person'`, so orgs are correctly excluded.
Expand Down Expand Up @@ -200,6 +202,8 @@ Each stage is idempotent; `(source, source_id)` and `content_hash` make re-runs

### 3.1 Per-type normalization and enrichment

> **Shipped vs. backlog (July 2026):** Contact (`src/contacts.js`), Photo (`connectors/photo-exif`), and Document (`connectors/documents`, #56) are shipped. Video is *ingested* by `photo-exif` (EXIF/metadata), but its Whisper/keyframe enrichment is backlog. **Email, Social post, and Location remain backlog** (see [`05-roadmap.md`](05-roadmap.md)). The rows below describe each type's target normalization — shipped or not.

| Type | Source connector | `occurred_at` | `text_repr` construction | Entity links |
|---|---|---|---|---|
| **Email** | IMAP, or Gmail/Google Takeout mbox | `Date:` header | Subject + cleaned body (strip quotes/signatures) + "From X to Y" | Sender/recipients via alias match (1.0) |
Expand Down Expand Up @@ -346,6 +350,8 @@ Keep the per-session server factory and Streamable HTTP transport from v2.2 unch

## 5. Memory Consolidation (the sleep cycle)

> **Shipped (consolidation v1).** This is live: `src/consolidate.js` (`npm run consolidate`) writes one `type='digest'` artifact per day and the planner is digest-aware — see [`06-consolidation.md`](06-consolidation.md) and [`05-roadmap.md`](05-roadmap.md) Milestone 6. The design below is the original spec (note the `type='note'` → `type='digest'` change, already reflected in step 1).

Humans don't keep raw sensory streams; they consolidate. A nightly batch job:

1. **Daily digest** — LLM summarizes yesterday's artifacts into one `type='digest'` artifact (originally specced as `type='note'`; superseded by the doc 04 §6 type registry) ("Emailed 3 donors for MSIH; brunch at Common Bond; shipped OB2 ingestion PR"). Digests are what make *"what was I doing last October"* answerable in one hit instead of 400.
Expand Down Expand Up @@ -384,7 +390,7 @@ Each phase is a new connector + enricher against a **stable core schema** — th
- **Backlog cost:** 20k photos × VLM caption ≈ $10–30 via cheap cloud VLMs, or free-but-slow locally. Budget the embedding calls too (trivial: ~$0.02/1M tokens).
- **Ingestion is where the work is.** Retrieval is a solved pattern; parsing Google Takeout, mbox quirks, HEIC conversion, and export-format drift is 70% of the engineering. Build connectors as isolated, restartable scripts with their own state.
- **Privacy tiering:** faces and raw location never leave the machine. Everything else is your call per-enricher. Since it's all local SQLite + files, backup = copy a folder — matches the metadata-hoarder doctrine: cheap storage, one place, permanent.
- **Identity resolution is the hard unsolved-in-general problem.** Aliases + confidence scores get you 90%; accept occasional manual merges via a `merge_entities` admin endpoint rather than chasing full auto-resolution — **delivered (#75):** `POST /api/v1/entities/merge` + the `merge_entities` MCP tool, backed by `mergeEntities` in `src/db.js`. A merge **tombstones** the absorbed entity (`entities.merged_into` points at the survivor; the row is never deleted — design-philosophy.md §1) and re-points its `entity_aliases`/`entity_links`/`entity_relations` rows to the survivor. `entity_aliases` repoints unconditionally (`(alias, alias_type)` is globally unique, so it can never collide), which is why `resolveEntityIds`/`about_entity` need no separate merge-redirect logic — an alias can never resolve to a tombstoned id in the first place. A direct keep↔absorb relation is dropped rather than becoming a self-loop (excluded from the `moved` count, since it's genuinely deleted, not moved); a repoint that would collide with a link/relation the survivor already holds for the same artifact/role (or same from/to/type) is resolved by **deleting the absorbed side's duplicate before repointing the rest** — never left orphaned pointing at the tombstoned id, so `get_artifact`/`about_entity` can never surface a stale link back to a merged-away entity. This is core-owned (connectors may never merge/assert entities, doc 04 §1.2), so it lives outside the `/api/v1/ingest` connector lane. Paired with a read-only `GET /api/v1/entities/duplicates` (`list_probable_duplicates`), which ranks candidate person-entity pairs by shared normalized phone/email in their contact attrs (the real residue — the contacts importer's own `resolveExistingEntity` only auto-merges on shared email or exact name, **never** phone) and by name similarity (typo-level; not nickname resolution, which needs a name dictionary and is out of scope). Detection never merges anything itself — a human decides. **Extended (#96):** a full contacts-curation surface over `/api/v1/entities` (list/get, create, `PATCH` fields, add/remove aliases & relationships, photo upload) plus a browser UI at `/ui/contacts.html` lets a human correct the graph directly — same "entity graph is mutable curation state, raw artifacts stay append-only, every change logged" posture as merge. See [`docs/09-contacts-ui.md`](09-contacts-ui.md).
- **Identity resolution is the hard unsolved-in-general problem.** Aliases + confidence scores get you 90%; accept occasional manual merges via a `merge_entities` admin endpoint rather than chasing full auto-resolution — **delivered (#75):** `POST /api/v1/entities/merge` + the `merge_entities` MCP tool, backed by `mergeEntities` in `src/db.js`. A merge **tombstones** the absorbed entity (`entities.merged_into` points at the survivor; the row is never deleted — design-philosophy.md §1) and re-points its `entity_aliases`/`entity_links`/`entity_relations` rows to the survivor. `entity_aliases` repoints unconditionally (`(alias, alias_type)` is globally unique, so it can never collide), which is why `resolveEntityIds`/`about_entity` need no separate merge-redirect logic — an alias can never resolve to a tombstoned id in the first place. A direct keep↔absorb relation is dropped rather than becoming a self-loop (excluded from the `moved` count, since it's genuinely deleted, not moved); a repoint that would collide with a link/relation the survivor already holds for the same artifact/role (or same from/to/type) is resolved by **deleting the absorbed side's duplicate before repointing the rest** — never left orphaned pointing at the tombstoned id, so `get_artifact`/`about_entity` can never surface a stale link back to a merged-away entity. This is core-owned (connectors may never merge/assert entities, doc 04 §1.2), so it lives outside the `/api/v1/ingest` connector lane. Paired with a read-only `GET /api/v1/entities/duplicates` (`list_probable_duplicates`), which ranks candidate person-entity pairs by shared normalized phone/email in their contact attrs (the real residue — the contacts importer's own `resolveExistingEntity` only auto-merges on shared email or exact name, **never** phone) and by name similarity (typo-level; not nickname resolution, which needs a name dictionary and is out of scope). Detection never merges anything itself — a human decides. **Extended (#96):** a full contacts-curation surface over `/api/v1/entities` (list/get, create, `PATCH` fields, add/remove aliases & relationships, photo upload) plus a browser UI at `/<token>/ui/contacts.html` (token-only when `UI_URL_TOKEN` is set, #169 — there is no bare `/ui` mount) lets a human correct the graph directly — same "entity graph is mutable curation state, raw artifacts stay append-only, every change logged" posture as merge. See [`docs/09-contacts-ui.md`](09-contacts-ui.md).

---

Expand Down
Loading
Loading