pi-lxmf 0.1.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,111 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2026-09-24
9
+
10
+ ### Added
11
+
12
+ - z.ai GLM quota watcher + peak-hours warning (`src/quota.js`, work doc #3):
13
+ when the active model is a z.ai GLM model (`provider === "zai"`), the
14
+ bridge watches the z.ai quota endpoint (`pi-glm-usage`'s) and delivers
15
+ exactly one LXMF message to the owner the moment the 5h quota bucket
16
+ recovers after a quota-exhausted run failure. Also warns the owner at
17
+ run start (and when the window opens mid-run) during z.ai peak hours
18
+ (Mon–Fri 14:00–18:00 SGT / UTC+8, 3× token cost). Entirely gated on
19
+ the active model being `zai` — nothing fires for Cortecs/Anthropic/etc.
20
+ A missing `zai.key` disables the watcher gracefully.
21
+
22
+ ### Fixed
23
+
24
+ - Pi session pointer is now scoped to the resolved `workdir` (work doc #4):
25
+ pointers live at `dataDir/sessions/<sha256(workdir)[:16]>.json` so
26
+ distinct repos keep distinct sessions. Previously the single
27
+ `dataDir/session` file was shared across every repo, so starting the
28
+ daemon in a different cwd resumed the previous repo's conversation.
29
+ One-time migration: the legacy `dataDir/session` is adopted for the
30
+ first workdir that reads it, then removed. Foundational to the
31
+ multi-repo `/cd` work (doc #2).
32
+
33
+ - Initial implementation of the `pi-lxmf` bridge: an LXMF ↔ Pi RPC daemon per
34
+ `SPEC.md`.
35
+ - `PiRpcClient` (`src/rpc.js`): spawns and supervises `pi --mode rpc`,
36
+ strict-JSONL framing (LF-only, `\r`-tolerant), id-correlated requests,
37
+ readiness probing, crash-restart with backoff and a crash-loop guard.
38
+ - Mesh side (`src/lxmf.js`): persistent Reticulum identity, shared-instance →
39
+ AutoInterface → TCP interface fallback, `LXMRouter` with periodic
40
+ announcing, optional propagation-node sync, chunked outbound delivery. A
41
+ `skipSharedInstance` config option bypasses the local rnsd shared instance
42
+ entirely in favour of own interfaces (AutoInterface + the `rnsHost`/
43
+ `rnsPort` TCP client) — for shared instances that do not forward routed,
44
+ multi-hop traffic to their local clients (observed on a Termux↔Columba
45
+ setup where the daemon's announces reached the mesh but every inbound link
46
+ request died at the rnsd, so senders never got delivery proofs). The
47
+ startup banner now also lists the attached interfaces.
48
+ - Bridge (`src/bridge.js`): owner-only access by configured Reticulum
49
+ identity hash (no first-contact pairing; derivation cross-validated
50
+ against @reticulum/core), serialized inbound pipeline, prompts (steer/follow-up mid-run), assistant
51
+ reply delivery with empty-tail recovery, extension-dialog auto-dismissal.
52
+ - Chat commands (`src/commands.js`): `/help`, `/status`, `/session`, `/new`,
53
+ `/name`, `/compact`, `/model`, `/think`, `/abort`, `/quit`, and the bare
54
+ `!` interrupt.
55
+ - Configuration and state (`src/config.js`): XDG-based config file with the
56
+ required `owner` identity hash, Pi session pointer persistence.
57
+ - End-to-end smoketest (`scripts/smoke.mjs` + `scripts/fake-pi.mjs`): runs
58
+ the real daemon against a fake `pi --mode rpc` and a second in-process
59
+ LXMF owner over a local rnsd shared instance.
60
+ - Inbound LXMF diagnostics (`attachInboundDiagnostics` in `src/lxmf.js`):
61
+ surfaces the inbound failure mode the bridge itself can't see — a
62
+ packet that decrypts but never dispatches. When the sender's identity
63
+ is unknown the router parks the message until an announce arrives (the
64
+ most common reason a sender sees its packet acknowledged but the bridge
65
+ never receives anything), and that is now logged; unparseable packets
66
+ are logged too. The peer-learn (`"peer"` event) log is filtered to the
67
+ configured owner identity only — routine mesh peers are no longer
68
+ logged.
69
+ Successfully-dispatched owner traffic is intentionally silent here —
70
+ the bridge logs its disposition (ignored / command / prompt) where the
71
+ decision is made. Ported from signalk-reticulum where this
72
+ instrumentation proved out the identity-parking failure mode.
73
+ - Outbound reply fallback: a failed reply over the arrival link is now
74
+ retried once more without the link (fresh DIRECT link, then
75
+ opportunistic packet). Battery-conscious mobile clients tear their
76
+ link down right after their own message is acknowledged, so the arrival
77
+ link is frequently gone by reply time. The same `LXMessage` object is
78
+ re-sent so every wire copy shares one message id and a deduplicating
79
+ client (Sideband, NomadNet) renders the reply once.
80
+ - Run-start acknowledgement via LXMF reaction: when a run starts and no
81
+ reply lands within a 2 s debounce window, the bridge sends a 🤔
82
+ reaction (LXMF `FIELD_REACTION`, §5.9.8) targeting the message that
83
+ triggered the run, so the owner sees their message acknowledged while
84
+ the agent works. A fast run whose reply beats the window sends no
85
+ extra message. The reaction is carried solely by the reaction field
86
+ (Sideband/NomadNet render it natively; no separate chat bubble is
87
+ emitted alongside it). The internal empty-reply recovery run is never
88
+ acknowledged. Added
89
+ `sendReaction(destinationHex, targetMessageId, emoji, opts)` to the
90
+ mesh adapter (`src/lxmf.js`), reusing the same retry path as
91
+ `sendText`.
92
+ - Fixed bzip2 compression provider (`src/bz2.js`): the wasm `compress()`
93
+ defaults its output buffer to the input length, so an LXMF reply that
94
+ compressed worse than its input (incompressible / small / already-
95
+ compressed data) threw `BZ_OUTBUFF_FULL` and failed delivery. The adapter
96
+ now sizes the output buffer with bzip2's documented worst-case headroom
97
+ (`input + 1% + 600` bytes).
98
+ - Security: the bridge now signature-verifies every inbound owner message
99
+ itself, closing a gap on the propagation-sync path. The router verifies
100
+ signatures on direct delivery, but a message pulled in via
101
+ `syncFromPropagationNode` whose sender identity is not yet recalled is
102
+ dispatched WITHOUT verification (mirroring Python's `SOURCE_UNKNOWN`
103
+ handling) — and the bridge's owner-hash check alone is forgeable there
104
+ (a 16-byte hash, no private key needed). The bridge now calls
105
+ `verifySender(message)` on the mesh adapter (recalls the sender identity
106
+ and checks the signature) and drops anything that isn't cryptographically
107
+ proven, including `unknown` (parked) results, so a synced message is only
108
+ admitted once the owner's identity has been learned. This makes the
109
+ SPEC.md §11 / README "signature-verified" claim hold for every path.
110
+ - GitHub Actions CI: tests (with lint and type checks) on every push, and
111
+ OIDC-based npm publishing on tag pushes (no registry token stored).
package/README.md ADDED
@@ -0,0 +1,202 @@
1
+ # pi-lxmf
2
+
3
+ Drive the [Pi](https://pi.dev) coding agent **entirely from an LXMF messaging
4
+ client** — [Sideband](https://github.com/markqvist/Sideband),
5
+ [Nomad Network](https://github.com/markqvist/NomadNet), or any other
6
+ [LXMF](https://github.com/markqvist/LXMF) peer — over the
7
+ [Reticulum](https://reticulum.network) mesh. Built on
8
+ [reticulum-js](https://reticulum.js.org/).
9
+
10
+ `pi-lxmf` is a small daemon for headless servers: your chat messages become
11
+ prompts, finished assistant replies are delivered back as chat messages, and
12
+ the usual Pi controls (`/new`, `/compact`, `/abort`, model and thinking
13
+ switches) work from chat. Exactly one configured owner — identified by their
14
+ **Reticulum identity hash** — controls the agent; everyone else is ignored.
15
+ Architecture and internals: [SPEC.md](SPEC.md).
16
+
17
+ ```text
18
+ Sideband / NomadNet ◄──LXMF──► pi-lxmf daemon ◄──RPC──► pi --mode rpc
19
+ (identity, announce, (supervised child,
20
+ session) restartable)
21
+ ```
22
+
23
+ ## Requirements
24
+
25
+ - Node.js ≥ 20
26
+ - `pi` on `PATH`, logged into a provider
27
+ - A Reticulum transport: a local `rnsd` (recommended), or AutoInterface
28
+ (IPv6 multicast), or an `rnsd` reachable over TCP
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ npm install -g pi-lxmf
34
+ # or from a checkout:
35
+ git clone … && cd pi-lxmf && npm install
36
+ node src/bin.js --help
37
+ ```
38
+
39
+ ## Quick start
40
+
41
+ 1. **Configure** — create `~/.config/pi-lxmf/config.json` (override with
42
+ `--config <path>` or `$PI_LXMF_CONFIG`) and `chmod 600` it:
43
+
44
+ ```json
45
+ {
46
+ "name": "pi on myserver",
47
+ "workdir": "/srv/myproject",
48
+ "model": "anthropic/claude-sonnet-4-5",
49
+ "owner": "<your Reticulum identity hash, 32 hex chars>"
50
+ }
51
+ ```
52
+
53
+ `owner` is **required** and takes your **Reticulum identity hash** — the
54
+ 32-hex identifier of your Ed25519 identity, *not* the `lxmf.delivery`
55
+ destination hash ("LXMF Address") your client displays. The identity hash
56
+ is protocol-agnostic (the same you across LXMF, Nomadnet, …) and is what a
57
+ future DACAR-style permission system would grant to; the daemon derives
58
+ the wire form internally.
59
+
60
+ 2. **Run the daemon** in the project directory (or set `workdir`):
61
+
62
+ ```bash
63
+ pi-lxmf
64
+ ```
65
+
66
+ The startup banner prints the node's identity and LXMF address:
67
+
68
+ ```text
69
+ pi-lxmf: pi on myserver starting
70
+ identity 6bd23ddae42b6ab1b90ef1ce62a41f31
71
+ lxmf 92b5be0e43370f01de77126b0eb11b53
72
+ announce pi on myserver
73
+ owner (identity) 3f9dd04e9216c0a3b8c7e5f1d2a60b44
74
+ pi-lxmf: ready — listening for LXMF messages
75
+ ```
76
+
77
+ 3. **Say hello** — in Sideband, start a conversation with the daemon's LXMF
78
+ address (scan the announce or enter the hash manually) and send a message.
79
+ Only the configured owner is answered; everyone else is dropped.
80
+
81
+ 4. **Trust the project once** — Pi's project trust cannot be prompted over
82
+ LXMF. Run `pi` interactively once in `workdir` (or preconfigure trust) so
83
+ project-local `.pi` resources load in RPC mode.
84
+
85
+ ## Chat commands
86
+
87
+ | You send | Action |
88
+ |---|---|
89
+ | plain text | a prompt to the agent (steered into a running turn by default) |
90
+ | `/help` | bridge commands + Pi commands available via prompt |
91
+ | `/status` | model, thinking, session, uptime, node + owner identity hashes |
92
+ | `/session` | message counts, tokens, cost, context usage |
93
+ | `/new` | fresh Pi session |
94
+ | `/name [name]` | show / set the session display name |
95
+ | `/compact [instructions]` | compact the conversation context |
96
+ | `/model [query]` | list models, or switch (`/model sonnet`) |
97
+ | `/think <level>` | set thinking level (`off`…`max`) |
98
+ | `/abort` | abort the run **and** drop queued messages |
99
+ | `!` | quick interrupt — abort only, queue intact |
100
+ | `/quit` | shut down the daemon and Pi |
101
+ | any other `/…` | passed to Pi (extension commands, `/skill:…`, templates) |
102
+
103
+ Replies are delivered per finished assistant message, chunked to fit
104
+ `chunkChars`. A run that ends without any reply triggers one recovery attempt,
105
+ then a `✅ done (no reply)` nudge. Extension dialogs raised inside Pi are
106
+ auto-declined (nobody is at a terminal) and reported to you.
107
+
108
+ ## Configuration
109
+
110
+ `~/.config/pi-lxmf/config.json` (XDG env vars respected). A missing file runs
111
+ on defaults.
112
+
113
+ | Field | Default | Meaning |
114
+ |---|---|---|
115
+ | `owner` | *(required)* | the owner's 32-hex **Reticulum identity hash** (not the LXMF address); the daemon derives the `lxmf.delivery` destination hash for wire comparison |
116
+ | `name` | `pi-lxmf <version>` | announce display name |
117
+ | `workdir` | daemon cwd | project directory Pi runs in (also where `AGENTS.md` is found) |
118
+ | `model` | Pi default | `--model` pattern passed to Pi |
119
+ | `piBin` | `pi` | Pi binary |
120
+ | `dataDir` | `~/.local/share/pi-lxmf` | state root (see below) |
121
+ | `rnsHost` / `rnsPort` | — | rnsd TCP interface, used when no local shared instance is found |
122
+ | `propagationNode` | — | `lxmf.propagation` hash for outbound submits + optional sync |
123
+ | `syncIntervalSec` | `0` (off) | pull messages from the propagation node on this cadence |
124
+ | `midRunBehavior` | `steer` | how prompts arriving mid-run are delivered: `steer` or `followUp` |
125
+ | `chunkChars` | `2500` | max characters per outbound LXMF message |
126
+ | `announceIntervalSec` | router default | re-announce cadence |
127
+
128
+ ### State (`dataDir`)
129
+
130
+ - `storage/` — the node's persistent Reticulum identity and caches. **This key
131
+ is the node's address; back it up and keep it secret.** Deleting it changes
132
+ your LXMF address.
133
+ - `session` — pointer to the current Pi session file. Sessions survive daemon
134
+ restarts (`pi --session`); `/new` starts a fresh one.
135
+
136
+ ### Mesh connectivity
137
+
138
+ The daemon attaches to a local `rnsd` shared instance when available (the
139
+ recommended setup — rnsd owns the radio/network interfaces), and otherwise
140
+ falls back to AutoInterface, optionally plus a TCP interface via
141
+ `rnsHost`/`rnsPort`. It announces itself periodically so peers can find it and
142
+ cached paths stay fresh.
143
+
144
+ If you configure a `propagationNode`, replies can be submitted for
145
+ store-and-forward delivery while you are unreachable, and `syncIntervalSec`
146
+ retrieves messages that arrived while the daemon was down.
147
+
148
+ ## Security
149
+
150
+ - Every inbound owner message is **signature-verified by the bridge**
151
+ (recalling the sender identity and checking the LXMF signature) before
152
+ it is processed; only messages from the configured owner's identity get
153
+ through (matched via the derived `lxmf.delivery` destination hash),
154
+ everyone else is dropped silently — the daemon never answers strangers.
155
+ The router itself only verifies signatures on direct delivery, so the
156
+ bridge re-checks every path (including propagation-node sync, where the
157
+ router dispatches unverified when the sender identity is unknown) — the
158
+ owner source hash alone is forgeable, so the cryptographic proof is what
159
+ actually authenticates the sender.
160
+ - The owner is **configured, never learned**: there is no first-contact
161
+ pairing, so a stray message can never seize control.
162
+ - Access is keyed by **identity hash**, so it can later be delegated to a
163
+ DACAR-style permission system without reconfiguration.
164
+ - The bridge never executes chat text locally — everything goes through Pi's
165
+ RPC protocol. Approval-gated tools stay gated (dialogs are declined).
166
+ - `/quit` and full agent control are available to the owner only.
167
+
168
+ ## Running as a service
169
+
170
+ Example systemd unit (adjust paths):
171
+
172
+ ```ini
173
+ [Unit]
174
+ Description=pi-lxmf bridge
175
+ After=network-online.target rnsd.service
176
+
177
+ [Service]
178
+ ExecStart=/usr/bin/pi-lxmf --config /etc/pi-lxmf/config.json
179
+ Restart=on-failure
180
+ User=pi
181
+
182
+ [Install]
183
+ WantedBy=multi-user.target
184
+ ```
185
+
186
+ The daemon supervises Pi itself: an unexpected Pi exit triggers a restart
187
+ (with the session resumed) and is reported to you over LXMF; a crash loop
188
+ gives up and says so.
189
+
190
+ ## Development
191
+
192
+ ```bash
193
+ npm test # unit tests (node --test)
194
+ npm run types # tsc --noEmit over the JSDoc-typed sources
195
+ npm run lint # biome
196
+ npm run smoke # end-to-end against a local rnsd: daemon + fake pi +
197
+ # a second in-process LXMF node as the owner
198
+ ```
199
+
200
+ ## License
201
+
202
+ EUPL-1.2