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.
@@ -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
+ }