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/commands.js
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file commands.js
|
|
3
|
+
*
|
|
4
|
+
* The bridge-command layer for chat messages (SPEC §7): parsing, the
|
|
5
|
+
* command table, model matching, and the formatters used by command
|
|
6
|
+
* replies. Commands are executed by the bridge with a `ctx` bundling the
|
|
7
|
+
* `PiRpcClient` and bridge state; each implementation returns the reply
|
|
8
|
+
* text (or a `{ text, shutdown }` action).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { basename } from "node:path";
|
|
12
|
+
import { formatDuration, formatTokens } from "./text.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parses bridge-command syntax out of a chat message.
|
|
16
|
+
*
|
|
17
|
+
* - `/name args…` and `!name args…` are commands (name lowercased).
|
|
18
|
+
* - A bare `!` is the quick interrupt (name `""`).
|
|
19
|
+
* - Anything else is not a command (a prompt).
|
|
20
|
+
*
|
|
21
|
+
* @param {string} text
|
|
22
|
+
* @returns {{name: string, args: string}|null}
|
|
23
|
+
*/
|
|
24
|
+
export function parseCommand(text) {
|
|
25
|
+
const trimmed = text.trim();
|
|
26
|
+
if (trimmed === "!") return { name: "", args: "" };
|
|
27
|
+
if (!trimmed.startsWith("/") && !trimmed.startsWith("!")) return null;
|
|
28
|
+
const rest = trimmed.slice(1);
|
|
29
|
+
if (rest.length === 0) return null;
|
|
30
|
+
const match = rest.match(/^(\S+)\s*([\s\S]*)$/);
|
|
31
|
+
if (!match) return null;
|
|
32
|
+
return { name: match[1].toLowerCase(), args: match[2].trim() };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Fuzzy-matches a model query against the available models: exact
|
|
37
|
+
* `provider/id` (case-insensitive) first, then substring on `provider/id`,
|
|
38
|
+
* then substring on the display name.
|
|
39
|
+
*
|
|
40
|
+
* @param {Array<{id?: string, provider?: string, name?: string}>} models
|
|
41
|
+
* @param {string} query
|
|
42
|
+
* @returns {{id?: string, provider?: string, name?: string}|null}
|
|
43
|
+
*/
|
|
44
|
+
export function matchModel(models, query) {
|
|
45
|
+
const q = query.trim().toLowerCase();
|
|
46
|
+
if (!q || !Array.isArray(models) || models.length === 0) return null;
|
|
47
|
+
const full = models.find((m) => `${m.provider}/${m.id}`.toLowerCase() === q);
|
|
48
|
+
if (full) return full;
|
|
49
|
+
const byId = models.find((m) =>
|
|
50
|
+
`${m.provider}/${m.id}`.toLowerCase().includes(q),
|
|
51
|
+
);
|
|
52
|
+
if (byId) return byId;
|
|
53
|
+
return models.find((m) => (m.name ?? "").toLowerCase().includes(q)) ?? null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Formats the model list with the current model marked.
|
|
58
|
+
*
|
|
59
|
+
* @param {Array<{id?: string, provider?: string, name?: string}>} models
|
|
60
|
+
* @param {{provider?: string, id?: string}|null} current
|
|
61
|
+
* @returns {string}
|
|
62
|
+
*/
|
|
63
|
+
export function formatModelList(models, current) {
|
|
64
|
+
const currentKey =
|
|
65
|
+
current?.provider && current?.id
|
|
66
|
+
? `${current.provider}/${current.id}`.toLowerCase()
|
|
67
|
+
: null;
|
|
68
|
+
const lines = models.map((m) => {
|
|
69
|
+
const key = `${m.provider}/${m.id}`;
|
|
70
|
+
const marker = key.toLowerCase() === currentKey ? " ← current" : "";
|
|
71
|
+
const name = m.name && m.name !== m.id ? ` — ${m.name}` : "";
|
|
72
|
+
return `${key}${name}${marker}`;
|
|
73
|
+
});
|
|
74
|
+
return [`Models (${models.length}):`, ...lines].join("\n");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Formats `get_state` + bridge info as the `/status` reply.
|
|
79
|
+
*
|
|
80
|
+
* @param {any} state - `get_state` data.
|
|
81
|
+
* @param {object} bridgeInfo
|
|
82
|
+
* @param {string} bridgeInfo.identityHash - This node's Reticulum identity hash.
|
|
83
|
+
* @param {string} bridgeInfo.deliveryHash - This node's `lxmf.delivery` destination hash.
|
|
84
|
+
* @param {string|null} bridgeInfo.owner - Paired owner hash.
|
|
85
|
+
* @param {number} bridgeInfo.uptimeMs
|
|
86
|
+
* @returns {string}
|
|
87
|
+
*/
|
|
88
|
+
export function formatStatus(state, bridgeInfo) {
|
|
89
|
+
const model = state?.model
|
|
90
|
+
? `${state.model.provider}/${state.model.id}`
|
|
91
|
+
: "(none)";
|
|
92
|
+
const session = state?.sessionFile
|
|
93
|
+
? `${state.sessionName ?? "(unnamed)"} · ${basename(state.sessionFile)}`
|
|
94
|
+
: "(none)";
|
|
95
|
+
return [
|
|
96
|
+
`model: ${model}`,
|
|
97
|
+
`thinking: ${state?.thinkingLevel ?? "off"}`,
|
|
98
|
+
`busy: ${state?.isStreaming ? "yes" : "no"}`,
|
|
99
|
+
`session: ${session}`,
|
|
100
|
+
`node: ${bridgeInfo.identityHash}`,
|
|
101
|
+
`lxmf: ${bridgeInfo.deliveryHash}`,
|
|
102
|
+
`owner (identity): ${bridgeInfo.owner ?? "?"}`,
|
|
103
|
+
`uptime: ${formatDuration(bridgeInfo.uptimeMs)}`,
|
|
104
|
+
].join("\n");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Formats `get_session_stats` data as the `/session` reply.
|
|
109
|
+
*
|
|
110
|
+
* @param {any} stats
|
|
111
|
+
* @returns {string}
|
|
112
|
+
*/
|
|
113
|
+
export function formatSessionStats(stats) {
|
|
114
|
+
if (!stats) return "No session stats available.";
|
|
115
|
+
return [
|
|
116
|
+
`messages: ${stats.userMessages ?? 0} in / ${stats.assistantMessages ?? 0} out` +
|
|
117
|
+
` (${stats.toolCalls ?? 0} tool calls)`,
|
|
118
|
+
`usage: ${formatTokens(stats)}`,
|
|
119
|
+
`session: ${stats.sessionId ?? "?"}`,
|
|
120
|
+
].join("\n");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* @typedef {object} CommandContext
|
|
125
|
+
* @property {import("./rpc.js").PiRpcClient} rpc
|
|
126
|
+
* @property {() => string} getTitle - Reply title (session name or node name).
|
|
127
|
+
* @property {() => {identityHash: string, deliveryHash: string, owner: string, uptimeMs: number}} getBridgeInfo
|
|
128
|
+
*/
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* @typedef {object} CommandResult
|
|
132
|
+
* @property {string} [text] - Reply text; omitted when there is nothing to say.
|
|
133
|
+
* @property {boolean} [shutdown] - Shut the bridge down (after replying).
|
|
134
|
+
*/
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The bridge command table. `run` returns the reply text or a result object.
|
|
138
|
+
*
|
|
139
|
+
* @type {Record<string, {description: string, run: (ctx: CommandContext, args: string) => Promise<string|CommandResult|void>}>}
|
|
140
|
+
*/
|
|
141
|
+
export const bridgeCommands = {
|
|
142
|
+
help: {
|
|
143
|
+
description: "List bridge commands",
|
|
144
|
+
async run(ctx) {
|
|
145
|
+
const mine = Object.entries(bridgeCommands).map(
|
|
146
|
+
([name, def]) => `/${name} — ${def.description}`,
|
|
147
|
+
);
|
|
148
|
+
/** @type {string[]} */
|
|
149
|
+
let piCommands = [];
|
|
150
|
+
try {
|
|
151
|
+
const commands = await ctx.rpc.getCommands();
|
|
152
|
+
piCommands = commands.map(
|
|
153
|
+
(/** @type {{name?: string, description?: string}} */ c) =>
|
|
154
|
+
`/${c.name}${c.description ? ` — ${c.description}` : ""}`,
|
|
155
|
+
);
|
|
156
|
+
} catch {
|
|
157
|
+
/* pi unavailable: bridge commands still listed */
|
|
158
|
+
}
|
|
159
|
+
const parts = [
|
|
160
|
+
["Bridge commands:", ...mine, "! (bare) — quick interrupt"].join("\n"),
|
|
161
|
+
];
|
|
162
|
+
if (piCommands.length > 0) {
|
|
163
|
+
parts.push(
|
|
164
|
+
["Pi commands (sent as prompts):", ...piCommands].join("\n"),
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
parts.push("Anything else is sent to the agent as a prompt.");
|
|
168
|
+
return parts.join("\n\n");
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
status: {
|
|
173
|
+
description: "Show model, session and bridge status",
|
|
174
|
+
async run(ctx) {
|
|
175
|
+
const state = await ctx.rpc.getState();
|
|
176
|
+
return formatStatus(state, ctx.getBridgeInfo());
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
session: {
|
|
181
|
+
description: "Show session stats (messages, tokens, cost)",
|
|
182
|
+
async run(ctx) {
|
|
183
|
+
return formatSessionStats(await ctx.rpc.getSessionStats());
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
new: {
|
|
188
|
+
description: "Start a fresh Pi session",
|
|
189
|
+
async run(ctx) {
|
|
190
|
+
const data = await ctx.rpc.newSession();
|
|
191
|
+
if (data?.cancelled) return "New session was cancelled by an extension.";
|
|
192
|
+
return "New session started.";
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
name: {
|
|
197
|
+
description: "Show or set the session display name",
|
|
198
|
+
async run(ctx, args) {
|
|
199
|
+
if (!args) {
|
|
200
|
+
const state = await ctx.rpc.getState();
|
|
201
|
+
return `Session name: ${state?.sessionName ?? "(none)"}`;
|
|
202
|
+
}
|
|
203
|
+
await ctx.rpc.setSessionName(args);
|
|
204
|
+
return `Session name set to: ${args}`;
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
compact: {
|
|
209
|
+
description: "Compact the conversation context",
|
|
210
|
+
async run(ctx, args) {
|
|
211
|
+
const result = await ctx.rpc.compact(args || undefined);
|
|
212
|
+
const before = result?.tokensBefore;
|
|
213
|
+
const after = result?.estimatedTokensAfter;
|
|
214
|
+
if (typeof before === "number" && typeof after === "number") {
|
|
215
|
+
return `Compacted: ~${before.toLocaleString()} → ~${after.toLocaleString()} tokens.`;
|
|
216
|
+
}
|
|
217
|
+
return "Compacted.";
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
model: {
|
|
222
|
+
description: "List models, or switch: /model <provider/id or search>",
|
|
223
|
+
async run(ctx, args) {
|
|
224
|
+
const models = await ctx.rpc.getAvailableModels();
|
|
225
|
+
if (!args) {
|
|
226
|
+
const state = await ctx.rpc.getState();
|
|
227
|
+
return formatModelList(models, state?.model ?? null);
|
|
228
|
+
}
|
|
229
|
+
const match = matchModel(models, args);
|
|
230
|
+
if (!match?.provider || !match?.id) {
|
|
231
|
+
return `No model matched "${args}". Use /model without arguments to list models.`;
|
|
232
|
+
}
|
|
233
|
+
await ctx.rpc.setModel(match.provider, match.id);
|
|
234
|
+
return `Model set to ${match.provider}/${match.id}.`;
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
think: {
|
|
239
|
+
description: "Set thinking level: /think <off|minimal|low|medium|high|max>",
|
|
240
|
+
async run(ctx, args) {
|
|
241
|
+
const levels = await ctx.rpc.getAvailableThinkingLevels();
|
|
242
|
+
const level = (args || "").toLowerCase();
|
|
243
|
+
if (!level || !levels.includes(level)) {
|
|
244
|
+
return `Thinking levels: ${levels.join(", ")} (current model).`;
|
|
245
|
+
}
|
|
246
|
+
await ctx.rpc.setThinkingLevel(level);
|
|
247
|
+
return `Thinking level set to ${level}.`;
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
|
|
251
|
+
abort: {
|
|
252
|
+
description: "Abort the current run and drop queued messages",
|
|
253
|
+
async run(ctx) {
|
|
254
|
+
/** @type {string[]} */
|
|
255
|
+
const dropped = [];
|
|
256
|
+
try {
|
|
257
|
+
const response = await ctx.rpc.clearQueue();
|
|
258
|
+
if (response?.success) {
|
|
259
|
+
dropped.push(...(response.data?.steering ?? []));
|
|
260
|
+
dropped.push(...(response.data?.followUp ?? []));
|
|
261
|
+
}
|
|
262
|
+
} catch {
|
|
263
|
+
/* older pi without clear_queue: abort alone still works */
|
|
264
|
+
}
|
|
265
|
+
ctx.rpc.abort();
|
|
266
|
+
return dropped.length > 0
|
|
267
|
+
? `Aborted. Dropped ${dropped.length} queued message(s).`
|
|
268
|
+
: "Aborted.";
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
|
|
272
|
+
quit: {
|
|
273
|
+
description: "Shut down the bridge and Pi",
|
|
274
|
+
async run() {
|
|
275
|
+
return { text: "Shutting down. Bye!", shutdown: true };
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The recovery prompt used when a run settles without any reply text
|
|
282
|
+
* (SPEC §6.3 — a run that ends on a tool call never wrote its answer).
|
|
283
|
+
*/
|
|
284
|
+
export const EMPTY_REPLY_RECOVERY_PROMPT =
|
|
285
|
+
"You are being driven over LXMF messaging. Your previous run finished " +
|
|
286
|
+
"without writing any reply text. Write the reply to the user's message " +
|
|
287
|
+
"now; if the work is not finished, describe the current state and what " +
|
|
288
|
+
"you still need to do.";
|
package/src/config.js
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file config.js
|
|
3
|
+
*
|
|
4
|
+
* Configuration loading/validation and the small machine-managed state
|
|
5
|
+
* files (Pi session pointer) described in SPEC §8.
|
|
6
|
+
*
|
|
7
|
+
* The controlling owner is configured (required) by their **Reticulum
|
|
8
|
+
* identity hash** — protocol-agnostic, and the key a future DACAR-style
|
|
9
|
+
* permission system (../dacar) would grant to — never by an LXMF
|
|
10
|
+
* destination hash. `src/identity.js` derives the wire form.
|
|
11
|
+
*
|
|
12
|
+
* Config resolution order for the file itself: explicit `path` argument,
|
|
13
|
+
* `$PI_LXMF_CONFIG`, then `$XDG_CONFIG_HOME/pi-lxmf/config.json`
|
|
14
|
+
* (default `~/.config/pi-lxmf/config.json`). A missing file is not an
|
|
15
|
+
* error — the daemon runs on defaults (and first-contact pairing).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
19
|
+
import {
|
|
20
|
+
existsSync,
|
|
21
|
+
mkdirSync,
|
|
22
|
+
readFileSync,
|
|
23
|
+
rmSync,
|
|
24
|
+
writeFileSync,
|
|
25
|
+
} from "node:fs";
|
|
26
|
+
import { homedir } from "node:os";
|
|
27
|
+
import { dirname, join } from "node:path";
|
|
28
|
+
|
|
29
|
+
/** Thrown for unusable configuration (bad path, bad JSON, invalid values). */
|
|
30
|
+
export class ConfigError extends Error {
|
|
31
|
+
/**
|
|
32
|
+
* @param {string} message
|
|
33
|
+
* @param {string} [file]
|
|
34
|
+
*/
|
|
35
|
+
constructor(message, file) {
|
|
36
|
+
super(file ? `${message} (${file})` : message);
|
|
37
|
+
this.name = "ConfigError";
|
|
38
|
+
this.file = file;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 16-byte destination/source hashes as 32 lowercase hex chars. */
|
|
43
|
+
const HEX32_RE = /^[0-9a-fA-F]{32}$/;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The resolved daemon configuration (output of {@link loadConfig}).
|
|
47
|
+
*
|
|
48
|
+
* @typedef {object} PiLxmfConfig
|
|
49
|
+
* @property {string} owner - Owner's 32-hex Reticulum identity hash (required;
|
|
50
|
+
* NOT the `lxmf.delivery` destination hash / "LXMF Address").
|
|
51
|
+
* @property {string} name - Announce display name.
|
|
52
|
+
* @property {string} workdir - Project directory Pi runs in.
|
|
53
|
+
* @property {string|null} model - `--model` pattern passed to Pi.
|
|
54
|
+
* @property {string} piBin - Pi binary.
|
|
55
|
+
* @property {string} dataDir - State root (storage, session pointer).
|
|
56
|
+
* @property {string|null} rnsHost - rnsd TCP interface host (fallback).
|
|
57
|
+
* @property {number|null} rnsPort - rnsd TCP interface port (fallback).
|
|
58
|
+
* @property {boolean} skipSharedInstance - Do not attach to the local rnsd
|
|
59
|
+
* shared instance; bring up own interfaces (AutoInterface + the optional
|
|
60
|
+
* `rnsHost`/`rnsPort` TCP client) instead. Needed where the shared rnsd
|
|
61
|
+
* fails to forward routed (multi-hop) traffic to its local clients.
|
|
62
|
+
* @property {string|null} propagationNode - `lxmf.propagation` hash.
|
|
63
|
+
* @property {number} syncIntervalSec - Propagation sync cadence (0 = off).
|
|
64
|
+
* @property {"steer"|"followUp"} midRunBehavior - streamingBehavior for mid-run prompts.
|
|
65
|
+
* @property {number} chunkChars - Max characters per outbound LXMF message.
|
|
66
|
+
* @property {number|null} announceIntervalSec - Re-announce cadence.
|
|
67
|
+
* @property {string|null} configPath - Config file the values came from.
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Validates and normalises a 32-hex destination hash (e.g. an
|
|
72
|
+
* `lxmf.delivery` address; never a raw 64-hex identity hash).
|
|
73
|
+
*
|
|
74
|
+
* @param {unknown} value
|
|
75
|
+
* @param {string} field
|
|
76
|
+
* @returns {string} lowercase hex
|
|
77
|
+
*/
|
|
78
|
+
function hashField(value, field) {
|
|
79
|
+
if (typeof value !== "string" || !HEX32_RE.test(value.trim())) {
|
|
80
|
+
throw new ConfigError(
|
|
81
|
+
`Config field "${field}" must be a 32-hex-character hash ` +
|
|
82
|
+
`(for "owner", the Reticulum identity hash — not the LXMF address), ` +
|
|
83
|
+
`got: ${JSON.stringify(value)}`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return value.trim().toLowerCase();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @param {unknown} value
|
|
91
|
+
* @param {string} field
|
|
92
|
+
* @param {number} fallback
|
|
93
|
+
* @param {number} [min]
|
|
94
|
+
* @returns {number}
|
|
95
|
+
*/
|
|
96
|
+
function intField(value, field, fallback, min = 0) {
|
|
97
|
+
if (value === undefined || value === null) return fallback;
|
|
98
|
+
const n = Number(value);
|
|
99
|
+
if (!Number.isFinite(n) || n < min || Math.floor(n) !== n) {
|
|
100
|
+
throw new ConfigError(
|
|
101
|
+
`Config field "${field}" must be an integer >= ${min}, got: ${JSON.stringify(value)}`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
return n;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @param {unknown} value
|
|
109
|
+
* @param {string} field
|
|
110
|
+
* @param {boolean} fallback
|
|
111
|
+
* @returns {boolean}
|
|
112
|
+
*/
|
|
113
|
+
function boolField(value, field, fallback) {
|
|
114
|
+
if (value === undefined || value === null) return fallback;
|
|
115
|
+
if (typeof value !== "boolean") {
|
|
116
|
+
throw new ConfigError(
|
|
117
|
+
`Config field "${field}" must be a boolean, got: ${JSON.stringify(value)}`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
return value;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Default config file path (`$PI_LXMF_CONFIG`, then XDG).
|
|
125
|
+
*
|
|
126
|
+
* @param {Record<string, string|undefined>} [env] - Defaults to `process.env`.
|
|
127
|
+
* @returns {string}
|
|
128
|
+
*/
|
|
129
|
+
export function defaultConfigPath(env = process.env) {
|
|
130
|
+
if (env.PI_LXMF_CONFIG) return env.PI_LXMF_CONFIG;
|
|
131
|
+
const base = env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
132
|
+
return join(base, "pi-lxmf", "config.json");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Default state/data directory (`$XDG_DATA_HOME/pi-lxmf`).
|
|
137
|
+
*
|
|
138
|
+
* @param {Record<string, string|undefined>} [env] - Defaults to `process.env`.
|
|
139
|
+
* @returns {string}
|
|
140
|
+
*/
|
|
141
|
+
export function defaultDataDir(env = process.env) {
|
|
142
|
+
const base = env.XDG_DATA_HOME || join(homedir(), ".local", "share");
|
|
143
|
+
return join(base, "pi-lxmf");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Known config keys and their validators/defaults. Used both to build the
|
|
148
|
+
* resolved config and to warn about typos in unknown keys.
|
|
149
|
+
*
|
|
150
|
+
* @type {Record<string, {required?: boolean}>}
|
|
151
|
+
*/
|
|
152
|
+
const KNOWN_KEYS = {
|
|
153
|
+
owner: {},
|
|
154
|
+
name: {},
|
|
155
|
+
workdir: {},
|
|
156
|
+
model: {},
|
|
157
|
+
piBin: {},
|
|
158
|
+
dataDir: {},
|
|
159
|
+
rnsHost: {},
|
|
160
|
+
rnsPort: {},
|
|
161
|
+
skipSharedInstance: {},
|
|
162
|
+
propagationNode: {},
|
|
163
|
+
syncIntervalSec: {},
|
|
164
|
+
midRunBehavior: {},
|
|
165
|
+
chunkChars: {},
|
|
166
|
+
announceIntervalSec: {},
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Loads, validates and resolves the daemon configuration.
|
|
171
|
+
*
|
|
172
|
+
* @param {object} [options]
|
|
173
|
+
* @param {string} [options.path] - Explicit config file path (highest precedence).
|
|
174
|
+
* @param {Record<string, string|undefined>} [options.env] - Defaults to `process.env`.
|
|
175
|
+
* @param {string} [options.cwd] - Default for `workdir`. Defaults to `process.cwd()`.
|
|
176
|
+
* @param {(msg: string) => void} [options.warn] - Sink for warnings (unknown keys). Defaults to `console.error`.
|
|
177
|
+
* @returns {Promise<PiLxmfConfig>}
|
|
178
|
+
*/
|
|
179
|
+
export async function loadConfig(options = {}) {
|
|
180
|
+
const env = options.env ?? process.env;
|
|
181
|
+
const cwd = options.cwd ?? process.cwd();
|
|
182
|
+
const warn = options.warn ?? console.error;
|
|
183
|
+
const file = options.path || defaultConfigPath(env);
|
|
184
|
+
|
|
185
|
+
/** @type {Record<string, unknown>} */
|
|
186
|
+
let raw = {};
|
|
187
|
+
/** @type {string|null} */
|
|
188
|
+
let configPath = null;
|
|
189
|
+
if (existsSync(file)) {
|
|
190
|
+
let text;
|
|
191
|
+
try {
|
|
192
|
+
text = readFileSync(file, "utf8");
|
|
193
|
+
} catch (e) {
|
|
194
|
+
throw new ConfigError(`Cannot read config file: ${e}`, file);
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
raw = JSON.parse(text);
|
|
198
|
+
} catch (e) {
|
|
199
|
+
throw new ConfigError(`Config file is not valid JSON: ${e}`, file);
|
|
200
|
+
}
|
|
201
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
202
|
+
throw new ConfigError("Config file must contain a JSON object", file);
|
|
203
|
+
}
|
|
204
|
+
configPath = file;
|
|
205
|
+
for (const key of Object.keys(raw)) {
|
|
206
|
+
if (!(key in KNOWN_KEYS)) {
|
|
207
|
+
warn(`pi-lxmf: unknown config key "${key}" ignored in ${file}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const { default: pkg } = await import("../package.json", {
|
|
213
|
+
with: { type: "json" },
|
|
214
|
+
});
|
|
215
|
+
const version = /** @type {{version?: string}} */ (pkg).version ?? "0.0.0";
|
|
216
|
+
|
|
217
|
+
if (raw.owner === undefined || raw.owner === null) {
|
|
218
|
+
throw new ConfigError(
|
|
219
|
+
'Config field "owner" is required: set it to your Reticulum identity ' +
|
|
220
|
+
"hash (32 hex chars — not the LXMF address). Messages from any other " +
|
|
221
|
+
"identity are dropped.",
|
|
222
|
+
configPath ?? undefined,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const midRunBehavior =
|
|
227
|
+
raw.midRunBehavior === undefined || raw.midRunBehavior === null
|
|
228
|
+
? "steer"
|
|
229
|
+
: raw.midRunBehavior;
|
|
230
|
+
if (midRunBehavior !== "steer" && midRunBehavior !== "followUp") {
|
|
231
|
+
throw new ConfigError(
|
|
232
|
+
`Config field "midRunBehavior" must be "steer" or "followUp", got: ${JSON.stringify(raw.midRunBehavior)}`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
owner: hashField(raw.owner, "owner"),
|
|
238
|
+
name:
|
|
239
|
+
typeof raw.name === "string" && raw.name.trim()
|
|
240
|
+
? raw.name.trim()
|
|
241
|
+
: `pi-lxmf ${version}`,
|
|
242
|
+
workdir:
|
|
243
|
+
typeof raw.workdir === "string" && raw.workdir.trim() ? raw.workdir : cwd,
|
|
244
|
+
model:
|
|
245
|
+
typeof raw.model === "string" && raw.model.trim()
|
|
246
|
+
? raw.model.trim()
|
|
247
|
+
: null,
|
|
248
|
+
piBin:
|
|
249
|
+
typeof raw.piBin === "string" && raw.piBin.trim()
|
|
250
|
+
? raw.piBin.trim()
|
|
251
|
+
: "pi",
|
|
252
|
+
dataDir:
|
|
253
|
+
typeof raw.dataDir === "string" && raw.dataDir.trim()
|
|
254
|
+
? raw.dataDir
|
|
255
|
+
: defaultDataDir(env),
|
|
256
|
+
rnsHost:
|
|
257
|
+
typeof raw.rnsHost === "string" && raw.rnsHost.trim()
|
|
258
|
+
? raw.rnsHost.trim()
|
|
259
|
+
: null,
|
|
260
|
+
rnsPort:
|
|
261
|
+
raw.rnsPort === undefined || raw.rnsPort === null
|
|
262
|
+
? null
|
|
263
|
+
: intField(raw.rnsPort, "rnsPort", 0, 1),
|
|
264
|
+
skipSharedInstance: boolField(
|
|
265
|
+
raw.skipSharedInstance,
|
|
266
|
+
"skipSharedInstance",
|
|
267
|
+
false,
|
|
268
|
+
),
|
|
269
|
+
propagationNode:
|
|
270
|
+
raw.propagationNode === undefined || raw.propagationNode === null
|
|
271
|
+
? null
|
|
272
|
+
: hashField(raw.propagationNode, "propagationNode"),
|
|
273
|
+
syncIntervalSec: intField(raw.syncIntervalSec, "syncIntervalSec", 0),
|
|
274
|
+
midRunBehavior,
|
|
275
|
+
chunkChars: intField(raw.chunkChars, "chunkChars", 2500, 1),
|
|
276
|
+
announceIntervalSec:
|
|
277
|
+
raw.announceIntervalSec === undefined || raw.announceIntervalSec === null
|
|
278
|
+
? null
|
|
279
|
+
: intField(raw.announceIntervalSec, "announceIntervalSec", 0, 60),
|
|
280
|
+
configPath,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// --- Machine-managed state ------------------------------------------------
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Reads a JSON state file, returning `null` when absent or corrupt.
|
|
288
|
+
*
|
|
289
|
+
* @param {string} path
|
|
290
|
+
* @returns {Record<string, any>|null}
|
|
291
|
+
*/
|
|
292
|
+
function readStateFile(path) {
|
|
293
|
+
if (!existsSync(path)) return null;
|
|
294
|
+
try {
|
|
295
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
296
|
+
return typeof parsed === "object" && parsed !== null ? parsed : null;
|
|
297
|
+
} catch {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Writes a JSON state file with `0600` permissions, creating directories.
|
|
304
|
+
*
|
|
305
|
+
* @param {string} path
|
|
306
|
+
* @param {Record<string, any>} data
|
|
307
|
+
*/
|
|
308
|
+
function writeStateFile(path, data) {
|
|
309
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
310
|
+
writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* The pre-migration session pointer path (kept for one-time adoption; see
|
|
315
|
+
* {@link readSessionPointer}).
|
|
316
|
+
*/
|
|
317
|
+
const LEGACY_SESSION_FILE = "session";
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Directory under `dataDir` holding the per-workdir session pointers.
|
|
321
|
+
*/
|
|
322
|
+
const SESSIONS_DIR = "sessions";
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* A stable, filesystem-safe key for a `workdir`: the first 16 hex chars of
|
|
326
|
+
* its SHA-256. The key is the absolute path only — not the model, not a
|
|
327
|
+
* session name — so switching models mid-session keeps the same pointer
|
|
328
|
+
* (the session is the conversation; the model is orthogonal).
|
|
329
|
+
*
|
|
330
|
+
* @param {string} workdir - Absolute workdir (the key is the path only).
|
|
331
|
+
* @returns {string} a 16-char hex key.
|
|
332
|
+
*/
|
|
333
|
+
function workdirKey(workdir) {
|
|
334
|
+
return createHash("sha256").update(workdir).digest("hex").slice(0, 16);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* The per-workdir session-pointer file path under `dataDir`.
|
|
339
|
+
*
|
|
340
|
+
* @param {string} dataDir
|
|
341
|
+
* @param {string} workdir
|
|
342
|
+
* @returns {string}
|
|
343
|
+
*/
|
|
344
|
+
function sessionPointerPath(dataDir, workdir) {
|
|
345
|
+
return join(dataDir, SESSIONS_DIR, `${workdirKey(workdir)}.json`);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Loads the persisted Pi session pointer for `workdir`, if any.
|
|
350
|
+
*
|
|
351
|
+
* One-time migration: if no per-workdir pointer exists yet but the legacy
|
|
352
|
+
* `${dataDir}/session` file does, it is adopted for the current workdir
|
|
353
|
+
* (the last session was here) and the legacy file is removed, so the
|
|
354
|
+
* adoption runs exactly once. A workdir with no pointer and no legacy file
|
|
355
|
+
* starts empty (fresh session).
|
|
356
|
+
*
|
|
357
|
+
* @param {string} dataDir
|
|
358
|
+
* @param {string} workdir - The resolved workdir the pointer is scoped to.
|
|
359
|
+
* @returns {{sessionFile: string}|null}
|
|
360
|
+
*/
|
|
361
|
+
export function readSessionPointer(dataDir, workdir) {
|
|
362
|
+
const path = sessionPointerPath(dataDir, workdir);
|
|
363
|
+
const existing = readStateFile(path);
|
|
364
|
+
if (existing && typeof existing.sessionFile === "string") {
|
|
365
|
+
return { sessionFile: existing.sessionFile };
|
|
366
|
+
}
|
|
367
|
+
// One-time adoption of the legacy single-file pointer.
|
|
368
|
+
const legacyPath = join(dataDir, LEGACY_SESSION_FILE);
|
|
369
|
+
const legacy = readStateFile(legacyPath);
|
|
370
|
+
if (legacy?.sessionFile && typeof legacy.sessionFile === "string") {
|
|
371
|
+
writeStateFile(path, { sessionFile: legacy.sessionFile });
|
|
372
|
+
try {
|
|
373
|
+
rmSync(legacyPath, { force: true });
|
|
374
|
+
} catch {
|
|
375
|
+
/* best effort — the adoption already wrote the new pointer */
|
|
376
|
+
}
|
|
377
|
+
return { sessionFile: legacy.sessionFile };
|
|
378
|
+
}
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Persists the Pi session pointer for `workdir` (the JSONL file `pi
|
|
384
|
+
* --session` resumes), keyed by the workdir so distinct repos keep distinct
|
|
385
|
+
* sessions.
|
|
386
|
+
*
|
|
387
|
+
* @param {string} dataDir
|
|
388
|
+
* @param {string} workdir - The resolved workdir the pointer is scoped to.
|
|
389
|
+
* @param {string} sessionFile - Absolute path to the session file.
|
|
390
|
+
*/
|
|
391
|
+
export function writeSessionPointer(dataDir, workdir, sessionFile) {
|
|
392
|
+
writeStateFile(sessionPointerPath(dataDir, workdir), { sessionFile });
|
|
393
|
+
}
|