beeperbox 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +43 -10
  2. package/package.json +2 -2
  3. package/server.js +82 -23
package/README.md CHANGED
@@ -1,18 +1,28 @@
1
- # beeperbox (lite mode)
1
+ ```
2
+ ╭──────────────────────────────────╮
3
+ │ ╔╗ ╔═╗╔═╗╔═╗╔═╗╦═╗╔╗ ╔═╗ ╦ ╦ │
4
+ │ ╠╩╗╠╣ ╠╣ ╠═╝╠╣ ╠╦╝╠╩╗║ ║ ╚╦╝ │
5
+ │ ╚═╝╚═╝╚═╝╩ ╚═╝╩╚═╚═╝╚═╝ ╩ ╩ │
6
+ │ one agent ──→ 50+ messengers │
7
+ ╰──────────────────────────────────╯
8
+ ```
9
+
10
+ <p align="center">
11
+ <a href="https://www.npmjs.com/package/beeperbox"><img src="https://img.shields.io/npm/v/beeperbox?label=npm&color=2a4f8c" alt="npm version"></a>
12
+ <a href="https://github.com/hamr0/beeperbox"><img src="https://img.shields.io/badge/source-github-2a4f8c" alt="source on GitHub"></a>
13
+ <img src="https://img.shields.io/badge/license-Apache%202.0-2a4f8c" alt="license: Apache 2.0">
14
+ </p>
2
15
 
3
16
  **Run beeperbox's MCP verb server against a Beeper Desktop you already have open — no Docker, no Electron, no Xvfb.**
4
17
 
5
18
  This is the *lite* half of [beeperbox](https://github.com/hamr0/beeperbox). The full project ships a Docker image with a headless Beeper Desktop inside; lite mode is the same single-file, zero-dependency MCP server pointed at a Beeper Desktop **you** run on your laptop. Identical verb surface, identical version — you just supply Beeper.
6
19
 
7
- - **Always-on / VPS / no local Beeper?** Use the [Docker image](https://github.com/hamr0/beeperbox#quick-start).
20
+ - **Always-on / VPS / no local Beeper?** Use the [Docker image](https://github.com/hamr0/beeperbox#quick-start-container).
8
21
  - **Beeper already open on your machine?** Use this.
9
22
 
10
- ## Prerequisites
23
+ ## Quick start
11
24
 
12
- 1. **Beeper Desktop** running locally.
13
- 2. **Developer API enabled:** Beeper → **Settings → Developers** → enable the API and create an access token (the same token the container uses).
14
-
15
- ## Run
25
+ **Prereqs:** Beeper Desktop running locally, with the Developer API enabled — Beeper → **Settings → Developers** → enable the API and create an access token (the same token the container uses).
16
26
 
17
27
  ```sh
18
28
  BEEPER_TOKEN=your-token-here npx beeperbox
@@ -20,12 +30,22 @@ BEEPER_TOKEN=your-token-here npx beeperbox
20
30
 
21
31
  That starts the MCP HTTP server on `http://127.0.0.1:23375`, pointed at the local Beeper Desktop API on `http://127.0.0.1:23373`. On boot it logs a one-line reachability verdict (`preflight OK: … N account(s)` or `preflight FAIL: …`) so a misconfigured token or API is obvious immediately.
22
32
 
23
- For stdio transport (Claude Code, Cursor, Cline, Continue, bareagent):
33
+ For stdio transport (Claude Code, Cursor, Cline, Continue, [bareagent](https://npmjs.com/package/bare-agent)):
24
34
 
25
35
  ```sh
26
36
  BEEPER_TOKEN=your-token-here npx beeperbox --stdio
27
37
  ```
28
38
 
39
+ ## The 12 tools
40
+
41
+ One opinionated MCP verb layer over Beeper — every tool returns a normalized `Chat` / `Message` schema, propagates `chat_id` + `network` onto every message, and is documented in-schema for the model. Reach across all 50+ networks without knowing which bridge you're talking to.
42
+
43
+ - **Read / triage** — `list_accounts` · `list_inbox` · `list_unread` · `get_chat` · `read_chat` · `search_messages`
44
+ - **Write / act** — `send_message` · `note_to_self` · `react_to_message` · `archive_chat`
45
+ - **Watch / reach** — `poll_messages` (read-only watch primitive, restart-safe cursor, `source` echo-guard) · `download_asset` (attachment bytes; every message carries `attachments[]`)
46
+
47
+ Full schemas and usage in the [main README](https://github.com/hamr0/beeperbox#the-mcp).
48
+
29
49
  ## Config
30
50
 
31
51
  | Env | Meaning | Default |
@@ -45,6 +65,19 @@ The server binds **loopback only** (`127.0.0.1`) by default, so it's safe with n
45
65
 
46
66
  There's no Docker restart policy in lite mode. For an always-on setup, run it under `systemd` or `pm2`.
47
67
 
48
- See the [full README](https://github.com/hamr0/beeperbox#lite-mode) and [docs/GUIDE.md](https://github.com/hamr0/beeperbox/blob/master/docs/GUIDE.md) for the complete tool reference and the container build.
68
+ See the [full README](https://github.com/hamr0/beeperbox#the-mcp) and [docs/GUIDE.md](https://github.com/hamr0/beeperbox/blob/master/docs/GUIDE.md) for the complete tool reference and the container build.
69
+
70
+ ## The bare ecosystem
71
+
72
+ Local-first, composable agent infrastructure. Same API patterns throughout — mix and match, each module works standalone.
73
+
74
+ - **[bareagent](https://npmjs.com/package/bare-agent)** — the think→act→observe loop. *Goal in → coordinated actions out.*
75
+ - **[bareguard](https://npmjs.com/package/bareguard)** — the single gate every action passes through. *Action in → allow / deny / ask-a-human out.*
76
+ - **[litectx](https://npmjs.com/package/litectx)** — code + memory graph with activation decay. *Query in → ranked context out.*
77
+ - **[barebrowse](https://npmjs.com/package/barebrowse)** — a real browser for agents. *URL in → pruned snapshot out.*
78
+ - **[baremobile](https://npmjs.com/package/baremobile)** — Android + iOS device control. *Screen in → pruned snapshot out.*
79
+ - **beeperbox** *(this)* — 50+ messaging networks via one MCP server. *Chat in → unified message stream out.*
80
+
81
+ ## License
49
82
 
50
- [Apache-2.0](https://github.com/hamr0/beeperbox/blob/master/LICENSE)
83
+ [Apache-2.0](https://github.com/hamr0/beeperbox/blob/master/LICENSE). Independent wrapper around Beeper Desktop, no affiliation with Beeper / Automattic.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "beeperbox",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Lite mode for beeperbox — the opinionated MCP verb server for Beeper Desktop, run standalone against a Beeper you already have open (no Docker, no Electron). The full headless-Beeper-in-Docker build lives at github.com/hamr0/beeperbox.",
5
5
  "bin": {
6
6
  "beeperbox": "server.js"
@@ -34,7 +34,7 @@
34
34
  "url": "git+https://github.com/hamr0/beeperbox.git",
35
35
  "directory": "mcp"
36
36
  },
37
- "homepage": "https://github.com/hamr0/beeperbox#lite-mode",
37
+ "homepage": "https://github.com/hamr0/beeperbox#lite-mode-npx",
38
38
  "bugs": "https://github.com/hamr0/beeperbox/issues",
39
39
  "author": "hamr0",
40
40
  "license": "Apache-2.0"
package/server.js CHANGED
@@ -95,6 +95,21 @@ const ASSET_TIMEOUT_MS = envIntNonNeg('BEEPERBOX_ASSET_TIMEOUT_MS', 30000);
95
95
  // neither, so this is its boot sanity check. Opt out with BEEPERBOX_PREFLIGHT=0.
96
96
  const PREFLIGHT_TIMEOUT_MS = envIntNonNeg('BEEPERBOX_PREFLIGHT_TIMEOUT_MS', 5000);
97
97
 
98
+ // The accountID -> network map is cached so chat normalizers don't re-fetch
99
+ // /v1/accounts on every call. But an UNBOUNDED cache is a resilience bug: an
100
+ // account added at runtime (e.g. WhatsApp via noVNC) would render network
101
+ // "unknown" in every chat verb until the MCP *process* restarts, and an empty/
102
+ // partial map captured during the backend's post-restart sync window would
103
+ // freeze for the whole process life — so a plain `docker restart` re-poisons it
104
+ // from a still-syncing backend and the account list stays wrong (the exact wedge
105
+ // multis reported). Bound it with a TTL, and never cache an EMPTY result. Set to
106
+ // 0 to disable caching (always read live).
107
+ const ACCOUNT_CACHE_TTL_MS = envIntNonNeg('BEEPERBOX_ACCOUNT_CACHE_TTL_MS', 60000);
108
+
109
+ // Injectable clock — overridable in tests so the TTL is verifiable without a
110
+ // real sleep. Production reads the wall clock.
111
+ let nowFn = () => Date.now();
112
+
98
113
  // Real attachment src_urls come in two shapes: remote Matrix content
99
114
  // (mxc:// / localmxc://) and, once Beeper caches the file locally,
100
115
  // file:///root/.config/BeeperTexts/media/... — verified against a live account.
@@ -293,17 +308,59 @@ async function getNoteToSelfChatID() {
293
308
  throw rpcError(-32002, 'note-to-self chat not found in top 100 chats — open Beeper Desktop and verify a "Note to self" chat exists');
294
309
  }
295
310
 
311
+ // Unwrap /v1/accounts into a bare array — Beeper returns a bare array today, but
312
+ // tolerate a {items:[...]} envelope. One place so list_accounts, getAccountMap,
313
+ // and preflight agree.
314
+ function accountList(accounts) {
315
+ return Array.isArray(accounts) ? accounts : (accounts?.items || []);
316
+ }
317
+
318
+ // accountCache: { map, at } | null. Bounded by ACCOUNT_CACHE_TTL_MS and NEVER
319
+ // populated from an empty result — see ACCOUNT_CACHE_TTL_MS above for why the old
320
+ // unbounded cache wedged after a runtime account-add / post-restart sync window.
296
321
  async function getAccountMap() {
297
- if (accountCache) return accountCache;
298
- const accounts = await beeperFetch('/v1/accounts');
299
- accountCache = {};
300
- for (const a of (Array.isArray(accounts) ? accounts : (accounts.items || []))) {
301
- accountCache[a.accountID] = {
302
- network: networkSlug(a.network),
303
- network_label: a.network,
304
- };
322
+ const now = nowFn();
323
+ const cacheValid =
324
+ accountCache &&
325
+ ACCOUNT_CACHE_TTL_MS > 0 &&
326
+ (now - accountCache.at) < ACCOUNT_CACHE_TTL_MS;
327
+ if (cacheValid) return accountCache.map;
328
+
329
+ const list = accountList(await beeperFetch('/v1/accounts'));
330
+ const map = {};
331
+ for (const a of list) {
332
+ map[a.accountID] = { network: networkSlug(a.network), network_label: a.network };
333
+ }
334
+ // An empty /v1/accounts is almost always the backend mid-sync (just after a
335
+ // restart or an account-add), not a real "no accounts" state. Caching it would
336
+ // freeze every chat verb's network labels until the *process* restarts. Serve
337
+ // it for THIS call (the verb still returns, just with "unknown" labels) but
338
+ // leave the cache empty so the very next call re-reads and self-heals the
339
+ // moment the backend populates. Log it so a recurrence is diagnosable, not silent.
340
+ if (list.length === 0) {
341
+ process.stderr.write('[beeperbox-mcp] /v1/accounts returned 0 accounts — Beeper may still be syncing; not caching, will re-read next call\n');
342
+ accountCache = null;
343
+ return map;
305
344
  }
306
- return accountCache;
345
+ accountCache = { map, at: now };
346
+ return map;
347
+ }
348
+
349
+ // Map one raw /v1/accounts entry into the list_accounts shape. `status` is the
350
+ // backend's per-account connection state ("connected" / "connecting" / …) —
351
+ // surfaced (not dropped) so a caller can tell a still-syncing bridge from a real
352
+ // one instead of reading a transient as "gone".
353
+ function normalizeAccount(a) {
354
+ return {
355
+ account_id: a.accountID,
356
+ network: networkSlug(a.network),
357
+ network_label: a.network,
358
+ status: a.status || null,
359
+ user: {
360
+ id: a.user?.id || null,
361
+ display_name: a.user?.fullName || a.user?.displayText || a.user?.username || null,
362
+ },
363
+ };
307
364
  }
308
365
 
309
366
  // ─── chat normalizer ──────────────────────────────────────────────
@@ -673,7 +730,7 @@ function applyEchoTags(messages, now) {
673
730
  const TOOLS = [
674
731
  {
675
732
  name: 'list_accounts',
676
- description: 'List all messaging accounts (networks) connected to this Beeper account. Each account corresponds to one platform — WhatsApp, Telegram, Discord, etc. Use this to see which platforms are reachable before calling other tools, or to discover what kinds of chats exist. Returns network slug (machine-readable, e.g. "whatsapp"), network label (human, e.g. "WhatsApp"), the underlying account ID, and the user\'s display name on that platform.',
733
+ description: 'List all messaging accounts (networks) connected to this Beeper account. Each account corresponds to one platform — WhatsApp, Telegram, Discord, etc. Use this to see which platforms are reachable before calling other tools, or to discover what kinds of chats exist. Returns network slug (machine-readable, e.g. "whatsapp"), network label (human, e.g. "WhatsApp"), the underlying account ID, the bridge connection `status` ("connected", "connecting", etc. — lets you tell a still-syncing account from a real one rather than reading a transient as "gone"), and the user\'s display name on that platform.',
677
734
  inputSchema: {
678
735
  type: 'object',
679
736
  properties: {},
@@ -827,17 +884,15 @@ const TOOLS = [
827
884
  async function callTool(name, args) {
828
885
  switch (name) {
829
886
  case 'list_accounts': {
830
- const accounts = await beeperFetch('/v1/accounts');
831
- const list = Array.isArray(accounts) ? accounts : (accounts.items || []);
832
- return list.map((a) => ({
833
- account_id: a.accountID,
834
- network: networkSlug(a.network),
835
- network_label: a.network,
836
- user: {
837
- id: a.user?.id || null,
838
- display_name: a.user?.fullName || a.user?.displayText || a.user?.username || null,
839
- },
840
- }));
887
+ const list = accountList(await beeperFetch('/v1/accounts'));
888
+ // A live read, so a 0 here is the backend's current truth — but that is far
889
+ // more often "Beeper still syncing after a restart/account-add" than a real
890
+ // empty account set. Log it so the operator can tell the difference instead
891
+ // of silently relaying an ambiguous empty list.
892
+ if (list.length === 0) {
893
+ process.stderr.write('[beeperbox-mcp] list_accounts: backend /v1/accounts returned 0 accounts — Beeper may still be syncing (retry shortly)\n');
894
+ }
895
+ return list.map(normalizeAccount);
841
896
  }
842
897
 
843
898
  case 'get_chat': {
@@ -1173,8 +1228,7 @@ async function preflight() {
1173
1228
  return;
1174
1229
  }
1175
1230
  try {
1176
- const accounts = await beeperFetch('/v1/accounts', { timeoutMs: PREFLIGHT_TIMEOUT_MS });
1177
- const list = Array.isArray(accounts) ? accounts : (accounts?.items || []);
1231
+ const list = accountList(await beeperFetch('/v1/accounts', { timeoutMs: PREFLIGHT_TIMEOUT_MS }));
1178
1232
  say(`preflight OK: ${BEEPER_API} reachable, token accepted, ${list.length} account(s)`);
1179
1233
  } catch (e) {
1180
1234
  say(`preflight FAIL: ${BEEPER_API} unreachable or token rejected — ${e.message}`);
@@ -1310,6 +1364,7 @@ module.exports = {
1310
1364
  selectDelivery,
1311
1365
  textHash,
1312
1366
  normalizeAttachments,
1367
+ normalizeAccount,
1313
1368
  assertServableSrcUrl,
1314
1369
  matchSentMessage,
1315
1370
  recordSent,
@@ -1317,4 +1372,8 @@ module.exports = {
1317
1372
  loadLedger,
1318
1373
  // test hook: drop the in-memory ledger so a test can re-load from a fresh path
1319
1374
  _resetLedger: () => { ledger = null; ledgerPersistWarned = false; },
1375
+ // account-map cache surface (resilience): the map + its TTL/empty-cache rules.
1376
+ getAccountMap,
1377
+ _resetAccountCache: () => { accountCache = null; },
1378
+ _setNow: (fn) => { nowFn = fn || (() => Date.now()); },
1320
1379
  };