@makerstufferupper/flypi 0.0.0-stage → 0.1.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.
Files changed (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +44 -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 +303 -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 +35 -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,303 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `flypi start | stop | restart | status | logs` — the daemon CLI (docs/architecture.md Phase 4.5).
4
+ *
5
+ * No dependency and no service manager needed, and no port arbitration either: the daemon picks its own
6
+ * port, then says so in `.flypi/daemon.json` (0600 — it carries the token). The CLI's job is to start it
7
+ * detached, wait until `/healthz` answers, and report the URL you would otherwise have to dig out of a log.
8
+ *
9
+ * node scripts/flypi.mjs start [--foreground] # or: npm run flypi -- start
10
+ * node scripts/flypi.mjs status | stop | restart | logs [-n 50] [-f]
11
+ * node scripts/flypi.mjs update [--check] # the newest published flypi (docs/architecture.md Phase 7)
12
+ *
13
+ * `--root DIR` (or FLYPI_ROOT) picks which `.flypi/` the CLI owns; in a checkout it defaults to the cwd,
14
+ * and the installed `bin/flypi.mjs` sets FLYPI_ROOT to the home directory before importing this.
15
+ */
16
+ import { mkdirSync, openSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
17
+ import { dirname, resolve } from "node:path";
18
+ import { spawn } from "node:child_process";
19
+ import { fileURLToPath } from "node:url";
20
+
21
+ const argv = process.argv.slice(2);
22
+ // `--root DIR` is accepted anywhere in the line and taken out before the subcommand is read: children are
23
+ // handed `--root` explicitly, so a subcommand like `secret` must not see it twice.
24
+ const rootAt = argv.indexOf("--root");
25
+ const rootFlag = rootAt === -1 ? undefined : argv.splice(rootAt, 2)[1];
26
+ const root = resolve(rootFlag ?? process.env.FLYPI_ROOT ?? process.cwd());
27
+ // The checkout the script lives in, which is where `tsx` and the app's imports resolve from. `--root` is
28
+ // how the daemon is told which `.flypi/` to own: they are not the same directory when the CLI is run with
29
+ // FLYPI_ROOT from somewhere else.
30
+ const checkout = resolve(fileURLToPath(import.meta.url), "..", "..");
31
+ const serveDaemon = resolve(fileURLToPath(import.meta.url), "..", "serve-daemon.ts");
32
+ const secretCli = resolve(fileURLToPath(import.meta.url), "..", "secret.ts");
33
+ const updateCli = resolve(fileURLToPath(import.meta.url), "..", "update.ts");
34
+ const infoPath = resolve(root, ".flypi", "daemon.json");
35
+ const logPath = resolve(root, ".flypi", "daemon.log");
36
+ /**
37
+ * The port and interface the last start was *named* with, kept next to the pid file and left there when the
38
+ * daemon stops. A clean stop removes the pid file, so without this a bare `flypi start` after `flypi stop`
39
+ * picks a new ephemeral port and invalidates the URL every bookmark and every published Docker port uses.
40
+ * No token in it, so no `0600`.
41
+ */
42
+ const flagsPath = resolve(root, ".flypi", "daemon-flags.json");
43
+
44
+ /**
45
+ * A pid file is only evidence if the process is still there; `kill(pid, 0)` asks without stopping it. That
46
+ * answer is not enough on its own: it succeeds on a zombie, and a container whose PID 1 does not reap
47
+ * (`docker run` without `--init`) leaves a killed daemon's entry in the process table for the life of the
48
+ * container — so `status` called it "running" and `start` called it "already running", forever, over a pid
49
+ * file the daemon never got to remove. Where `/proc` is readable the state says whether it is a process or a
50
+ * corpse; elsewhere (macOS, Windows) `kill(pid, 0)` is the only evidence there is, and launchd/Windows reap
51
+ * what they orphan, so a lingering zombie is not the case there.
52
+ */
53
+ const alive = (pid) => {
54
+ try {
55
+ process.kill(pid, 0);
56
+ } catch {
57
+ return false;
58
+ }
59
+ try {
60
+ // `<pid> (comm) <state> …`, and `comm` can hold spaces and parentheses — hence the last `)`, not the first.
61
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
62
+ return stat.slice(stat.lastIndexOf(")") + 2)[0] !== "Z";
63
+ } catch {
64
+ return true;
65
+ }
66
+ };
67
+
68
+ /**
69
+ * Flags `flypi start` hands to the daemon. `--port`/`--host` because a detached daemon is otherwise
70
+ * unreachable by number or by interface: it picks an ephemeral port, and a published Docker port cannot
71
+ * reach the container's own loopback. `--embeddings none`/`--judge none` because they are the difference
72
+ * between a first run that downloads ~1 GB of ONNX models and one that comes up keyword-only in seconds.
73
+ *
74
+ * `carried` is the daemon that was just stopped, the stale pid file a crashed one left, or the flags file a
75
+ * previous start wrote. A bare `flypi restart` or a bare `flypi start` means "that daemon again", so the port
76
+ * and interface it was reached by carry over — forgetting them moves a container's daemon off its published
77
+ * port with nothing to explain the dead URL.
78
+ * The memory flags do not carry over: the settings file is the persistent place for those, and the flags
79
+ * are the one-off override of it.
80
+ */
81
+ const flagValue = (name) => {
82
+ const i = argv.indexOf(name);
83
+ return i === -1 ? undefined : argv[i + 1];
84
+ };
85
+ const daemonFlags = (carried) => {
86
+ // A port named on this command line is a demand: a container's published port is useless on another
87
+ // number, so the daemon has to fail loudly rather than move. A port carried over from an earlier start
88
+ // is a preference, so `--port-may-move` lets it bind another one instead of dying on `EADDRINUSE`.
89
+ const named = flagValue("--port");
90
+ const port = named ?? carried?.port;
91
+ const host = flagValue("--host") ?? carried?.host;
92
+ return [
93
+ ...(port === undefined ? [] : ["--port", String(port), ...(named === undefined ? ["--port-may-move"] : [])]),
94
+ ...(host === undefined ? [] : ["--host", String(host)]),
95
+ ...["--embeddings", "--judge"].flatMap((name) => (flagValue(name) ? [name, flagValue(name)] : [])),
96
+ ];
97
+ };
98
+
99
+ const readJson = (file) => {
100
+ try {
101
+ return JSON.parse(readFileSync(file, "utf8"));
102
+ } catch {
103
+ return null;
104
+ }
105
+ };
106
+
107
+ const readInfo = () => readJson(infoPath);
108
+
109
+ const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
110
+
111
+ async function health(port) {
112
+ try {
113
+ const response = await fetch(`http://127.0.0.1:${port}/healthz`);
114
+ return response.ok ? await response.json() : null;
115
+ } catch {
116
+ return null;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Echo what the daemon has written since it was spawned. A first run fetches ~1 GB of memory models before
122
+ * it is listening, and a silent wait is indistinguishable from a hang; `logFrom` is the log's size at spawn,
123
+ * so an earlier run's lines are not replayed.
124
+ */
125
+ const echoLog = (from) => {
126
+ try {
127
+ const text = readFileSync(logPath, "utf8");
128
+ if (text.length > from) process.stdout.write(text.slice(from));
129
+ return text.length;
130
+ } catch {
131
+ return from;
132
+ }
133
+ };
134
+
135
+ // Generous, because the thing it waits on is a download: the models are ~1 GB on a machine that has never
136
+ // run flypi. A child that exits is reported immediately, so this only ever costs time on a real hang.
137
+ const STARTUP_TIMEOUT_MS = 15 * 60_000;
138
+ async function waitForStartup(stalePid, died, logFrom, timeoutMs = STARTUP_TIMEOUT_MS) {
139
+ const until = Date.now() + timeoutMs;
140
+ let shown = logFrom;
141
+ while (Date.now() < until) {
142
+ shown = echoLog(shown);
143
+ const info = readInfo();
144
+ if (info && info.pid !== stalePid && alive(info.pid)) {
145
+ // The health page is the proof it is serving, not just spawned. Loading the memory models can
146
+ // take a moment, so this is a poll rather than one attempt.
147
+ const status = await health(info.port);
148
+ if (status) return { info, status };
149
+ }
150
+ // A child that has already exited is not going to answer: report the log now, do not make the user
151
+ // wait out the model-loading timeout to find out the process never started.
152
+ if (died() !== null) return null;
153
+ await sleep(200);
154
+ }
155
+ return null;
156
+ }
157
+
158
+ function report(info, status) {
159
+ console.log(`flypi: running (pid ${info.pid}) — root ${root}`);
160
+ console.log(`flypi: agent http://${info.host}:${info.port} (token ${info.token})`);
161
+ console.log(`flypi: memory http://127.0.0.1:${info.memoryPort}/mcp`);
162
+ if (status) console.log(`flypi: started ${status.startedAt}, version ${status.version}, ${status.jobs} job(s) queued`);
163
+ // The daemon's own daily answer (src/daemon/update.ts) — `status` does not reach the network itself.
164
+ if (status?.updateAvailable) console.log(`flypi: ${status.updateAvailable} is available — \`flypi update\` installs it`);
165
+ if (status?.updateReady) console.log(`flypi: ${status.updateReady} is downloaded — it installs once the daemon has been idle`);
166
+ }
167
+
168
+ async function start({ foreground = false, previous } = {}) {
169
+ const existing = readInfo();
170
+ if (existing && alive(existing.pid)) {
171
+ report(existing, await health(existing.port));
172
+ console.log("flypi: already running — `flypi restart` to restart it");
173
+ return 0;
174
+ }
175
+ // A file left by a dead process would otherwise be mistaken for the daemon we are about to start.
176
+ if (existing) rmSync(infoPath, { force: true });
177
+ // Most recent word wins: the daemon being restarted, the stale pid file a crash left, then the flags a
178
+ // previous start named and nothing has removed since.
179
+ const carried = previous ?? existing ?? readJson(flagsPath);
180
+ const flags = daemonFlags(carried);
181
+ // The `.flypi/` directory is created here, not assumed: on a machine that has never run flypi, `--root`
182
+ // has no `.flypi/` in it yet, and neither the log nor the flags file would open.
183
+ mkdirSync(dirname(logPath), { recursive: true });
184
+ // Only a start that names them writes the file — an ephemeral port the daemon picked is not worth making
185
+ // sticky — and it merges, so naming just `--port` does not drop an `--host` that is still in force.
186
+ const namedFlags = { ...(flagValue("--port") ? { port: Number(flagValue("--port")) } : {}), ...(flagValue("--host") ? { host: flagValue("--host") } : {}) };
187
+ if (Object.keys(namedFlags).length > 0) writeFileSync(flagsPath, `${JSON.stringify({ ...readJson(flagsPath), ...namedFlags })}\n`);
188
+ if (foreground) {
189
+ const child = spawn(process.execPath, ["--import", "tsx", serveDaemon, "--root", root, ...flags], { cwd: checkout, stdio: "inherit" });
190
+ return new Promise((done) => child.on("exit", (code) => done(code ?? 0)));
191
+ }
192
+ // The log's directory is created above, not assumed.
193
+ const log = openSync(logPath, "a");
194
+ const logFrom = statSync(logPath).size;
195
+ const child = spawn(process.execPath, ["--import", "tsx", serveDaemon, "--root", root, ...flags], { cwd: checkout, detached: true, stdio: ["ignore", log, log] });
196
+ child.unref();
197
+ let exitCode = null;
198
+ child.once("exit", (code) => {
199
+ exitCode = code ?? 0;
200
+ });
201
+ console.log(`flypi: starting (pid ${child.pid}) — log: ${logPath}`);
202
+ const started = await waitForStartup(existing?.pid, () => exitCode, logFrom);
203
+ if (!started) {
204
+ const why = exitCode === null ? `no /healthz within ${Math.round(STARTUP_TIMEOUT_MS / 60_000)} minutes` : `it exited (code ${exitCode}) before it was listening`;
205
+ console.error(`flypi: ${why}. Last log lines:\n${tail(logPath, 20)}`);
206
+ return 1;
207
+ }
208
+ report(started.info, started.status);
209
+ return 0;
210
+ }
211
+
212
+ const tail = (file, lines) => {
213
+ try {
214
+ return readFileSync(file, "utf8").split("\n").filter(Boolean).slice(-lines).join("\n");
215
+ } catch {
216
+ return "(no log yet)";
217
+ }
218
+ };
219
+
220
+ async function stop() {
221
+ const info = readInfo();
222
+ if (!info || !alive(info.pid)) {
223
+ console.log("flypi: not running");
224
+ return 0;
225
+ }
226
+ // SIGTERM, and the daemon's own shutdown: stop the loop, close the sockets, remove its pid file. A
227
+ // turn that was running is left as `interrupted` for the next start to report — never re-run.
228
+ process.kill(info.pid, "SIGTERM");
229
+ // The proof it stopped is the record it removes on the way out, not the process table: `kill(pid, 0)`
230
+ // succeeds on a zombie, and a container whose PID 1 does not reap (`docker run` without `--init`) leaves a
231
+ // cleanly-shut-down daemon sitting there — so the old check called a clean stop a failure.
232
+ const stopped = () => !alive(info.pid) || readInfo() === null;
233
+ for (let waited = 0; waited < 10_000 && !stopped(); waited += 100) await sleep(100);
234
+ console.log(stopped() ? "flypi: stopped" : `flypi: pid ${info.pid} did not stop — kill it if you must`);
235
+ return 0;
236
+ }
237
+
238
+ async function status() {
239
+ const info = readInfo();
240
+ if (!info) {
241
+ console.log(`flypi: no ${infoPath} — never started, or it stopped and cleaned up after itself`);
242
+ return 1;
243
+ }
244
+ if (!alive(info.pid)) {
245
+ console.log(`flypi: not running (pid ${info.pid} is gone; it died or was killed) — the file is left so you can see where`);
246
+ return 1;
247
+ }
248
+ report(info, await health(info.port));
249
+ return 0;
250
+ }
251
+
252
+ function logs({ lines = 40, follow = false } = {}) {
253
+ console.log(tail(logPath, lines));
254
+ if (!follow) return 0;
255
+ const child = spawn("tail", ["-f", logPath], { stdio: "inherit" });
256
+ return new Promise((done) => child.on("exit", (code) => done(code ?? 0)));
257
+ }
258
+
259
+ /**
260
+ * `flypi secret …` (docs/secrets.md Phase 5.5). Run as a child for the same reason `start --foreground`
261
+ * is: the app is TypeScript, and this script is plain node so the daemon CLI works without a build.
262
+ * stdio is inherited so a value can be piped in and never appear in the argument list.
263
+ */
264
+ async function secret(args) {
265
+ const child = spawn(process.execPath, ["--import", "tsx", secretCli, "--root", root, ...args], { cwd: checkout, stdio: "inherit" });
266
+ return new Promise((done) => child.on("exit", (code) => done(code ?? 0)));
267
+ }
268
+
269
+ /**
270
+ * `flypi update` (docs/architecture.md Phase 7): the newest published flypi, installed. Run as a child for
271
+ * the same reason `secret` is — the app is TypeScript and this script is plain node. The child stops and
272
+ * starts the daemon through this CLI, so the port/interface carry-over stays in one place.
273
+ */
274
+ async function update(args) {
275
+ const child = spawn(process.execPath, ["--import", "tsx", updateCli, "--root", root, ...args], { cwd: checkout, stdio: "inherit" });
276
+ return new Promise((done) => child.on("exit", (code) => done(code ?? 0)));
277
+ }
278
+
279
+ const command = argv[0] ?? "status";
280
+ const flagNumber = (name) => {
281
+ const i = argv.indexOf(name);
282
+ return i === -1 ? undefined : Number(argv[i + 1]);
283
+ };
284
+ const result = await {
285
+ start: () => start({ foreground: argv.includes("--foreground") }),
286
+ stop,
287
+ restart: async () => {
288
+ // Read the pid file before stopping: the daemon removes it on the way out, and the port and interface
289
+ // it was reached by are what a bare `flypi restart` has to reuse.
290
+ const previous = readInfo();
291
+ await stop();
292
+ return start({ foreground: argv.includes("--foreground"), previous });
293
+ },
294
+ status,
295
+ logs: () => logs({ ...(flagNumber("-n") ? { lines: flagNumber("-n") } : {}), follow: argv.includes("-f") || argv.includes("--follow") }),
296
+ secret: () => secret(argv.slice(1)),
297
+ update: () => update(argv.slice(1)),
298
+ }[command]?.();
299
+ if (result === undefined) {
300
+ console.log("usage: flypi [--root DIR] start [--foreground] [--port N] [--host H] [--embeddings none] [--judge none] | stop | restart | status | logs [-n N] [-f] | update [--check] | secret …");
301
+ process.exit(2);
302
+ }
303
+ process.exit(result);
@@ -0,0 +1,97 @@
1
+ /**
2
+ * C1's instrument (docs/prompt-cache.md): one real conversation, two turns, on a real route. A turn boundary
3
+ * is where the prompt cache is allowed to break (Phase 4.7), and `npm run probe` runs one turn, so it never
4
+ * measures that. This prints what the provider reported per model call and per turn — the same accounting
5
+ * `src/memory/recall.ts` writes into the `turns` row — plus the size of the block each turn injected and the
6
+ * session prompt it carries.
7
+ *
8
+ * npm run probe:turn -- local/llama_a
9
+ *
10
+ * Needs the memory MCP service up and FLYPI_MEMORY_TOKEN set, or recall degrades to the short "unreachable"
11
+ * note and nothing is persisted — which since C2 means the boundary is clean for the wrong reason, so a run
12
+ * without the token measures nothing about the block.
13
+ *
14
+ * What a healthy run looks like since C2 (docs/prompt-cache.md): the block's message appears once, after the
15
+ * turn that retrieved it, with the same hash in every later request, and the first call of turn 2 re-pays only the
16
+ * new user message (18–19 tokens on the routes configured here) where C1 measured thousands.
17
+ *
18
+ * `FLYPI_CACHE_PROBE=1` adds the request half of the picture: one line per model request, and one per message
19
+ * inside it, hashed (src/shared/cache-probe.ts). Two runs of the same command then answer the question the
20
+ * usage numbers cannot on their own — the 0-vs-1462 at a boundary is either the request bytes changing
21
+ * (diff shows where) or the route dropping a prefix it was given (diff is empty).
22
+ *
23
+ * `--no-memory` runs the same two turns with no memory server configured, which is C1's control: nothing
24
+ * transient is injected, so the prefix is byte-identical across the boundary and whatever the route still
25
+ * re-pays is its own forgetting, not the block's. `probe:boundary` prices the block case from the
26
+ * conversations that really ran.
27
+ */
28
+ import { createHash } from "node:crypto";
29
+ import { ModelRuntime } from "@earendil-works/pi-coding-agent";
30
+ import { flypiPaths } from "../src/agent/paths.ts";
31
+ import { createFlypiSession } from "../src/agent/runtime.ts";
32
+ import { cacheProbeEnabled, cacheProbeLines } from "../src/shared/cache-probe.ts";
33
+ import { addUsage, emptyUsage, usageOf } from "../src/shared/transcript.ts";
34
+
35
+ const PROMPTS = ["List the files in the current directory.", "How many of those are markdown files?"];
36
+ const args = process.argv.slice(2);
37
+ const control = args.includes("--no-memory");
38
+ const modelRef = args.find((arg) => !arg.startsWith("--"));
39
+ const probing = cacheProbeEnabled();
40
+ let blockChars = 0;
41
+ let requests = 0;
42
+
43
+ const paths = flypiPaths(process.cwd());
44
+ if (control) console.log("memory: off — no recalled block, no memory instruction in the prompt (C1's control)");
45
+ const modelRuntime = await ModelRuntime.create({ authPath: paths.authPath, modelsPath: paths.modelsPath });
46
+ const model = modelRef ? modelRuntime.getModel(modelRef.slice(0, modelRef.indexOf("/")), modelRef.slice(modelRef.indexOf("/") + 1)) : undefined;
47
+ if (modelRef && !model) throw new Error(`no such model: ${modelRef}`);
48
+ const { session } = await createFlypiSession({
49
+ root: paths.root,
50
+ tools: ["read", "bash"],
51
+ // The control takes the memory server out of *this session's* config rather than out of the file: the
52
+ // same "no memory server configured" the app gets when mcp.json is absent, and nothing on disk changes,
53
+ // so a SIGKILL or a second overlapping run cannot leave the real config disabled. `blockChars=0` in the
54
+ // output below is the proof the control really ran.
55
+ ...(control ? { mcpConfig: { servers: [], errors: [] } } : {}),
56
+ ...(model ? { model } : {}),
57
+ onMemoryBlock: (block) => {
58
+ blockChars = block.length;
59
+ },
60
+ });
61
+
62
+ // pi's own seam for looking at a request without changing it: `onPayload` is called with the body the
63
+ // provider is about to get, and returning undefined keeps it. This is the only view of the bytes a prefix
64
+ // cache actually keys on — pi's internal context still carries timestamps and other fields the adapter drops.
65
+ if (probing) {
66
+ session.agent.onPayload = (payload) => {
67
+ requests += 1;
68
+ console.log([`request ${requests}`, ...cacheProbeLines(payload)].join("\n"));
69
+ };
70
+ console.log(`FLYPI_CACHE_PROBE=1: hashing every request; diff two runs to see what the route could not reuse`);
71
+ }
72
+
73
+ let turn = 0;
74
+ for (const prompt of PROMPTS) {
75
+ turn += 1;
76
+ const before = session.messages.length;
77
+ blockChars = 0;
78
+ const sys = session.systemPrompt ?? "";
79
+ console.log(`turn ${turn} systemPrompt chars=${sys.length} sha=${createHash("sha256").update(sys).digest("hex").slice(0, 12)}`);
80
+ await session.prompt(prompt);
81
+ let total = emptyUsage();
82
+ let calls = 0;
83
+ for (const message of session.messages.slice(before)) {
84
+ const usage = usageOf((message as { usage?: unknown })?.usage);
85
+ if (!usage) continue;
86
+ calls += 1;
87
+ total = addUsage(total, usage);
88
+ console.log(
89
+ `turn ${turn} call ${calls} prompt=${usage.input + usage.cacheRead + usage.cacheWrite} in=${usage.input} out=${usage.output} cacheRead=${usage.cacheRead} cacheWrite=${usage.cacheWrite}`,
90
+ );
91
+ }
92
+ console.log(`turn ${turn} TOTAL in=${total.input} out=${total.output} cacheRead=${total.cacheRead} cacheWrite=${total.cacheWrite} blockChars=${blockChars}`);
93
+ }
94
+ session.dispose();
95
+ // shortcut: the MCP client keeps a socket open after the last turn, so the process would outlive its
96
+ // output; exiting from the write callback is the smallest way to make this usable in a pipeline.
97
+ process.stdout.write("done\n", () => process.exit(0));
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Phase 0 gate: prove the pi runtime is isolated to .flypi/ and can complete a turn.
4
+ *
5
+ * npm run probe # config check + one real turn ("list files in cwd")
6
+ * npm run probe -- --no-turn # config check only (offline / CI)
7
+ * npm run probe -- --root DIR # check a different checkout
8
+ * npm run probe -- --model provider/id # run that route instead of the settings default
9
+ *
10
+ * Exits 1 with a one-line message on any problem — never a stack trace.
11
+ * The session wiring itself lives in src/agent/runtime.ts, which the server shares.
12
+ */
13
+ import { readFileSync } from "node:fs";
14
+ import { resolve } from "node:path";
15
+ import { ModelRuntime } from "@earendil-works/pi-coding-agent";
16
+ import { assertFlypiOwned, flypiPaths } from "../src/agent/paths.ts";
17
+ import { createFlypiSession, FlypiConfigError, type FlypiModel } from "../src/agent/runtime.ts";
18
+ import { addUsage, emptyUsage, usageOf } from "../src/shared/transcript.ts";
19
+
20
+ function fail(message: string): never {
21
+ console.error(`flypi probe: ${message}`);
22
+ process.exit(1);
23
+ }
24
+
25
+ const argv = process.argv.slice(2);
26
+ const flagValue = (name: string): string | undefined => {
27
+ const i = argv.indexOf(name);
28
+ return i === -1 ? undefined : argv[i + 1];
29
+ };
30
+ const root = resolve(flagValue("--root") ?? process.cwd());
31
+ const noTurn = argv.includes("--no-turn");
32
+ const modelRef = flagValue("--model");
33
+ const paths = flypiPaths(root);
34
+
35
+ try {
36
+ assertFlypiOwned(paths);
37
+ } catch (error) {
38
+ fail((error as Error).message);
39
+ }
40
+
41
+ console.log(`config dir ${paths.agentDir}`);
42
+ console.log(`sessions ${paths.sessionsDir}`);
43
+
44
+ const modelRuntime = await ModelRuntime.create({ authPath: paths.authPath, modelsPath: paths.modelsPath });
45
+ let model: FlypiModel | undefined;
46
+ if (modelRef) {
47
+ const slash = modelRef.indexOf("/");
48
+ if (slash < 1) fail(`--model wants "provider/id", got "${modelRef}"`);
49
+ model = modelRuntime.getModel(modelRef.slice(0, slash), modelRef.slice(slash + 1));
50
+ if (!model) fail(`model "${modelRef}" is not defined in ${paths.modelsPath}`);
51
+ }
52
+
53
+ let session;
54
+ try {
55
+ ({ session } = await createFlypiSession({ root, tools: ["read", "bash"], modelRuntime, model }));
56
+ } catch (error) {
57
+ if (!(error instanceof FlypiConfigError)) throw error;
58
+ fail(error.message);
59
+ }
60
+
61
+ const tools = session.getActiveToolNames();
62
+ const mcp = (
63
+ JSON.parse(readFileSync(paths.mcpPath, "utf8")) as { mcpServers?: Record<string, unknown> }
64
+ ).mcpServers ?? {};
65
+
66
+ console.log(`model ${session.model?.provider}/${session.model?.id}`);
67
+ console.log(`thinking ${session.thinkingLevel}`);
68
+ console.log(`tools ${tools.join(", ")}`);
69
+ console.log(`session file ${session.sessionFile ?? "(none)"}`);
70
+ console.log(`mcp servers ${Object.keys(mcp).join(", ") || "(none configured)"} — connecting them is Phase 5`);
71
+
72
+ if (!tools.includes("read") || !tools.includes("bash")) {
73
+ fail(`expected the read and bash tools to be active, got: ${tools.join(", ") || "(none)"}`);
74
+ }
75
+
76
+ if (!noTurn) {
77
+ const used = new Set<string>();
78
+ const unsubscribe = session.subscribe((event) => {
79
+ if (event.type === "tool_execution_start") used.add(event.toolName);
80
+ });
81
+ try {
82
+ await session.prompt("List the files in the current directory.");
83
+ } catch (error) {
84
+ fail(`the turn failed: ${(error as Error).message}`);
85
+ } finally {
86
+ unsubscribe();
87
+ }
88
+ const answer = session.getLastAssistantText();
89
+ console.log(`turn tools ${[...used].join(", ") || "(none)"}`);
90
+ console.log(`turn answer ${answer ?? "(none)"}`);
91
+ if (!answer) fail("the model returned no text");
92
+ if (used.size === 0) fail("the turn completed without calling any tool");
93
+ if (!session.sessionFile?.startsWith(paths.sessionsDir)) {
94
+ fail(`session file ${session.sessionFile} is not inside ${paths.sessionsDir}`);
95
+ }
96
+
97
+ // Phase 4.6 A0 (docs/prompt-cache.md): whether the provider's cache numbers reach the session at all.
98
+ // Printed, never asserted — a route that reports no cached tokens is the finding, not a probe failure.
99
+ let total = emptyUsage();
100
+ let calls = 0;
101
+ for (const message of session.messages) {
102
+ const usage = usageOf((message as { usage?: unknown }).usage);
103
+ if (!usage) continue;
104
+ calls++;
105
+ total = addUsage(total, usage);
106
+ console.log(
107
+ `call ${calls} in=${usage.input} out=${usage.output} cacheRead=${usage.cacheRead} cacheWrite=${usage.cacheWrite}`,
108
+ );
109
+ }
110
+ console.log(`turn usage in=${total.input} out=${total.output} cacheRead=${total.cacheRead} cacheWrite=${total.cacheWrite}`);
111
+ console.log(
112
+ `cache numbers ${
113
+ total.cacheRead || total.cacheWrite ? "arrive" : "none — this route reported no cached tokens to the session"
114
+ }`,
115
+ );
116
+ }
117
+
118
+ session.dispose();
119
+ console.log("flypi probe: ok");
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Record `tests/fixtures/transcript-log` — the event sequence one real multi-tool turn produces
4
+ * (read → edit → read → bash → answer, with the real pi tools running against a throwaway
5
+ * checkout), plus the text render of it. The same fixture drives the `renderMessage()` golden,
6
+ * the UI component tests and `scripts/smoke.mjs`, which is why it is recorded once and committed.
7
+ *
8
+ * npm run record
9
+ *
10
+ * Determinism: timestamps, the stub model's per-call nonce, and absolute paths under the temp
11
+ * root are stripped (tests/helpers/flypi-harness.ts `normalize`). What is left is what a client
12
+ * would actually render. The `node --test` output in the bash card carries durations, so
13
+ * re-recording can shift those numbers — that is the one thing that legitimately changes.
14
+ */
15
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { harness, normalize } from "../tests/helpers/flypi-harness.ts";
18
+ import { reduceEvents, renderTranscriptText } from "../src/shared/transcript.ts";
19
+ import { FIX_THE_FAILING_TEST, fixTheFailingTest, writeFailingTestRepo } from "../tests/helpers/recorded-turn.ts";
20
+
21
+ const FIXTURE_DIR = join(import.meta.dirname, "..", "tests", "fixtures", "transcript-log");
22
+
23
+ const h = await harness(() => fixTheFailingTest(), { tools: ["read", "edit", "bash"] });
24
+ const root = h.root;
25
+ writeFailingTestRepo(root);
26
+ try {
27
+ const conversation = await h.registry.create();
28
+ const events: unknown[] = [];
29
+ h.registry.attach(conversation.id, (_seq, event) => events.push(event));
30
+ await h.registry.prompt(conversation.id, FIX_THE_FAILING_TEST);
31
+ for (let attempt = 0; attempt < 500 && h.registry.get(conversation.id).session.isStreaming; attempt++) {
32
+ await new Promise((resolve) => setTimeout(resolve, 20));
33
+ }
34
+ // The session file must exist on disk before it is reported; a turn flushes it.
35
+ const sessionPath = h.registry.get(conversation.id).session.sessionFile;
36
+
37
+ const normalized = normalize(events, root) as Record<string, unknown>[];
38
+ mkdirSync(FIXTURE_DIR, { recursive: true });
39
+ writeFileSync(join(FIXTURE_DIR, "events.json"), `${JSON.stringify(normalized, null, 1)}\n`);
40
+ writeFileSync(
41
+ join(FIXTURE_DIR, "transcript.txt"),
42
+ renderTranscriptText(reduceEvents(normalized as never)),
43
+ );
44
+ writeFileSync(
45
+ join(FIXTURE_DIR, "README.md"),
46
+ `# transcript-log — a recorded multi-tool turn
47
+
48
+ \`events.json\` is the pi session event stream from one real turn: a scripted stub model
49
+ (tests/helpers/stub-model.ts) driving the real pi \`read\`, \`edit\` and \`bash\` tools against a
50
+ throwaway checkout whose failing test is a numeric sort. Regenerate with \`npm run record\`.
51
+ Timestamps, the stub nonce and temp-root paths are stripped; the bash card's test-runner
52
+ durations are the one thing that can differ between recordings.
53
+
54
+ Why it is committed: the same events drive the \`renderMessage()\` golden (\`transcript.txt\`),
55
+ the UI component tests and \`scripts/smoke.mjs\`. If those disagree, one of them is wrong.
56
+ Recorded against pi ${process.env.FLYPI_PI_VERSION ?? "1.0.4"}; session file \`${sessionPath ? "<root>/…jsonl" : "(none)"}\`.
57
+ `,
58
+ );
59
+ console.log(`recorded ${normalized.length} events → tests/fixtures/transcript-log`);
60
+ console.log(readFileSync(join(FIXTURE_DIR, "transcript.txt"), "utf8"));
61
+ } finally {
62
+ h.cleanup();
63
+ }
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Re-embed the memories the current index cannot represent. Run this after changing the embedding model
4
+ * (docs/decisions.md): the old vectors are not comparable to the new ones, so until they are rewritten
5
+ * those memories are reachable by keywords only.
6
+ *
7
+ * npm run reembed:memory
8
+ * npm run reembed:memory -- --db /path/to/flypi.db
9
+ */
10
+ import { resolve } from "node:path";
11
+ import { flypiPaths } from "../src/agent/paths.ts";
12
+ import { memoryConfig } from "../src/memory/config.ts";
13
+ import { openMemoryDb } from "../src/memory/db.ts";
14
+ import { createEmbedder } from "../src/memory/embed.ts";
15
+ import { MemoryStore } from "../src/memory/store.ts";
16
+
17
+ const argv = process.argv.slice(2);
18
+ const flagValue = (name: string): string | undefined => {
19
+ const i = argv.indexOf(name);
20
+ return i === -1 ? undefined : argv[i + 1];
21
+ };
22
+
23
+ const paths = flypiPaths(resolve(flagValue("--root") ?? process.cwd()));
24
+ const config = memoryConfig(paths);
25
+ if (flagValue("--embeddings")) config.embeddings.provider = flagValue("--embeddings") === "none" ? "none" : "transformers";
26
+
27
+ const embedder = await createEmbedder(config.embeddings);
28
+ if (embedder.error) console.log(`flypi reembed: ${embedder.error}`);
29
+
30
+ const db = openMemoryDb(flagValue("--db") ?? paths.memoryDbPath);
31
+ const store = new MemoryStore(db, embedder);
32
+ const before = store.indexIdentity();
33
+ console.log(`flypi reembed: ${before.rows} stored vectors, ${before.stale} not from ${before.expected?.model ?? "anything we can produce"}`);
34
+
35
+ const { embedded, skipped } = await store.reembed("cli");
36
+ console.log(`flypi reembed: rewrote ${embedded.length} ${embedded.length === 1 ? "memory" : "memories"}${embedded.length ? `: ${embedded.join(", ")}` : ""}`);
37
+ for (const row of skipped) console.log(`flypi reembed: #${row.id} stays keyword-only: ${row.why}`);
38
+
39
+ const after = store.indexIdentity();
40
+ console.log(`flypi reembed: index now holds ${after.rows} vectors, ${after.stale} from another model`);
41
+ db.close();
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `npm run release [patch|minor|major] [--dry-run]` — the version bump *is* the release.
4
+ *
5
+ * package.json is the single version: the daemon reports it on `/healthz`, `flypi status` prints it, and the
6
+ * update check compares against it. So the increment is `npm version`, which bumps it, commits, and tags
7
+ * `v<version>` — and refuses to run on a dirty tree, which is the gate that makes a tag mean something.
8
+ * Then push the tag and publish; npm runs `prepublishOnly` (typecheck, tests, the UI build) on the way to
9
+ * the registry, so anything that gets published has passed the gates.
10
+ *
11
+ * `--dry-run` prints every step and runs none of them.
12
+ */
13
+ import { spawnSync } from "node:child_process";
14
+ import { readFileSync } from "node:fs";
15
+
16
+ const argv = process.argv.slice(2);
17
+ const dryRun = argv.includes("--dry-run");
18
+ const kind = argv.find((a) => ["patch", "minor", "major", "preminor", "premajor"].includes(a)) ?? "patch";
19
+
20
+ /** `remote: true` marks the two steps that leave this machine, so the dry-run output says which is which. */
21
+ function run(command, args, { remote = false } = {}) {
22
+ if (dryRun) {
23
+ console.log(`flypi release: would run${remote ? " [remote]" : ""}: ${command} ${args.join(" ")}`);
24
+ return 0;
25
+ }
26
+ const result = spawnSync(command, args, { stdio: "inherit" });
27
+ if (result.status !== 0) process.exit(result.status ?? 1);
28
+ return result.status;
29
+ }
30
+
31
+ run("npm", ["version", kind, "-m", "release %s"]);
32
+ const version = JSON.parse(readFileSync("package.json", "utf8")).version;
33
+ run("git", ["push", "--follow-tags"], { remote: true });
34
+ run("npm", ["publish", "--access", "public"], { remote: true });
35
+ console.log(dryRun ? `flypi release: dry run only - a ${kind} bump from ${version}; nothing committed, pushed or published` : `flypi release: ${version} published`);