EchoHoard
by Luissalet
README.md
# Echo's Hoard
Everything you copy, kept on your own PC, searchable, with the things you copy often pinned. The app watches your clipboard in the background; an assistant reaches the same history through MCP, so it can answer "what was that URL I copied an hour ago", paste back the last thing you copied, or put new text on your clipboard for you.
Everything stays on the machine: SQLite for the clip history, PNG files for images, no accounts, no network.
Part of the Hoard family (see `faustus-plugin.json`).
## What it does
- **Clips** = whatever you copy: text, or an image stored as a PNG. With optional local OCR, image text becomes searchable and available to Faustus as clearly marked derived text; without OCR, the clip keeps a placeholder like "[imagen 1254×1254]". Each clip tracks kind, a one-line preview, the source app/window when known, first/last seen, how many times the same content was copied, pinned, label, tags.
- **Dedupe**: copying the same content again bumps `last_seen_at`/`times` on the existing clip instead of creating a new one.
- **Kind detection**: url, email, path, code, number, image, or plain text — from the content alone (see `echo/detect.py`).
- **Sensitive content** (privacy first): a clip that looks like a password, an API key/token, a credit card number (Luhn-checked) or an IBAN is flagged `sensitive` and its real content is replaced by a placeholder like `[oculto: contraseña]` **before it ever reaches the database** — not hidden in the UI, never stored. The assistant never receives it, not even partially. A per-app exclusion list (`ECHO_EXCLUDE_APPS`, default `KeePass,1Password,Bitwarden,keepassxc`) skips capture entirely for password managers. A pause switch stops capture (UI + tool).
- **Retention**: everything is kept for `ECHO_RETENTION_DAYS` (default 30) unless pinned; capped at `ECHO_MAX_CLIPS` (default 5000), oldest unpinned dropped first; images capped at `ECHO_MAX_IMAGE_MB` (default 200) total. A soft delete keeps a 24h undo window before the janitor purges it for good. Housekeeping runs at startup and every 10 minutes.
- **Search**: FTS5 over text/label/tags/source window title, diacritics-insensitive, prefix match, filterable by kind/pinned/time/app.
- **Capture backend** (`echo/backends/`): a small interface (`read()`, `write()`, `foreground()`) with three implementations — Windows (`ctypes` against user32/kernel32, no pywin32, polls `GetClipboardSequenceNumber` cheaply and only reads on change; retries a locked clipboard a few times, then skips that change rather than crashing), generic/cross-platform (`pyperclip`, polling text by comparing sha256; no images, no source app), and a fake backend used by tests. Selected by `ECHO_BACKEND` (`auto`/`windows`/`generic`/`fake`).
## Requirements
- Windows 10/11 (also runs on Linux/macOS with the generic backend, without images or source-app detection), Python 3.11+ (3.13 fine), Node 22 only to build the client.
- Python's `sqlite3` must have FTS5 (the official Windows builds do). The app fails loudly at startup otherwise.
## Install and run (Windows)
```bat
git clone <this repo> echo-hoard
cd echo-hoard
python -m venv venv
venv\Scripts\pip install -r requirements.txt
npm install
npm run build
venv\Scripts\python -m echo
```
Open http://127.0.0.1:5188, go to **Historial** to see and search what you've copied, **Fijados** for your pinned clips, and **Estado** to pause/resume watching and purge old clips.
- `python scripts/launch.py` starts the app on a free port and opens the browser.
- `python scripts/dev.py` runs uvicorn `--reload` + the Vite dev server (proxying `/api`).
## Configuration (environment)
| Variable | Default | Meaning |
| --- | --- | --- |
| `ECHO_PORT` / `PORT` | `5188` | Preferred port; `PORT_STRICT=1` pins it, otherwise the first free port from there. |
| `ECHO_DATA_DIR` | `<repo>/data` | Database (`echo-hoard.db`), `images/`, `mcp-token`. |
| `ECHO_BACKEND` | `auto` | `auto` \| `windows` \| `generic` \| `fake`. |
| `ECHO_AUTOSTART` | `1` | `0` disables starting the watcher automatically with the app. |
| `ECHO_RETENTION_DAYS` | `30` | Unpinned clips older than this are purged; `0` keeps forever. |
| `ECHO_MAX_CLIPS` | `5000` | Cap on unpinned clips; oldest dropped first. |
| `ECHO_MAX_IMAGE_MB` | `200` | Cap on total image storage; oldest images dropped first. |
| `ECHO_OCR_ENABLED` | `0` | Set to `1` to read the text of newly copied images. The family's OCR engine (Kafka's Hoard, through the hub: `fam_docs.ocr_image` from Hoard Link) is used when it is running, otherwise a local Tesseract (PATH, `ECHO_TESSERACT`, or the standard Windows install folders). Images are still saved if neither is available or it fails. |
| `ECHO_OCR_LANG` | `spa+eng` | Language(s) as Tesseract spells them, for example `eng+spa`; the first one is the language asked of the family service. A missing pack makes the local engine retry with `eng`. |
| `ECHO_EXCLUDE_APPS` | `KeePass,1Password,Bitwarden,keepassxc` | Comma-separated process names never captured. |
| `ECHO_ALLOWED_HOSTS` | | Extra host names accepted behind a tunnel (see below). |
### 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, list the extra host names in `ECHO_ALLOWED_HOSTS`, comma-separated, exact names or `*.suffix`: `ECHO_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).
## API
All JSON; errors are `{ "error": "..." }` (plus a machine-readable `code`, and `issues` for bad input). Agent-tool answers are capped at about 20 KB (`truncated` says what was cut). The request guard, the error envelope, the PWA files, the single-page-app server (`index.html` is never cached), the MCP bridge, the agent routes, the database class and the search-query builder are the shared Hoard Link commons (`echo/hoard_link/`), not code of this app. A second `python -m echo` reports the running instance and exits without touching the token or the database; the token in `data/mcp-token` is created once and stays.
- `GET /api/health` → `{ service: "echo-hoard", version, dataDirConfigured, hoard_link }` (the shared `hoard_link.service.health_router`); `GET /api/status`
- `POST /api/pause`, `POST /api/resume`
- `GET /api/clips?kind&pinned&since&until&app&limit&offset`, `POST /api/clips` `{text}` (sets the clipboard AND stores it), `GET/PATCH/DELETE /api/clips/{id}`, `POST /api/clips/{id}/restore`, `POST /api/clips/{id}/copy` (put it back on the clipboard), `GET /api/clips/{id}/image`
- `DELETE /api/clips?before=<iso>` (bulk purge, irreversible; UI confirms)
- `GET /api/search?q&kind&pinned&since&until&app&limit`
- `GET /api/agent/tools` (catalog + instructions), `POST /api/agent/call` (Bearer token from `data/mcp-token`)
## Faustus assistant
Once the shared Hoard Link launcher is installed, the app shows the family's Faustus panel (a floating button). The conversation, the model and the tools live in Faustus; this app only tells the panel what is open.
- **Context per view.** The view reports the page (`history`, `pinned`, `status`), the active filters (`kind`, search text) and the ids of the clips on screen (up to 20). A clip whose label is being edited is sent as kind `clip` with its real id, clip kind, source app, last seen, times, pinned, label, tags, size and a preview of at most 200 characters. The full clip text is never sent, and sensitive or deleted clips are left out entirely, also from the visible ids. Nothing is sent as an object where no clip is being edited.
- **Unsaved fields.** The panel asks for them with the `echo:context-request` window event. The open label editor (`clip-label`) answers with the label typed so far and a `dirty` flag.
- **Refresh.** When the assistant finishes a turn the open view reloads in place (`hoard:copilot-turn-end` / `window.hoardCopilot.onTurnEnd`, merged into one reload). A label you are typing keeps your text; an untouched one follows the new value.
- **Saving.** The label editor sends only the changed field in `PATCH /api/clips/{id}`, which merges just the fields it carries (`null` and unknown fields are ignored, an empty label clears it).
- **Tests.** `node --test client/copilot-context.test.mjs` and `venv\Scripts\python -m pytest -q tests/test_api.py`.
## MCP tools
`mcp_server.py` is a stdio bridge: it fetches the tool list from the running app and proxies every call to `POST /api/agent/call` with the token from `<DATA_DIR>/mcp-token`. It never opens the database. Env: `ECHO_URL`, `ECHO_TOKEN_FILE` (or `ECHO_TOKEN`).
| Tool | What it does |
| --- | --- |
| `clip_recent` | The most recently copied clips. Sensitive ones come back as `[oculto]`. |
| `clip_search` | Full-text search over the clipboard history. |
| `clip_get` | The full text of one clip (paginated); refuses sensitive clips outright. |
| `clip_bundle` | Read up to 20 selected clips together, in order, with source and date for a comparison or summary in Faustus. |
| `clip_set` | Put text on the clipboard and store it as a clip (write, idempotent by content). |
| `clip_copy` | Put an existing clip back on the clipboard (write; refuses sensitive unless `allow_sensitive`, and even then never returns the content). |
| `clip_pin` | Pin/unpin and set label/tags (write). |
| `clip_delete` | Soft-delete a clip (write, destructive; 24h undo window). |
| `clip_status` | Whether Echo is watching, counts, retention config. |
| `clip_capture` | Pause/resume watching (write). |
The shipped instructions tell the assistant: this is the user's own data, quote it only when asked; a sensitive clip is never returned, not even partially, and never ask the user to unhide one; `clip_set` changes what the user will paste next, so say what was put there; prefer `clip_search` with a time window over listing everything; never read the data folder or database directly.
## Tests
```bat
venv\Scripts\python -m pytest -q
```
Covers kind detection (URL/email/path/code/number/text table), sensitive detection (passwords by window title and by length, API key prefixes, JWT-like strings, Luhn-checked card numbers, IBAN checksum, and the negatives: a normal sentence, a plain 16-char word, a URL), dedupe bumping `times`, retention/cap/janitor with an injected clock, FTS with accents, pin/label/tags, soft delete + restore + purge, the fake backend driving the watcher end to end (push three contents, one a duplicate, → two clips, one with `times: 2`), pause/resume, the HTTP API, agent tools through `/api/agent/call` with the token (including the sensitive refusals), the request guard, the PWA endpoints, and a subprocess end-to-end test through the MCP stdio client. No test touches the real clipboard.
## License
MIT — Luissalet.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues