pi-lxmf 0.2.0 → 0.2.2

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 CHANGED
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.2] - 2026-09-28
11
+
12
+ ### Changed
13
+
14
+ - Bridge command replies (especially `/help`, `/status`, `/session`,
15
+ `/model`, `/cd`) are now authored in Markdown — section headings, bullet
16
+ lists and code spans around commands, models, paths and hashes — matching
17
+ the content-format metadata (`FIELD_RENDERER: RENDERER_MARKDOWN`) every
18
+ outbound message already carries, so they render nicely in Sideband and
19
+ NomadNet instead of as plain text.
20
+ - The `/cd` switch banner uses a Markdown horizontal rule and a bold
21
+ headline instead of a `📂 ───` decoration line.
22
+
23
+ ## [0.2.1] - 2026-09-28
24
+
25
+ ### Added
26
+
27
+ - Outbound LXMF messages now signal their content format: every content
28
+ chunk carries the `FIELD_RENDERER` field set to `RENDERER_MARKDOWN`, so
29
+ clients (Sideband, NomadNet) render pi's Markdown replies correctly
30
+ instead of showing raw markup as plain text.
31
+
10
32
  ## [0.2.0] - 2026-09-27
11
33
 
12
34
  ### Added
package/SPEC.md CHANGED
@@ -181,6 +181,10 @@ example:
181
181
  paragraph boundaries where possible, each chunk suffixed
182
182
  `[… n/N]` except the last. Chunking keeps single LXMF messages
183
183
  reasonable for phone UIs and for mesh airtime.
184
+ - **Content format:** every chunk carries the LXMF `FIELD_RENDERER` field
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.
184
188
  - **Errors:** failures to deliver a reply are logged and retried once;
185
189
  persistent failure is reported in the next successful message (LXMF has
186
190
  no channel over which to report its own failure).
@@ -363,7 +367,9 @@ in one place so it can be retargeted per subtree.
363
367
 
364
368
  Command parsing: first whitespace-separated token, case-insensitive,
365
369
  leading `!` equivalent to `/` for bridge commands (a bare `!` is the
366
- 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.
367
373
 
368
374
  ## 8. Configuration and state
369
375
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lxmf",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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>",
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
- const lines = ["📂 ───────────────────", `📂 Switched to ${rel}`];
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 ${basename(pointer.sessionFile)}`
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 `${key}${name}${marker}`;
117
+ return `- \`${key}\`${name}${marker}`;
116
118
  });
117
- return [`Models (${models.length}):`, ...lines].join("\n");
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
- ? `${state.model.provider}/${state.model.id}`
137
+ ? `\`${state.model.provider}/${state.model.id}\``
136
138
  : "(none)";
137
139
  const session = state?.sessionFile
138
- ? `${state.sessionName ?? "(unnamed)"} · ${basename(state.sessionFile)}`
140
+ ? `${state.sessionName ?? "(unnamed)"} (\`${basename(state.sessionFile)}\`)`
139
141
  : "(none)";
140
142
  return [
141
- `model: ${model}`,
142
- `thinking: ${state?.thinkingLevel ?? "off"}`,
143
- `busy: ${state?.isStreaming ? "yes" : "no"}`,
144
- `session: ${session}`,
145
- `cwd: ${bridgeInfo.cwd ?? "?"}`,
146
- `workdir: ${bridgeInfo.workdir ?? "?"}`,
147
- `node: ${bridgeInfo.identityHash}`,
148
- `lxmf: ${bridgeInfo.deliveryHash}`,
149
- `owner (identity): ${bridgeInfo.owner ?? "?"}`,
150
- `uptime: ${formatDuration(bridgeInfo.uptimeMs)}`,
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 = [`cwd: ${relative(workdir, cwd) || "."} (${cwd})`];
171
+ const lines = [
172
+ "**Repos**",
173
+ "",
174
+ `- Current: \`${relative(workdir, cwd) || "."}\` (\`${cwd}\`)`,
175
+ ];
168
176
  const recent = bridgeInfo.recentWorkdirs ?? [];
169
- if (recent.length === 0) {
170
- lines.push("recent: (none)");
171
- } else {
172
- lines.push("recent:");
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
- `messages: ${stats.userMessages ?? 0} in / ${stats.assistantMessages ?? 0} out` +
193
+ "**Session**",
194
+ "",
195
+ `- Messages: ${stats.userMessages ?? 0} in / ${stats.assistantMessages ?? 0} out` +
188
196
  ` (${stats.toolCalls ?? 0} tool calls)`,
189
- `usage: ${formatTokens(stats)}`,
190
- `session: ${stats.sessionId ?? "?"}`,
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]) => `/${name} — ${def.description}`,
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
- `/${c.name}${c.description ? ` — ${c.description}` : ""}`,
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
- ["Bridge commands:", ...mine, "! (bare) — quick interrupt"].join("\n"),
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):", ...piCommands].join("\n"),
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 ${ctx.workdir}.`;
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
@@ -53,6 +53,20 @@ export function isUnknownIdentityError(e) {
53
53
  return e instanceof Error && UNKNOWN_IDENTITY_MESSAGE.test(e.message);
54
54
  }
55
55
 
56
+ /**
57
+ * Builds the fields map signaling the outbound message content format
58
+ * (FIELD_RENDERER, SPEC §5.9.4): pi's replies are Markdown, and clients
59
+ * such as Sideband only render them as such when the field says so —
60
+ * without it they show raw `**bold**`/backtick fences as plain text.
61
+ *
62
+ * @returns {Map<number, number>} `{FIELD_RENDERER: RENDERER_MARKDOWN}`
63
+ */
64
+ export function contentFields() {
65
+ const fields = new Map();
66
+ fields.set(LXMFConstants.FIELD_RENDERER, LXMFConstants.RENDERER_MARKDOWN);
67
+ return fields;
68
+ }
69
+
56
70
  /**
57
71
  * Waits until `transport` can recall the identity for `destinationHash`,
58
72
  * soliciting it first: a path request makes the destination itself (or any
@@ -526,6 +540,7 @@ export async function startLxmf(config, options = {}) {
526
540
  sourceHash: /** @type {Uint8Array} */ (deliveryDest.destinationHash),
527
541
  destinationHash: fromHex(destinationHex),
528
542
  content,
543
+ fields: contentFields(),
529
544
  ...(i === 0 && sendOptions.title ? { title: sendOptions.title } : {}),
530
545
  });
531
546
  await sendWithRetry(message, sendOptions.link);