@makerstufferupper/flypi 0.0.0-stage → 0.1.3

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.
Files changed (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +64 -2
  3. package/assets/screenshot.png +0 -0
  4. package/bin/flypi.mjs +28 -0
  5. package/dist/ui/assets/index-CG1CuGxu.js +105 -0
  6. package/dist/ui/assets/index-qaKjH5fH.css +1 -0
  7. package/dist/ui/flypi-logo-dark.jpeg +0 -0
  8. package/dist/ui/flypi-logo-light.jpeg +0 -0
  9. package/dist/ui/index.html +17 -0
  10. package/package.json +71 -5
  11. package/scripts/benchmark-nli.ts +273 -0
  12. package/scripts/flypi.mjs +310 -0
  13. package/scripts/probe-turn-boundary.ts +97 -0
  14. package/scripts/probe.ts +119 -0
  15. package/scripts/record-transcript.ts +63 -0
  16. package/scripts/reembed-memory.ts +41 -0
  17. package/scripts/release.mjs +38 -0
  18. package/scripts/secret.ts +24 -0
  19. package/scripts/serve-daemon.ts +101 -0
  20. package/scripts/serve-memory.ts +78 -0
  21. package/scripts/serve.ts +89 -0
  22. package/scripts/smoke-memory.mjs +239 -0
  23. package/scripts/smoke-secrets.mjs +153 -0
  24. package/scripts/smoke.mjs +59 -0
  25. package/scripts/start.mjs +294 -0
  26. package/scripts/turn-boundary.ts +52 -0
  27. package/scripts/update.ts +92 -0
  28. package/scripts/warm-models.ts +41 -0
  29. package/src/agent/config-files.ts +529 -0
  30. package/src/agent/mcp-signin.ts +137 -0
  31. package/src/agent/mcp-status.ts +168 -0
  32. package/src/agent/onboarding.ts +74 -0
  33. package/src/agent/packages.ts +150 -0
  34. package/src/agent/paths.ts +80 -0
  35. package/src/agent/permission-gate.ts +70 -0
  36. package/src/agent/pi-mcp.ts +97 -0
  37. package/src/agent/runtime.ts +347 -0
  38. package/src/daemon/index.ts +442 -0
  39. package/src/daemon/jobs.ts +202 -0
  40. package/src/daemon/update.ts +248 -0
  41. package/src/memory/capture.ts +109 -0
  42. package/src/memory/config.ts +90 -0
  43. package/src/memory/consolidate.ts +218 -0
  44. package/src/memory/db.ts +393 -0
  45. package/src/memory/embed.ts +154 -0
  46. package/src/memory/judge.ts +167 -0
  47. package/src/memory/mcp.ts +496 -0
  48. package/src/memory/media.ts +135 -0
  49. package/src/memory/progress.ts +39 -0
  50. package/src/memory/recall.ts +528 -0
  51. package/src/memory/review.ts +86 -0
  52. package/src/memory/store.ts +1034 -0
  53. package/src/memory/wiki.ts +140 -0
  54. package/src/secrets/cli.ts +248 -0
  55. package/src/secrets/config.ts +133 -0
  56. package/src/secrets/crypto.ts +180 -0
  57. package/src/secrets/extension.ts +253 -0
  58. package/src/secrets/redact.ts +132 -0
  59. package/src/secrets/vault.ts +396 -0
  60. package/src/server/event-log.ts +49 -0
  61. package/src/server/extension-ui.ts +118 -0
  62. package/src/server/index.ts +398 -0
  63. package/src/server/memory-api.ts +158 -0
  64. package/src/server/protocol.ts +947 -0
  65. package/src/server/run-store.ts +136 -0
  66. package/src/server/secrets-api.ts +88 -0
  67. package/src/server/session-registry.ts +593 -0
  68. package/src/shared/attachments.ts +40 -0
  69. package/src/shared/cache-probe.ts +47 -0
  70. package/src/shared/markdown.ts +17 -0
  71. package/src/shared/metrics.ts +212 -0
  72. package/src/shared/sqlite.ts +74 -0
  73. package/src/shared/transcript.ts +404 -0
  74. package/src/shared/turn-boundary.ts +98 -0
  75. package/src/shared/views.ts +31 -0
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Fetch and load the two local memory models into `.flypi/models` before anything needs them, so the
4
+ * download is one visible step rather than a stall inside the first chat turn (docs/memory.md Phase 3).
5
+ * `npm run start` runs this; it is also the command to re-run after changing the model in
6
+ * `.flypi/agent/settings.json`.
7
+ *
8
+ * npm run models:warm
9
+ * npm run models:warm -- --embeddings none --judge none # skip a model, keyword-only memory
10
+ *
11
+ * Exits 1 when the configured embedder cannot load. A keyword-only store is a supported state, but it
12
+ * should be a decision made here, not a surprise discovered at chat time.
13
+ */
14
+ import { resolve } from "node:path";
15
+ import { flypiPaths } from "../src/agent/paths.ts";
16
+ import { memoryConfig } from "../src/memory/config.ts";
17
+ import { createEmbedder } from "../src/memory/embed.ts";
18
+ import { modelProgress } from "../src/memory/progress.ts";
19
+ import { createJudge } from "../src/memory/judge.ts";
20
+
21
+ const argv = process.argv.slice(2);
22
+ const flagValue = (name: string): string | undefined => {
23
+ const i = argv.indexOf(name);
24
+ return i === -1 ? undefined : argv[i + 1];
25
+ };
26
+
27
+ const paths = flypiPaths(resolve(flagValue("--root") ?? process.cwd()));
28
+ const config = memoryConfig(paths);
29
+ for (const key of ["--embeddings", "--judge"] as const) {
30
+ const value = flagValue(key);
31
+ if (value) config[key === "--embeddings" ? "embeddings" : "judge"].provider = value === "none" ? "none" : "transformers";
32
+ }
33
+
34
+ console.log(`flypi models: cache ${config.embeddings.cacheDir}`);
35
+ const embedder = await createEmbedder(config.embeddings, modelProgress(config.embeddings.model));
36
+ console.log(`flypi models: embeddings ${embedder.identity ? `${embedder.identity.model} (${embedder.identity.quantized ? "q8" : "fp32"})` : `none — ${embedder.error ?? "configured off"}`}`);
37
+ // The judge only has candidates when there are vectors to find them with (scripts/serve-memory.ts).
38
+ const judge = embedder.identity ? await createJudge(config.judge, modelProgress(config.judge.model)) : {};
39
+ console.log(`flypi models: judge ${judge.classify ? config.judge.model : `none${judge.error ? ` — ${judge.error}` : " (no candidates without embeddings)"}`}`);
40
+
41
+ if (config.embeddings.provider !== "none" && !embedder.identity) process.exit(1);
@@ -0,0 +1,529 @@
1
+ /**
2
+ * The agent-dir files the settings UI edits: model provider endpoints in `models.json`, MCP servers in
3
+ * `mcp.json`, and the default a new conversation starts on in `settings.json` (`getConfig` /
4
+ * `saveConfig` in docs/protocol.md).
5
+ *
6
+ * Every write reads the file, changes the one entry named, and writes the rest back untouched.
7
+ * These files hold far more than the panel shows — a model entry carries `compat`, `cost` and
8
+ * `contextWindow`, a provider can carry `headers` — so a panel that rewrote them from its own
9
+ * fields would quietly destroy a working configuration. Model entries are only ever added to:
10
+ * removing one is an edit to the file, because there is no way to tell "delete this model" from
11
+ * "the panel does not show it". The raw editor (`files` below) is how the fields its forms do not hold get
12
+ * edited: it replaces a whole file, so it is checked against the text the editor was loaded with.
13
+ *
14
+ * Values stay in the form pi reads them: an `apiKey`, a header or an `env` value may be `${ENV_VAR}` or
15
+ * `"!command"`
16
+ * (docs/secrets.md), so the panel never turns a reference into a plaintext secret.
17
+ */
18
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
+ import { dirname } from "node:path";
20
+ import { FlypiConfigError } from "./runtime.ts";
21
+ import type { FlypiPaths } from "./paths.ts";
22
+
23
+ /** pi's own rule for MCP server names (docs/mcp.md "Configuration rules"), which tools are named after. */
24
+ const NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
25
+
26
+ /** What the settings panel shows for one provider. Only the fields pi itself documents. */
27
+ export interface EndpointRow {
28
+ name: string;
29
+ baseUrl: string;
30
+ api: string;
31
+ apiKey: string;
32
+ /** The model ids that provider defines. Read-only in the panel; adding is additive. */
33
+ models: string[];
34
+ }
35
+
36
+ /** What the settings panel shows for one `mcp.json` entry, with pi's defaults filled in. */
37
+ export interface McpRow {
38
+ name: string;
39
+ url: string;
40
+ command: string;
41
+ args: string[];
42
+ /** `headers.Authorization`, the one header the panel edits; the rest stay in the file. */
43
+ authorization: string;
44
+ /** Every `env` entry, held exactly as the file holds it — a `${NAME}` or `!command` is not resolved. */
45
+ env: Record<string, string>;
46
+ cwd: string;
47
+ description: string;
48
+ exposure: McpExposure;
49
+ /** Per-tool overrides of `exposure`, keyed as pi reads them: an offered tool name or a `*` pattern. */
50
+ toolExposure: Record<string, McpExposure>;
51
+ oauth: McpOAuth;
52
+ enabled: boolean;
53
+ }
54
+
55
+ /**
56
+ * The four OAuth fields the panel edits. pi has more (`clientName`, `clientRegistration`, `callbackPort`,
57
+ * `authServerMetadataUrl`); those stay in the file untouched, the same way the headers this form does not
58
+ * show do.
59
+ */
60
+ export interface McpOAuth {
61
+ clientId: string;
62
+ clientSecret: string;
63
+ callbackUrl: string;
64
+ scope: string;
65
+ }
66
+
67
+ const OAUTH_FIELDS = ["clientId", "clientSecret", "callbackUrl", "scope"] as const;
68
+
69
+ export type McpExposure = "direct" | "deferred" | "codemode" | "hidden";
70
+
71
+ export interface ConfigSnapshot {
72
+ endpoints: EndpointRow[];
73
+ mcpServers: McpRow[];
74
+ /** What a new conversation starts on: `defaultProvider` / `defaultModel` in settings.json. */
75
+ defaults: Defaults;
76
+ /** `models.json` and `settings.json` as their exact bytes, for the raw editor. */
77
+ files: ConfigFileRow[];
78
+ /** A config file that will not parse. The panel says so rather than listing an empty config. */
79
+ errors: string[];
80
+ }
81
+
82
+ /** The two files the panel offers as text. `mcp.json` is not one: its form already covers every field. */
83
+ export type ConfigFileName = "models" | "settings";
84
+
85
+ const FILE_PATHS: Record<ConfigFileName, "modelsPath" | "settingsPath"> = { models: "modelsPath", settings: "settingsPath" };
86
+
87
+ export interface ConfigFileRow {
88
+ name: ConfigFileName;
89
+ /** The file exactly as it is on disk, `""` when it does not exist yet. */
90
+ text: string;
91
+ }
92
+
93
+ export interface ConfigFileEdit {
94
+ name: ConfigFileName;
95
+ /** The whole file, as typed. */
96
+ text: string;
97
+ /** The text the editor was loaded with. See `applyConfigEdits`. */
98
+ base?: string;
99
+ }
100
+
101
+ /**
102
+ * The pair `resolveConfiguredModel()` reads (src/agent/runtime.ts). Both halves are needed: an endpoint
103
+ * can define several models, so "this endpoint is the default" is only finished once one of its ids is
104
+ * named.
105
+ */
106
+ export interface Defaults {
107
+ provider: string;
108
+ model: string;
109
+ }
110
+
111
+ export interface EndpointEdit {
112
+ name: string;
113
+ baseUrl?: string;
114
+ api?: string;
115
+ apiKey?: string;
116
+ /** New model ids to add. An id already defined is left alone, with its `compat` and cost. */
117
+ models?: string[];
118
+ remove?: true;
119
+ }
120
+
121
+ export interface McpEdit {
122
+ name: string;
123
+ url?: string;
124
+ command?: string;
125
+ args?: string[];
126
+ authorization?: string;
127
+ env?: Record<string, string>;
128
+ cwd?: string;
129
+ description?: string;
130
+ exposure?: McpExposure;
131
+ toolExposure?: Record<string, McpExposure>;
132
+ oauth?: Partial<McpOAuth>;
133
+ enabled?: boolean;
134
+ remove?: true;
135
+ }
136
+
137
+ export interface ConfigEdits {
138
+ endpoints?: EndpointEdit[];
139
+ mcpServers?: McpEdit[];
140
+ /** Write the default pair into settings.json. Absent means that file is not touched. */
141
+ defaults?: Defaults;
142
+ /** Replace one of the two files wholesale — the raw editor's way to reach what the forms do not hold. */
143
+ files?: ConfigFileEdit[];
144
+ }
145
+
146
+ export function readConfig(paths: FlypiPaths): ConfigSnapshot {
147
+ const errors: string[] = [];
148
+ const models = readJsonObject(paths.modelsPath, errors);
149
+ const mcp = readJsonObject(paths.mcpPath, errors);
150
+ const settings = readJsonObject(paths.settingsPath, errors);
151
+ return {
152
+ endpoints: Object.entries(objects(models.providers)).map(([name, entry]) => ({
153
+ name,
154
+ baseUrl: text(entry.baseUrl),
155
+ api: text(entry.api),
156
+ // pi resolves the credential from auth.json first; the panel edits what models.json holds.
157
+ apiKey: text(entry.apiKey),
158
+ models: modelIdsOf(models, name),
159
+ })),
160
+ mcpServers: Object.entries(objects(mcp.mcpServers)).map(([name, entry]) => ({
161
+ name,
162
+ url: text(entry.url),
163
+ command: text(entry.command),
164
+ args: list(entry.args).filter((arg): arg is string => typeof arg === "string"),
165
+ authorization: text(objects(entry.headers).Authorization),
166
+ env: strings(entry.env),
167
+ cwd: text(entry.cwd),
168
+ description: text(entry.description),
169
+ exposure: exposure(entry.exposure),
170
+ toolExposure: toolExposures(entry.toolExposure),
171
+ oauth: oauthFields(entry.oauth),
172
+ enabled: entry.enabled !== false,
173
+ })),
174
+ defaults: {
175
+ // Empty strings rather than an error: settings.json may legitimately not say yet, and the panel
176
+ // has to say so instead of failing the whole snapshot.
177
+ provider: text(settings.defaultProvider),
178
+ model: text(settings.defaultModel),
179
+ },
180
+ files: (Object.keys(FILE_PATHS) as ConfigFileName[]).map((name) => ({
181
+ name,
182
+ text: existsSync(paths[FILE_PATHS[name]]) ? readFileSync(paths[FILE_PATHS[name]], "utf8") : "",
183
+ })),
184
+ errors,
185
+ };
186
+ }
187
+
188
+ /**
189
+ * The memory endpoint as `.flypi/agent/mcp.json` describes it — the same file `recall()` connects through,
190
+ * so no process can listen on one port and the agent on another (docs/decisions.md: one source of truth).
191
+ * A `${FLYPI_MEMORY_TOKEN}` placeholder means the token comes from the environment.
192
+ *
193
+ * Every entrypoint that starts sessions asks this before serving anyone a chat, and prints `note` when there
194
+ * is one. Without it the only symptom is a model reporting that the memory tools are missing: the entry is
195
+ * valid, the server may be up, and the request is simply refused for want of a token nobody exported
196
+ * (docs/risks.md risk 8 — nothing about memory is allowed to be silent).
197
+ */
198
+ export function memoryEndpoint(
199
+ paths: FlypiPaths,
200
+ ): { port?: number; token?: string; note?: string } {
201
+ const row = readConfig(paths).mcpServers.find((server) => server.name === "flypi-memory" && server.enabled);
202
+ if (!row) return { note: "no flypi-memory entry in .flypi/agent/mcp.json — flypi's own agent will not see the memory tools" };
203
+ const endpoint = row.url ? new URL(row.url) : undefined;
204
+ const bearer = /^Bearer\s+(.*)$/.exec(row.authorization)?.[1];
205
+ if (bearer === "${FLYPI_MEMORY_TOKEN}") {
206
+ const fromEnv = process.env.FLYPI_MEMORY_TOKEN;
207
+ return {
208
+ ...(endpoint?.port ? { port: Number(endpoint.port) } : {}),
209
+ ...(fromEnv ? { token: fromEnv } : {}),
210
+ ...(!fromEnv ? { note: "mcp.json asks for ${FLYPI_MEMORY_TOKEN}, which is not set — its requests will be refused" } : {}),
211
+ };
212
+ }
213
+ return {
214
+ ...(endpoint?.port ? { port: Number(endpoint.port) } : {}),
215
+ ...(bearer ? { token: bearer } : {}),
216
+ };
217
+ }
218
+
219
+ /**
220
+ * Write the `flypi-memory` entry into `mcp.json` when the file has none. A daemon that picked its own memory
221
+ * port and minted its own token has no other way to tell the agent where they are, and with no entry the
222
+ * agent has no memory tools — which is the state an installed CLI starts in, having never had an agent dir
223
+ * (docs/architecture.md Phase 7).
224
+ *
225
+ * An entry that already exists is never touched, even a broken one: it may hold `${FLYPI_MEMORY_TOKEN}`, a
226
+ * vault reference or an `exposure` someone chose, and `memoryEndpoint()` reads the file as the source of
227
+ * truth for the port and token the server binds. Returns whether the file was written.
228
+ */
229
+ export function ensureMemoryEntry(paths: FlypiPaths, port: number, token: string): boolean {
230
+ if (readConfig(paths).mcpServers.some((server) => server.name === "flypi-memory")) return false;
231
+ applyConfigEdits(paths, {
232
+ mcpServers: [{
233
+ name: "flypi-memory",
234
+ url: `http://127.0.0.1:${port}/mcp`,
235
+ authorization: `Bearer ${token}`,
236
+ // The same entry `scripts/start.mjs` writes for a checkout — that one cannot import this file,
237
+ // because it runs before `npm install` — so both writers produce one shape.
238
+ description: "flypi memory store: memory_search, memory_write, memory_read, memory_supersede, memory_forget",
239
+ // pi's default is `codemode`, which would register the server without ever offering its tools.
240
+ exposure: "direct",
241
+ }],
242
+ });
243
+ return true;
244
+ }
245
+
246
+ /**
247
+ * Write an empty `settings.json` and `models.json` when they are not there. A fresh install has no agent dir
248
+ * at all, and a missing file is reported as `could not read …: ENOENT` — which reads like a broken install,
249
+ * where the truth is that no model is configured yet. With the files present the settings panel lists an
250
+ * empty config you can add to, and a new conversation says which half is missing (`resolveConfiguredModel`,
251
+ * src/agent/runtime.ts). A file that exists is never touched, parseable or not: a working `models.json`
252
+ * carries `compat`, `cost` and `contextWindow` nothing here can rebuild, and a file that will not parse
253
+ * belongs to the operator to fix. Returns what it wrote, so the daemon can say which.
254
+ */
255
+ export function ensureAgentConfig(paths: FlypiPaths): string[] {
256
+ const created: string[] = [];
257
+ for (const path of [paths.settingsPath, paths.modelsPath]) {
258
+ if (existsSync(path)) continue;
259
+ writeJson(path, {}); // 0600 and its directory created, like every other agent-dir file
260
+ created.push(path);
261
+ }
262
+ return created;
263
+ }
264
+
265
+ /**
266
+ * Apply the panel's edits. An edit writes only the fields it carries, so an untouched field of an
267
+ * entry is untouched on disk too. Everything is applied and validated before anything is written: a
268
+ * bad name in the second row must not leave the first row half-applied. Adding or deleting an endpoint
269
+ * also keeps the default pair in step (`syncDefault`). The default is written last, into `settings.json`,
270
+ * and checked against the files as they are now — an edit that adds a model id and points the default at it
271
+ * in the same request must not be refused for something it just wrote.
272
+ * That also means a rejected default leaves endpoint edits it travelled with already written: they are
273
+ * different files and different intents, and there is no transaction across three files.
274
+ */
275
+ export function applyConfigEdits(paths: FlypiPaths, edits: ConfigEdits): void {
276
+ if (edits.files?.length) {
277
+ // First, so an endpoint edit or a default in the same request is checked against the file as the raw
278
+ // editor just wrote it.
279
+ for (const file of edits.files) {
280
+ const path = paths[FILE_PATHS[file.name]];
281
+ // A raw write replaces the whole file, credentials and all, so a file that moved since the editor
282
+ // loaded it is refused rather than overwritten: the panel's own lists and the daemon write these too.
283
+ const current = existsSync(path) ? readFileSync(path, "utf8") : "";
284
+ if (file.base !== undefined && file.base !== current) {
285
+ throw new FlypiConfigError(`${file.name}.json changed since this editor loaded it — reload it, re-apply your edit, then save`);
286
+ }
287
+ // pi reads this file for every conversation, so a file it cannot parse is refused here instead of at
288
+ // the next newSession.
289
+ parseJsonObject(file.text, path);
290
+ writeText(path, file.text);
291
+ }
292
+ }
293
+
294
+ if (edits.endpoints?.length) {
295
+ const file = readForWrite(paths.modelsPath);
296
+ const providers = objects(file.providers);
297
+ for (const edit of edits.endpoints) {
298
+ const name = checkedName(edit.name, "endpoint");
299
+ if (edit.remove) {
300
+ delete providers[name];
301
+ continue;
302
+ }
303
+ const created = !providers[name];
304
+ const entry = providers[name] ?? (providers[name] = {});
305
+ if (edit.baseUrl !== undefined) entry.baseUrl = edit.baseUrl;
306
+ if (edit.api !== undefined) entry.api = edit.api;
307
+ // Empty means "no key here", which is what lets a key move to auth.json from the panel.
308
+ if (edit.apiKey !== undefined) assign(entry, "apiKey", edit.apiKey);
309
+ if (edit.models?.length) {
310
+ const models = (entry.models ??= []) as unknown[];
311
+ for (const id of edit.models) {
312
+ if (!models.some((model) => isObject(model) && model.id === id)) models.push({ id });
313
+ }
314
+ }
315
+ // Only a new endpoint is held to this: an existing entry may legitimately name no models,
316
+ // because pi's built-in providers supply them.
317
+ if (created) {
318
+ if (!text(entry.baseUrl)) throw new FlypiConfigError(`endpoint "${name}" needs a baseUrl, e.g. http://127.0.0.1:8000/v1`);
319
+ if (!Array.isArray(entry.models) || entry.models.length === 0) {
320
+ throw new FlypiConfigError(`endpoint "${name}" defines no model: name at least one model id, or delete it`);
321
+ }
322
+ }
323
+ }
324
+ file.providers = providers;
325
+ writeJson(paths.modelsPath, file);
326
+ syncDefault(paths, edits.endpoints);
327
+ }
328
+
329
+ if (edits.mcpServers?.length) {
330
+ const file = readForWrite(paths.mcpPath);
331
+ const servers = objects(file.mcpServers);
332
+ for (const edit of edits.mcpServers) {
333
+ const name = checkedName(edit.name, "MCP server");
334
+ if (edit.remove) {
335
+ delete servers[name];
336
+ continue;
337
+ }
338
+ const entry = servers[name] ?? (servers[name] = {});
339
+ for (const key of ["url", "command", "description", "cwd"] as const) {
340
+ if (edit[key] !== undefined) assign(entry, key, edit[key] as string);
341
+ }
342
+ if (edit.args !== undefined) assign(entry, "args", edit.args);
343
+ if (edit.authorization !== undefined) {
344
+ const headers = objects(entry.headers);
345
+ assign(headers, "Authorization", edit.authorization);
346
+ assign(entry, "headers", headers);
347
+ }
348
+ // `env` and `toolExposure` are written as whole maps: a row carries every entry the file has, so
349
+ // rewriting one cannot drop another, and an empty value is how the panel says "take that key out".
350
+ if (edit.env !== undefined) assign(entry, "env", kept(edit.env));
351
+ if (edit.toolExposure !== undefined) assign(entry, "toolExposure", kept(edit.toolExposure));
352
+ // Field by field, like the Authorization header: an entry that carries pi's other OAuth keys keeps
353
+ // them, because this form does not show them and so has no right to rewrite them.
354
+ if (edit.oauth !== undefined) {
355
+ const oauth = objects(entry.oauth);
356
+ for (const field of OAUTH_FIELDS) assign(oauth, field, edit.oauth[field]);
357
+ assign(entry, "oauth", oauth);
358
+ }
359
+ // pi treats the defaults (`exposure: "codemode"`, `enabled: true`) as absent keys, so the
360
+ // panel writes them the same way and the file stays what `pi mcp add` would have written.
361
+ // Gated on the edit carrying them: an edit that names only a url — the daemon correcting a port —
362
+ // must not reset an exposure or an enabled flag the operator chose.
363
+ if (edit.exposure !== undefined) assign(entry, "exposure", edit.exposure !== "codemode" ? edit.exposure : undefined);
364
+ if (edit.enabled !== undefined) assign(entry, "enabled", edit.enabled === false ? false : undefined);
365
+ if (!text(entry.url) && !text(entry.command)) {
366
+ throw new FlypiConfigError(`MCP server "${name}" needs a url (HTTP) or a command (stdio)`);
367
+ }
368
+ }
369
+ file.mcpServers = servers;
370
+ writeJson(paths.mcpPath, file);
371
+ }
372
+
373
+ if (edits.defaults) {
374
+ const provider = checkedName(edits.defaults.provider, "endpoint");
375
+ const defined = modelIdsOf(readForWrite(paths.modelsPath), provider);
376
+ if (!defined.length) {
377
+ throw new FlypiConfigError(
378
+ `default "${provider}/${edits.defaults.model}" names no endpoint with a model in ${paths.modelsPath}`,
379
+ );
380
+ }
381
+ if (!defined.includes(edits.defaults.model)) {
382
+ throw new FlypiConfigError(
383
+ `default model "${provider}/${edits.defaults.model}" is not defined by that endpoint: it defines ${defined.join(", ")}`,
384
+ );
385
+ }
386
+ // Only these two keys of settings.json are ours; the thinking level, tool list and anything else
387
+ // the user put there stay as they are.
388
+ const file = readForWrite(paths.settingsPath);
389
+ file.defaultProvider = provider;
390
+ file.defaultModel = edits.defaults.model;
391
+ writeJson(paths.settingsPath, file);
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Keep the pair a new conversation starts on in step with the endpoints that exist: an agent dir that has
397
+ * never named one gets the first endpoint this request added, and a default whose endpoint this request
398
+ * deleted moves to another endpoint that defines a model, or is cleared when none does. Anything else is
399
+ * left alone — a pair naming a provider that is not in `models.json` may be one of pi's built-ins, chosen
400
+ * deliberately, and is not dangling just because an unrelated endpoint went away. An explicit `defaults` in
401
+ * the same request still wins: this runs before it.
402
+ */
403
+ function syncDefault(paths: FlypiPaths, endpoints: EndpointEdit[]): void {
404
+ const settings = readForWrite(paths.settingsPath);
405
+ const provider = text(settings.defaultProvider);
406
+ const removed = new Set(endpoints.filter((edit) => edit.remove).map((edit) => edit.name));
407
+ if (provider ? !removed.has(provider) : !endpoints.some((edit) => !edit.remove)) return;
408
+
409
+ const models = readForWrite(paths.modelsPath);
410
+ // The first endpoint that can actually start a conversation, preferring the ones this request just added:
411
+ // the thing the user acted on is the one they meant, and a provider naming no model id is a
412
+ // `resolveConfiguredModel()` failure whatever it is pointed at.
413
+ const added = endpoints.filter((edit) => !edit.remove).map((edit) => edit.name);
414
+ const next = [...added, ...Object.keys(objects(models.providers))].find((name) => modelIdsOf(models, name).length);
415
+ if (next) {
416
+ settings.defaultProvider = next;
417
+ settings.defaultModel = modelIdsOf(models, next)[0];
418
+ } else {
419
+ delete settings.defaultProvider;
420
+ delete settings.defaultModel;
421
+ }
422
+ writeJson(paths.settingsPath, settings);
423
+ }
424
+
425
+ /** The model ids one provider defines in a models.json object. */
426
+ function modelIdsOf(file: Record<string, unknown>, provider: string): string[] {
427
+ const entry = objects(file.providers)[provider];
428
+ if (!entry) return [];
429
+ return list(entry.models).filter(isObject).map((model) => text(model.id)).filter(Boolean);
430
+ }
431
+
432
+ function checkedName(name: string, what: string): string {
433
+ if (!NAME.test(name)) {
434
+ throw new FlypiConfigError(`${what} name "${name}" may contain letters, digits, "-" and "_" only, and must start with a letter or digit`);
435
+ }
436
+ return name;
437
+ }
438
+
439
+ /** Set a key, or remove it when the value is empty/undefined — pi reads an absent key as its default. */
440
+ function assign(target: Record<string, unknown>, key: string, value: unknown): void {
441
+ const empty = value === undefined || value === "" || (Array.isArray(value) && value.length === 0) || (isObject(value) && Object.keys(value).length === 0);
442
+ if (empty) delete target[key];
443
+ else target[key] = value;
444
+ }
445
+
446
+ /** Drop the entries saying "remove this key", so a map left with nothing in it can be dropped as a whole. */
447
+ function kept<T>(pairs: Record<string, T>): Record<string, T> {
448
+ return Object.fromEntries(Object.entries(pairs).filter(([, value]) => value !== undefined && value !== ""));
449
+ }
450
+
451
+ /** A file that is not there yet starts empty; one that will not parse is never overwritten. */
452
+ function readForWrite(path: string): Record<string, unknown> {
453
+ if (!existsSync(path)) return {};
454
+ return parseJsonObject(readFileSync(path, "utf8"), path);
455
+ }
456
+
457
+ function readJsonObject(path: string, errors: string[]): Record<string, unknown> {
458
+ if (!existsSync(path)) return {};
459
+ try {
460
+ return parseJsonObject(readFileSync(path, "utf8"), path);
461
+ } catch (error) {
462
+ errors.push(`${path}: ${(error as Error).message}`);
463
+ return {};
464
+ }
465
+ }
466
+
467
+ function parseJsonObject(raw: string, path: string): Record<string, unknown> {
468
+ const value = JSON.parse(raw) as unknown;
469
+ if (!isObject(value)) throw new FlypiConfigError(`${path} must contain a JSON object`);
470
+ return value;
471
+ }
472
+
473
+ function writeJson(path: string, value: Record<string, unknown>): void {
474
+ writeText(path, `${JSON.stringify(value, null, 2)}\n`);
475
+ }
476
+
477
+ /** One file, byte for byte as given — the raw editor's text is written exactly as it was typed. */
478
+ function writeText(path: string, text: string): void {
479
+ mkdirSync(dirname(path), { recursive: true });
480
+ // Same as auth.json: these files can hold a plaintext key, so they are never readable by the group. The
481
+ // mode in writeFileSync only applies on create, hence the chmod.
482
+ writeFileSync(path, text, { mode: 0o600 });
483
+ chmodSync(path, 0o600);
484
+ }
485
+
486
+ function isObject(value: unknown): value is Record<string, unknown> {
487
+ return typeof value === "object" && value !== null && !Array.isArray(value);
488
+ }
489
+
490
+ function objects(value: unknown): Record<string, Record<string, unknown>> {
491
+ return isObject(value) ? (value as Record<string, Record<string, unknown>>) : {};
492
+ }
493
+
494
+ /** One map inside an entry (`env`, `toolExposure`, `headers`), as key/value pairs; anything else is nothing. */
495
+ function entryList(value: unknown): [string, unknown][] {
496
+ return Object.entries(isObject(value) ? value : {});
497
+ }
498
+
499
+ function list(value: unknown): unknown[] {
500
+ return Array.isArray(value) ? value : [];
501
+ }
502
+
503
+ function text(value: unknown): string {
504
+ return typeof value === "string" ? value : "";
505
+ }
506
+
507
+ /** The string values only: a value that is not a string is not something the panel can hold or rewrite. */
508
+ function strings(value: unknown): Record<string, string> {
509
+ return Object.fromEntries(entryList(value).filter(([, item]) => typeof item === "string") as [string, string][]);
510
+ }
511
+
512
+ /** pi's own per-tool overrides, minus any level it does not know — an entry the panel cannot hold is not shown. */
513
+ function toolExposures(value: unknown): Record<string, McpExposure> {
514
+ return Object.fromEntries(entryList(value).filter(([, item]) => isExposure(item)) as [string, McpExposure][]);
515
+ }
516
+
517
+ /** The four OAuth fields this panel edits. pi's others are not read here, so they are never written back. */
518
+ function oauthFields(value: unknown): McpOAuth {
519
+ const oauth = objects(value);
520
+ return { clientId: text(oauth.clientId), clientSecret: text(oauth.clientSecret), callbackUrl: text(oauth.callbackUrl), scope: text(oauth.scope) };
521
+ }
522
+
523
+ function isExposure(value: unknown): value is McpExposure {
524
+ return value === "direct" || value === "deferred" || value === "hidden" || value === "codemode";
525
+ }
526
+
527
+ function exposure(value: unknown): McpExposure {
528
+ return isExposure(value) ? value : "codemode";
529
+ }