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/src/bridge.js ADDED
@@ -0,0 +1,622 @@
1
+ /**
2
+ * @file bridge.js
3
+ *
4
+ * The LXMF ↔ Pi bridge (SPEC §6): owner admission and pairing, the
5
+ * serialized inbound pipeline (commands vs prompts), reply delivery, and
6
+ * supervision of Pi lifecycle events.
7
+ *
8
+ * Reply model: every *finalized* assistant message that contains text is
9
+ * delivered as its own LXMF message while an LXMF-triggered exchange is
10
+ * active (pi-msg's model — intermediate replies are never lost when several
11
+ * messages were queued mid-run). `agent_settled` closes the exchange; if
12
+ * nothing at all was delivered for it, the empty-tail recovery runs once
13
+ * before falling back to a "done (no reply)" nudge.
14
+ */
15
+
16
+ import {
17
+ bridgeCommands,
18
+ EMPTY_REPLY_RECOVERY_PROMPT,
19
+ parseCommand,
20
+ } from "./commands.js";
21
+ import { deriveLxmfDestinationHash } from "./identity.js";
22
+ import { isZaiModel } from "./quota.js";
23
+ import { assistantText } from "./rpc.js";
24
+ import { errorText } from "./text.js";
25
+
26
+ /** Extension-UI methods that expect a response (dialogs to decline). */
27
+ const DIALOG_METHODS = new Set(["select", "confirm", "input", "editor"]);
28
+
29
+ /** Emoji sent as a run-start acknowledgement when no reply lands fast. */
30
+ const REACTION_EMOJI = "🤔";
31
+ /** How long after `agent_start` to wait for a reply before acknowledging. */
32
+ const REACTION_DEBOUNCE_MS = 2000;
33
+
34
+ /**
35
+ * Diagnostic sink satisfied by `console`.
36
+ *
37
+ * @typedef {object} Logger
38
+ * @property {(msg: string) => void} log
39
+ * @property {(msg: string) => void} error
40
+ */
41
+
42
+ /**
43
+ * Machine-managed state persistence (session pointer).
44
+ *
45
+ * @typedef {object} BridgeState
46
+ * @property {() => {sessionFile: string}|null} loadSession
47
+ * @property {(file: string) => void} saveSession
48
+ */
49
+
50
+ /**
51
+ * The mesh-side adapter the bridge talks to (returned by `startLxmf`, faked
52
+ * in tests).
53
+ *
54
+ * @typedef {object} MeshAdapter
55
+ * @property {EventTarget} lxmf - The LXMRouter (dispatches "message" events).
56
+ * @property {(destHex: string, text: string, opts?: {link?: any, title?: string}) => Promise<void>} sendText
57
+ * @property {(destHex: string, targetMessageId: Uint8Array, emoji: string, opts?: {link?: any}) => Promise<void>} sendReaction
58
+ * @property {(message: any) => Promise<"verified"|"unknown"|"invalid">} verifySender
59
+ * @property {string} identityHash
60
+ * @property {string} deliveryHash
61
+ */
62
+
63
+ /**
64
+ * Local hex encoder (keeps this module testable without @reticulum/core).
65
+ *
66
+ * @param {Uint8Array} bytes
67
+ * @returns {string}
68
+ */
69
+ function toHexString(bytes) {
70
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
71
+ }
72
+
73
+ /**
74
+ * The bridge. Wire it up with `start()`; drive readiness with
75
+ * `setRpcReady(true)` once the RPC client answers commands.
76
+ */
77
+ export class Bridge {
78
+ /**
79
+ * @param {object} options
80
+ * @param {import("./config.js").PiLxmfConfig} options.config - Resolved configuration (`loadConfig`).
81
+ * @param {import("./rpc.js").PiRpcClient} options.rpc
82
+ * @param {MeshAdapter} options.mesh
83
+ * @param {BridgeState} options.state
84
+ * @param {import("./quota.js").GlmQuotaWatcher} [options.quotaWatcher] - z.ai quota/peak watcher; enabled when the active model is a GLM model.
85
+ * @param {Logger} [options.log] - Diagnostic sink.
86
+ * @param {(reason: string) => void} [options.onShutdown] - Called when the bridge wants the daemon to exit.
87
+ */
88
+ constructor(options) {
89
+ this.config = options.config;
90
+ this.rpc = options.rpc;
91
+ this.mesh = options.mesh;
92
+ this.state = options.state;
93
+ this.quotaWatcher = options.quotaWatcher || null;
94
+ this.log = options.log || console;
95
+ this.onShutdown = options.onShutdown || (() => {});
96
+
97
+ /** The owner's Reticulum identity hash (protocol-agnostic, from config). */
98
+ this.ownerIdentity = options.config.owner;
99
+ /** The owner's derived lxmf.delivery destination hash (wire form). */
100
+ this.ownerDestinationHash = deriveLxmfDestinationHash(options.config.owner);
101
+ /** @type {string|null} */
102
+ this.sessionName = null;
103
+ /** @type {string|null} */
104
+ this.lastSessionFile = null;
105
+ /** @type {any} */
106
+ this.lastLink = undefined;
107
+ /** The `message_id` of the message that opened the current exchange, kept for the run-start reaction. */
108
+ /** @type {Uint8Array|null} */
109
+ this.lastTriggerMessageId = null;
110
+ /** @type {NodeJS.Timeout|null} */
111
+ this.reactionTimer = null;
112
+ this.reactionPending = false;
113
+ this.reactionDebounceMs = REACTION_DEBOUNCE_MS;
114
+
115
+ this.rpcReady = false;
116
+ /** @type {{promise: Promise<void>, resolve: () => void}|null} */
117
+ this.whenReady = null;
118
+
119
+ this.busy = false;
120
+ this.exchangeActive = false;
121
+ this.sentThisExchange = 0;
122
+ this.recovering = false;
123
+ /** @type {string|null} */
124
+ this.lastError = null;
125
+ /** @type {string|null} */
126
+ this.failedNote = null;
127
+ /** The most recently observed active model (gates the GLM quota watcher). */
128
+ /** @type {any} */
129
+ this.activeModel = null;
130
+
131
+ this.startedAt = Date.now();
132
+ /** @type {Promise<void>} */
133
+ this.queue = Promise.resolve();
134
+ this.shutdownRequested = false;
135
+ this.subscribed = false;
136
+ }
137
+
138
+ /**
139
+ * Subscribes to mesh and Pi events. Idempotent.
140
+ */
141
+ start() {
142
+ if (this.subscribed) return;
143
+ this.subscribed = true;
144
+
145
+ this.mesh.lxmf.addEventListener("message", (/** @type {any} */ event) => {
146
+ void this.onLxmfMessage(event);
147
+ });
148
+
149
+ this.rpc.addEventListener("event", (/** @type {any} */ event) => {
150
+ this.handlePiEvent(event.detail);
151
+ });
152
+ this.rpc.addEventListener("ready", () => {
153
+ this.setRpcReady(true);
154
+ });
155
+ this.rpc.addEventListener("restarting", (/** @type {any} */ event) => {
156
+ this.setRpcReady(false);
157
+ const { code, signal } = event.detail ?? {};
158
+ void this.deliver(
159
+ `⚠️ pi exited unexpectedly (code ${code ?? "?"} signal ${signal ?? "?"}) — restarting…`,
160
+ );
161
+ });
162
+ this.rpc.addEventListener("dead", (/** @type {any} */ event) => {
163
+ this.setRpcReady(false);
164
+ const reason = event.detail?.reason ?? "unknown reason";
165
+ void this.deliver(
166
+ `⛔ pi is not recovering: ${reason} — bridge shutting down.`,
167
+ );
168
+ this.requestShutdown(`pi dead: ${reason}`);
169
+ });
170
+ }
171
+
172
+ /**
173
+ * Marks the RPC client usable (or unusable, during supervised restarts).
174
+ * Inbound messages queue until ready.
175
+ *
176
+ * @param {boolean} ready
177
+ */
178
+ setRpcReady(ready) {
179
+ if (ready && !this.rpcReady) {
180
+ this.rpcReady = true;
181
+ if (this.whenReady) {
182
+ this.whenReady.resolve();
183
+ this.whenReady = null;
184
+ }
185
+ return;
186
+ }
187
+ if (!ready && this.rpcReady) {
188
+ this.rpcReady = false;
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Resolves once `setRpcReady(true)` has been called.
194
+ *
195
+ * @returns {Promise<void>}
196
+ */
197
+ async waitReady() {
198
+ if (this.rpcReady) return;
199
+ if (!this.whenReady) {
200
+ /** @type {(value?: any) => void} */
201
+ let resolve = () => {};
202
+ const promise = new Promise((r) => {
203
+ resolve = r;
204
+ });
205
+ this.whenReady = { promise, resolve };
206
+ }
207
+ await this.whenReady.promise;
208
+ }
209
+
210
+ /**
211
+ * Handles one inbound LXMF message event.
212
+ *
213
+ * The router signature-verifies on the direct-delivery path, but a message
214
+ * pulled in via propagation-node sync is dispatched without verification
215
+ * when the sender's identity is not yet recalled. The owner-hash check
216
+ * alone is forgeable on that path (a 16-byte hash, no private key needed),
217
+ * so we re-verify the signature here regardless of delivery path and drop
218
+ * anything that isn't cryptographically proven to be the owner's.
219
+ *
220
+ * @param {{detail?: {message?: any, link?: any}}} event
221
+ */
222
+ async onLxmfMessage(event) {
223
+ const message = event.detail?.message;
224
+ const link = event.detail?.link;
225
+ if (!message?.sourceHash) return;
226
+ // The LXMF source_hash is the sender's lxmf.delivery DESTINATION hash —
227
+ // the wire form compared against the owner's derived destination hash.
228
+ const sourceHex = toHexString(message.sourceHash);
229
+
230
+ if (sourceHex !== this.ownerDestinationHash) {
231
+ this.log.log(
232
+ `pi-lxmf: inbound from ${sourceHex}: dropped (not the owner)`,
233
+ );
234
+ return;
235
+ }
236
+
237
+ // Serialize all inbound handling so prompts and commands keep order.
238
+ // The signature verification happens inside the queue (it is async) so
239
+ // the queue chain is set synchronously here — callers awaiting
240
+ // `bridge.queue` see this message's run.
241
+ const run = this.queue
242
+ .then(() => this.admitMessage(message, link))
243
+ .catch((e) => {
244
+ this.log.error(`pi-lxmf: inbound handling failed: ${errorText(e)}`);
245
+ });
246
+ this.queue = run;
247
+ }
248
+
249
+ /**
250
+ * Verifies the signature of an inbound owner message (closing the
251
+ * propagation-sync verification gap — see {@link onLxmfMessage}) and, if
252
+ * it checks out, processes it. The owner-hash check has already run.
253
+ *
254
+ * @param {any} message
255
+ * @param {any} link
256
+ */
257
+ async admitMessage(message, link) {
258
+ // Cryptographic proof that the sender holds the owner's private key.
259
+ // `unknown` (sender identity not recalled yet, e.g. a synced message
260
+ // whose announce hasn't arrived) is treated as unverified and dropped —
261
+ // a re-sync after the announce lands will re-deliver it.
262
+ const proof = await this.mesh.verifySender(message);
263
+ if (proof !== "verified") {
264
+ this.log.log(`pi-lxmf: inbound from owner: dropped (signature ${proof})`);
265
+ return;
266
+ }
267
+
268
+ const content =
269
+ typeof message.content === "string" ? message.content.trim() : "";
270
+ if (!content) {
271
+ this.log.log("pi-lxmf: inbound from owner: ignored (empty body)");
272
+ return;
273
+ }
274
+ this.lastLink = link ?? this.lastLink;
275
+ this.lastTriggerMessageId = message.messageId ?? null;
276
+ await this.processInbound(content, link);
277
+ }
278
+
279
+ /**
280
+ * Processes one accepted inbound message: bridge command or prompt.
281
+ *
282
+ * @param {string} content
283
+ * @param {any} link
284
+ */
285
+ async processInbound(content, link) {
286
+ await this.waitReady();
287
+
288
+ const parsed = parseCommand(content);
289
+ if (parsed) {
290
+ if (parsed.name === "") {
291
+ this.log.log("pi-lxmf: inbound from owner: abort (interrupt)");
292
+ this.rpc.abort();
293
+ await this.deliver("⛔ aborted (queue intact).");
294
+ return;
295
+ }
296
+ const command = bridgeCommands[parsed.name];
297
+ if (command) {
298
+ this.log.log(`pi-lxmf: inbound from owner: command /${parsed.name}`);
299
+ try {
300
+ const result = await command.run(this.commandContext(), parsed.args);
301
+ const text = typeof result === "string" ? result : result?.text;
302
+ if (text) await this.deliver(text);
303
+ if (result && typeof result === "object" && result.shutdown) {
304
+ this.requestShutdown("owner sent /quit");
305
+ }
306
+ } catch (e) {
307
+ await this.deliver(`⚠️ /${parsed.name} failed: ${errorText(e)}`);
308
+ }
309
+ return;
310
+ }
311
+ // Unknown /…: fall through — Pi dispatches extension commands and
312
+ // skills, and anything else becomes a normal prompt.
313
+ }
314
+
315
+ this.log.log(
316
+ `pi-lxmf: inbound from owner: prompt (${content.length} chars)`,
317
+ );
318
+ await this.sendPrompt(content, link);
319
+ }
320
+
321
+ /**
322
+ * Sends a prompt to Pi, choosing `streamingBehavior` from authoritative
323
+ * state and retrying once on the accept/steer race.
324
+ *
325
+ * @param {string} content
326
+ * @param {any} link
327
+ */
328
+ async sendPrompt(content, link) {
329
+ this.lastLink = link ?? this.lastLink;
330
+
331
+ /** @type {any} */
332
+ let state = null;
333
+ try {
334
+ state = await this.rpc.getState();
335
+ } catch {
336
+ /* between restarts or briefly unresponsive: fall back to the busy hint */
337
+ }
338
+ this.observeState(state);
339
+
340
+ const streaming =
341
+ state?.isStreaming === true || (state === null && this.busy);
342
+ /** @type {any} */
343
+ let response;
344
+ // Open the exchange optimistically, before the write: pi can emit the
345
+ // acceptance response and the run's events in the same stdout chunk, and
346
+ // the response promise only resolves on a later microtask — events
347
+ // handled in between must already count towards this exchange.
348
+ this.exchangeActive = true;
349
+ try {
350
+ response = await this.rpc.prompt(
351
+ content,
352
+ streaming ? this.config.midRunBehavior : undefined,
353
+ );
354
+ } catch (e) {
355
+ this.exchangeActive = false;
356
+ await this.deliver(`⚠️ could not send prompt: ${errorText(e)}`);
357
+ return;
358
+ }
359
+ if (response.success !== true) {
360
+ const error = typeof response.error === "string" ? response.error : "";
361
+ if (/stream/i.test(error)) {
362
+ // A run started between our get_state and the prompt: retry queued.
363
+ try {
364
+ response = await this.rpc.prompt(content, this.config.midRunBehavior);
365
+ } catch (e) {
366
+ this.exchangeActive = false;
367
+ await this.deliver(`⚠️ could not send prompt: ${errorText(e)}`);
368
+ return;
369
+ }
370
+ }
371
+ }
372
+ if (response.success !== true) {
373
+ this.exchangeActive = false;
374
+ const error =
375
+ typeof response.error === "string" ? response.error : "unknown error";
376
+ await this.deliver(`⚠️ prompt rejected: ${error}`);
377
+ }
378
+ }
379
+
380
+ /**
381
+ * Handles a Pi RPC event.
382
+ *
383
+ * @param {any} event
384
+ */
385
+ handlePiEvent(event) {
386
+ if (!event || typeof event !== "object") return;
387
+ switch (event.type) {
388
+ case "agent_start":
389
+ this.busy = true;
390
+ this.scheduleReaction();
391
+ if (this.quotaWatcher) this.quotaWatcher.onAgentStart(!this.recovering);
392
+ break;
393
+ case "agent_settled":
394
+ this.busy = false;
395
+ if (this.quotaWatcher) this.quotaWatcher.onAgentSettled();
396
+ void this.onSettled();
397
+ break;
398
+ case "message_end": {
399
+ if (event.message?.role !== "assistant") break;
400
+ const text = assistantText(event.message);
401
+ if (text && this.exchangeActive) {
402
+ // A real reply landed: no need for the run-start acknowledgement.
403
+ this.clearReaction();
404
+ this.sentThisExchange += 1;
405
+ void this.deliver(text);
406
+ }
407
+ break;
408
+ }
409
+ case "auto_retry_end":
410
+ if (event.success === false && event.finalError) {
411
+ this.lastError = String(event.finalError);
412
+ if (this.quotaWatcher)
413
+ this.quotaWatcher.onError(String(event.finalError));
414
+ }
415
+ break;
416
+ case "compaction_end":
417
+ if (!event.aborted && !event.result && event.errorMessage) {
418
+ this.lastError = `compaction failed: ${event.errorMessage}`;
419
+ if (this.quotaWatcher) this.quotaWatcher.onError(event.errorMessage);
420
+ }
421
+ break;
422
+ case "extension_ui_request": {
423
+ const method = /** @type {string} */ (event.method);
424
+ if (!DIALOG_METHODS.has(method)) break;
425
+ this.rpc.respondUi(event.id, { cancelled: true });
426
+ const title = event.title ? `: ${event.title}` : "";
427
+ void this.deliver(
428
+ `⛔ dialog dismissed${title} (nobody is at the terminal).`,
429
+ );
430
+ break;
431
+ }
432
+ default:
433
+ break;
434
+ }
435
+ }
436
+
437
+ /**
438
+ * Closes the current exchange; runs empty-tail recovery when the owner
439
+ * got no reply at all for it.
440
+ */
441
+ async onSettled() {
442
+ // The run is over — any pending run-start acknowledgement is moot
443
+ // (and recovery, which follows, is never acknowledged itself).
444
+ this.clearReaction();
445
+ if (!this.exchangeActive) return;
446
+ const sent = this.sentThisExchange;
447
+ this.exchangeActive = false;
448
+ this.sentThisExchange = 0;
449
+
450
+ if (sent > 0) {
451
+ this.recovering = false;
452
+ return;
453
+ }
454
+ if (this.lastError) {
455
+ const error = this.lastError;
456
+ this.lastError = null;
457
+ this.recovering = false;
458
+ await this.deliver(`⚠️ run failed: ${error}`);
459
+ return;
460
+ }
461
+ if (!this.recovering) {
462
+ this.recovering = true;
463
+ // Optimistically re-open the exchange before the write: the recovery
464
+ // run's events can arrive in the same stdout chunk as its response.
465
+ this.exchangeActive = true;
466
+ this.sentThisExchange = 0;
467
+ try {
468
+ const response = await this.rpc.prompt(EMPTY_REPLY_RECOVERY_PROMPT);
469
+ if (response?.success !== true) {
470
+ this.exchangeActive = false;
471
+ this.recovering = false;
472
+ await this.deliver("✅ done (no reply) — your turn");
473
+ }
474
+ } catch {
475
+ this.exchangeActive = false;
476
+ this.recovering = false;
477
+ await this.deliver("✅ done (no reply) — your turn");
478
+ }
479
+ return;
480
+ }
481
+ this.recovering = false;
482
+ await this.deliver("✅ done (no reply) — your turn");
483
+ }
484
+
485
+ /**
486
+ * Records session observations (name/file) and refreshes the persisted
487
+ * pointer + the RPC client's resume path.
488
+ *
489
+ * @param {any} state - `get_state` data.
490
+ */
491
+ observeState(state) {
492
+ if (!state) return;
493
+ if (state.sessionName !== undefined) {
494
+ this.sessionName = state.sessionName ?? null;
495
+ }
496
+ const file = state.sessionFile;
497
+ if (typeof file === "string" && file && file !== this.lastSessionFile) {
498
+ this.lastSessionFile = file;
499
+ try {
500
+ this.state.saveSession(file);
501
+ } catch (e) {
502
+ this.log.error(`pi-lxmf: could not persist session pointer: ${e}`);
503
+ }
504
+ this.rpc.setSessionPath(file);
505
+ }
506
+ // Track the active model (gates the GLM quota watcher when present).
507
+ if (state.model !== undefined) {
508
+ this.activeModel = state.model ?? null;
509
+ if (this.quotaWatcher)
510
+ this.quotaWatcher.setEnabled(isZaiModel(this.activeModel));
511
+ }
512
+ }
513
+
514
+ /**
515
+ * The command execution context handed to `bridgeCommands`.
516
+ */
517
+ commandContext() {
518
+ return {
519
+ rpc: this.rpc,
520
+ getTitle: () => this.replyTitle(),
521
+ getBridgeInfo: () => ({
522
+ identityHash: this.mesh.identityHash,
523
+ deliveryHash: this.mesh.deliveryHash,
524
+ owner: this.ownerIdentity,
525
+ uptimeMs: Date.now() - this.startedAt,
526
+ }),
527
+ };
528
+ }
529
+
530
+ /**
531
+ * @returns {string} Title for the first chunk of a reply.
532
+ */
533
+ replyTitle() {
534
+ return this.sessionName ?? this.config.name;
535
+ }
536
+
537
+ /**
538
+ * Delivers a reply to the owner. Failed deliveries are noted and
539
+ * prepended to the next successful one (LXMF has no side channel).
540
+ *
541
+ * @param {string} text
542
+ */
543
+ async deliver(text) {
544
+ const payload = this.failedNote ? `${this.failedNote}\n\n${text}` : text;
545
+ try {
546
+ await this.mesh.sendText(this.ownerDestinationHash, payload, {
547
+ link: this.lastLink,
548
+ title: this.replyTitle(),
549
+ });
550
+ this.failedNote = null;
551
+ } catch (e) {
552
+ this.failedNote = `[previous reply could not be delivered: ${errorText(e)}]`;
553
+ this.log.error(`pi-lxmf: LXMF delivery failed: ${errorText(e)}`);
554
+ }
555
+ }
556
+
557
+ /**
558
+ * @param {string} reason
559
+ */
560
+ requestShutdown(reason) {
561
+ if (this.shutdownRequested) return;
562
+ this.clearReaction();
563
+ if (this.quotaWatcher) this.quotaWatcher.stop();
564
+ this.shutdownRequested = true;
565
+ this.log.log(`pi-lxmf: shutdown requested (${reason})`);
566
+ this.onShutdown(reason);
567
+ }
568
+
569
+ /**
570
+ * Schedules a best-effort LXMF reaction to the message that opened the
571
+ * current exchange, acknowledging "a run started" when no reply lands
572
+ * within the debounce window (so fast runs don't get an extra message
573
+ * ahead of the real answer). Skipped for the internal empty-reply
574
+ * recovery run, which is not an owner-triggered exchange (see `onSettled`).
575
+ */
576
+ scheduleReaction() {
577
+ this.clearReaction();
578
+ if (this.recovering) return; // recovery run — not owner-triggered
579
+ const target = this.lastTriggerMessageId;
580
+ if (!target) return; // nothing to react to (e.g. command-triggered)
581
+ this.reactionPending = true;
582
+ this.reactionTimer = setTimeout(() => {
583
+ this.reactionTimer = null;
584
+ if (!this.reactionPending) return;
585
+ this.reactionPending = false;
586
+ void this.sendReaction(target);
587
+ }, this.reactionDebounceMs);
588
+ if (typeof this.reactionTimer.unref === "function") {
589
+ this.reactionTimer.unref();
590
+ }
591
+ }
592
+
593
+ /**
594
+ * Cancels any pending run-start reaction. Idempotent.
595
+ */
596
+ clearReaction() {
597
+ if (this.reactionTimer) {
598
+ clearTimeout(this.reactionTimer);
599
+ this.reactionTimer = null;
600
+ }
601
+ this.reactionPending = false;
602
+ }
603
+
604
+ /**
605
+ * Sends the run-start acknowledgement reaction to the owner. Best-effort:
606
+ * a failure is logged but never blocks the exchange (it's not a reply).
607
+ *
608
+ * @param {Uint8Array} targetMessageId
609
+ */
610
+ async sendReaction(targetMessageId) {
611
+ try {
612
+ await this.mesh.sendReaction(
613
+ this.ownerDestinationHash,
614
+ targetMessageId,
615
+ REACTION_EMOJI,
616
+ { link: this.lastLink },
617
+ );
618
+ } catch (e) {
619
+ this.log.error(`pi-lxmf: reaction send failed: ${errorText(e)}`);
620
+ }
621
+ }
622
+ }
package/src/bz2.js ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * @file bz2.js
3
+ *
4
+ * Adapts `@digitaldefiance/bzip2-wasm` to the @reticulum/core `Bzip2`
5
+ * interface (`{ compress, decompress }`) that `Reticulum` expects as its
6
+ * `compressionProvider` (and `Link`/`Resource` as their `bz2` field) for
7
+ * §10 Resource compression.
8
+ *
9
+ * Outbound LXMF bodies larger than the link MDU travel as Resources; without
10
+ * a bz2 provider they go uncompressed, which is wasteful on slow mesh links.
11
+ */
12
+
13
+ import BZip2 from "@digitaldefiance/bzip2-wasm";
14
+
15
+ /**
16
+ * Creates and initialises a @reticulum/core-compatible bz2 adapter.
17
+ *
18
+ * `BZip2.init()` loads the WASM module, so this is async and must complete
19
+ * before any Resource transfer.
20
+ *
21
+ * @returns {Promise<{compress: (data: Uint8Array) => Uint8Array, decompress: (data: Uint8Array, outputLen: number) => Uint8Array}>}
22
+ */
23
+ export async function createBz2() {
24
+ const bz = new BZip2();
25
+ await bz.init();
26
+ return {
27
+ /**
28
+ * @param {Uint8Array} data
29
+ * @returns {Uint8Array}
30
+ */
31
+ compress: (data) => {
32
+ // bzip2's worst-case expansion for incompressible / small / already-
33
+ // compressed input is input + 1% + 600 bytes (bzip2 docs, `BZ2_bzBuff
34
+ // ToBuffCompress`). The wasm `compress()` defaults its output buffer
35
+ // to `data.length`, so without headroom a reply that compresses worse
36
+ // than its input throws BZ_OUTBUFF_FULL and the whole send fails.
37
+ const outLen = data.length + Math.floor(data.length / 100) + 600;
38
+ return bz.compress(data, 5, outLen);
39
+ },
40
+ /**
41
+ * @param {Uint8Array} data
42
+ * @param {number} outputLen - Expected uncompressed length (from the adv).
43
+ */
44
+ decompress: (data, outputLen) => bz.decompress(data, outputLen),
45
+ };
46
+ }