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
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,15 @@ First feature driven by a real consumer ([multis](https://github.com/hamr0/multi
- **The cursor is opaque and fully stateless server-side** — persist it to disk and resume across a process/container restart with no missed or duplicated messages. Same-millisecond messages are deduplicated by id (the `{ts, ids@ts}` cursor encoding), which is the specific bug-class hand-rolled pollers get wrong.
- **Echo-guard via a new `source` field on every `Message`** (`"api"` | `"external"`), plus an echoed **`client_tag`**. On a single Beeper account both the human owner's own typed messages and the agent's API replies are `is_self: true`, so `is_self` cannot guard against an agent answering its own sends. `send_message` / `note_to_self` now accept an optional `client_tag` and record what they send to a ledger persisted in the config volume; on read-back (`poll_messages`, `read_chat`, `search_messages`) those messages are marked `source: "api"` with the tag echoed. Branch on `source`, not `is_self`: skip `"api"`, process `"external"` (the owner's own Note-to-self commands included). Retires the brittle text-prefix echo-hack.
- **Schema additions are additive** (`source`, `client_tag` on `Message`; `client_tag` on the two send tools) — no existing field renamed or removed. New tool + new schema fields = MINOR per the versioning policy.
- **Honest limitation (documented loudly):** the `source` match is best-effort and **unverified against a live Beeper account** (CI has none — the standing gate limitation). Primary match is the `pendingMessageID`; because Beeper may swap it for a real bridge id on ack, a content fallback also matches, but only for the account's own messages in the same chat within 15 minutes — conservative so a human re-typing identical text is never mis-tagged and dropped.
- **Reliable echo via final-id resolution** (second multis ask). The first cut matched read-backs by the `pendingMessageID` returned at send time, but Beeper swaps that for the real bridge id on ack — so the id never matched and every match degraded to the fragile 15-minute text fallback. `send_message` / `note_to_self` now **resolve the pending id to the final bridge id** (GET `.../messages/{pendingMessageID}` until the id swaps, bounded + best-effort) and record **both** ids in the ledger, so a read-back matches by **exact id** whether or not the swap happened. The content fallback survives only as a last-ditch net **and only for sends whose final id could not be resolved** — once a send is resolved, its text fallback is retired, so a human re-typing identical text is no longer mis-tagged as the agent's own and dropped. Lets a consumer drop its own send-echo bookkeeping. Tunable via `BEEPERBOX_RESOLVE_RETRIES` (default 4) / `BEEPERBOX_RESOLVE_DELAY_MS` (default 250) / `BEEPERBOX_RESOLVE_TIMEOUT_MS` (default 3000, per-attempt fetch timeout so a hung API can't stall a send); set retries to 0 to disable resolution. Worst-case added send latency is bounded at `retries × (timeout + delay)`.
- **Additive return fields** on `send_message` / `note_to_self`: `pending_message_id` (the raw pending id) and `resolved` (bool); `message_id` is now the final bridge id when `resolved: true`, else the pending id. No field removed.
- **Honest limitation (documented loudly):** the `source` match is **unverified against a live Beeper account** (CI has none — the standing gate limitation). The exact-id resolution depends on Beeper's live id/ack behavior (the `pendingMessageID` → final-id swap was proven against a real account by multis but is not exercised in CI); validate against your own account before relying on it for a high-stakes auto-responder.
- **New env var `BEEPERBOX_SENT_LEDGER`** (default `/root/.config/beeperbox-sent-ledger.json`, inside the persisted config volume) — the echo-guard ledger path. Best-effort: a failed write degrades the guard to in-memory for that run and warns once to stderr; it never fails a send.
- **Input bounds on the new caller-supplied fields** (from a `/security` pass on the diff): the opaque `cursor`'s `ids` array is capped at 4096 on decode — rejected as malformed beyond that (a real cursor only holds the ids sharing one millisecond, so this never rejects a server-issued cursor) — closing an O(n)-per-message CPU-amplification path; and `client_tag` is truncated to 256 chars before it enters the ledger, closing a ledger-bloat path. Both are exercised by unit tests that fail without the cap.

### Tests
- **`mcp/server.test.js`** — `node --test` unit suite over the pure cursor/dedup/echo-guard logic (encode/decode round-trip, strict-after filtering, same-millisecond id dedup, cursor advance, a full seed→poll→restart-resume walk, the `source` matcher's id/content/window/cross-chat cases, and a real-temp-file ledger persistence round-trip). No live Beeper needed — this is the half of the feature that *is* deterministically testable, and it covers exactly the bug-class. Wired into `mcp-test.yml` as a fast `unit` job; `poll_messages` added to the guard-check + smoke-test tool-contract assertions.
- **Final-id resolution cases** (26 tests now): a read-back tagged by its resolved bridge id; the key acceptance test that **a human re-typing identical text after a resolved send stays `external`** (the bug the resolution fixes); two identical-text sends each matched by their own resolved id; an unresolved entry still caught by the text fallback (safety net preserved); and an `addResolvedId` persist/reload round-trip. The decision logic was POC-validated against the prior matcher to confirm the old behavior mis-tags the human (the test can fail), and the entrypoint supervisor's `API_WAS_UP` gate was POC-simulated across the never-logged-in / half-dead / process-death scenarios.

### Documentation
- **Made the raw `/v1/` contract explicit** (`beeperbox.context.md`, `docs/GUIDE.md`) so direct-API consumers match the MCP layer instead of re-discovering its quirks: the `?limit=N` ~25-item *floor* (it's a lower bound — over-fetch then slice), the client-side cursor-enumeration recipe `poll_messages` implements (no server-side `since=`), and the canonical field heuristics (note-to-self = `participants.total === 1 && items[0].isSelf`; network via `accountID` → `/v1/accounts`; group = `type === 'group'`; `pendingMessageID` is not a stable delivered id).
Expand All @@ -39,6 +42,11 @@ First feature driven by a real consumer ([multis](https://github.com/hamr0/multi
### Fixed — `docker restart` no longer segfaults the display `[PATCH]`
- **`docker restart` reliably wedged the container with a stale Xvfb lock.** `docker restart` re-runs the entrypoint but preserves the container's writable layer, so Xvfb's `/tmp/.X99-lock` from the previous boot survived into the new process. Xvfb then saw display `:99` as "already active", half-initialized it, and **segfaulted** (`(EE) Server is already active for display 99`) — the backend never bound `127.0.0.1:23373`, socat looped on connection-refused, and the container sat stuck in `health: starting`. Only a full `docker compose down && up` (which discards `/tmp`) recovered. The entrypoint now removes the stale `/tmp/.X99-lock` and `/tmp/.X11-unix/X99` before starting Xvfb, so `docker restart` — the natural operation after any config change — is survivable. No runtime-contract change (MCP tools, HTTP API, schemas, ports bit-identical); PATCH per the versioning policy.

### Added — beepertexts is now supervised (no more silent half-dead container) `[MINOR]`
- **The entrypoint supervises Beeper Desktop instead of launching it unattended.** Previously beepertexts was started once with `&`: if its API/renderer process crashed while the Electron launcher lingered, the container looked "up" and the MCP layer (`:23375`) kept answering, but every tool call failed with `-32603 fetch failed` because the API (`:23373`) was gone — a half-dead container that never self-healed. The entrypoint now runs a supervision loop that **relaunches beepertexts if the process dies, and recycles it if the API stays down after having been up** (watching both the PID and `:23373/v1/spec`, since the launcher can outlive its crashed children).
- **First-run login is protected by an `API_WAS_UP` gate.** Until the user enables "Start API on launch", the API is down *by design*, so the loop only ever recycles-for-API-down once the API has come up at least once this run — a never-logged-in container just runs Beeper steadily and waits for the human (the prior behavior). Clean `docker stop` shutdown (SIGTERM → flush → exit) is preserved.
- **Tunable / reversible:** `BEEPERBOX_SUPERVISE` (default `1`; set `0` for the old forward-signal-and-wait behavior), `BEEPERBOX_SUPERVISE_INTERVAL` (default `10`s), `BEEPERBOX_SUPERVISE_API_GRACE` (default `6` checks ≈ 60s of API-down before recycle). The Docker `HEALTHCHECK` already probes the API through the socat path, so a genuinely dead backend still marks the container unhealthy; supervision adds the *recovery* the healthcheck alone can't provide.

## [0.5.1] — 2026-05-25 `[PATCH]`

Release-pipeline hardening and documentation. PATCH per the versioning policy — these change how releases are *built and described*, not what the running container does: the MCP tool surface, raw/HTTP API, `Chat`/`Message` schemas, and default ports are bit-identical to v0.5.0, and no client-code edits are required. The headline is that the release path is now **gated on the guard tests** with a `:previous` rollback tag, so a broken upstream Beeper can no longer silently become `:latest`.
Expand Down
8 changes: 4 additions & 4 deletions beeperbox.context.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,16 +199,16 @@ while (true) {
Send a text message to a chat. Markdown supported.

**Arguments:** `{chat_id: string, text: string, reply_to_message_id?: string, client_tag?: string}`.
**Returns:** `{chat_id, message_id, client_tag, status: "sent"}`.
**Note:** `message_id` is Beeper's `pendingMessageID` — use it for downstream `react_to_message` on the just-sent message.
**Returns:** `{chat_id, message_id, pending_message_id, resolved, client_tag, status: "sent"}`.
**Note:** `message_id` is the **final bridge id** when beeperbox could resolve it (`resolved: true`), else the `pendingMessageID` (`resolved: false`). `pending_message_id` is always the raw pending id. Use `message_id` for downstream `react_to_message`. The resolution is what lets the echo-guard match read-backs by **exact id**, not text (see the Message schema caveat).
**`client_tag` (echo-guard):** optional idempotency/echo key. beeperbox records it against this send and echoes it back on the message — marked `source: "api"` — when it reappears in `poll_messages` / `read_chat`. Lets an agent recognize and skip its own programmatic sends without a brittle text-prefix hack (see `poll_messages` and the Message schema).

### `note_to_self`

Send a message to the bot's own Beeper-native Note to self chat. Auto-resolves the correct chat ID, so no `chat_id` parameter needed.

**Arguments:** `{text: string, client_tag?: string}`.
**Returns:** `{chat_id, message_id, client_tag, status: "sent"}`.
**Returns:** `{chat_id, message_id, pending_message_id, resolved, client_tag, status: "sent"}` — same shape as `send_message` (`message_id` is the resolved final id when `resolved: true`).
**`client_tag`:** same echo-guard semantics as `send_message` — recorded and echoed back as `source: "api"` on read-back, so a `poll_messages` loop can skip the agent's own self-notes.
**Use for:** agent self-notes ("processed 5 customer messages"), debug output, scheduled reminders, anything you want recorded but NOT seen by anyone else. The note-to-self chat is excluded from `list_inbox` / `list_unread` / `search_messages`, so messages here will not pollute customer views.

Expand Down Expand Up @@ -294,7 +294,7 @@ Two normalized shapes. Learn them once and every tool returns the same thing.
| `source` | **`"api"`** if *this* beeperbox sent the message via `send_message` / `note_to_self`; **`"external"`** otherwise. This — not `is_self` — is the echo-guard: on one account both the owner's own typed messages and the agent's API replies are `is_self: true`, so only `source` can tell "I sent this programmatically" from "the human owner sent this". Skip `source === "api"` in a poll loop; process `"external"` (the owner's own Note-to-self commands included). |
| `client_tag` | The `client_tag` passed to `send_message` / `note_to_self` for this message, echoed back, else `null`. An idempotency key the agent can correlate to a specific send. |

> **`source` matching is best-effort and unverified against a live Beeper account** (CI has none). The primary match is the `pendingMessageID` returned by the send; because Beeper may swap that for a real bridge id on ack, a content-based fallback also matches, but only for the account's own (`is_self`) messages in the same chat within a 15-minute window — deliberately conservative so a human re-typing identical text is never mis-tagged and dropped. The ledger is persisted to the config volume so the guard survives restarts. Validate against your own account before relying on it for a high-stakes auto-responder, and keep a secondary guard if a missed echo would be costly.
> **`source` matching is primarily by exact id; best-effort and unverified against a live Beeper account** (CI has none). On send, beeperbox resolves the `pendingMessageID` to the **final bridge id** Beeper assigns on ack (it GETs the message back until the id swaps) and records both, so a read-back matches by **exact id** whether or not the swap happened. A content-based fallback survives only as a last-ditch net, and **only for sends whose final id could not be resolved** — matching the account's own (`is_self`) messages in the same chat within a 15-minute window. Once a send is resolved, its text fallback is retired, so a human re-typing identical text is **not** mis-tagged as the agent's own and dropped. The ledger is persisted to the config volume so the guard survives restarts. Validate against your own account before relying on it for a high-stakes auto-responder, and keep a secondary guard if a missed echo would be costly.

## Raw `/v1/` contract (for direct-API consumers)

Expand Down
6 changes: 4 additions & 2 deletions docs/GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -490,7 +490,7 @@ If you call `/v1/*` directly instead of going through the MCP tools, you inherit
- **Group** = `chat.type === 'group'` (and not note-to-self) — there is no `isGroup` field.
- **Last activity** = `chat.lastActivity` (camelCase ISO 8601), not `last_activity`.
- **Sender-is-self** = `message.isSender === true` — true for anything the account sent from *any* client, so it is *not* an "I sent it via the API" signal (the `source` field the MCP layer adds is a beeperbox-side ledger with no raw-API equivalent).
- **Sent message id** = `POST .../messages` returns `pendingMessageID`, a local pending id Beeper replaces with a real id on bridge ack — don't treat it as a stable delivered id.
- **Sent message id** = `POST .../messages` returns `pendingMessageID`, a local pending id Beeper replaces with a real id on bridge ack — don't treat it as a stable delivered id. To get the final id, GET `.../messages/{pendingMessageID}` until its `id` differs (the swap is async); the MCP layer does this for you and returns `message_id` / `resolved`.

## MCP tools reference

Expand All @@ -507,7 +507,7 @@ beeperbox exposes 11 semantic tools over Model Context Protocol on two interchan
| `get_chat` | `chat_id` | `Chat` | Refresh one chat's state before replying |
| `read_chat` | `chat_id` | Array of `Message` (oldest first) | Pull conversation context for the LLM to reason about |
| `search_messages` | `query` | Array of `Message` | Follow-up lookups, historical context, "what did X say about Y" |
| `send_message` | `chat_id`, `text` | `{chat_id, message_id, client_tag, status}` | The headline reply/notify tool (optional `client_tag` echo key) |
| `send_message` | `chat_id`, `text` | `{chat_id, message_id, pending_message_id, resolved, client_tag, status}` | The headline reply/notify tool (optional `client_tag` echo key). `message_id` is the resolved final bridge id when `resolved: true`, else the pending id |
| `note_to_self` | `text` | same | Agent self-notes, debug output, scheduled reminders — auto-resolves to the Beeper-native Note to self chat (won't leak into per-platform saved-messages chats) |
| `react_to_message` | `chat_id`, `message_id`, `emoji` | `{...status: reacted}` | Lightweight ack, no full reply needed |
| `archive_chat` | `chat_id` | `{chat_id, archived}` | Clean handled chats out of inbox (closest primitive to mark-as-read that Beeper exposes) |
Expand Down Expand Up @@ -964,6 +964,8 @@ docker exec beeperbox curl -sf http://127.0.0.1:23373/v1/info > /dev/null && ech
- If that prints `API OK`: socat is the problem. Restart the container.
- If it prints `API DOWN`: Beeper API isn't running. You probably haven't enabled it yet — go back to [first-run setup](#first-run-setup) step 3, or you forgot to turn on **Start API on launch**.

Once you have logged in and the API has come up at least once, the entrypoint **supervises** beepertexts — if it later crashes (process death, or the launcher lingering with a dead API), it is relaunched/recycled automatically, so the half-dead "MCP answers but every tool call fails" state self-heals within ~60s. Tune or disable it with `BEEPERBOX_SUPERVISE` / `BEEPERBOX_SUPERVISE_INTERVAL` / `BEEPERBOX_SUPERVISE_API_GRACE`. The supervisor never recycles before that first login (the API is down by design until you enable it).

### `401 Unauthorized` on every call except `/v1/info`

Your token is missing or wrong. Confirm:
Expand Down
Loading
Loading