Files
MeshBot/AGENTS.md
T
kamilkrzywda cdd52a3ddb
CI / build + unit (pull_request) Successful in 1m20s
Send #rss get summaries as 3-5 separate mesh messages (v0.21.0)
The single-message summary was too small (owner request). The summarizer
prompt now asks for exactly 3-5 lines, one item per line, in the format
'<short header>: <one or two short sentences>' with a <=20-char header,
no intro/outro/numbering. maxTokens stays 2500 (reasoning-token headroom).

The reply is parsed into items: drop empty lines and lines without a ':'
header separator, cap at 5; fewer than 3 valid lines sends what we got
(no failure/retry), zero valid lines sends nothing. Each item goes out as
its own mesh message via respond(id, line) - all threaded to the trigger
packet id on its channel. Per-item backstop fitOneLine() hard-cuts any
line >185 chars at a word boundary near 180 so every item fits one packet;
the old whole-reply fitOnePacket() cut is gone.

CommandService sends array replies line by line through the same routing
as single replies (respond per line, or legacy sendText fallback per line
when there is no packet id). LLM failure path unchanged: single
'summary failed' reply.
2026-09-25 20:09:16 +00:00

14 KiB

MeshBot — Workflow Rules

Change process (mandatory)

  1. Small, incremental changes — work is done one change at a time, in smaller parts; each part must be tested thoroughly with unit tests covering it before moving on. Connected issues/tasks that reference the same part of code MAY be done together on one branch/PR with a single version bump (e.g. two tweaks to the same codec function).
  2. Feature branch + PR — every change lands on a feature branch off main (no direct pushes to main). The branch carries the commits below; open a pull request to main.
  3. Changelog entry — every such change is listed in CHANGELOG.md with date, time and version (format: ## v<version> — YYYY-MM-DD HH:MM, then bullet points of what changed).
  4. Version bump — the version is updated in package.json.
  5. Verification must pass — there is no CI pipeline yet; before opening a PR both local checks must be green: npm run build and npm test (vitest unit suite).
  6. Tag + manual deploy after merge — once merged, tag the version on main (e.g. v0.1.0). The service (http://bot.dev.kkhost.pl) is updated manually afterwards: docker compose up -d --build on the host.

Verification

  1. Local checks — npm run build (nest build) and npm test (vitest; no e2e suite yet). Both must pass before a PR is opened.
  2. Docker — docker compose build, then after docker compose up -d: curl -s -H "Host: bot.dev.kkhost.pl" http://localhost/api/status returns JSON ({ connected, myNodeNum, host, port }). "connected":false is normal while the bridge/device is down — the service retries every 5 s.

API

  1. Live base URL — http://bot.dev.kkhost.pl (Traefik → docker-compose stack on this host; container port 3000 via external network dev_net, no published ports).
  2. Endpoints — all under the /api global prefix: GET /api/status ({ connected, myNodeNum, host, port }), POST /api/send (body { "text": "...", "to"?: <node number | node name | "!b" | "broadcast">, "replyId"?: <packet id> } — numbers pass through as-is (integer 1..0xffffffff), names resolve like GET /api/nodes/:id, omitted/""/"!b"/"broadcast" → broadcast; unknown or ambiguous targets are a 400. Optional replyId (integer in 1..0xffffffff, else 400) references an earlier message's packet id from GET /api/messages — when set, the recipient app renders the text as a threaded reply/quote of that packet (Data.reply_id, field 7). Returns { id }), POST /api/respond (body { "messageId": <packet id>, "content": "..." }, returns { id }; reply to an earlier message by packet id — routing is automatic: rx records are answered as a DM back to the original sender unless the original was a broadcast (then the reply is a broadcast), tx records go to their destination, injected messages without a sender get a broadcast; the reply always rides the record's channel and carries replyId = messageId so the recipient app renders it as a threaded quote of that packet; 400 on invalid messageId/content, 404 when no message has the given id (in memory or in SQLite), 503 when the bridge is down), GET /api/channels (the device's Meshtastic channels as { index, name, uplinkEnabled, downlinkEnabled }[]; psk/keys redacted), GET /api/device (device info captured during the initial sync: { myNodeNum, firmwareVersion?, metadata?, localConfig: {<section>: ...}, moduleConfig: {<module>: ...} } — DeviceMetadata as-is plus per-section Config/ModuleConfig messages; security publicKey/privateKey/adminKey and network wifiPsk redacted to "REDACTED"; in-memory, reset on reconnect), GET /api/messages (message history newest first: rx text with from and to, tx sends with to + status pending→delivered/failed via routing ACKs — broadcasts stay pending because firmware strips want_ack; last 100 in memory (seeded from SQLite on startup) — backed by SQLite: re-upserted every MESH_POLL_MS and pruned to the newest 1000 rows, so it survives process restarts and bridge reconnects), POST /api/messages (body { "text": "...", "from"?: <node number> }, returns { id }; synthetic inbox injection for external triggers — records an rx entry in the history as if received on the mesh (channel 0) so external systems can push messages to trigger communication later; NOT transmitted over RF, nothing goes to the bridge or air; appears in GET /api/messages like any other rx message), GET /api/forwarded (the last 100 rx texts that passed the notify gate and were forwarded to opencode, newest first as { id, from?, text, channel, at }[]: channel in the MESH_NOTIFY_CHANNEL list (ch0: directed DMs only), not a self-echo, notifier wired; id is the packet id for real rx or the generated record id for injected, from omitted when there is no sender; in-memory only, reset on restart), GET /api/telemetry (telemetry history newest first as { from, at /* epoch ms */, kind, data }[]: live TELEMETRY_APP packets — device metrics and environment sensor readings where nodes have them; kind is the meshtastic.Telemetry oneof case name (deviceMetrics/environmentMetrics/airQualityMetrics/powerMetrics/localStats/healthMetrics/hostMetrics/trafficManagementStats), data that variant's payload with proto uint64 fields as decimal strings; last 100, in-memory, reset on reconnect), GET /api/nodes (known nodes sorted by num as { num, longName?, shortName?, hwModel?, batteryLevel?, lastHeard: number|null, position?: { lat, lon, alt? }, snr?, hopsAway?, viaMqtt? }[], from the initial sync plus live POSITION_APP updates; reset on reconnect) and GET /api/nodes/:id (lookup by exact node number/numeric string, then case-insensitive exact long/short name, else unique substring; 404 when ambiguous or missing). Scalar API docs are served at / (OpenAPI JSON at /docs-json). Keep this list in sync with the controller. All POST /api/send traffic targets channel index 3 (test) on purpose — never the primary channel; override via MESH_SEND_CHANNEL (default 3). Message history is persisted to SQLite: file path from MESH_DB_PATH (default ./data/mesh-bot.db, parent dir auto-created), poller re-upserts every MESH_POLL_MS ms (default 1000) and prunes the table to the newest 1000 rows. Outbound notification: when an RX text arrives on a channel in the MESH_NOTIFY_CHANNEL comma-separated list (default 3; entries trimmed, empty/invalid ones ignored), the in-process OpencodeService notifier (src/opencode, registered in MeshModule) pushes it to opencode — fire-and-forget, no response handling. Channel 0 is the shared default channel where other users talk: only directed DMs (both sender and recipient present) notify there, ch0 broadcasts are ignored. It resolves the mesh-bot workspace dynamically per request from the opencode API (OPENCODE_API, default http://172.24.0.1:9898; matched by OPENCODE_WORKSPACE_PATH (default /home/krzyw/projects/sandbox/mesh-bot), fallback name mesh-bot, status ready preferred) and posts to its most recently updated session (time.updated) — or the pinned OPENCODE_SESSION when set, which skips discovery. The message POST body is exactly {"parts":[{"type":"text","text":msg}]} with msg = New mesh message (id <packetId>)[ from node <n>]: "<text>" — please react to it. (the from node part omitted when absent; the packet id is the rx packet's id, or the generated record id for injected messages), HTTP Basic auth via OPENCODE_USER/OPENCODE_PASS; empty credentials disable the notifier entirely (one startup log line, no-op). Every call has a 15 s timeout; a read timeout on the final message POST means DELIVERED (opencode accepts immediately but holds the response until the agent turn finishes) and is treated as success without retry, while workspace/session GET failures are retried up to 3 times with backoff (2 s / 10 s / 30 s) before being dropped. Injected messages (POST /api/messages, channel 0) trigger it too when 0 is in the list — injections ride ch0 by design and are exempt from the ch0 DM-only rule. Messages from our own node are never notified (self-echo filter).
  3. MCP endpoint — POST /mcp (excluded from the /api prefix in main.ts, like Scalar at /): an in-process MCP server over the official SDK's Streamable HTTP transport in stateless mode (sessionIdGenerator: undefined, fresh McpServer + transport per request). Tools map 1:1 to MeshService methods: mesh_status, send_message(text, to?, replyId?) (to resolves like POST /api/send; optional replyId integer in 1..0xffffffff makes the message render as a threaded reply/quote of that packet id from GET /api/messages or get_messages; errors surface as tool errors with the same messages), respond(messageId, content) (reply to an earlier message by packet id from get_messages; routing is automatic — rx → DM back to the sender unless the original was a broadcast, tx → its destination, no-sender injected records → broadcast; rides the record's channel with replyId = messageId so the recipient app renders it as a threaded quote; invalid args or an unknown id are tool errors), list_channels, list_nodes, get_node(id) (tool error when missing/ambiguous), get_messages, inject_message(text, from?) (synthetic inbox injection for external triggers — records an rx entry in the history as if received on the mesh; NOT sent over RF; same validation as POST /api/messages), get_telemetry (same bounded recent window as GET /api/telemetry), device_info (same redaction as GET /api/device). Results are JSON in a text content block; sends ride channel index 3 like everything else (respond rides the record's channel). Note: the SDK's root entry is broken upstream (#2273) — import via subpaths (@modelcontextprotocol/sdk/server/mcp.js etc.).

Other

  1. For longer terminal tasks you should use pty_* tools.
  2. Host npm (10.9.4) has an arborist crash bug ("edgesOut") — if plain npm misbehaves on installs, use npx npm@latest .... The lockfile must stay in sync with package.json: the Docker build runs npm ci.
  • MESHTASTIC.md — rules for Meshtastic communication (owner authority, language, response protocol); read before acting on mesh messages.
  • Hourly cron weather report job (src/weather): fetches Open-Meteo current conditions and broadcasts a one-line report to the mesh on the pinned send channel; env WEATHER_CRON/WEATHER_LAT/WEATHER_LON/WEATHER_CITY.
  • Channel-2 auto-responder (src/commands): event-driven — CommandService.onRx() is called by MeshService for every decoded rx text (right after the notify gate) and dispatches exact, case-sensitive #command matches on COMMAND_CHANNEL (env, default 2) through a single-registration-point registry (handlers registered in the constructor via register(name, fn); handler gets { text, from?, id? }, returns a reply string — or an array of lines, sent as one message per line through the same routing — or void = no reply). First command: #ping → replies #pong!. Replies go through MeshService.respond(id, reply): auto-routed (rx → DM back to the original sender unless the original was a broadcast; tx → its destination), riding the original record's channel with replyId = id so the recipient app renders it as a threaded quote — COMMAND_CHANNEL is only the trigger/listen gate. Only when the trigger has no packet id does it fall back to legacy sendText routing on COMMAND_CHANNEL. A failing handler/respond only logs a warn. DI: provided by CommandModule's factory (injects MeshService, attaches itself via attachCommands()) — no constructor-level cycle; forwardRef was tried and left one side null in NestJS 12's injector. Prefix dispatch (registerPrefix(name, fn)) lets one handler own a whole family: the #rss subcommands live in src/rss (RssService.handle, registered as prefix 'rss' from the CommandModule factory — exact matches still win over prefixes). Subcommands: #rss help; #rss add <url> <name> (validates URL, fetches with 15 s timeout, requires ≥1 titled item, rejects duplicate names case-insensitively; LLM generates 2-4 lowercase tags from up to 8 latest titles — on LLM failure the source is added with empty tags and the reply says so); #rss remove <name>; #rss tags (all distinct tags); #rss sources; #rss get <name|#tag> (LLM headline summary as 3-5 SEPARATE mesh messages: prompt asks for exactly 3-5 lines, one item per line, <short header>: <one or two short sentences> with ≤20-char header, no intro/outro/numbering; reply parsed into items — drop empty/separator-less lines, cap at 5, fewer than 3 → send what we got (no retry), zero → no message; each item sent via its own respond() threaded to the trigger packet id, per-line backstop fitOneLine() hard-cuts >185-char lines at a word boundary near 180 so every item fits one packet; maxTokens 2500 is headroom because the LLM server's reasoning tokens count against it — see rss.service.ts comment; #tag collects items across ALL sources carrying that tag — one dead feed does not sink the summary). Storage: rss_sources(name TEXT PRIMARY KEY, url, tags, created_at) table in the EXISTING MESH_DB_PATH file via a second node:sqlite connection (names matched case-insensitively); feed parsing via fast-xml-parser (RSS 2.0 + Atom; malformed/no-items → "not a valid feed"); LLM via injected LlmService.chat() with explicit maxTokens (50 tags / 2500 summary headroom).
  • LLM client (src/llm): LlmService wraps the local OpenAI-compatible server (default http://192.168.1.70:1111/v1, no auth) with native fetch — plain feature module, no Mesh/Schedule dependency. Methods: listModels() → data[].id; chat(messages, { maxTokens?, temperature? }) → choices[0].message.content (non-2xx throws with status + body snippet); summarize(text) = chat() with system prompt "Summarize the following text concisely." Env: LLM_API_BASE, LLM_MODEL (default qwen3.8-27b), LLM_TIMEOUT_MS (default 60000, via AbortSignal.timeout).