CI / build + unit (pull_request) Successful in 1m20s
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.
14 KiB
14 KiB
MeshBot — Workflow Rules
Change process (mandatory)
- 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).
- 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 tomain. - Changelog entry — every such change is listed in
CHANGELOG.mdwith date, time and version (format:## v<version> — YYYY-MM-DD HH:MM, then bullet points of what changed). - Version bump — the version is updated in
package.json. - Verification must pass — there is no CI pipeline yet; before opening a PR both local checks must be green:
npm run buildandnpm test(vitest unit suite). - 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 --buildon the host.
Verification
- Local checks —
npm run build(nest build) andnpm test(vitest; no e2e suite yet). Both must pass before a PR is opened. - Docker —
docker compose build, then afterdocker compose up -d:curl -s -H "Host: bot.dev.kkhost.pl" http://localhost/api/statusreturns JSON ({ connected, myNodeNum, host, port })."connected":falseis normal while the bridge/device is down — the service retries every 5 s.
API
- 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). - Endpoints — all under the
/apiglobal 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 likeGET /api/nodes/:id, omitted/""/"!b"/"broadcast" → broadcast; unknown or ambiguous targets are a 400. OptionalreplyId(integer in 1..0xffffffff, else 400) references an earlier message's packet id fromGET /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 carriesreplyId = messageIdso 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 withfromandto, tx sends withto+statuspending→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 inGET /api/messageslike 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,fromomitted 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) andGET /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. AllPOST /api/sendtraffic targets channel index 3 (test) on purpose — never the primary channel; override viaMESH_SEND_CHANNEL(default3). Message history is persisted to SQLite: file path fromMESH_DB_PATH(default./data/mesh-bot.db, parent dir auto-created), poller re-upserts everyMESH_POLL_MSms (default 1000) and prunes the table to the newest 1000 rows. Outbound notification: when an RX text arrives on a channel in theMESH_NOTIFY_CHANNELcomma-separated list (default3; entries trimmed, empty/invalid ones ignored), the in-processOpencodeServicenotifier (src/opencode, registered inMeshModule) 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, defaulthttp://172.24.0.1:9898; matched byOPENCODE_WORKSPACE_PATH(default/home/krzyw/projects/sandbox/mesh-bot), fallback namemesh-bot, statusreadypreferred) and posts to its most recently updated session (time.updated) — or the pinnedOPENCODE_SESSIONwhen 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.(thefrom nodepart omitted when absent; the packet id is the rx packet's id, or the generated record id for injected messages), HTTP Basic auth viaOPENCODE_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 when0is 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). - MCP endpoint —
POST /mcp(excluded from the/apiprefix in main.ts, like Scalar at/): an in-process MCP server over the official SDK's Streamable HTTP transport in stateless mode (sessionIdGenerator: undefined, freshMcpServer+ transport per request). Tools map 1:1 to MeshService methods:mesh_status,send_message(text, to?, replyId?)(toresolves likePOST /api/send; optionalreplyIdinteger in 1..0xffffffff makes the message render as a threaded reply/quote of that packet id fromGET /api/messagesorget_messages; errors surface as tool errors with the same messages),respond(messageId, content)(reply to an earlier message by packet id fromget_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 asPOST /api/messages),get_telemetry(same bounded recent window asGET /api/telemetry),device_info(same redaction asGET /api/device). Results are JSON in a text content block; sends ride channel index 3 like everything else (respondrides the record's channel). Note: the SDK's root entry is broken upstream (#2273) — import via subpaths (@modelcontextprotocol/sdk/server/mcp.jsetc.).
Other
- For longer terminal tasks you should use pty_* tools.
- Host npm (10.9.4) has an arborist crash bug ("edgesOut") — if plain
npmmisbehaves on installs, usenpx npm@latest .... The lockfile must stay in sync with package.json: the Docker build runsnpm 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; envWEATHER_CRON/WEATHER_LAT/WEATHER_LON/WEATHER_CITY. - Channel-2 auto-responder (
src/commands): event-driven —CommandService.onRx()is called byMeshServicefor every decoded rx text (right after the notify gate) and dispatches exact, case-sensitive#commandmatches onCOMMAND_CHANNEL(env, default 2) through a single-registration-point registry (handlers registered in the constructor viaregister(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 throughMeshService.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 withreplyId = idso the recipient app renders it as a threaded quote —COMMAND_CHANNELis only the trigger/listen gate. Only when the trigger has no packet id does it fall back to legacy sendText routing onCOMMAND_CHANNEL. A failing handler/respond only logs a warn. DI: provided byCommandModule's factory (injectsMeshService, attaches itself viaattachCommands()) — no constructor-level cycle;forwardRefwas tried and left one side null in NestJS 12's injector. Prefix dispatch (registerPrefix(name, fn)) lets one handler own a whole family: the#rsssubcommands live insrc/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 backstopfitOneLine()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;#tagcollects 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 viafast-xml-parser(RSS 2.0 + Atom; malformed/no-items → "not a valid feed"); LLM via injectedLlmService.chat()with explicit maxTokens (50 tags / 2500 summary headroom). - LLM client (
src/llm):LlmServicewraps the local OpenAI-compatible server (defaulthttp://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(defaultqwen3.8-27b),LLM_TIMEOUT_MS(default 60000, viaAbortSignal.timeout).