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/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
|
+
}
|