diff --git a/README.md b/README.md index 2614434..adb83d9 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 (, )` 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 (, )` 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: @@ -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 `//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 `//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 @@ -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 -- ` (the `--` forwards the path to the script) after downloading GeoNames' `cities1000.txt` dump (population ≥ 1,000) from — 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 | diff --git a/docs/03-ob2-design.md b/docs/03-ob2-design.md index 2c650a3..5a83e50 100644 --- a/docs/03-ob2-design.md +++ b/docs/03-ob2-design.md @@ -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. @@ -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) | @@ -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. @@ -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 `//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). --- diff --git a/docs/05-roadmap.md b/docs/05-roadmap.md index c538fe1..06d01a7 100644 --- a/docs/05-roadmap.md +++ b/docs/05-roadmap.md @@ -13,6 +13,8 @@ The minimum server work required before any connector can exist. Deliberately small; no framework, no plugin loader. +**Status: ✅ shipped** (`src/ingest.js`, `src/ingest-types.js`, `schemas/ingest.v1.json`). + **Deliverables** 1. `POST /api/v1/ingest` — single-artifact upsert on `(source, source_id)`, Zod-validated against the payload in doc 04 §3 @@ -32,6 +34,8 @@ The minimum server work required before any connector can exist. Deliberately sm The ~40-line Claude Code `SessionEnd` hook. First live consumer of the contract; also the demo asset. +**Status: ✅ shipped** (`connectors/devsession-claude/`). + **Deliverables** 1. Hook script (Node): reads hook JSON from stdin → reads transcript → calls local LM Studio/Ollama chat model for a structured summary (what/decisions/why/next-steps) → `POST /api/v1/ingest` with `type='dev_session'`, `source='devsession'`, `source_id` = session UUID, `extra` = {project, cwd} @@ -61,6 +65,8 @@ Short deliberate pause to validate retrieval against real ingested data before a The relationship-data connector; proves deterministic entity links at volume and the hub-and-spoke topology. +**Status: ✅ shipped** (`connectors/imessage/`). + **Deliverables** 1. Mac Mini sync script (Node, better-sqlite3): read-only attach to `~/Library/Messages/chat.db`, WAL-safe; cursor = last ROWID in a local state file @@ -79,6 +85,8 @@ The relationship-data connector; proves deterministic entity links at volume and Makes the photo library time/place-queryable with zero inference; proves upsert-as-enrichment. +**Status: ✅ shipped** (`connectors/photo-exif/`; the caption/face enrichment workers ship with it — a face-stack rebuild is tracked separately). + **Deliverables** 1. Batch scan script: `exifr` over the archive → `DateTimeOriginal`, GPS → `POST /ingest/batch`; `content_hash` for cross-import dedup; minimal `text_repr` @@ -152,8 +160,9 @@ rule when their turn comes: - **Dev-workflow event capture** — GitHub issue/PR creation recorded as `x-dev-event` artifacts, shipped as [`connectors/gh-event-claude/`](../connectors/gh-event-claude/) (#89 — Claude Code `PostToolUse` hook); a sibling of the Milestone 1 `devsession-claude` session-capture connector (captures the discrete event, not the conversation) - **Location visits** — Google Timeline / Owntracks pings segmented into visits via the event lane (doc 04 §5) - **Video/audio** — Whisper transcripts + keyframe captions; **social-media exports** (Takeout, X archive) -- **Face clustering** (local-only, `insightface`), **CLIP visual-similarity second index** (doc 03 §3.3), **`merge_entities` admin endpoint** (doc 03 §7), cross-device sync, temporal knowledge graph -- **Contacts management UI — shipped (#96):** a browser UI (`/ui/contacts.html`) + `/api/v1/entities` CRUD surface to curate the entity graph (fix aliases/fields, edit relationships, set a photo). See [`docs/09-contacts-ui.md`](09-contacts-ui.md). +- **Face clustering** (local-only, `insightface`), **CLIP visual-similarity second index** (doc 03 §3.3), cross-device sync, temporal knowledge graph — *(the **`merge_entities` admin endpoint** already shipped #75: `POST /api/v1/entities/merge` + the `merge_entities` MCP tool; see doc 03 §7)* +- **Contacts management UI — shipped (#96):** a browser UI (token capability-URL `//ui/contacts.html` when `UI_URL_TOKEN` is set, #169) + `/api/v1/entities` CRUD surface to curate the entity graph (fix aliases/fields, edit relationships, set a photo). See [`docs/09-contacts-ui.md`](09-contacts-ui.md). +- **Contact import formats** — vCard / CardDAV / Google-Contacts vCard only; **no CSV import** yet (#126). --- diff --git a/docs/08-preparing-contacts.md b/docs/08-preparing-contacts.md index 8830fc1..a54af28 100644 --- a/docs/08-preparing-contacts.md +++ b/docs/08-preparing-contacts.md @@ -20,7 +20,7 @@ This doc has two parts: a **primer** (what actually matters and why, grounded in | **Company flag** | `X-ABSHOWAS:COMPANY` / `KIND:org` → `kind='org'` entity (#88) | Keeps businesses out of the *person* graph (dedup, face-matching, and person recall all skip `kind='org'`) so they don't surface as bogus people. | | **Company (`ORG`)** on a person | Seeds a `worksAt` person→org edge (#88), resolved by the org **name** | Turns "Title at Acme" from fuzzy text into a queryable employment edge — `about_entity('Acme')` lists its people. Only forms if a **company contact** named exactly `Acme` is also imported (either order); an unmatched `ORG` invents no org entity, just stages the edge until one exists. | | **Relationships** | `RELATED`/`X-ABRELATEDNAMES` → `entity_relations` edges (resolved by name) | Builds the person↔person graph (spouse/parent/sibling) that powers `about_entity`. Only resolves if the related name matches the *other* contact's name exactly. | -| **Contact photo** (`PHOTO`) | *(preserved once #74 "preserve vCard PHOTO on contact import" ships — currently dropped on import)* | The future face-recognition seed that can auto-label anonymous photo clusters. Keep it on the card. | +| **Contact photo** (`PHOTO`) | **Preserved (#74):** decoded and written to `CONTACTS_RAW_DIR` (default `raw/contacts`) as a content-addressed file, with the contact artifact's `raw_path` pointing at it. | The face-recognition seed that can auto-label anonymous photo clusters. Keep it on the card. | **Two things that are NOT worth your time:** diff --git a/docs/09-contacts-ui.md b/docs/09-contacts-ui.md index f92a050..6bac34e 100644 --- a/docs/09-contacts-ui.md +++ b/docs/09-contacts-ui.md @@ -37,6 +37,18 @@ Core-owned curation surface — never a connector concern (contract §1.2), same | `DELETE /api/v1/entities/:id/relations/:relationId` | — | `{ removed }` | | `POST /api/v1/entities/:id/photo` | raw image bytes (`Content-Type: image/*`) | `{ photoFile }` | | `GET /api/v1/entities/:id/photo` | — | image bytes (`404` if none) | +| `GET /api/v1/entities/photos` | — | contact reference-photo list for the photo-exif face matcher (#112) | + +**Proposed-entity review queue** (#119/#143 — the approval gate for *suggested* entities; same `/api/v1/entities` family). A connector/agent *suggestion* is never minted without approval; trusted direct creation via `POST /api/v1/entities` (above) is the separate curation path: + +| Method + path | Body / query | Result | +|---|---|---| +| `GET /api/v1/entities/proposed` | `?status&limit` | `{ proposals: [...] }` — the review queue | +| `POST /api/v1/entities/proposed/:id/approve` | — | mints the entity + retro-links its queued artifacts | +| `POST /api/v1/entities/proposed/:id/reject` | — | marks it rejected; the row is retained, never re-raised | +| `POST /api/v1/entities/proposed/stage-from-directory` | — | re-runs the side-directory proposal pass (#162) | + +> **Pending rename (#219, display-only):** the UI's *Proposed* panel and *Approve* control are being relabeled **Candidates** / **Promote**. The `proposed_entities` table, the `/proposed/*` endpoints above, and the element ids are **unchanged** — only user-facing strings change. Backing helpers live in `src/db.js` (`listEntities`, `getEntityProfile`, `createEntity`, `updateEntityAttrs`, `addAlias`, `removeAlias`, `removeRelation`, `setEntityPhotoFile`,