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 +111 -0
- package/README.md +202 -0
- package/SPEC.md +469 -0
- package/package.json +52 -0
- package/src/bin.js +220 -0
- package/src/bridge.js +622 -0
- package/src/bz2.js +46 -0
- package/src/commands.js +288 -0
- package/src/config.js +393 -0
- package/src/identity.js +93 -0
- package/src/lxmf.js +380 -0
- package/src/quota.js +512 -0
- package/src/rpc.js +627 -0
- package/src/text.js +97 -0
package/SPEC.md
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
# pi-lxmf — Driving Pi over LXMF: Implementation Specification
|
|
2
|
+
|
|
3
|
+
## 1. Overview & objectives
|
|
4
|
+
|
|
5
|
+
`pi-lxmf` is a bridge that lets a human drive a [Pi](https://pi.dev) coding
|
|
6
|
+
agent running on a headless server entirely from an LXMF messaging client
|
|
7
|
+
(Sideband, Nomad Network, or any `@reticulum/lxmf` peer) over the Reticulum
|
|
8
|
+
Network Stack.
|
|
9
|
+
|
|
10
|
+
The design goal is the experience of
|
|
11
|
+
[pi-msg](https://github.com/zachpmanson/pi-msg) (which bridges Pi to XMPP):
|
|
12
|
+
chat messages become prompts, finished assistant replies are delivered back
|
|
13
|
+
as chat messages, and the usual Pi controls (`/new`, `/compact`, `/abort`,
|
|
14
|
+
model/thinking switches, session stats) work over chat without anyone at a
|
|
15
|
+
terminal. The LXMF side is built on [reticulum-js](https://reticulum.js.org/)
|
|
16
|
+
(`@reticulum/lxmf`), wire-compatible with the Python LXMF reference, so any
|
|
17
|
+
existing LXMF client can drive the agent with no server-side changes beyond
|
|
18
|
+
running one daemon.
|
|
19
|
+
|
|
20
|
+
Objectives, in priority order:
|
|
21
|
+
|
|
22
|
+
1. **Single owner, full control.** One configured owner — identified by
|
|
23
|
+
their Reticulum identity hash — drives the agent; everyone else is
|
|
24
|
+
ignored. Plain text is a prompt,
|
|
25
|
+
slash commands map to Pi controls, and replies always reach the owner.
|
|
26
|
+
2. **Robust asynchronous operation.** LXMF is store-and-forward and mesh
|
|
27
|
+
transport is slow and lossy; the bridge never assumes a fast round trip.
|
|
28
|
+
Long replies are chunked, runs may take many minutes, and a restart
|
|
29
|
+
resumes the previous Pi session.
|
|
30
|
+
3. **No terminal required.** Nothing in the operation of the bridge assumes
|
|
31
|
+
a TUI. Extension dialogs raised inside Pi are auto-declined and reported
|
|
32
|
+
over LXMF.
|
|
33
|
+
4. **Plain Node.js, small surface.** ESM JavaScript with JSDoc types,
|
|
34
|
+
`node --test` tests, no build step — matching the conventions of
|
|
35
|
+
`pi-rngit-work-document-skill` and the reticulum-js packages.
|
|
36
|
+
|
|
37
|
+
Non-goals for the initial version: group conversations (LXMF has no MUC),
|
|
38
|
+
file attachments in either direction, streaming/preview of in-flight turns,
|
|
39
|
+
and acting as an LXMF propagation node. These are covered in §13.
|
|
40
|
+
|
|
41
|
+
## 2. Prior art and reference material
|
|
42
|
+
|
|
43
|
+
| Reference | What is taken from it |
|
|
44
|
+
|---|---|
|
|
45
|
+
| [pi-msg](https://github.com/zachpmanson/pi-msg) | Overall architecture: a bridge daemon spawns `pi --mode rpc` and translates between chat and Pi's JSONL RPC protocol. Command surface (`/new`, `/abort` vs `!`, `/model`, `/session`, …), session persistence across restarts, empty-tail reply recovery, auto-dismissing extension dialogs. |
|
|
46
|
+
| [@llblab/pi-telegram](https://pi.dev/packages/@llblab/pi-telegram) | The JS/Pi-package conventions studied for this spec: config under the agent/home directory, queue-instead-of-interrupt handling of messages that arrive mid-run. (Its first-contact pairing was deliberately *not* adopted — see §6.1.) |
|
|
47
|
+
| `reticulum-js` (`@reticulum/core`, `@reticulum/lxmf`, `@reticulum/node`) | The entire LXMF transport: `LXMRouter`, `LXMessage`, interface setup (shared instance → AutoInterface → TCP fallback), announce app-data conventions, identity persistence. See `../reticulum-js/examples/lxmf_echobot.js` and `lxmf_sender.js` for the canonical wiring. |
|
|
48
|
+
| `pi-rngit-work-document-skill` | Project conventions: ESM + JSDoc, Biome with `--use-editorconfig=true`, `node --test`, EUPL-1.2, `@reticulum/*` dependency set, `createBz2()` adapter for Resource compression. |
|
|
49
|
+
| Pi RPC protocol (`docs/rpc.md` of `@earendil-works/pi-coding-agent`) | The exact command/event vocabulary used below. |
|
|
50
|
+
|
|
51
|
+
## 3. Architecture
|
|
52
|
+
|
|
53
|
+
```text
|
|
54
|
+
┌──────────────┐ LXMF (signed, encrypted, ┌─────────────────────────────┐
|
|
55
|
+
│ Owner's │ store-and-forward over │ pi-lxmf bridge daemon │
|
|
56
|
+
│ LXMF client │ the Reticulum mesh) │ (Node.js, this package) │
|
|
57
|
+
│ (Sideband / │ ◄─────────────────────────► │ │
|
|
58
|
+
│ NomadNet) │ │ LXMRouter ◄──┐ │
|
|
59
|
+
└──────────────┘ │ │ spawn/JSONL│
|
|
60
|
+
│ PiRpcClient ──┴── pi --mode rpc
|
|
61
|
+
└─────────────────────────────┘
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Two processes, deliberately:
|
|
65
|
+
|
|
66
|
+
- **The bridge daemon** (`pi-lxmf` bin) owns everything long-lived and
|
|
67
|
+
precious: the Reticulum identity (the node's LXMF address), the mesh
|
|
68
|
+
interfaces, the announce loop, and the session pointer.
|
|
69
|
+
It must survive Pi crashes and Pi upgrades without changing identity.
|
|
70
|
+
- **`pi --mode rpc`** is a child process, disposable and restartable. All
|
|
71
|
+
agent control flows through its documented RPC protocol (JSON commands on
|
|
72
|
+
stdin, JSONL events on stdout), so the bridge depends only on Pi's stable
|
|
73
|
+
RPC surface — not on extension-internal APIs.
|
|
74
|
+
|
|
75
|
+
### 3.1 Why not an in-process Pi extension
|
|
76
|
+
|
|
77
|
+
An earlier pi-msg revision ran the bridge as an in-process extension and was
|
|
78
|
+
rebuilt on RPC mode because `sendUserMessage` cannot reach Pi's command
|
|
79
|
+
layer; the same trade-off applies here. Additionally:
|
|
80
|
+
|
|
81
|
+
- RPC mode exits when its stdin closes, so a long-running headless
|
|
82
|
+
deployment needs a supervisor holding the pipes anyway — the daemon *is*
|
|
83
|
+
that supervisor, and putting the bridge logic there removes the
|
|
84
|
+
indirection.
|
|
85
|
+
- Session lifecycle commands (`new_session`, `compact`, `set_model`,
|
|
86
|
+
`export_html`, …) are first-class RPC commands, keeping the bridge out of
|
|
87
|
+
the extension API churn.
|
|
88
|
+
- A Pi crash must not take the mesh identity down with it; the daemon
|
|
89
|
+
restarts Pi and resumes the session file.
|
|
90
|
+
|
|
91
|
+
A small companion Pi extension remains a future option for agent-side
|
|
92
|
+
*tools* (e.g. proactive file sends, §13); it is not needed to drive the
|
|
93
|
+
agent.
|
|
94
|
+
|
|
95
|
+
## 4. Process management (PiRpcClient)
|
|
96
|
+
|
|
97
|
+
`src/rpc.js` implements `PiRpcClient`, a spawn-and-talk client for
|
|
98
|
+
`pi --mode rpc`:
|
|
99
|
+
|
|
100
|
+
- **Spawn:** `pi --mode rpc` with `cwd` = configured `workdir`, plus
|
|
101
|
+
`--model <pattern>` when configured and `--session <file>` when a saved
|
|
102
|
+
session pointer exists and the file is non-empty. The daemon's
|
|
103
|
+
environment is inherited.
|
|
104
|
+
- **Framing:** Pi's RPC protocol is strict JSONL with `\n` as the only
|
|
105
|
+
record delimiter. Node's `readline` is **not** protocol-compliant (it
|
|
106
|
+
also splits on U+2028/U+2029, which are valid inside JSON strings), so
|
|
107
|
+
`rpc.js` implements its own line reader: a `StringDecoder`-backed buffer
|
|
108
|
+
split on `\n`, tolerating a trailing `\r`. The same reader is reused for
|
|
109
|
+
stderr logging.
|
|
110
|
+
- **Correlation:** every command is sent with a generated `id`; `response`
|
|
111
|
+
events are matched by `id` and resolve a pending promise (with timeout).
|
|
112
|
+
Non-response events are forwarded to the bridge through an `onEvent`
|
|
113
|
+
callback. Lines are bounded (8 MiB) since assistant/tool payloads can be
|
|
114
|
+
large.
|
|
115
|
+
- **Readiness:** RPC mode accepts commands once its stdin reader is
|
|
116
|
+
attached; the daemon probes with `get_state` (retries with backoff,
|
|
117
|
+
~10 s budget) before considering Pi ready.
|
|
118
|
+
- **Streaming state:** the daemon tracks `agent_start` / `agent_settled`
|
|
119
|
+
(plus `compaction_*`, `auto_retry_*`) to maintain a *busy* hint, but
|
|
120
|
+
treats `get_state` → `isStreaming` as authoritative before every prompt
|
|
121
|
+
(see §6.2).
|
|
122
|
+
- **Restart:** if Pi exits unintentionally (not a `/quit`), the daemon
|
|
123
|
+
respawns it after a short backoff, re-applies `--session` from the last
|
|
124
|
+
persisted pointer, and notifies the owner over LXMF. Repeated crashes
|
|
125
|
+
(e.g. 3 within a minute) stop the respawn loop and report to the owner.
|
|
126
|
+
- **Shutdown:** SIGINT/SIGTERM or `/quit` → best-effort `get_state` to
|
|
127
|
+
persist the session pointer, SIGINT to Pi (hard kill after 3 s), stop
|
|
128
|
+
announcing, exit.
|
|
129
|
+
|
|
130
|
+
## 5. LXMF subsystem
|
|
131
|
+
|
|
132
|
+
`src/lxmf.js` owns the mesh side, wired like the reticulum-js `lxmf_echobot`
|
|
133
|
+
example:
|
|
134
|
+
|
|
135
|
+
1. **Storage & identity.** A `FileStorageAdapter` rooted at
|
|
136
|
+
`<dataDir>/storage` persists the Ed25519 identity (and known
|
|
137
|
+
destinations/ratchets) across restarts. `Identity.loadOrGenerate` loads
|
|
138
|
+
or creates it. This identity *is* the node's stable LXMF address.
|
|
139
|
+
2. **Compression.** A `createBz2()` adapter (bzip2-wasm, as in
|
|
140
|
+
`pi-rngit-work-document-skill`) is installed as the Reticulum
|
|
141
|
+
`compressionProvider` so oversized replies transferred as §10 Resources
|
|
142
|
+
are compressed.
|
|
143
|
+
3. **Interfaces.** Prefer `LocalClientInterface.connectToSharedInstance()`
|
|
144
|
+
(attach to a running `rnsd`, which owns the real mesh interfaces). When
|
|
145
|
+
no shared instance is reachable, fall back to `AutoInterface`, plus a
|
|
146
|
+
`TCPClientInterface` when `rnsHost`/`rnsPort` are configured.
|
|
147
|
+
4. **Router.** `new LXMRouter(identity, rns)` + `await lxmf.init()`
|
|
148
|
+
registers the `lxmf.delivery` destination (with forward-secrecy
|
|
149
|
+
ratchets, per the router's own init).
|
|
150
|
+
5. **Announcing.** `startAnnouncing(name)` with `name` from config
|
|
151
|
+
(default `pi-lxmf <version>`) fires an immediate announce and re-announces
|
|
152
|
+
on the default cadence so cached mesh paths stay fresh; the §4.3
|
|
153
|
+
msgpack app-data makes Sideband display the name.
|
|
154
|
+
6. **Receiving.** `message` events carry a signature-verified `LXMessage`
|
|
155
|
+
(`detail.message`) and the link id (`detail.link`). The router already
|
|
156
|
+
deduplicates by LXMF message id within a process lifetime. Note: the
|
|
157
|
+
router only verifies signatures on the direct-delivery path — a message
|
|
158
|
+
pulled in via propagation sync (step 8) whose sender identity is not yet
|
|
159
|
+
recalled is dispatched unverified, so the bridge re-verifies every
|
|
160
|
+
inbound owner message itself (see §11).
|
|
161
|
+
7. **Sending.** Replies are `LXMessage`s from our `deliveryDest` to the
|
|
162
|
+
owner's LXMF address (their `lxmf.delivery` destination hash), sent with
|
|
163
|
+
`lxmf.send(reply, identity, link)` — reusing the inbound link when one
|
|
164
|
+
exists. The router handles direct-link
|
|
165
|
+
delivery (Resources for bodies over the link MDU) and falls back to
|
|
166
|
+
opportunistic delivery when no link can be established.
|
|
167
|
+
8. **Propagation (optional).** When `propagationNode` is configured (an
|
|
168
|
+
`lxmf.propagation` destination hash), it is set as the outbound node
|
|
169
|
+
(`setOutboundPropagationNode`) and, when `syncIntervalSec` > 0, the
|
|
170
|
+
daemon periodically calls `syncFromPropagationNode(identity)` so
|
|
171
|
+
messages that arrived while the daemon was down are still delivered.
|
|
172
|
+
|
|
173
|
+
### 5.1 Outbound message shape
|
|
174
|
+
|
|
175
|
+
- **Title:** the current session display name (from `get_state`), else the
|
|
176
|
+
configured announce name — only on the first chunk of a reply.
|
|
177
|
+
- **Chunking:** content longer than `chunkChars` (default 2500) is split on
|
|
178
|
+
paragraph boundaries where possible, each chunk suffixed
|
|
179
|
+
`[… n/N]` except the last. Chunking keeps single LXMF messages
|
|
180
|
+
reasonable for phone UIs and for mesh airtime.
|
|
181
|
+
- **Errors:** failures to deliver a reply are logged and retried once;
|
|
182
|
+
persistent failure is reported in the next successful message (LXMF has
|
|
183
|
+
no channel over which to report its own failure).
|
|
184
|
+
|
|
185
|
+
## 6. Bridge semantics
|
|
186
|
+
|
|
187
|
+
`src/bridge.js` connects the two subsystems. All inbound LXMF handling is
|
|
188
|
+
serialized through a single promise chain so prompts keep their order.
|
|
189
|
+
|
|
190
|
+
### 6.1 Owner model
|
|
191
|
+
|
|
192
|
+
- The controlling owner is **configured, never learned**: the required
|
|
193
|
+
`owner` config field holds their **Reticulum identity hash** (32 hex) —
|
|
194
|
+
the protocol-agnostic identifier of their Ed25519 identity, not the
|
|
195
|
+
`lxmf.delivery` destination hash ("LXMF Address") the wire carries. The
|
|
196
|
+
identity → destination-hash derivation (`src/identity.js`, proven against
|
|
197
|
+
`Destination.IN` in tests) expands it to the wire form, and inbound
|
|
198
|
+
messages whose LXMF source hash differs are dropped (logged at debug). No
|
|
199
|
+
reply is sent to non-owners — the bridge must not acknowledge its existence
|
|
200
|
+
to strangers.
|
|
201
|
+
- Rationale: one identity can host many destinations (`lxmf.delivery`,
|
|
202
|
+
`nomadnetwork.node`, propagation nodes, …), so identity-keyed access is
|
|
203
|
+
stable across protocols and maps directly onto future DACAR-based
|
|
204
|
+
permission management (§13) — grants are made to identities. The same
|
|
205
|
+
lesson was learned in `../signalk-reticulum`, which migrated crew entries
|
|
206
|
+
from destination hashes to identity hashes.
|
|
207
|
+
- There is deliberately **no first-contact pairing**: a stray message can
|
|
208
|
+
never seize control.
|
|
209
|
+
|
|
210
|
+
### 6.2 Inbound pipeline
|
|
211
|
+
|
|
212
|
+
For each accepted message:
|
|
213
|
+
|
|
214
|
+
1. Strip whitespace; ignore empty content.
|
|
215
|
+
2. If the text is a **bridge command** (§7), execute it locally — these
|
|
216
|
+
never reach the LLM.
|
|
217
|
+
3. Otherwise the text is a **prompt**: query `get_state` for authoritative
|
|
218
|
+
`isStreaming`, then send a `prompt` RPC command (opening the reply
|
|
219
|
+
exchange optimistically, before the write):
|
|
220
|
+
- idle → `{"type":"prompt","message":…}` (no `streamingBehavior`);
|
|
221
|
+
- streaming → `streamingBehavior` from `midRunBehavior` config
|
|
222
|
+
(`"steer"` default, matching pi-msg: mid-run chat messages are
|
|
223
|
+
injected at the next yield point; `"followUp"` queues them instead).
|
|
224
|
+
- If Pi rejects the prompt because a run started in between, retry once
|
|
225
|
+
with `streamingBehavior` set; a final rejection closes the exchange
|
|
226
|
+
again and is reported to the owner.
|
|
227
|
+
- Slash-prefixed text that is *not* a bridge command is passed through
|
|
228
|
+
unchanged: Pi's `prompt` dispatches extension commands immediately
|
|
229
|
+
(even mid-run) and expands `/skill:…` and prompt templates itself.
|
|
230
|
+
Unknown `/…` text simply becomes a prompt, as in pi-msg.
|
|
231
|
+
4. Increment the *pending reply* counter and, on success, remember the
|
|
232
|
+
message's link id as the preferred reply channel.
|
|
233
|
+
|
|
234
|
+
### 6.3 Reply delivery
|
|
235
|
+
|
|
236
|
+
- `message_end` events with `role === "assistant"` are delivered as their
|
|
237
|
+
own LXMF message whenever an LXMF-triggered exchange is active — pi-msg's
|
|
238
|
+
model. Delivering each finalized message (rather than only the last one at
|
|
239
|
+
settle time) means intermediate replies are never lost when several owner
|
|
240
|
+
messages were queued mid-run; commentary blocks that precede tool calls
|
|
241
|
+
also give the owner progress visibility on a channel where nothing else
|
|
242
|
+
would. An exchange is opened **optimistically at prompt-write time**: pi can
|
|
243
|
+
emit the acceptance response and the run's events in the same stdout
|
|
244
|
+
chunk, and the response promise only resolves on a later microtask, so
|
|
245
|
+
events handled in between must already count towards the exchange (a race
|
|
246
|
+
proven by the smoketest's fake pi, which answers in a single chunk).
|
|
247
|
+
- On `agent_settled` the exchange closes; if nothing at all was delivered
|
|
248
|
+
for it:
|
|
249
|
+
- a recorded run failure (`auto_retry_end`/`compaction_end` error) is
|
|
250
|
+
reported as `⚠️ run failed: …`;
|
|
251
|
+
- otherwise **empty-tail recovery** runs once per exchange: a `prompt`
|
|
252
|
+
asking the agent to write the reply it never wrote (a run that ends on a
|
|
253
|
+
tool call produced no text; pi-msg's "empty-tail recovery"), opened with
|
|
254
|
+
the same optimistic exchange semantics; if that recovery run *also*
|
|
255
|
+
settles without text, the `✅ done (no reply) — your turn` nudge is sent
|
|
256
|
+
instead of looping.
|
|
257
|
+
- Retries, compaction, and queued follow-ups all precede `agent_settled`,
|
|
258
|
+
so the no-reply nudge only fires after Pi truly stops.
|
|
259
|
+
- Runs not triggered by LXMF (there is no local user in the intended
|
|
260
|
+
deployment, but scheduled extensions could start runs) do not open an
|
|
261
|
+
exchange; their output is not mirrored to the owner in v1.
|
|
262
|
+
|
|
263
|
+
### 6.4 Extension UI requests
|
|
264
|
+
|
|
265
|
+
`extension_ui_request` events (`select`/`confirm`/`input`/`editor`) are
|
|
266
|
+
answered with `{"type":"extension_ui_response","id":…,"cancelled":true}`
|
|
267
|
+
(nobody is at a TUI; approval-gated tools are declined over the bridge),
|
|
268
|
+
and the owner is informed: `⛔ dialog dismissed: <title>`. Fire-and-forget
|
|
269
|
+
UI methods (`notify`, `setStatus`, `setWidget`, …) are ignored.
|
|
270
|
+
|
|
271
|
+
### 6.5 z.ai GLM quota watcher and peak-hours warning
|
|
272
|
+
|
|
273
|
+
When the active model is a z.ai GLM model (`provider === "zai"`), the bridge
|
|
274
|
+
runs a `GlmQuotaWatcher` (`src/quota.js`) that does two things, both gated
|
|
275
|
+
on the active model being GLM — nothing fires for non-z.ai providers (e.g.
|
|
276
|
+
Cortecs, Anthropic):
|
|
277
|
+
|
|
278
|
+
- **Quota-recovery notification.** When a run fails with a z.ai
|
|
279
|
+
quota-exhausted error (matched from `auto_retry_end`/`compaction_end`
|
|
280
|
+
error messages), the watcher polls the same z.ai quota endpoint
|
|
281
|
+
`pi-glm-usage` uses (`https://api.z.ai/api/monitor/usage/quota/limit`,
|
|
282
|
+
Bearer `~/.pi/agent/auth.json` → `zai.key`, honouring `PI_AUTH_DIR`) every
|
|
283
|
+
60s and delivers **exactly one** LXMF message to the owner the moment
|
|
284
|
+
the 5h bucket drops below 100%. One notification per exhausted episode;
|
|
285
|
+
rate-limit-only errors (transient, retried by Pi) do not arm it. A
|
|
286
|
+
missing `zai.key` disables the watcher gracefully (logged once).
|
|
287
|
+
- **Peak-hours warning.** z.ai charges 3× tokens Mon–Fri 14:00–18:00
|
|
288
|
+
Singapore Standard Time (UTC+8). The owner is warned when an
|
|
289
|
+
owner-triggered run starts inside that window, and when the window
|
|
290
|
+
opens mid-run, so they can decide whether to stop or continue. The
|
|
291
|
+
internal empty-reply recovery run is never warned.
|
|
292
|
+
|
|
293
|
+
Notifications and warnings go to the configured `owner`; under future
|
|
294
|
+
DACAR per-subtree identity ACLs (§13) the "who to notify" decision stays
|
|
295
|
+
in one place so it can be retargeted per subtree.
|
|
296
|
+
|
|
297
|
+
## 7. Chat command reference
|
|
298
|
+
|
|
299
|
+
| Owner sends | Action | LLM turn |
|
|
300
|
+
|---|---|---|
|
|
301
|
+
| plain text | `prompt` (steered/queued mid-run per config) | yes |
|
|
302
|
+
| `/help` | list bridge commands and available Pi commands (`get_commands`) | no |
|
|
303
|
+
| `/status` | model, thinking level, busy state, session name/file, bridge uptime, node identity hashes | no |
|
|
304
|
+
| `/session` | `get_session_stats` — message counts, tokens, cost, context usage | no |
|
|
305
|
+
| `/new` | `new_session`, persist the new session pointer | no |
|
|
306
|
+
| `/name [name]` | `set_session_name`, or show current name | no |
|
|
307
|
+
| `/compact [instructions]` | `compact` (custom instructions appended) | summarizer only |
|
|
308
|
+
| `/model [query]` | no arg: list models (`get_available_models`, current marked); with arg: fuzzy-match `provider/id` or name, then `set_model` | no |
|
|
309
|
+
| `/think <level>` | `set_thinking_level` | no |
|
|
310
|
+
| `/abort` (or `/stop`) | `clear_queue` (tolerated if unknown) then `abort`; reports how many queued messages were dropped | no |
|
|
311
|
+
| `!` (bare) | `abort` only — quick interrupt, queue intact | no |
|
|
312
|
+
| `/quit` | graceful bridge shutdown (Pi and daemon) | no |
|
|
313
|
+
| any other `/…` | passed to Pi `prompt` (extension commands, `/skill:…`, templates) | maybe |
|
|
314
|
+
|
|
315
|
+
Command parsing: first whitespace-separated token, case-insensitive,
|
|
316
|
+
leading `!` equivalent to `/` for bridge commands (a bare `!` is the
|
|
317
|
+
interrupt). Commands are recognized only from the owner.
|
|
318
|
+
|
|
319
|
+
## 8. Configuration and state
|
|
320
|
+
|
|
321
|
+
**Config file** — first of: `--config <path>` flag, `$PI_LXMF_CONFIG`,
|
|
322
|
+
`$XDG_CONFIG_HOME/pi-lxmf/config.json`, `~/.config/pi-lxmf/config.json`.
|
|
323
|
+
JSON, `0600`, unknown keys rejected with a warning.
|
|
324
|
+
|
|
325
|
+
| Field | Type | Default | Meaning |
|
|
326
|
+
|---|---|---|---|
|
|
327
|
+
| `owner` | string | *(required)* | the owner's 32-hex **Reticulum identity hash** (not the LXMF address); expanded to the `lxmf.delivery` destination hash for wire comparison |
|
|
328
|
+
| `name` | string | `pi-lxmf <version>` | announce display name |
|
|
329
|
+
| `workdir` | string | daemon cwd | project directory Pi runs in (also where Pi discovers `AGENTS.md`) |
|
|
330
|
+
| `model` | string | Pi default | `--model` pattern passed to Pi |
|
|
331
|
+
| `piBin` | string | `pi` | Pi binary |
|
|
332
|
+
| `dataDir` | string | `$XDG_DATA_HOME/pi-lxmf` (`~/.local/share/pi-lxmf`) | state root (see below) |
|
|
333
|
+
| `rnsHost` / `rnsPort` | string / number | — | rnsd TCP interface, used only when no shared instance is found |
|
|
334
|
+
| `propagationNode` | string | — | `lxmf.propagation` hash for outbound submits + optional sync |
|
|
335
|
+
| `syncIntervalSec` | number | `0` (off) | propagation sync cadence |
|
|
336
|
+
| `midRunBehavior` | `"steer"` \| `"followUp"` | `"steer"` | `streamingBehavior` for prompts arriving mid-run |
|
|
337
|
+
| `chunkChars` | number | `2500` | max characters per outbound LXMF message |
|
|
338
|
+
| `announceIntervalSec` | number | router default | re-announce cadence |
|
|
339
|
+
|
|
340
|
+
**State files** under `dataDir` (machine-managed, never hand-edited):
|
|
341
|
+
|
|
342
|
+
- `storage/` — Reticulum persistence (identity, known destinations,
|
|
343
|
+
ratchets) via `FileStorageAdapter`.
|
|
344
|
+
- `sessions/<key>.json` — per-workdir Pi session pointers
|
|
345
|
+
(`{ "sessionFile": … }`), keyed by the first 16 hex chars of
|
|
346
|
+
`SHA-256(workdir)` so distinct repos keep distinct sessions (the session
|
|
347
|
+
is the conversation; switching models mid-session keeps the same pointer).
|
|
348
|
+
Written on `new_session`, on graceful shutdown, and whenever
|
|
349
|
+
`get_state` observes a change. On daemon start the pointer for the
|
|
350
|
+
current `workdir` is passed as `--session` if it still exists (a
|
|
351
|
+
missing/empty pointer starts a fresh session). One-time migration: a
|
|
352
|
+
pre-scoping legacy `session` file is adopted for the first workdir that
|
|
353
|
+
reads it, then removed, so the adoption runs exactly once.
|
|
354
|
+
|
|
355
|
+
**Operational note:** Pi's project trust is not prompted for over LXMF.
|
|
356
|
+
Operators run Pi interactively once in `workdir` (or preconfigure trust) so
|
|
357
|
+
project-local `.pi` resources load in RPC mode.
|
|
358
|
+
|
|
359
|
+
## 9. Package layout
|
|
360
|
+
|
|
361
|
+
```text
|
|
362
|
+
pi-lxmf/
|
|
363
|
+
├── package.json # name pi-lxmf, type module, bin { "pi-lxmf": "src/bin.js" }
|
|
364
|
+
├── CHANGELOG.md # Keep a Changelog format, Unreleased segment
|
|
365
|
+
├── src/
|
|
366
|
+
│ ├── bin.js # CLI: flags (--config, --version), config load, daemon loop, signals
|
|
367
|
+
│ ├── config.js # load/merge/validate config; XDG paths; session-pointer persistence
|
|
368
|
+
│ ├── identity.js # identity-hash → destination-hash derivation (DACAR-ready owner keying)
|
|
369
|
+
│ ├── rpc.js # PiRpcClient: spawn, JSONL framing, id correlation, typed command helpers, restart
|
|
370
|
+
│ ├── lxmf.js # mesh side: storage, identity, interfaces, router, announce, send (chunking), sync
|
|
371
|
+
│ ├── bridge.js # inbound pipeline, command dispatch, reply delivery, empty-tail recovery, dialogs
|
|
372
|
+
│ ├── commands.js # command table: parse + implementations as pure-ish functions over (rpc, ctx)
|
|
373
|
+
│ ├── text.js # chunking + formatting helpers (dependency-free)
|
|
374
|
+
│ └── bz2.js # bzip2-wasm adapter (as in pi-rngit-work-document-skill)
|
|
375
|
+
├── scripts/
|
|
376
|
+
│ ├── fake-pi.mjs # minimal `pi --mode rpc` stand-in for the smoketest
|
|
377
|
+
│ └── smoke.mjs # end-to-end smoketest against a local rnsd (below)
|
|
378
|
+
└── test/ # node --test, see §12
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
Dependencies (runtime): `@reticulum/core`, `@reticulum/lxmf`,
|
|
382
|
+
`@reticulum/node` (all `^0.9.0`), `@digitaldefiance/bzip2-wasm` — the same
|
|
383
|
+
set `pi-rngit-work-document-skill` uses. Node ≥ 20. License EUPL-1.2.
|
|
384
|
+
`rngit: rns://adafb3153efd4d96d532568a5208b3b5/reticulum/pi-lxmf` once the
|
|
385
|
+
repository exists on the node.
|
|
386
|
+
|
|
387
|
+
## 10. Logging and diagnostics
|
|
388
|
+
|
|
389
|
+
- Daemon logs to stderr: startup banner (identity hash, delivery
|
|
390
|
+
destination hash, interfaces, workdir, paired owner), Pi lifecycle
|
|
391
|
+
events, LXMF send/receive summaries (hashes and sizes, never full
|
|
392
|
+
bodies), errors.
|
|
393
|
+
- `PI_LXMF_DEBUG=1` enables Pi's stderr forwarding and RPC event tracing.
|
|
394
|
+
- The startup banner doubles as the setup script: it prints the exact
|
|
395
|
+
LXMF address the owner should configure/expect.
|
|
396
|
+
|
|
397
|
+
## 11. Security model
|
|
398
|
+
|
|
399
|
+
- **Owner-only control, configured not learned.** Exactly one Reticulum
|
|
400
|
+
identity (configured by identity hash; see §6.1) can drive the agent.
|
|
401
|
+
The bridge signature-verifies every inbound owner message itself before
|
|
402
|
+
processing (`mesh.verifySender`, recalling the sender identity and
|
|
403
|
+
checking the LXMF signature). This is necessary because the router only
|
|
404
|
+
verifies signatures on the direct-delivery path: a message pulled in via
|
|
405
|
+
propagation-node sync whose sender identity is not yet recalled is
|
|
406
|
+
dispatched without verification, and the owner source-hash check alone is
|
|
407
|
+
forgeable on that path (a 16-byte hash, no private key needed). An
|
|
408
|
+
unverified message — including one whose sender identity is unknown
|
|
409
|
+
("parked") — is dropped; a re-sync after the owner's announce lands will
|
|
410
|
+
re-deliver it. There is no first-contact pairing: a stray message can
|
|
411
|
+
never seize control, and the daemon never answers strangers. Identity-
|
|
412
|
+
keyed access is ready for delegation to DACAR-based permission management
|
|
413
|
+
(§13) without reconfiguration.
|
|
414
|
+
- **No shell surface.** The bridge never executes chat text locally; only
|
|
415
|
+
Pi's RPC commands are used. The agent's own tool use is governed by Pi's
|
|
416
|
+
normal permissions, not relaxed by the bridge. Declined dialogs mean
|
|
417
|
+
approval-gated tools stay gated.
|
|
418
|
+
- **Key hygiene.** The Reticulum identity key is the root of the node's
|
|
419
|
+
identity; `dataDir` must be backed up and treated as secret
|
|
420
|
+
(`FileStorageAdapter` writes `0600`).
|
|
421
|
+
- **Unauthenticated replies are impossible** (outbound messages are signed
|
|
422
|
+
by our identity); non-owner inbound is dropped silently.
|
|
423
|
+
|
|
424
|
+
## 12. Testing strategy
|
|
425
|
+
|
|
426
|
+
`node --test`, no network, no mesh:
|
|
427
|
+
|
|
428
|
+
- `config.test.js` — defaults, XDG env overrides, merge precedence,
|
|
429
|
+
validation errors, and session-pointer persistence round-trips.
|
|
430
|
+
- `rpc.test.js` — the JSONL reader (chunked multi-byte UTF-8, `\r\n`
|
|
431
|
+
tolerance, U+2028 inside strings must *not* split a record), and
|
|
432
|
+
request/response correlation against a fake child stream.
|
|
433
|
+
- `identity.test.js` — the identity → destination-hash derivation,
|
|
434
|
+
cross-validated against @reticulum/core's `Destination.IN` (so a configured
|
|
435
|
+
identity hash really expands to the wire form the router compares).
|
|
436
|
+
- `commands.test.js` — command parsing (`/x`, `!`, bare `!`, case,
|
|
437
|
+
args), model fuzzy matching over a fixture model list, command routing
|
|
438
|
+
(bridge command vs passthrough prompt).
|
|
439
|
+
- `bridge.test.js` — smoketests of the full loop with a fake `PiRpcClient`
|
|
440
|
+
and a fake LXMF sender: prompt→reply delivery, mid-run steering choice,
|
|
441
|
+
empty-tail recovery then nudge, `/new` + session pointer update,
|
|
442
|
+
dialog auto-dismiss, non-owner drop (by derived destination hash),
|
|
443
|
+
chunking.
|
|
444
|
+
|
|
445
|
+
Mesh-level behaviour is exercised end-to-end by `scripts/smoke.mjs` (`npm
|
|
446
|
+
run smoke`), which needs a local rnsd shared instance: it spawns the real
|
|
447
|
+
daemon against a fake `pi --mode rpc` child, pairs a second in-process LXMF
|
|
448
|
+
node as the owner (admitted via a preconfigured identity hash, exercising
|
|
449
|
+
the derivation end-to-end), and verifies a prompt round-trip, `/help`, and
|
|
450
|
+
reply chunking — all over real LXMF. Announce visibility against Sideband on
|
|
451
|
+
an actual mesh is the remaining manual step.
|
|
452
|
+
|
|
453
|
+
## 13. Future work
|
|
454
|
+
|
|
455
|
+
- **File attachments** both ways: LXMF `fields` file attachments for
|
|
456
|
+
inbound images into Pi prompts, and a `send_file` companion extension
|
|
457
|
+
tool for outbound artifacts (pi-msg's XEP-0363 analogue).
|
|
458
|
+
- **Durable inbound journal** (pi-msg's inbox): persist inbound messages
|
|
459
|
+
before handing them to Pi and replay unacknowledged ones after a crash,
|
|
460
|
+
for exactly-once-ish delivery on top of LXMF's at-least-once.
|
|
461
|
+
- **Proactive notifications:** mirror locally-started runs' output to the
|
|
462
|
+
owner (pi-telegram's "connected companion projection").
|
|
463
|
+
- **DACAR-based permissions** (../dacar): replace the single `owner`
|
|
464
|
+
identity with grants/revocations synced over the mesh, keyed by identity
|
|
465
|
+
hash as v1 already is.
|
|
466
|
+
- **Multiple owners.**
|
|
467
|
+
- **`/export` → LXMF attachment** of the rendered HTML session.
|
|
468
|
+
- **Propagation-node role** for the bridge itself, serving its owner's
|
|
469
|
+
messages while the daemon is down.
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-lxmf",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Drive the Pi coding agent over LXMF messaging (Reticulum mesh) from a headless server.",
|
|
5
|
+
"license": "EUPL-1.2",
|
|
6
|
+
"author": "Henri Bergius <henri.bergius@iki.fi>",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "src/bridge.js",
|
|
9
|
+
"bin": {
|
|
10
|
+
"pi-lxmf": "src/bin.js"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"pi",
|
|
14
|
+
"pi-package",
|
|
15
|
+
"reticulum",
|
|
16
|
+
"rns",
|
|
17
|
+
"lxmf",
|
|
18
|
+
"mesh",
|
|
19
|
+
"bot"
|
|
20
|
+
],
|
|
21
|
+
"files": [
|
|
22
|
+
"src",
|
|
23
|
+
"CHANGELOG.md",
|
|
24
|
+
"SPEC.md"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"start": "node src/bin.js",
|
|
28
|
+
"lint": "npx @biomejs/biome check --use-editorconfig=true src/ test/ scripts/",
|
|
29
|
+
"format": "npx @biomejs/biome check --use-editorconfig=true --write src/ test/ scripts/",
|
|
30
|
+
"types": "tsc",
|
|
31
|
+
"test": "node --test --test-force-exit test/*.test.js",
|
|
32
|
+
"smoke": "node scripts/smoke.mjs"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@digitaldefiance/bzip2-wasm": "^1.1.1",
|
|
36
|
+
"@reticulum/core": "^0.9.0",
|
|
37
|
+
"@reticulum/lxmf": "^0.9.0",
|
|
38
|
+
"@reticulum/node": "^0.9.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^26.6.2",
|
|
42
|
+
"typescript": "^6.0.3"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=20"
|
|
46
|
+
},
|
|
47
|
+
"repository": {
|
|
48
|
+
"type": "git",
|
|
49
|
+
"url": "git://github.com/bergie/pi-lxmf.git"
|
|
50
|
+
},
|
|
51
|
+
"rngit": "rns://adafb3153efd4d96d532568a5208b3b5/reticulum/pi-lxmf"
|
|
52
|
+
}
|