pi-lxmf 0.2.1 → 0.3.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 +23 -0
- package/SPEC.md +6 -4
- package/package.json +4 -4
- package/src/bridge.js +5 -3
- package/src/commands.js +48 -35
- package/src/lxmf.js +93 -205
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.0] - 2026-10-03
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Upgraded to reticulum-js 0.9.3 (`@reticulum/core`, `@reticulum/lxmf`, `@reticulum/node`) and adopted its API ergonomics: outbound delivery now uses the router's own `send()` escalation (`{ linkId, fallback, solicit, timeoutMs }` — DIRECT link → opportunistic packet with recipient-identity solicitation → propagation store-and-forward), replacing the hand-rolled `waitForPeerIdentity` announce-wait and multi-attempt retry chain in `src/lxmf.js`. The unknown-identity failure is now the typed `UnknownIdentityError` from `@reticulum/core`. `startLxmf` awaits the new `Reticulum.ready()` before loading the node identity.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- The smoke script spawned its fake `pi` wrapper with a `#!/usr/bin/sh` shebang, which does not exist on macOS (ENOENT on every spawn); it now uses `/bin/sh`. The `/help` assertion also still expected the pre-Markdown `Bridge commands:` heading and never matched since the response was reformatted; it now greps `Bridge commands`.
|
|
19
|
+
|
|
20
|
+
## [0.2.2] - 2026-09-28
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- Bridge command replies (especially `/help`, `/status`, `/session`,
|
|
25
|
+
`/model`, `/cd`) are now authored in Markdown — section headings, bullet
|
|
26
|
+
lists and code spans around commands, models, paths and hashes — matching
|
|
27
|
+
the content-format metadata (`FIELD_RENDERER: RENDERER_MARKDOWN`) every
|
|
28
|
+
outbound message already carries, so they render nicely in Sideband and
|
|
29
|
+
NomadNet instead of as plain text.
|
|
30
|
+
- The `/cd` switch banner uses a Markdown horizontal rule and a bold
|
|
31
|
+
headline instead of a `📂 ───` decoration line.
|
|
32
|
+
|
|
10
33
|
## [0.2.1] - 2026-09-28
|
|
11
34
|
|
|
12
35
|
### Added
|
package/SPEC.md
CHANGED
|
@@ -182,9 +182,9 @@ example:
|
|
|
182
182
|
`[… n/N]` except the last. Chunking keeps single LXMF messages
|
|
183
183
|
reasonable for phone UIs and for mesh airtime.
|
|
184
184
|
- **Content format:** every chunk carries the LXMF `FIELD_RENDERER` field
|
|
185
|
-
set to `RENDERER_MARKDOWN` (§5.9.4), since pi's replies
|
|
186
|
-
clients (Sideband, NomadNet) use it to
|
|
187
|
-
raw markup as plain text.
|
|
185
|
+
set to `RENDERER_MARKDOWN` (§5.9.4), since pi's replies and the bridge
|
|
186
|
+
command replies are Markdown — clients (Sideband, NomadNet) use it to
|
|
187
|
+
render them instead of showing raw markup as plain text.
|
|
188
188
|
- **Errors:** failures to deliver a reply are logged and retried once;
|
|
189
189
|
persistent failure is reported in the next successful message (LXMF has
|
|
190
190
|
no channel over which to report its own failure).
|
|
@@ -367,7 +367,9 @@ in one place so it can be retargeted per subtree.
|
|
|
367
367
|
|
|
368
368
|
Command parsing: first whitespace-separated token, case-insensitive,
|
|
369
369
|
leading `!` equivalent to `/` for bridge commands (a bare `!` is the
|
|
370
|
-
interrupt). Commands are recognized only from the owner.
|
|
370
|
+
interrupt). Commands are recognized only from the owner. Command replies
|
|
371
|
+
are Markdown-formatted (§5.1): section headings, bullet lists, code spans
|
|
372
|
+
around commands, models, paths and hashes.
|
|
371
373
|
|
|
372
374
|
## 8. Configuration and state
|
|
373
375
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-lxmf",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Drive the Pi coding agent over LXMF messaging (Reticulum mesh) from a headless server.",
|
|
5
5
|
"license": "EUPL-1.2",
|
|
6
6
|
"author": "Henri Bergius <henri.bergius@iki.fi>",
|
|
@@ -33,9 +33,9 @@
|
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@digitaldefiance/bzip2-wasm": "^1.1.1",
|
|
36
|
-
"@reticulum/core": "^0.9.
|
|
37
|
-
"@reticulum/lxmf": "^0.9.
|
|
38
|
-
"@reticulum/node": "^0.9.
|
|
36
|
+
"@reticulum/core": "^0.9.3",
|
|
37
|
+
"@reticulum/lxmf": "^0.9.3",
|
|
38
|
+
"@reticulum/node": "^0.9.3"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@types/node": "^26.6.2",
|
package/src/bridge.js
CHANGED
|
@@ -634,11 +634,13 @@ export class Bridge {
|
|
|
634
634
|
const rel = relative(this.config.workdir, absPath) || ".";
|
|
635
635
|
// A visually loud banner: project changes are the main boundaries
|
|
636
636
|
// in the message history, so they must be easy to spot while
|
|
637
|
-
// scrolling back.
|
|
638
|
-
|
|
637
|
+
// scrolling back. Replies are Markdown on the wire (§5.1), so the
|
|
638
|
+
// separator is a horizontal rule and the headline bold.
|
|
639
|
+
const lines = ["---", "", `**📂 Switched to \`${rel}\`**`];
|
|
639
640
|
lines.push(
|
|
641
|
+
"",
|
|
640
642
|
pointer
|
|
641
|
-
? `🔁 Resuming session
|
|
643
|
+
? `🔁 Resuming session \`${basename(pointer.sessionFile)}\``
|
|
642
644
|
: "✨ Fresh session",
|
|
643
645
|
);
|
|
644
646
|
return lines.join("\n");
|
package/src/commands.js
CHANGED
|
@@ -97,7 +97,9 @@ export function matchModel(models, query) {
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
/**
|
|
100
|
-
* Formats the model list with the current model marked.
|
|
100
|
+
* Formats the model list with the current model marked. Markdown: every
|
|
101
|
+
* outbound message carries `FIELD_RENDERER: RENDERER_MARKDOWN` (§5.1),
|
|
102
|
+
* so replies render as such in Sideband/NomadNet.
|
|
101
103
|
*
|
|
102
104
|
* @param {Array<{id?: string, provider?: string, name?: string}>} models
|
|
103
105
|
* @param {{provider?: string, id?: string}|null} current
|
|
@@ -110,15 +112,15 @@ export function formatModelList(models, current) {
|
|
|
110
112
|
: null;
|
|
111
113
|
const lines = models.map((m) => {
|
|
112
114
|
const key = `${m.provider}/${m.id}`;
|
|
113
|
-
const marker = key.toLowerCase() === currentKey ? " ← current" : "";
|
|
115
|
+
const marker = key.toLowerCase() === currentKey ? " ← **current**" : "";
|
|
114
116
|
const name = m.name && m.name !== m.id ? ` — ${m.name}` : "";
|
|
115
|
-
return
|
|
117
|
+
return `- \`${key}\`${name}${marker}`;
|
|
116
118
|
});
|
|
117
|
-
return [
|
|
119
|
+
return [`**Models (${models.length})**`, "", ...lines].join("\n");
|
|
118
120
|
}
|
|
119
121
|
|
|
120
122
|
/**
|
|
121
|
-
* Formats `get_state` + bridge info as the `/status` reply.
|
|
123
|
+
* Formats `get_state` + bridge info as the `/status` reply (Markdown).
|
|
122
124
|
*
|
|
123
125
|
* @param {any} state - `get_state` data.
|
|
124
126
|
* @param {object} bridgeInfo
|
|
@@ -132,28 +134,30 @@ export function formatModelList(models, current) {
|
|
|
132
134
|
*/
|
|
133
135
|
export function formatStatus(state, bridgeInfo) {
|
|
134
136
|
const model = state?.model
|
|
135
|
-
?
|
|
137
|
+
? `\`${state.model.provider}/${state.model.id}\``
|
|
136
138
|
: "(none)";
|
|
137
139
|
const session = state?.sessionFile
|
|
138
|
-
? `${state.sessionName ?? "(unnamed)"}
|
|
140
|
+
? `${state.sessionName ?? "(unnamed)"} (\`${basename(state.sessionFile)}\`)`
|
|
139
141
|
: "(none)";
|
|
140
142
|
return [
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
143
|
+
"**Status**",
|
|
144
|
+
"",
|
|
145
|
+
`- Model: ${model}`,
|
|
146
|
+
`- Thinking: ${state?.thinkingLevel ?? "off"}`,
|
|
147
|
+
`- Busy: ${state?.isStreaming ? "yes" : "no"}`,
|
|
148
|
+
`- Session: ${session}`,
|
|
149
|
+
`- Cwd: \`${bridgeInfo.cwd ?? "?"}\``,
|
|
150
|
+
`- Workdir: \`${bridgeInfo.workdir ?? "?"}\``,
|
|
151
|
+
`- Node: \`${bridgeInfo.identityHash}\``,
|
|
152
|
+
`- LXMF: \`${bridgeInfo.deliveryHash}\``,
|
|
153
|
+
`- Owner: \`${bridgeInfo.owner ?? "?"}\``,
|
|
154
|
+
`- Uptime: ${formatDuration(bridgeInfo.uptimeMs)}`,
|
|
151
155
|
].join("\n");
|
|
152
156
|
}
|
|
153
157
|
|
|
154
158
|
/**
|
|
155
159
|
* Formats the `/cd` (no arguments) reply: the current repo and the
|
|
156
|
-
* recently used repos under the daemon workdir.
|
|
160
|
+
* recently used repos under the daemon workdir (Markdown).
|
|
157
161
|
*
|
|
158
162
|
* @param {object} bridgeInfo
|
|
159
163
|
* @param {string} bridgeInfo.workdir
|
|
@@ -164,19 +168,21 @@ export function formatStatus(state, bridgeInfo) {
|
|
|
164
168
|
export function formatRepoList(bridgeInfo) {
|
|
165
169
|
const workdir = bridgeInfo.workdir;
|
|
166
170
|
const cwd = bridgeInfo.cwd ?? workdir;
|
|
167
|
-
const lines = [
|
|
171
|
+
const lines = [
|
|
172
|
+
"**Repos**",
|
|
173
|
+
"",
|
|
174
|
+
`- Current: \`${relative(workdir, cwd) || "."}\` (\`${cwd}\`)`,
|
|
175
|
+
];
|
|
168
176
|
const recent = bridgeInfo.recentWorkdirs ?? [];
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
for (const w of recent) lines.push(` ${relative(workdir, w) || "."}`);
|
|
174
|
-
}
|
|
177
|
+
const names = recent.map((w) => `\`${relative(workdir, w) || "."}\``);
|
|
178
|
+
lines.push(
|
|
179
|
+
names.length > 0 ? `- Recent: ${names.join(", ")}` : "- Recent: (none)",
|
|
180
|
+
);
|
|
175
181
|
return lines.join("\n");
|
|
176
182
|
}
|
|
177
183
|
|
|
178
184
|
/**
|
|
179
|
-
* Formats `get_session_stats` data as the `/session` reply.
|
|
185
|
+
* Formats `get_session_stats` data as the `/session` reply (Markdown).
|
|
180
186
|
*
|
|
181
187
|
* @param {any} stats
|
|
182
188
|
* @returns {string}
|
|
@@ -184,10 +190,12 @@ export function formatRepoList(bridgeInfo) {
|
|
|
184
190
|
export function formatSessionStats(stats) {
|
|
185
191
|
if (!stats) return "No session stats available.";
|
|
186
192
|
return [
|
|
187
|
-
|
|
193
|
+
"**Session**",
|
|
194
|
+
"",
|
|
195
|
+
`- Messages: ${stats.userMessages ?? 0} in / ${stats.assistantMessages ?? 0} out` +
|
|
188
196
|
` (${stats.toolCalls ?? 0} tool calls)`,
|
|
189
|
-
|
|
190
|
-
|
|
197
|
+
`- Usage: ${formatTokens(stats)}`,
|
|
198
|
+
`- Session: \`${stats.sessionId ?? "?"}\``,
|
|
191
199
|
].join("\n");
|
|
192
200
|
}
|
|
193
201
|
|
|
@@ -217,7 +225,7 @@ export const bridgeCommands = {
|
|
|
217
225
|
description: "List bridge commands",
|
|
218
226
|
async run(ctx) {
|
|
219
227
|
const mine = Object.entries(bridgeCommands).map(
|
|
220
|
-
([name, def]) =>
|
|
228
|
+
([name, def]) => `- \`/${name}\` — ${def.description}`,
|
|
221
229
|
);
|
|
222
230
|
/** @type {string[]} */
|
|
223
231
|
let piCommands = [];
|
|
@@ -225,17 +233,22 @@ export const bridgeCommands = {
|
|
|
225
233
|
const commands = await ctx.rpc.getCommands();
|
|
226
234
|
piCommands = commands.map(
|
|
227
235
|
(/** @type {{name?: string, description?: string}} */ c) =>
|
|
228
|
-
|
|
236
|
+
`- \`/${c.name}\`${c.description ? ` — ${c.description}` : ""}`,
|
|
229
237
|
);
|
|
230
238
|
} catch {
|
|
231
239
|
/* pi unavailable: bridge commands still listed */
|
|
232
240
|
}
|
|
233
241
|
const parts = [
|
|
234
|
-
[
|
|
242
|
+
[
|
|
243
|
+
"**Bridge commands**",
|
|
244
|
+
"",
|
|
245
|
+
...mine,
|
|
246
|
+
"- `!` (bare) — quick interrupt",
|
|
247
|
+
].join("\n"),
|
|
235
248
|
];
|
|
236
249
|
if (piCommands.length > 0) {
|
|
237
250
|
parts.push(
|
|
238
|
-
["Pi commands (sent as prompts)
|
|
251
|
+
["**Pi commands** (sent as prompts)", "", ...piCommands].join("\n"),
|
|
239
252
|
);
|
|
240
253
|
}
|
|
241
254
|
parts.push("Anything else is sent to the agent as a prompt.");
|
|
@@ -274,7 +287,7 @@ export const bridgeCommands = {
|
|
|
274
287
|
const target = resolveCwdTarget(ctx.workdir, args);
|
|
275
288
|
if (!target) {
|
|
276
289
|
ctx.log?.(`pi-lxmf: /cd refused: ${args} not under workdir`);
|
|
277
|
-
return `⚠️ /cd refused: "${args}" is not a directory under
|
|
290
|
+
return `⚠️ /cd refused: "${args}" is not a directory under \`${ctx.workdir}\`.`;
|
|
278
291
|
}
|
|
279
292
|
if (target === ctx.getBridgeInfo().cwd) {
|
|
280
293
|
return `Already in ${target}.`;
|
|
@@ -331,7 +344,7 @@ export const bridgeCommands = {
|
|
|
331
344
|
const levels = await ctx.rpc.getAvailableThinkingLevels();
|
|
332
345
|
const level = (args || "").toLowerCase();
|
|
333
346
|
if (!level || !levels.includes(level)) {
|
|
334
|
-
return `Thinking levels: ${levels.join(", ")} (current model).`;
|
|
347
|
+
return `Thinking levels: ${levels.map((l) => `\`${l}\``).join(", ")} (current model).`;
|
|
335
348
|
}
|
|
336
349
|
await ctx.rpc.setThinkingLevel(level);
|
|
337
350
|
return `Thinking level set to ${level}.`;
|
package/src/lxmf.js
CHANGED
|
@@ -11,7 +11,13 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { join } from "node:path";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
fromHex,
|
|
16
|
+
Identity,
|
|
17
|
+
Reticulum,
|
|
18
|
+
toHex,
|
|
19
|
+
UnknownIdentityError,
|
|
20
|
+
} from "@reticulum/core";
|
|
15
21
|
import { LXMessage, LXMFConstants, LXMRouter } from "@reticulum/lxmf";
|
|
16
22
|
import {
|
|
17
23
|
AutoInterface,
|
|
@@ -23,34 +29,27 @@ import { createBz2 } from "./bz2.js";
|
|
|
23
29
|
import { chunkText } from "./text.js";
|
|
24
30
|
|
|
25
31
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* How long {@link waitForPeerIdentity} waits for a solicited announce
|
|
36
|
-
* before giving up (and `sendWithRetry` falling back to its plain retries).
|
|
37
|
-
* Generous on purpose: the peer may be several slow mesh hops away, and the
|
|
38
|
-
* common trigger (the startup notification racing the owner's first
|
|
39
|
-
* announce after a daemon restart) is worth waiting for — the alternative
|
|
40
|
-
* parks the message in the bridge's `failedNote` until the *next* reply.
|
|
32
|
+
* How long the router may solicit a peer for its announce (path request →
|
|
33
|
+
* awaited announce via `transport.recallOrSolicitIdentity`) before giving
|
|
34
|
+
* up on a send. Generous on purpose: the peer may be several slow mesh
|
|
35
|
+
* hops away, and the common trigger (the startup notification racing the
|
|
36
|
+
* owner's first announce after a daemon restart) is worth waiting for —
|
|
37
|
+
* the alternative parks the message in the bridge's `failedNote` until
|
|
38
|
+
* the *next* reply.
|
|
41
39
|
*/
|
|
42
40
|
const PEER_DISCOVERY_WAIT_MS = 30_000;
|
|
43
41
|
|
|
44
42
|
/**
|
|
45
|
-
* Whether `e` is the
|
|
46
|
-
*
|
|
47
|
-
*
|
|
43
|
+
* Whether `e` is the typed unknown-identity failure the router throws when
|
|
44
|
+
* a destination's identity is neither recallable nor solicitable within the
|
|
45
|
+
* send's time budget — no link can be established and opportunistic
|
|
46
|
+
* encryption is impossible without the recipient's public key.
|
|
48
47
|
*
|
|
49
48
|
* @param {unknown} e
|
|
50
|
-
* @returns {e is
|
|
49
|
+
* @returns {e is UnknownIdentityError}
|
|
51
50
|
*/
|
|
52
51
|
export function isUnknownIdentityError(e) {
|
|
53
|
-
return e instanceof
|
|
52
|
+
return e instanceof UnknownIdentityError;
|
|
54
53
|
}
|
|
55
54
|
|
|
56
55
|
/**
|
|
@@ -68,91 +67,30 @@ export function contentFields() {
|
|
|
68
67
|
}
|
|
69
68
|
|
|
70
69
|
/**
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
* re-enters that state until the owner's next announce.
|
|
82
|
-
*
|
|
83
|
-
* @param {any} transport - `rns.transport` (EventTarget with
|
|
84
|
-
* `recallIdentity`, `requestPath`; tolerates missing methods for test
|
|
85
|
-
* doubles).
|
|
86
|
-
* @param {Uint8Array} destinationHash
|
|
87
|
-
* @param {number} timeoutMs
|
|
88
|
-
* @returns {Promise<boolean>} `true` when the identity is recallable on return.
|
|
89
|
-
*/
|
|
90
|
-
export async function waitForPeerIdentity(
|
|
91
|
-
transport,
|
|
92
|
-
destinationHash,
|
|
93
|
-
timeoutMs,
|
|
94
|
-
) {
|
|
95
|
-
const destHex = toHex(destinationHash);
|
|
96
|
-
const recall = () =>
|
|
97
|
-
Promise.resolve()
|
|
98
|
-
.then(() => transport?.recallIdentity(destinationHash))
|
|
99
|
-
.catch(() => null);
|
|
100
|
-
if (await recall()) return true;
|
|
101
|
-
try {
|
|
102
|
-
await transport?.requestPath?.(destinationHash);
|
|
103
|
-
} catch {
|
|
104
|
-
/* best effort — a late announce still has the timeout window */
|
|
105
|
-
}
|
|
106
|
-
if (await recall()) return true;
|
|
107
|
-
return new Promise((resolve) => {
|
|
108
|
-
let settled = false;
|
|
109
|
-
/** @type {NodeJS.Timeout|null} */
|
|
110
|
-
let timer = null;
|
|
111
|
-
const finish = (/** @type {boolean} */ ok) => {
|
|
112
|
-
if (settled) return;
|
|
113
|
-
settled = true;
|
|
114
|
-
if (timer) clearTimeout(timer);
|
|
115
|
-
transport.removeEventListener("announce", onAnnounce);
|
|
116
|
-
resolve(ok);
|
|
117
|
-
};
|
|
118
|
-
// The transport dispatches "announce" only after `rememberIdentity`
|
|
119
|
-
// completed, so a matching event implies a recallable identity; the
|
|
120
|
-
// re-check is belt-and-braces against half-fakes in tests.
|
|
121
|
-
const onAnnounce = (/** @type {any} */ ev) => {
|
|
122
|
-
const announced = ev?.detail?.destinationHash;
|
|
123
|
-
if (!announced || toHex(announced) !== destHex) return;
|
|
124
|
-
void recall().then((identity) => finish(Boolean(identity)));
|
|
125
|
-
};
|
|
126
|
-
timer = setTimeout(() => finish(false), timeoutMs);
|
|
127
|
-
transport.addEventListener("announce", onAnnounce);
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Builds the outbound retry chain behind `sendText`/`sendReaction`:
|
|
133
|
-
*
|
|
134
|
-
* 1. `lxmf.send` over the given link (DIRECT; the router falls back to an
|
|
135
|
-
* opportunistic packet internally when no link can be established),
|
|
136
|
-
* 2. on the router's unknown-identity failure: solicit the destination
|
|
137
|
-
* (path request → announce) and wait for its announce — the restart
|
|
138
|
-
* race, since reticulum-js keeps the destination→identity map in
|
|
139
|
-
* memory and an immediate retry cannot succeed,
|
|
140
|
-
* 3. retry over the same link, then once more without it (the arrival
|
|
141
|
-
* link is usually gone by reply time on battery-conscious clients),
|
|
142
|
-
* 4. store-and-forward via the configured propagation node — the owner is
|
|
143
|
-
* likely off-mesh entirely; their next sync picks the message up.
|
|
70
|
+
* Builds the outbound delivery call behind `sendText`/`sendReaction`. Since
|
|
71
|
+
* reticulum-js 0.9.3 the router escalates on its own
|
|
72
|
+
* (`send(message, identity, { linkId, fallback, solicit, timeoutMs })`):
|
|
73
|
+
* DIRECT link → opportunistic packet (soliciting the recipient's identity
|
|
74
|
+
* via `transport.recallOrSolicitIdentity` when no announce has been heard —
|
|
75
|
+
* the restart race, since reticulum-js keeps the destination→identity map
|
|
76
|
+
* in memory) → propagation store-and-forward when an outbound node is set.
|
|
77
|
+
* All of that happens inside one `send` call against one serialized message,
|
|
78
|
+
* so every wire copy shares one message id and a deduplicating client
|
|
79
|
+
* renders the reply once.
|
|
144
80
|
*
|
|
145
|
-
* The
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
81
|
+
* The one gap left: the router's propagation handoff
|
|
82
|
+
* (`submitToPropagationNode`) still declines when the *node's* announce has
|
|
83
|
+
* not been heard yet (fresh start), while a recipient identity is solicited
|
|
84
|
+
* internally. `sendWithRetry` catches that failure, solicits the node and
|
|
85
|
+
* resends. Factored out of `startLxmf` with injected dependencies so the
|
|
86
|
+
* behavior is testable against a fake router.
|
|
149
87
|
*
|
|
150
88
|
* @param {object} deps
|
|
151
89
|
* @param {LXMRouter} deps.lxmf - Initialised router.
|
|
152
90
|
* @param {Identity} deps.identity - The node's LXMF identity (signs sends).
|
|
153
91
|
* @param {string|null} [deps.propagationNodeHex] - Configured propagation
|
|
154
92
|
* node's `lxmf.propagation` hash; enables the store-and-forward fallback
|
|
155
|
-
* (
|
|
93
|
+
* (`fallback: "propagation"` on the router's send).
|
|
156
94
|
* @param {(msg: string) => void} [deps.log] - Diagnostic sink.
|
|
157
95
|
* @param {number} [deps.peerWaitMs] - Per-peer announce wait (overridable in tests).
|
|
158
96
|
* @returns {{sendWithRetry: (message: LXMessage, link?: any) => Promise<void>}}
|
|
@@ -168,106 +106,57 @@ export function createRetrySender({
|
|
|
168
106
|
? fromHex(propagationNodeHex)
|
|
169
107
|
: null;
|
|
170
108
|
|
|
171
|
-
/**
|
|
172
|
-
* Last-resort store-and-forward through the configured propagation
|
|
173
|
-
* node, reached from `sendWithRetry` after direct and opportunistic
|
|
174
|
-
* delivery both failed — typically the owner being off-mesh entirely
|
|
175
|
-
* (the mobile case). The propagated form is encrypted to the *recipient's*
|
|
176
|
-
* public key (`dest_hash ‖ E(src‖sig‖payload)`), so it needs their
|
|
177
|
-
* identity (by then known — the earlier sends failed on reachability,
|
|
178
|
-
* not identity) but **no live path**: the node holds the message until
|
|
179
|
-
* the owner's next sync. A node whose announce hasn't been heard yet
|
|
180
|
-
* (fresh start) is solicited and waited for like unknown recipients are.
|
|
181
|
-
*
|
|
182
|
-
* @param {LXMessage} message
|
|
183
|
-
* @param {Uint8Array} nodeHash - The configured node's `lxmf.propagation`
|
|
184
|
-
* hash (callers guarantee it is set).
|
|
185
|
-
*/
|
|
186
|
-
async function submitViaPropagationNode(message, nodeHash) {
|
|
187
|
-
const describe = (/** @type {unknown} */ e) =>
|
|
188
|
-
e instanceof Error ? e.message : String(e);
|
|
189
|
-
const nodeHex = toHex(nodeHash);
|
|
190
|
-
try {
|
|
191
|
-
try {
|
|
192
|
-
await lxmf.submitToPropagationNode(message, identity);
|
|
193
|
-
} catch (e) {
|
|
194
|
-
if (!/Propagation node identity unknown/.test(describe(e))) throw e;
|
|
195
|
-
log(
|
|
196
|
-
`pi-lxmf: propagation node ${nodeHex} unknown — requesting path, ` +
|
|
197
|
-
`waiting up to ${Math.round(peerWaitMs / 1000)}s for its announce`,
|
|
198
|
-
);
|
|
199
|
-
const learned = await waitForPeerIdentity(
|
|
200
|
-
lxmf.rns.transport,
|
|
201
|
-
nodeHash,
|
|
202
|
-
peerWaitMs,
|
|
203
|
-
);
|
|
204
|
-
if (!learned) throw e;
|
|
205
|
-
await lxmf.submitToPropagationNode(message, identity);
|
|
206
|
-
}
|
|
207
|
-
log(
|
|
208
|
-
"pi-lxmf: owner unreachable directly — submitted via propagation " +
|
|
209
|
-
"node (delivered on their next sync)",
|
|
210
|
-
);
|
|
211
|
-
} catch (e) {
|
|
212
|
-
log(`pi-lxmf: propagation submit failed (${describe(e)})`);
|
|
213
|
-
throw e;
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
109
|
/**
|
|
218
110
|
* @param {LXMessage} message
|
|
219
111
|
* @param {any} [link]
|
|
220
112
|
*/
|
|
221
113
|
async function sendWithRetry(message, link) {
|
|
114
|
+
const options =
|
|
115
|
+
/** @type {{linkId?: Uint8Array, fallback?: "propagation"|"opportunistic", timeoutMs?: number}} */ ({
|
|
116
|
+
fallback: propagationNodeHash ? "propagation" : "opportunistic",
|
|
117
|
+
timeoutMs: peerWaitMs,
|
|
118
|
+
...(link ? { linkId: link } : {}),
|
|
119
|
+
});
|
|
222
120
|
try {
|
|
223
|
-
await lxmf.send(message, identity,
|
|
121
|
+
await lxmf.send(message, identity, options);
|
|
224
122
|
} catch (e) {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
)
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
123
|
+
// Direct and opportunistic both failed and the router fell through
|
|
124
|
+
// to its propagation handoff — but the node's announce has not
|
|
125
|
+
// landed yet (fresh start), so the submit declined instead of
|
|
126
|
+
// queueing. Solicit the node and resend; the message keeps its
|
|
127
|
+
// messageId across serialize() calls, so wire-level dedup still
|
|
128
|
+
// holds.
|
|
129
|
+
if (
|
|
130
|
+
propagationNodeHash &&
|
|
131
|
+
e instanceof Error &&
|
|
132
|
+
/Propagation node identity unknown/.test(e.message)
|
|
133
|
+
) {
|
|
235
134
|
log(
|
|
236
|
-
`pi-lxmf:
|
|
237
|
-
|
|
238
|
-
const learned = await waitForPeerIdentity(
|
|
239
|
-
lxmf.rns.transport,
|
|
240
|
-
message.destinationHash,
|
|
241
|
-
peerWaitMs,
|
|
242
|
-
);
|
|
243
|
-
log(
|
|
244
|
-
learned
|
|
245
|
-
? `pi-lxmf: learned ${destHex} — retrying delivery`
|
|
246
|
-
: `pi-lxmf: no announce from ${destHex} in ${Math.round(peerWaitMs / 1000)}s — retrying anyway`,
|
|
247
|
-
);
|
|
248
|
-
}
|
|
249
|
-
try {
|
|
250
|
-
await lxmf.send(message, identity, link);
|
|
251
|
-
} catch (e2) {
|
|
252
|
-
// The arrival link is likely gone (the peer closed it after its
|
|
253
|
-
// message was acknowledged). Retry without it: `LXMRouter.send`
|
|
254
|
-
// then establishes a fresh DIRECT link, falling back to an
|
|
255
|
-
// opportunistic packet. Same message object → same message id, so
|
|
256
|
-
// a deduplicating client renders the reply once.
|
|
257
|
-
log(
|
|
258
|
-
`pi-lxmf: link retry failed (${e2 instanceof Error ? e2.message : e2}), retrying without link`,
|
|
135
|
+
`pi-lxmf: propagation node ${propagationNodeHex} unknown — requesting path, ` +
|
|
136
|
+
`waiting up to ${Math.round(peerWaitMs / 1000)}s for its announce`,
|
|
259
137
|
);
|
|
260
138
|
try {
|
|
261
|
-
await lxmf.
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
139
|
+
await lxmf.rns.transport.recallOrSolicitIdentity(
|
|
140
|
+
propagationNodeHash,
|
|
141
|
+
peerWaitMs,
|
|
142
|
+
);
|
|
143
|
+
} catch (solicitError) {
|
|
144
|
+
if (solicitError instanceof UnknownIdentityError) {
|
|
145
|
+
log(
|
|
146
|
+
`pi-lxmf: no announce from propagation node ${propagationNodeHex} in ${Math.round(peerWaitMs / 1000)}s — giving up`,
|
|
147
|
+
);
|
|
148
|
+
throw e;
|
|
149
|
+
}
|
|
150
|
+
throw solicitError;
|
|
269
151
|
}
|
|
152
|
+
await lxmf.send(message, identity, options);
|
|
153
|
+
log(
|
|
154
|
+
"pi-lxmf: owner unreachable directly — submitted via propagation " +
|
|
155
|
+
"node (delivered on their next sync)",
|
|
156
|
+
);
|
|
157
|
+
return;
|
|
270
158
|
}
|
|
159
|
+
throw e;
|
|
271
160
|
}
|
|
272
161
|
}
|
|
273
162
|
|
|
@@ -400,6 +289,9 @@ export async function startLxmf(config, options = {}) {
|
|
|
400
289
|
storageAdapter: new FileStorageAdapter(storageDir),
|
|
401
290
|
compressionProvider: bz2,
|
|
402
291
|
});
|
|
292
|
+
// Persistor hydration and background services (interface discovery) must
|
|
293
|
+
// be settled before the identity is loaded and announces go out.
|
|
294
|
+
await rns.ready();
|
|
403
295
|
|
|
404
296
|
/** @type {string[]} */
|
|
405
297
|
const interfaceNames = [];
|
|
@@ -467,13 +359,13 @@ export async function startLxmf(config, options = {}) {
|
|
|
467
359
|
});
|
|
468
360
|
log(`pi-lxmf: announcing as "${config.name}"`);
|
|
469
361
|
|
|
470
|
-
// Optional propagation-node integration:
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
//
|
|
476
|
-
//
|
|
362
|
+
// Optional propagation-node integration: a periodic sync pulls messages
|
|
363
|
+
// that arrived while this daemon was down, and (since reticulum-js 0.9.3)
|
|
364
|
+
// the router's own send escalation submits outbound messages to the node
|
|
365
|
+
// as the last fallback when neither a direct link nor opportunistic
|
|
366
|
+
// delivery can be established — the `fallback: "propagation"` option set
|
|
367
|
+
// by `createRetrySender` below is what makes the config effective on the
|
|
368
|
+
// outbound side.
|
|
477
369
|
const propagationNodeHash = config.propagationNode
|
|
478
370
|
? fromHex(config.propagationNode)
|
|
479
371
|
: null;
|
|
@@ -513,15 +405,12 @@ export async function startLxmf(config, options = {}) {
|
|
|
513
405
|
|
|
514
406
|
/**
|
|
515
407
|
* Sends `text` to `destinationHex` (a 32-hex lxmf.delivery source hash),
|
|
516
|
-
* chunked to `chunkChars`, titled on the first chunk.
|
|
517
|
-
*
|
|
518
|
-
*
|
|
519
|
-
*
|
|
520
|
-
*
|
|
521
|
-
*
|
|
522
|
-
* arrival link can be gone by reply time; the same `LXMessage` object is
|
|
523
|
-
* re-sent so all wire copies share one message id and a deduplicating
|
|
524
|
-
* client shows the reply once (learned in signalk-reticulum's deliverer).
|
|
408
|
+
* chunked to `chunkChars`, titled on the first chunk. Delivery escalates
|
|
409
|
+
* inside the router's `send`: DIRECT link → opportunistic packet (with
|
|
410
|
+
* recipient-identity solicitation) → propagation store-and-forward — see
|
|
411
|
+
* {@link createRetrySender} for how the options are set. All copies on
|
|
412
|
+
* the wire share one message id, so a deduplicating client shows the
|
|
413
|
+
* reply once.
|
|
525
414
|
*
|
|
526
415
|
* @param {string} destinationHex
|
|
527
416
|
* @param {string} text
|
|
@@ -553,8 +442,7 @@ export async function startLxmf(config, options = {}) {
|
|
|
553
442
|
* reaction field is rendered natively by Sideband/NomadNet (confirmed in
|
|
554
443
|
* live testing); no `content` is set so no separate chat bubble is
|
|
555
444
|
* produced alongside the reaction. Reuses `sendWithRetry` for the same
|
|
556
|
-
*
|
|
557
|
-
* message id).
|
|
445
|
+
* router-escalated delivery as `sendText` (one message id on the wire).
|
|
558
446
|
*
|
|
559
447
|
* @param {string} destinationHex
|
|
560
448
|
* @param {Uint8Array} targetMessageId - The `message_id` of the message being reacted to.
|