Skip to main content
Glama
Luissalet

People's Hoard MCP Server

by Luissalet
README.md
# People's Hoard

Local-first personal CRM: who people are, what you know about them, when you last talked and what to remember — stored in a single SQLite file on your own computer and exposed to an assistant through MCP.

Spanish version: [`README.es.md`](README.es.md).

## Run

Requires Node.js 22.13 or later (it uses the built-in `node:sqlite` with FTS5). No native modules, no Docker.

```sh
npm install
npm run build
npm start          # http://127.0.0.1:5182
```

`npm run open` starts the server and opens the browser on Windows. `npm run dev` runs the API (`node --watch`) and Vite together with the `/api` proxy configured automatically.

The server binds to `127.0.0.1` only. If port 5182 is busy it walks up to the next free port and prints the address; set `PORT_STRICT=1` to fail instead.

### Environment variables

| Variable | Purpose |
| --- | --- |
| `PEOPLE_PORT` / `PORT` | Preferred port (default `5182`). |
| `PORT_STRICT=1` | Do not fall back to another port. |
| `PEOPLE_DATA_DIR` | Data folder (default `<repo>/data`, gitignored). Contains `peoples-hoard.db` and `mcp-token`. |
| `PEOPLE_ALLOWED_HOSTS` | Extra host names accepted behind a tunnel (see below). |
| `PEOPLE_MAIL_AUTO=0` | Turn off the 30-minute pass that reads the hub's mail for the last contact (default on; see From the rest of the family). |
| `PEOPLE_COMMITMENTS_AUTO=0` | Turn off the background sweep that reads meeting minutes from the Hoard Link hub (default on; see Commitments). |
| `HOARD_HUB_URL` / `HOARD_EVENTS=0` | Where the Hoard Link hub is (found by itself by default), and a switch to stop sending events. |
| `PEOPLE_URL` | MCP bridge: base URL of the running app (default `http://127.0.0.1:5182`). Must be local. |
| `PEOPLE_TOKEN_FILE` / `PEOPLE_TOKEN` | MCP bridge: where to read the bearer token (default `<data dir>/mcp-token`). |

### Access from your phone (behind a tunnel)

The server binds 127.0.0.1 and only answers requests whose `Host` is `localhost`, `127.0.0.1` or `[::1]`. To reach it from your phone through a tunnel that fronts the app (a private mesh network, a reverse proxy), list the extra host names in `PEOPLE_ALLOWED_HOSTS`, comma-separated, exact names or `*.suffix`: `PEOPLE_ALLOWED_HOSTS=my-pc.example,*.ts.net`. Port and letter case are ignored, and the `Origin` of API calls must resolve to one of those hosts too (any scheme or port). Cross-site *fetches* are still refused; opening the app from another page (a link, a bookmarklet, the share sheet) is a normal navigation and works.

Once opened through the tunnel, the browser offers to install it (PWA).

## What it does

- **Personas** — instant search (accent-insensitive, matches partial names anywhere in a word, plus nickname and alias), circle chips as a filter, cards with name, circles, "last contact 12 days ago" and a birthday-soon badge, and a quick new-person form.
- **Persona page** — a live conversation brief combining facts, five recent contacts and open reminders; header (name, nickname, circles, birthday, location, desired contact cadence) editable as one form; summary and notes as textareas that save on blur; facts as an editable key/value list ("le gusta" / "el senderismo"); a contact timeline with a one-line add form ("he hablado hoy"); reminders with due date and a "done" checkbox; an alias editor (WhatsApp, e-mail, phone, other handle); archive, merge-with-a-duplicate and delete.
- **Commitments** — who promised what to whom and by when, in two directions: what you owe a person (`i_owe`) and what a person owes you (`owed_to_me`). A page with filters (direction, status, person, overdue, text), a review queue, a form to add one, and the two ways to bring them in (below); a *Compromisos* section on every person page; a badge on the navigation with overdue items and proposals waiting. See *Commitments* below.
- **Agenda** — upcoming birthdays with age when the year is known, reminders due, a *Compromisos* block (overdue and due this week) and an "abandonados" list of people you have not contacted within their desired cadence, with a one-click "he hablado hoy" that logs a quick interaction. **Descargar calendario** exports active birthdays as yearly events, open reminders and open commitments with a due day as dated events in an `.ics` file.
- **Ajustes** — data folder and version, JSON export/import for backups.

### Commitments

A commitment has a direction (`i_owe` / `owed_to_me`), a person (or the name as written when they are not in the book), a text, a due day (`due`) or the words used (`due_text`), a status (`open`, `done`, `dropped`) and its source (`funes`, `chat`, `manual`, `text`, `mail`, `whatsapp`, with a reference, the literal quote and, for chat messages, the time of the message). People's Hoard owns them; other apps only feed it.

- **From meetings.** Funes writes the minutes of a recorded meeting (summary, decisions and action items with evidence). People's Hoard asks for them through the Hoard Link hub (`scribe_minutes` on Funes, never by reading Funes's files) and turns each action item into a commitment: what *you* said you would do is `i_owe` (the other person is the counterpart), what someone else said is `owed_to_me`. Names resolve like everywhere else (exact, alias, fuzzy). Nothing goes straight into the list unless the direction and the person are both certain: a name that matches nobody or several people, an owner the minutes do not name (or give as `otros` or a speaker label such as `S1`), my own promise with no recipient the book knows, and a promise between two other people all go to a **review queue** instead. There you choose who owes (a promise made by *me* or *to me*), pick a candidate, create the person or discard; an item already in the list can be corrected the same way with *Editar* (direction, person, day). The meeting is logged once on each person's timeline as *Reunión: <title>*. While the hub answers, a sweep every 60 seconds reads `funes.minutes.ready` events (`GET <hub>/api/events?type=funes.minutes.ready&since_id=…`, with this app's token; the last id is remembered in the database; the first contact starts from "now", so old meetings are not replayed on their own: use `commitments_ingest_minutes` for those). `PEOPLE_COMMITMENTS_AUTO=0` switches the sweep off. To read a meeting again after a fix, `commitments_ingest_minutes(session_id, replace=true)` (add `regenerate=true` to have Funes write the minutes again) first drops what the earlier reading left that you have not touched (open, never edited, not chosen in the review queue; plus proposals still waiting) and then ingests; done, dropped, edited or deliberately discarded items stay.
- **From text.** Paste a mail or a conversation: the local model (through the hub) proposes commitments, each with the exact words that support it; proposals whose quote is not literally in the text are dropped, and everything goes to the review queue, never straight in. With no model loaded the answer is `no_model`.
- **From WhatsApp.** `commitments_from_messages` (tool) / `POST /api/commitments/from-messages` takes a batch of messages the caller already read (Faustus hands them over through the hub, only for chats the user enabled; this app never reads WhatsApp) — each `{ id, chat_id, contact_name, phone, sender: "me" | "them" (or from_me), text, timestamp (ISO or epoch), is_group? }` — and records the promises in them. The pass is deterministic and fixed: Spanish and English promise verbs (`te lo mando`, `te paso`, `te pago`, `voy a mandarte`, `me encargo de`, `I'll send`, …), debts (`te debo`, `me debes`, `you owe me`, `tienes que mandarme`, `quedamos en que me lo pasas`) and time expressions (`el lunes`, `mañana`, `esta semana` = that Sunday, `la semana que viene`, `antes del 15`, `el 15 de octubre`, `en dos semanas`, `a fin de mes`) resolved against the day the message was written, never against today. Direction follows who is bound: `te lo mando` from you is `i_owe`, from them `owed_to_me`; `me debes` from you is `owed_to_me`, from them `i_owe`. Each commitment keeps `source = whatsapp`, the sentence that makes it (`source.quote`), the message time (`source.at`) and a reference `<chat>|<message id>`. Left alone on purpose: questions, conditionals and doubts anywhere in the clause, before or after the verb (`si quieres te lo mando`, `te lo mando el lunes si puedo`, `unless`, `a menos que`, `salvo que`, `aunque`, `creo que`, `no sé si`, `if possible`), hedges (`igual`, `maybe`), negations, past tense (`te lo mandé`), courtesy (`te mando un abrazo`), promises relayed through the chat (`dile a Ana que te lo mando`), the figure `me debes una`, quoted, forwarded and reported text, and group chats. An emphatic yes is not a condition (`si o si`, `sí o sí`, `no matter what`), though a real «si puedo» next to it still cancels the promise. A weekday after `de`/`del` or before `pasado` is a reference, not a deadline (`la cena del sábado pasado`). A message with several promises gives one commitment per clause (`te mando el informe el lunes y te llamo el martes`); a courtesy clause only drops itself. Irony (`sí, claro`, `eso dijo`, laughter plus `sin falta`), a «mañana» written between 00:00 and 05:59 (it may mean the same night) and a phone number in nobody's card whose name fits one person do not enter the list: they wait in the review queue (`doubtful`, `late_night`, `phone_unknown`; settling the last one saves the number on that person's card). A message may carry `name_unverified: true` (Faustus sets it for a hidden-id chat that only has a nickname and no phone): it is treated like an unknown phone, so it waits in review instead of being matched by the name alone. Emoji and laughter are removed from the action. Weak verbs (`te llamo`, `I'll call you`) count only with a day. The person is matched by phone (the alias of any kind, or the number in the chat id), then by name; a name that matches nobody keeps the name as written (no person is created), one that matches several goes to the review queue. A ledger of message ids (a hash of chat and id, never the text) makes re-sending a batch a no-op; `reprocess: true` reads them again (the same promise is still never recorded twice) and a commitment you deleted does not come back. `refine: true` additionally lets the local model read the messages the rules found nothing in; its findings only go to the review queue, with the quote checked against the message (off by default, never required); the text of those messages then goes to the hub's local model (it stays on the PC), and when the hub or the model is not there they are not marked as read, so the next call tries them again. The events are the usual ones: a promise of yours with a day sends `people.commitment.added` (direction and `source: whatsapp` in the data, so the hub's deadline rule works unchanged), the rest `people.commitment.noted`; the events carry only the action in plain words (`Mandar algo`, `Saldar una deuda`, `Llamar`, or `Compromiso de WhatsApp` when it is not known), never the sentence of the message nor the quote.
- **From the assistant.** `commitment_add` records one said in a chat. A day is only kept if it is a real day or the words name exactly one ("el martes", "en dos semanas", "15 de octubre"); otherwise the words stay as `due_text`: nothing is guessed.
- **Events.** `people.commitment.added {title, due, ref, person, …}` is sent only for a commitment you owe (`i_owe`) that has a day (when a commitment becomes that later, a day added or the direction fixed, it is sent then): the hub's rule turns it into a deadline in the paperwork app, with `ref = hoard://people/commitment/<id>` pointing back. Any other new commitment (someone owes you, or there is no day yet) is announced as `people.commitment.noted` with the same data as before. `people.commitment.done` and `people.commitment.overdue` (once per item; a new deadline earns a new one) are sent as always.
- **Merge and delete.** Merging two people moves their commitments; deleting a person keeps their commitments with the name as written.

### From the rest of the family (through the hub)

Everything here talks to the Hoard Link hub and stays quiet without it; nothing is read from other apps' files.

- **Who was at a meeting.** `people_from_minutes {minutes_id}` asks Funes for the minutes (`minutes_get`, falling back to `scribe_minutes` on an older Funes when asked to write them), matches each attendee by name, alias or e-mail address (several matches are never guessed) and writes *Reunión: <title>* on the day of the meeting on each person's timeline. It creates nobody: attendees that match no one come back as `unmatched`, several people as `ambiguous`. It is idempotent per meeting and person, also against the commitments road above (same title, same day, once), and links the person to the minutes in the hub's reference graph (`hoard://people/person/<id>` to `hoard://funes/minutes/<id>`). The hub's rule for `funes.minutes.ready` calls it by itself.
- **Last contact from mail.** The hub reads the inbox once for the family. People's Hoard registers a mail interest whose `from_addresses` are the e-mail aliases of the people in the book (not archived people, one per mailbox, at most 300; registered again when the set changes, and a changed set re-reads the hub's stored mail), reads what came from them every 30 minutes while it runs (and with `contacts_sync_mail` or **Leer ahora** in the settings), and, for each person, keeps the date, the channel *Correo* and the subject as a one-line note, at most one line per person and day. The mail goes through the family's `famMailRouter` (hub only: People has no mail helper), asking for the list and category headers, so a newsletter or a Gmail promotions/social/forums mail from a contact's address is not counted as contact; the body is never stored, and no mail is claimed (it is not this app's). Only while the hub's mail gateway is on (`mailAvailable()`); the setting *Actualizar el último contacto con el correo* and `PEOPLE_MAIL_AUTO=0` turn it off.
- **Gift ideas.** Per person: an idea, an optional page and an optional budget (`gift_idea_add`, `gift_ideas`, the *Ideas de regalo* section of the person's page). `gift_watch {idea_id}` (or *Vigilar precio*) asks Tantalus, through the hub, to watch it (`watcher_add` with the idea's text, page and budget, `source_ref = hoard://people/gift/<id>`) and keeps the watcher id; asking again answers the same watcher. `gift_days_before` days before a birthday (setting, default 21) the saved ideas that are still to give go to the daily digest as a `digest.item` event: "Cumpleaños de X en N días: ideas guardadas: …", once per person and birthday.
- **Family agenda.** `GET /api/family/agenda` (the app's own token) answers with birthdays (`birthday`, with the age when the year is known), follow-ups due by each person's cadence, last contact plus the days they wanted to talk (`followup`; one already overdue stays on the first day of a window that includes today) and open commitments with a day (`deadline`, both directions; what I owe is `high`). The manifest says `"x-family": {"agenda": true}`.

Birthdays are stored as `YYYY-MM-DD` (year known) or `--MM-DD` (year unknown); the upcoming window and age computation handle the Dec→Jan boundary and Feb 29 in non-leap years.

## API

All routes are JSON, validated with zod, and answer errors as `{ "error": "…" }` with a proper status code.

| Route | Purpose |
| --- | --- |
| `GET /api/health` | `{ service: "peoples-hoard", version, dataDirConfigured }`, no auth. |
| `GET /api/state` | UI bootstrap: version, data dir, circle counts, commitment counts (open, overdue, proposals waiting). |
| `GET /api/people?q=&circle=&archived=` | Search/filter people (`archived`: `false` default, `true`, or `all`). |
| `GET/POST/PATCH/DELETE /api/people[/:id]` | People CRUD; `GET /:id` returns the full record. |
| `GET /api/people/:id/brief` | Live conversation brief with source ids, differing facts flagged and open commitments both ways. |
| `POST /api/people/merge` | `{ keep_id, drop_id }` — merges a duplicate into a person. |
| `POST/PATCH/DELETE /api/people/:id/aliases[/:id]`, `/facts`, `/interactions`, `/reminders` | Nested CRUD for each person. |
| `GET /api/resolve?name=` | Find a person by name/alias: exact → alias → fuzzy, with candidates and scores. |
| `GET /api/upcoming?days=30` | Birthdays in the window (with age when known), reminders due, and "neglected" people. |
| `GET /api/calendar.ics` | Download all active birthdays, open reminders and open commitments with a due day as iCalendar events; Feb 29 birthdays recur on leap years. |
| `GET/POST /api/commitments`, `GET/PATCH/DELETE /api/commitments/:id` | Commitments. List filters: `person`, `direction`, `status` (`open` default, `done`, `dropped`, `all`), `overdue=1`, `due_before`, `q`. The list also returns `pending_review`. |
| `GET /api/commitments/digest?days=7` | Overdue and upcoming, grouped by person, with ready-to-say lines. |
| `GET /api/commitments/review`, `POST /api/commitments/review/:id` | The review queue; `{ action: "accept" \| "discard", person_id \| create_person \| no_person, direction, text, due, due_text }`. |
| `POST /api/commitments/ingest` | `{ session_id, regenerate?, replace? }`: ask Funes for a meeting's minutes through the hub and ingest them. |
| `POST /api/commitments/extract` | `{ text, person_hint? }`: propose commitments from pasted text (to the review queue). |
| `POST /api/commitments/from-messages` | `{ messages, refine?, reprocess? }`: record the promises found in a batch of WhatsApp messages (see *From WhatsApp*); answers with counts and the commitments created. |
| `GET/POST /api/commitments/sync` | State of the background sweep; `POST` runs one now. |
| `GET /api/circles` | Circle names with counts. |
| `GET /api/export` / `POST /api/import` | JSON backup and restore (people, aliases, facts, interactions, reminders and commitments). |
| `GET/POST/PATCH/DELETE /api/gifts…` | Gift ideas: `GET /api/gifts?person=&status=`, `POST /api/people/:id/gifts`, `PATCH`/`DELETE /api/gifts/:id`, `POST /api/gifts/:id/watch`. |
| `POST /api/meetings/from-minutes` | `{ minutes_id, generate? }`: the same as `people_from_minutes`. |
| `GET/POST /api/mail-sync` | State of the mail pass; `POST` runs one now (`{ force }` also when switched off). |
| `GET/POST /api/settings` | `{ gift_days_before, mail_sync }`. |
| `GET /api/family/agenda` | The family agenda contract (birthdays, follow-ups, commitments with a day); needs this app's own bearer token. |
| `GET /api/agent/tools` | Tool catalogue (name, description, JSON schema, annotations) and the assistant instructions. |
| `POST /api/agent/call` | `{ name, arguments }` with `Authorization: Bearer <token>`; used by the MCP bridge. |
| `GET /api/copilot/info` | Faustus copilot: `{ app_id, app_name, data_space_id, panel_url, ... }`; 503 `copilot_unavailable` when Faustus is not reachable. |
| `GET /api/copilot/panel.js` | The Faustus panel script, relayed from Faustus. |
| `GET /api/copilot/launcher.js` | The floating button and panel launcher the app adds to every HTML page. |
| `POST /api/copilot/proxy` | `{ path, method, body? }`: relays the panel's chat calls to Faustus (only `/api/chat_stream`, `/api/chat/*`, `/api/session(s)` and `/api/hoard-copilot/*`), adds this app's origin and keeps the Faustus token out of the browser. |

## Faustus copilot

Every page has a floating **Faustus** button (bottom right) that opens the Faustus assistant in a side panel. The conversation, model, memory and tools live in Faustus: this app only relays the panel (`/api/copilot/*`) and keeps no history. It needs Faustus running (`HOARD_FAUSTUS_URL`, default `http://127.0.0.1:7000`); without it the button explains that the copilot is unavailable and the rest of the app is unaffected.

What every turn tells the assistant (`window.hoardCopilot.context()`, read through the app's own API):

- `view.page` (`people`, `person`, `agenda`, `commitments`, `settings`), the search and circle filters of Personas, the window of days in Agenda, the commitment filters and the number of proposals waiting in Compromisos.
- `active_objects`: on a person's page, the person (circles, birthday, location, summary, counts), their facts, open reminders, open commitments and gift ideas; in Compromisos, the person used as a filter and the commitment whose edit form is open. A creation form, `#/personas/new` or an unknown id adds no object and never fails.
- `view.editor`: the fields typed in the open form and which of them changed (the person's data, the summary and notes drafts, a new fact, alias, contact, reminder, gift idea, commitment or person, the merge search, the pasted meeting or text, the settings). Several open forms are listed in `view.editors`.

When the assistant finishes a turn the open page reloads its data in place: the list, the person, their commitments and gifts, the agenda. Forms keep what you typed. Editing a person's data saves **only the fields you changed**, so a change the assistant made to another field in the meantime is not overwritten (the backend `PATCH` merges).

Turn it off with `HOARD_COPILOT=0` (no `/api/copilot/*` routes, no button). The client is in `client/src/copilot-context.js` (context and refresh) and `client/src/copilot-hooks.js` (what each page and form reports).

## Connect an assistant (MCP)

`server/mcp.js` is a stdio MCP server that proxies every call to the running app, so only one process ever opens the database. Keep the app running while the assistant works.

```json
{
  "command": "node",
  "args": ["C:/path/to/peoples-hoard/server/mcp.js"],
  "env": { "PEOPLE_URL": "http://127.0.0.1:5182", "PEOPLE_TOKEN_FILE": "C:/path/to/peoples-hoard/data/mcp-token" }
}
```

`faustus-plugin.json` describes the app for Faustus (health check, launch hint and the MCP command with placeholders).

Tools (27):

| Tool | Purpose |
| --- | --- |
| `find_people` | Fuzzy, accent-insensitive, partial-name search over people, nicknames and aliases; returns scored candidates. |
| `get_person` | Full record: facts, last 10 interactions, open reminders, open commitments both ways, days since last contact. |
| `prepare_person_chat` | Compact conversation brief, recomputed after edits, with source ids and conflicting fact values flagged. |
| `upsert_person` | Create or update a person by id/exact name; all fields besides name are partial. |
| `add_alias` | Attach a WhatsApp/e-mail/phone/other handle so future messages resolve to a person; idempotent. |
| `add_fact` | Record a free-form key/value fact ("le gusta" / "el senderismo"); idempotent. |
| `log_interaction` | Log a contact and update when you last spoke. |
| `add_reminder` | Create a reminder, optionally tied to a person. |
| `complete_reminder` | Mark a reminder done. |
| `upcoming` | Birthdays, reminders due and neglected people within N days, with a one-line summary. |
| `list_people` | List people, optionally filtered by circle. |
| `commitments_list` | Commitments filtered by person, direction, status, overdue and due date. |
| `commitment_add` | Record a promise: I owe someone something, or they owe me. Words like "el viernes" become a date when they name one day. |
| `commitment_update` | Change text, day, person or direction of a commitment. |
| `commitment_done` / `commitment_drop` | Mark a commitment fulfilled, or no longer applicable. |
| `commitments_review` | List the review queue, or settle one proposal (accept with a person, or discard). Asks the user first. |
| `commitments_ingest_minutes` | Ask Funes (through the hub) for a meeting's minutes and record their action items (`replace` re-reads a meeting, dropping what the user has not touched; `regenerate` rewrites the minutes). Reports `no_model`, `hub_down`, `tool_missing`, `unknown_session` or `funes_error` instead of failing. |
| `commitments_extract_text` | Propose commitments from pasted text with the local model; they go to the review queue. |
| `commitments_from_messages` | Record the promises in a batch of chat messages (WhatsApp): Spanish/English rules, days resolved from the message date, deduplicated by message id, person matched by phone or name; `refine` adds an optional model pass that only proposes. |
| `commitments_digest` | What is overdue and due soon, in words: "le debes a X…", "X te debe…". |
| `people_from_minutes` | Log "Reunión: <title>" on each attendee of a meeting who is in the book (minutes from Funes through the hub); reports `unmatched` and `ambiguous`; idempotent. |
| `contacts_sync_mail` | Read the hub's mail from the people in the book and keep date, channel and subject as their last contact (never a body); `status` is `ok`, `off`, `mail_unavailable`, `no_addresses`, `register_failed` or `read_failed`. |
| `gift_idea_add` / `gift_ideas` | Save a gift idea for a person (page and budget optional; the same idea is not saved twice) / list ideas for one person or everyone. |
| `gift_watch` | Ask Tantalus (through the hub) to watch a gift idea's price or stock and keep the watcher id. Reports `hub_down`, `tool_missing`, `tantalus_unavailable` or `tantalus_error`. |
| `find_duplicate_people` | Likely duplicate pairs (similar names, the same e-mail or phone written differently, the same birthday) with score, reasons and which one to keep; read-only, `GET /api/people/duplicates` is the same. |
| `merge_people` | Merge a duplicate into another person; commitments move too (destructive). |
| `delete_person` | Delete a person and everything linked to them; commitments stay with the name as written (destructive). |

Every description ends with a `Sinónimos:` line of Spanish words. Ambiguous names return `candidates` so the assistant can ask instead of guessing — two people can share a first name.

## Data and limits

- `data/peoples-hoard.db` — SQLite in WAL mode; schema migrations in `server/db.js` (`schema_version` table); a hand-maintained FTS5 index (`people_fts`) over name, nickname, aliases, summary, notes and facts, with accent folding when the SQLite build supports it.
- `data/mcp-token` — 32 random bytes written at every start; never committed.
- Search combines a fold-based substring pass (catches mid-word partial matches and accents) with an FTS5 prefix pass for broader recall over notes/facts.
- Requests are accepted only from `localhost` / `127.0.0.1` origins; cross-site requests are rejected.
- An alias's `kind` + `value` is globally unique: one WhatsApp name, e-mail or phone number can only ever resolve to one person.
- The only network calls are to the local Hoard Link hub (events, the Funes minutes proxy, the local model, its mail gateway, Tantalus's watchers and the reference graph); when it is not there the app works as before and says so where it matters.

## Verification

```sh
npm test        # node --test tests/*.test.js — dates/folding, domain logic, HTTP API, agent auth and tools, commitments (with a fake hub)
npm run build   # vite build → dist/
```

Tests use temporary data directories and never touch `data/`.

## Shared with the family (Hoard Link 0.8)

`server/hoard-link.js` and `server/hoard-commons/` are vendored copies of the commons. People uses them for: spoken due dates (`parseDue`: weekdays, «en dos semanas», «fin de mes», «3 nov»; vague words such as «la semana que viene» stay as `due_text`), the `.ics` export (`buildIcs`: birthdays repeat yearly, all-day, with the same UIDs as before), accent folding, `addDays` / `daysBetween`, contact handle normalisation (`normalizeEmail`, `normalizePhone`: an alias keeps the value as typed and a comparison key `aliases.norm`, so `Ana+news@Example.com` and `ana@example.com`, or `600 11 22 33` and `+34 600112233`, are one handle, and one handle still belongs to one person; rows written before the key existed are filled in the first time they are looked at), the duplicate finder (`nameSimilarity`), the mail router for the last contact, the guard, the agent routes (`/api/agent/*`, one JSON error envelope `{ error, code?, issues?, candidates? }`), the MCP bridge, the SPA server, the SQLite wrapper (`BEGIN IMMEDIATE`, savepoints, busy timeout, WAL checkpoint on exit), the background timers and the port and token helpers. The MCP token (`<data>/mcp-token`) is created once and kept across restarts; SIGINT / SIGTERM stop the background passes and close the database cleanly.

## Layout

```
server/   app.js (Express), index.js (boot), db.js, people.js, aliases.js, handles.js, duplicates.js, facts.js,
          interactions.js, reminders.js, commitments.js, whatsapp.js, commitments-poller.js, meetings.js, mailsync.js, gifts.js, agenda.js, background.js, hub-chat.js, upcoming.js, calendar.js, dates.js, text.js, routes.js,
          agent-tools.js, mcp.js,
          hoard-link.js + hoard-commons/ (vendored from Hoard Link 0.8: due dates, iCalendar, text and handle normalisation, guard, agent routes, MCP bridge, SPA, database, background timers, token and port helpers; do not edit)
client/   React 19 + Vite + Tailwind v4 (pages: Personas, Persona with its gift ideas, Agenda, Compromisos, Ajustes)
scripts/  launch.mjs, dev.mjs
tests/    node:test suites
```

License: MIT (see `LICENSE`).

Maintenance

ActivityMaintained
ResponsivenessNo issues