@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,593 @@
1
+ /**
2
+ * One `AgentSession` per conversationId (docs/architecture.md Phase 1).
3
+ *
4
+ * The registry owns lifecycle and the event fan-out; the WS layer never touches a session.
5
+ * Sessions keep running with no client attached — the event log is per conversation, not per
6
+ * socket, so a closed tab loses nothing. Phase 4.5 widens this (daemon, `runs` table); the
7
+ * rule that matters already lives here: **a session whose turn is still running is never
8
+ * evicted** — `session.isIdle` covers runs, compaction, retries and queued work.
9
+ */
10
+ import { randomUUID } from "node:crypto";
11
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
12
+ import { join, resolve } from "node:path";
13
+ import { SessionManager, type AgentSessionEvent, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
14
+ import { flypiPaths, type FlypiPaths } from "../agent/paths.ts";
15
+ import { applyConfigEdits, readConfig, type ConfigEdits } from "../agent/config-files.ts";
16
+ import { applyPackageEdits, readPackages, type PackageEdits } from "../agent/packages.ts";
17
+ import { createFlypiSession, type FlypiModel, type FlypiSession, type FlypiThinkingLevel } from "../agent/runtime.ts";
18
+ import { mcpStatus } from "../agent/mcp-status.ts";
19
+ import { signIn as startMcpSignIn, submitRedirect as submitMcpRedirect } from "../agent/mcp-signin.ts";
20
+ import { redactSecrets } from "../secrets/redact.ts";
21
+ import type { SecretStore } from "../secrets/vault.ts";
22
+ import { isImage, type FlypiAttachment } from "../shared/attachments.ts";
23
+ import { EventLog } from "./event-log.ts";
24
+ import { createExtensionUiBridge } from "./extension-ui.ts";
25
+ import type { CommandRow, DebugInfo, ExtensionUiFrame, SessionSummary, ConfigSnapshot, McpStatusSnapshot, PackagesSnapshot } from "./protocol.ts";
26
+ import type { FlypiMessage } from "../shared/transcript.ts";
27
+
28
+ export type EventListener = (seq: number, event: AgentSessionEvent) => void;
29
+ /** What an extension said to the user (`src/server/extension-ui.ts`) — a second channel, not a pi event. */
30
+ export type ExtensionUiListener = (frame: ExtensionUiFrame) => void;
31
+ /** Supplies the session for a conversation: production uses `createFlypiSession`, tests use the stub model. */
32
+ export type CreateSession = (options: {
33
+ sessionManager: SessionManager;
34
+ /** flypi's conversation id. It is the key the memory store records turns under, so the session factory
35
+ * has to pass it down (docs/memory.md Phase 4). */
36
+ conversationId: string;
37
+ /** Report the memory block the turn injected, so `debugInfo` can show it. */
38
+ onMemoryBlock: (block: string) => void;
39
+ /**
40
+ * What `ctx.ui` does for this conversation. It has to reach `bindExtensions` — pi binds the UI once, at
41
+ * `session_start`, and binding twice would reconnect every MCP server.
42
+ */
43
+ extensionUi: ExtensionUIContext;
44
+ }) => Promise<FlypiSession>;
45
+
46
+ export interface Conversation {
47
+ readonly id: string;
48
+ readonly log: EventLog<AgentSessionEvent>;
49
+ readonly listeners: Set<EventListener>;
50
+ session: FlypiSession;
51
+ /**
52
+ * The seq each message arrived on, for the whole life of the conversation — the log ring drops old
53
+ * events, a message on screen never stops being forkable. The value is pi's own message object, not
54
+ * a copy, which is what lets `fork()` find the session entry behind it.
55
+ */
56
+ messages: Map<number, FlypiMessage>;
57
+ /**
58
+ * The `<relevant_memories>` block the last turn actually put in front of the model, or null when it put none
59
+ * there. Held in a mutable box because the callback that fills it is handed to the session factory before
60
+ * this object exists. The block itself is a session entry since Phase 4.7 C2; this is only how the debug
61
+ * panel learns what the turn it is showing was given, including a note that was never persisted.
62
+ */
63
+ memory: { block: string | null };
64
+ /**
65
+ * The conversation's extension UI bridge: `ctx.ui.notify/select/confirm/input` reach the page through it,
66
+ * and it holds the dialogs still waiting for an answer. It outlives the session — `switchSession` binds a
67
+ * new session to the same bridge, so a dialog survives the chat being reopened.
68
+ */
69
+ extensionUi: ReturnType<typeof createExtensionUiBridge>;
70
+ /** Sockets watching this conversation's extension output. Detached with the socket, like event listeners. */
71
+ uiListeners: Set<ExtensionUiListener>;
72
+ unsubscribe: () => void;
73
+ lastSettledAt: number;
74
+ disposed: boolean;
75
+ }
76
+
77
+ export interface SessionRegistryOptions {
78
+ root: string;
79
+ createSession?: CreateSession;
80
+ /** Evict conversations idle for this long with no client attached. */
81
+ idleTtlMs?: number;
82
+ logCapacity?: number;
83
+ now?: () => number;
84
+ /**
85
+ * Turn lifecycle, for the daemon's `runs` record (docs/architecture.md Phase 4.5): "a turn was running
86
+ * when this process went away" is only knowable across processes if it is on disk. Absent means no
87
+ * record is kept — a bare `npm run serve` has no memory store to write one to.
88
+ */
89
+ onRunStatus?: (conversationId: string, status: "running" | "idle" | "aborted", info: { seq: number; sessionFile?: string }) => void;
90
+ /**
91
+ * The vault, when this process opened one (docs/secrets.md Phase 5.5). Every conversation then gets the
92
+ * secret tools and the redaction; without it the sessions have neither, which is the honest state of a
93
+ * process that does not hold the vault's key.
94
+ */
95
+ vault?: SecretStore;
96
+ }
97
+
98
+ const DEFAULT_IDLE_TTL_MS = 15 * 60 * 1000;
99
+
100
+ export class SessionRegistry {
101
+ private readonly conversations = new Map<string, Conversation>();
102
+ private readonly paths: FlypiPaths;
103
+ private readonly createSession: CreateSession;
104
+ private readonly idleTtlMs: number;
105
+ private readonly now: () => number;
106
+ private readonly onRunStatus: SessionRegistryOptions["onRunStatus"];
107
+
108
+ constructor(private readonly options: SessionRegistryOptions) {
109
+ this.paths = flypiPaths(options.root);
110
+ this.createSession =
111
+ options.createSession ??
112
+ (({ sessionManager, conversationId, onMemoryBlock }) =>
113
+ createFlypiSession({ root: options.root, sessionManager, conversationId, onMemoryBlock, vault: options.vault, review: true }).then((r) => r.session));
114
+ this.idleTtlMs = options.idleTtlMs ?? DEFAULT_IDLE_TTL_MS;
115
+ this.now = options.now ?? Date.now;
116
+ this.onRunStatus = options.onRunStatus;
117
+ }
118
+
119
+ get size(): number {
120
+ return this.conversations.size;
121
+ }
122
+
123
+ async create(): Promise<Conversation> {
124
+ return this.bind(
125
+ SessionManager.create(this.paths.root, this.paths.sessionsDir),
126
+ );
127
+ }
128
+
129
+ /** Rebind a conversation to an existing session file. The conversationId — what the client holds — does not change. */
130
+ async switchSession(conversationId: string, sessionPath: string): Promise<Conversation> {
131
+ const conversation = this.get(conversationId);
132
+ const target = this.sessionFileIn(sessionPath);
133
+ this.teardown(conversation);
134
+ conversation.session = await this.createSession({
135
+ sessionManager: SessionManager.open(target, this.paths.sessionsDir),
136
+ conversationId,
137
+ onMemoryBlock: (block) => (conversation.memory.block = block),
138
+ extensionUi: conversation.extensionUi.uiContext,
139
+ });
140
+ conversation.memory.block = null;
141
+ this.watch(conversation);
142
+ // The transcript a client is looking at becomes the opened session's, from the start — so the
143
+ // log restarts and the seed above is the whole of it. The client re-reads it with `resume`.
144
+ conversation.log.reset();
145
+ this.seedHistory(conversation);
146
+ return conversation;
147
+ }
148
+
149
+ /**
150
+ * Branch a conversation's session at one of its answers into a new conversation. The new session
151
+ * holds the path to that message and nothing after it; the source keeps running untouched.
152
+ *
153
+ * Two pi behaviours do the work, relied on rather than reinvented: the message objects in session
154
+ * entries are the same objects the events carried, so identity finds the entry; and
155
+ * `createBranchedSession()` moves the `SessionManager` it runs onto the new file, so it runs here on
156
+ * a throwaway opened from the file, never on the live session.
157
+ */
158
+ async fork(conversationId: string, messageSeq: number): Promise<Conversation> {
159
+ const source = this.get(conversationId);
160
+ if (!source.session.sessionFile) throw new Error(`conversation ${conversationId} has no persisted session to fork`);
161
+ const message = source.messages.get(messageSeq);
162
+ if (!message) throw new Error(`conversation ${conversationId} has no message at seq ${messageSeq}`);
163
+ const role = (message as { role?: string }).role;
164
+ if (role !== "assistant") throw new Error(`a fork starts from an answer, not from a ${role ?? "non"} message`);
165
+ const entry = source.session.sessionManager.getBranch().find((candidate) => candidate.type === "message" && candidate.message === message);
166
+ if (!entry) throw new Error(`the message at seq ${messageSeq} is not in ${source.session.sessionFile}`);
167
+ const branch = SessionManager.open(source.session.sessionFile, this.paths.sessionsDir);
168
+ if (!branch.createBranchedSession(entry.id)) throw new Error(`forking ${source.session.sessionFile} at ${entry.id} wrote no session file`);
169
+ return this.bind(branch);
170
+ }
171
+
172
+ get(conversationId: string): Conversation {
173
+ const conversation = this.conversations.get(conversationId);
174
+ if (!conversation) throw new Error(`unknown conversation ${conversationId}`);
175
+ return conversation;
176
+ }
177
+
178
+ log(conversationId: string): EventLog<AgentSessionEvent> {
179
+ return this.get(conversationId).log;
180
+ }
181
+
182
+ prompt(conversationId: string, text: string, mode?: "steer" | "followUp", attachments: FlypiAttachment[] = []): Promise<void> {
183
+ // Images are the model's input; any other file is written to disk and named by path, so the agent
184
+ // opens it with its own tools. flypi never decides what a PDF or a spreadsheet contains.
185
+ const images = attachments.filter(isImage).map((image) => ({ type: "image" as const, data: image.data, mimeType: image.mimeType }));
186
+ const files = attachments.filter((attachment) => !isImage(attachment)).map((attachment) => this.saveUpload(attachment));
187
+ const body = [text.trim(), ...files.map((path) => `[attached file: ${path}]`)].filter(Boolean).join("\n");
188
+ if (!body && images.length === 0) throw new Error("a prompt needs text or an attachment");
189
+ // A new turn has not injected anything yet: the panel must not show last turn's block as this one's.
190
+ this.get(conversationId).memory.block = null;
191
+ // The daemon needs to know a turn is in flight before the first event of it can fire: a process that
192
+ // dies mid-turn leaves the row `running`, and that is what a restart reconciles (Phase 4.5). pi's
193
+ // `preflightResult` is where that is knowable — it fires once the run's messages are prepared, before
194
+ // its first request and before any event — and it answers "handled" for input that starts no run at
195
+ // all, which is what an extension command is, and what a refused prompt is.
196
+ // No mode + already streaming ⇒ pi rejects rather than guessing. That rejection is the error frame.
197
+ return this.get(conversationId).session.prompt(body, {
198
+ ...(images.length ? { images } : {}),
199
+ ...(mode ? { streamingBehavior: mode } : {}),
200
+ preflightResult: (disposition) => {
201
+ if (disposition !== "handled") this.markRun(conversationId, "running");
202
+ },
203
+ });
204
+ }
205
+
206
+ /**
207
+ * A pasted non-image file, kept where the agent can reach it. The name the user pasted it under is
208
+ * kept (it is what they recognise) but never allowed to choose the directory.
209
+ * shortcut: nothing prunes .flypi/uploads; add an age sweep to the daemon if it ever grows (Phase 4.5).
210
+ */
211
+ private saveUpload(attachment: FlypiAttachment): string {
212
+ mkdirSync(this.paths.uploadsDir, { recursive: true });
213
+ const name = attachment.name.replace(/[^\w.-]/g, "_").slice(0, 100) || "file";
214
+ const path = join(this.paths.uploadsDir, `${randomUUID()}-${name}`);
215
+ writeFileSync(path, Buffer.from(attachment.data, "base64"));
216
+ return path;
217
+ }
218
+
219
+ abort(conversationId: string): Promise<void> {
220
+ // Recorded before the abort lands: if the process dies on the way, the row says why the turn stopped.
221
+ this.markRun(conversationId, "aborted");
222
+ return this.get(conversationId).session.abort();
223
+ }
224
+
225
+ async setModel(conversationId: string, provider: string, modelId: string): Promise<void> {
226
+ const session = this.get(conversationId).session;
227
+ const model: FlypiModel | undefined = session.modelRuntime.getModel(provider, modelId);
228
+ if (!model) throw new Error(`model ${provider}/${modelId} is not available`);
229
+ await session.setModel(model);
230
+ }
231
+
232
+ setThinkingLevel(conversationId: string, level: FlypiThinkingLevel): void {
233
+ this.get(conversationId).session.setThinkingLevel(level);
234
+ }
235
+
236
+ /**
237
+ * The session files flypi owns, for the sidebar's list. pi's own list, so flypi keeps no second index
238
+ * of conversations (docs/decisions.md: one source of truth) — which is also why a chat you have only
239
+ * started, with nothing written, has no row: pi creates the file on its first entry.
240
+ */
241
+ async listSessions(): Promise<SessionSummary[]> {
242
+ const sessions = await SessionManager.list(this.paths.root, this.paths.sessionsDir);
243
+ return sessions.map((session) => ({
244
+ path: session.path,
245
+ ...(session.name ? { name: session.name } : {}),
246
+ modified: session.modified.toISOString(),
247
+ messageCount: session.messageCount,
248
+ firstMessage: session.firstMessage,
249
+ }));
250
+ }
251
+
252
+ /** Rename = pi's own session name, so the TUI and the web UI show the same thing. */
253
+ rename(conversationId: string, name: string): void {
254
+ this.get(conversationId).session.setSessionName(name);
255
+ }
256
+
257
+ /**
258
+ * Delete a session file for good. Anything that was showing it goes first: pi appends lazily, so a
259
+ * live session bound to a deleted file would write it back and the row would come straight back.
260
+ * A turn still running is refused rather than cut short — that work would be lost silently.
261
+ * ponytail: one user, one browser. Another tab holding the same session loses its conversation id
262
+ * when this runs; if tabs ever share the server, tell them over the socket instead.
263
+ */
264
+ async deleteSession(sessionPath: string): Promise<void> {
265
+ const target = this.sessionFileIn(sessionPath);
266
+ for (const conversation of this.conversations.values()) {
267
+ if (conversation.session.sessionFile !== target) continue;
268
+ if (!conversation.session.isIdle) throw new Error(`session ${target} is mid-turn; abort it first`);
269
+ this.teardown(conversation);
270
+ this.conversations.delete(conversation.id);
271
+ }
272
+ rmSync(target); // no force: a file that is not there is an error frame, not a silent success
273
+ }
274
+
275
+ /**
276
+ * The config files the settings panel edits — the agent dir's `models.json` and `mcp.json`, read through
277
+ * flypi's own paths so the panel can never reach the host `~/.pi` (risk 1). Not conversation-scoped:
278
+ * a conversation already holds the model runtime it started with, and a new one reads the new files.
279
+ */
280
+ configSnapshot(): ConfigSnapshot {
281
+ return readConfig(this.paths);
282
+ }
283
+
284
+ /** Apply the panel's edits and hand back the refreshed files, so the panel shows what is on disk. */
285
+ saveConfig(edits: ConfigEdits): ConfigSnapshot {
286
+ applyConfigEdits(this.paths, edits);
287
+ return readConfig(this.paths);
288
+ }
289
+
290
+ /**
291
+ * The extension packages `.flypi/agent/settings.json` declares (src/agent/packages.ts). Not
292
+ * conversation-scoped: the list is the agent dir's, and what a conversation holds is the set of extensions
293
+ * it loaded when it started — which `applyPackages` replaces, below.
294
+ */
295
+ packages(): PackagesSnapshot {
296
+ return readPackages(this.paths);
297
+ }
298
+
299
+ /**
300
+ * Install, remove or update a package, then put the new set into the conversations that are open, so the
301
+ * tool someone just installed is in the chat in front of them and not only in the next one. A turn in
302
+ * flight is never reloaded — that conversation keeps the extensions it started the turn with.
303
+ */
304
+ async applyPackages(edits: PackageEdits): Promise<PackagesSnapshot> {
305
+ const snapshot = await applyPackageEdits(this.paths, edits);
306
+ await this.reloadExtensions();
307
+ return snapshot;
308
+ }
309
+
310
+ /**
311
+ * Re-run each idle conversation's extensions against the packages on disk now. `reload()` swaps the extension
312
+ * runtime but does not re-emit `session_start` for a headless session, and that event is what connects the
313
+ * MCP servers — so without the `bindExtensions` that follows, reloading for an extension would silently drop
314
+ * every MCP tool the conversation had (tests/agent/packages.test.ts).
315
+ */
316
+ async reloadExtensions(): Promise<void> {
317
+ for (const conversation of this.conversations.values()) {
318
+ if (!conversation.session.isIdle) continue;
319
+ try {
320
+ await conversation.session.reload();
321
+ await conversation.session.bindExtensions({});
322
+ } catch (error) {
323
+ // The package is installed either way; a conversation that could not reload keeps the set it has.
324
+ console.error(`flypi: conversation ${conversation.id} kept its old extensions: ${(error as Error).message}`);
325
+ }
326
+ }
327
+ }
328
+
329
+ /**
330
+ * The MCP servers asked directly whether they are up, for the settings view (src/agent/mcp-status.ts).
331
+ * Deliberately not the registry's own sessions: a running conversation holds the connections it made when
332
+ * it started, and the question the panel answers is whether a server named in the file answers now.
333
+ */
334
+ mcpStatus(): Promise<McpStatusSnapshot> {
335
+ return mcpStatus(this.paths);
336
+ }
337
+
338
+ /**
339
+ * Start an OAuth sign-in for one configured server (docs/architecture.md Phase 5, risk 5): the URL goes out
340
+ * through `onAuthorizationUrl` as an `mcpAuth` frame, the redirect comes back on `submitRedirect`, and the
341
+ * returned promise settles when the tokens are stored. Not a conversation's business — the next conversation
342
+ * is the one that gets the connection.
343
+ */
344
+ mcpSignIn(name: string, onAuthorizationUrl: (url: string) => void): Promise<void> {
345
+ return startMcpSignIn(this.paths, name, onAuthorizationUrl);
346
+ }
347
+
348
+ /** The pasted redirect URL. False when no sign-in is waiting for that name, which the WS layer reports. */
349
+ submitMcpRedirect(name: string, redirectUrl: string): boolean {
350
+ return submitMcpRedirect(name, redirectUrl);
351
+ }
352
+
353
+ /**
354
+ * Every path a client names must be one of flypi's own session files, never something it guessed. A bare
355
+ * file name — the address bar's `/sessions/<id>` (ui/lib/route.ts) — is resolved inside the sessions dir;
356
+ * `resolve` and the check below are what keep `..` from getting out of it.
357
+ */
358
+ private sessionFileIn(sessionPath: string): string {
359
+ const target = resolve(sessionPath.includes("/") ? sessionPath : join(this.paths.sessionsDir, sessionPath));
360
+ if (!target.startsWith(`${this.paths.sessionsDir}/`)) {
361
+ throw new Error(`session file ${target} is not inside ${this.paths.sessionsDir}`);
362
+ }
363
+ return target;
364
+ }
365
+
366
+ /**
367
+ * What the debug panel shows, and what the sidebar's selectors list. `memoryBlock` is the block the
368
+ * last turn actually injected (not what a recall would return now), or null when no turn injected one.
369
+ *
370
+ * `models` is deliberately pi's *available* set, not its catalog: the built-in catalog is ~1.5k
371
+ * models across 43 providers, and every one you have not configured a credential for is noise.
372
+ * The session's current model is reported whether or not its provider is available, so the
373
+ * selector can still show what you are actually running.
374
+ */
375
+ async debugInfo(conversationId: string): Promise<DebugInfo> {
376
+ const session = this.get(conversationId).session;
377
+ const runtime = session.modelRuntime;
378
+ const model = session.model;
379
+ const context = session.getContextUsage();
380
+ const configured = await Promise.all(
381
+ runtime.getProviders().map(async (provider) => (await runtime.getAvailable(provider.id)).map((m) => ({ provider: provider.id, modelId: m.id, input: m.input ?? [] }))),
382
+ );
383
+ return {
384
+ systemPrompt: session.systemPrompt,
385
+ activeTools: session.getActiveToolNames(),
386
+ models: configured.flat(),
387
+ ...(model ? { model: { provider: model.provider, modelId: model.id, input: model.input ?? [] } } : {}),
388
+ thinkingLevel: session.thinkingLevel,
389
+ ...(session.sessionName ? { sessionName: session.sessionName } : {}),
390
+ // The chat's context gauge reads pi's estimate rather than a second accounting of the context.
391
+ ...(context ? { context } : {}),
392
+ memoryBlock: this.get(conversationId).memory.block,
393
+ commands: commandsOf(session),
394
+ };
395
+ }
396
+
397
+ /** Live events for one socket. Detach it when the socket closes. */
398
+ attach(conversationId: string, listener: EventListener): () => void {
399
+ const conversation = this.get(conversationId);
400
+ conversation.listeners.add(listener);
401
+ return () => conversation.listeners.delete(listener);
402
+ }
403
+
404
+ /** The same for what extensions say to the user. Detached with the socket, like the event listener. */
405
+ attachUi(conversationId: string, listener: ExtensionUiListener): () => void {
406
+ const conversation = this.get(conversationId);
407
+ conversation.uiListeners.add(listener);
408
+ return () => conversation.uiListeners.delete(listener);
409
+ }
410
+
411
+ /**
412
+ * The page answered an extension's question. False when nothing is waiting on that id — it timed out, or
413
+ * another tab got there first — which is what tells the chat to stop showing the dialog.
414
+ */
415
+ replyExtension(conversationId: string, dialogId: string, value: string | null): boolean {
416
+ return this.get(conversationId).extensionUi.reply(dialogId, value);
417
+ }
418
+
419
+ /**
420
+ * Evict conversations that have been idle for `idleTtlMs` with no client. A running turn
421
+ * is never evicted, and `teardown()` makes dispose exactly-once.
422
+ */
423
+ evictIdle(): string[] {
424
+ const now = this.now();
425
+ const evicted: string[] = [];
426
+ for (const conversation of [...this.conversations.values()]) {
427
+ if (conversation.listeners.size > 0 || !conversation.session.isIdle) continue;
428
+ if (now - conversation.lastSettledAt < this.idleTtlMs) continue;
429
+ this.teardown(conversation);
430
+ this.conversations.delete(conversation.id);
431
+ evicted.push(conversation.id);
432
+ }
433
+ return evicted;
434
+ }
435
+
436
+ dispose(): void {
437
+ for (const conversation of this.conversations.values()) this.teardown(conversation);
438
+ this.conversations.clear();
439
+ }
440
+
441
+ private async bind(sessionManager: SessionManager): Promise<Conversation> {
442
+ const id = randomUUID();
443
+ const memory = { block: null as string | null };
444
+ const uiListeners = new Set<ExtensionUiListener>();
445
+ const extensionUi = createExtensionUiBridge((frame) => {
446
+ for (const listener of uiListeners) listener({ type: "extensionUi", conversationId: id, ...frame });
447
+ });
448
+ const conversation: Conversation = {
449
+ id,
450
+ log: new EventLog<AgentSessionEvent>(this.options.logCapacity),
451
+ listeners: new Set(),
452
+ messages: new Map(),
453
+ session: await this.createSession({
454
+ sessionManager,
455
+ conversationId: id,
456
+ onMemoryBlock: (block) => (memory.block = block),
457
+ extensionUi: extensionUi.uiContext,
458
+ }),
459
+ extensionUi,
460
+ uiListeners,
461
+ memory,
462
+ unsubscribe: () => {},
463
+ lastSettledAt: this.now(),
464
+ disposed: false,
465
+ };
466
+ this.conversations.set(conversation.id, conversation);
467
+ this.watch(conversation);
468
+ this.seedHistory(conversation);
469
+ return conversation;
470
+ }
471
+
472
+ /**
473
+ * pi loads a session's history into the session but does not re-emit it as events, so flypi seeds
474
+ * it into the conversation's event log. That keeps one kind of transcript source: a new, reopened
475
+ * or forked conversation all reach the UI the same way, through `resume`.
476
+ * ponytail: turn boundaries are not replayed, so a reopened conversation's "current turn" usage
477
+ * covers its whole history. Emit synthetic turn frames if the sidebar ever needs it per turn.
478
+ */
479
+ private seedHistory(conversation: Conversation): void {
480
+ for (const message of conversation.session.messages) {
481
+ const buffered = conversation.log.push({ type: "message_end", message } as AgentSessionEvent);
482
+ conversation.messages.set(buffered.seq, message);
483
+ }
484
+ }
485
+
486
+ /**
487
+ * The last-resort filter on the live stream (docs/secrets.md Phase 5.5). The `message_end` hook in
488
+ * `src/secrets/extension.ts` is what keeps the session file clean, but the UI renders the frames that
489
+ * arrive *before* that, and whatever is pushed here is what a reconnect replays — so a value the model
490
+ * repeats would be on screen for a second and in the replay log for the life of the conversation.
491
+ *
492
+ * Every text field of the frame is rewritten, not just the one the reducer reads: the frame also carries
493
+ * pi's in-progress `message`, the finished block at `text_end.content` and this chunk's `delta`, and a
494
+ * value left in any of those is a copy of it sitting in the browser. The partials are the accumulated text,
495
+ * which is why the filter reads them rather than the deltas: a value split across two chunks is caught as
496
+ * soon as its last piece lands. The gap that stays open is the part of the value that has not streamed yet,
497
+ * and the Secrets view says so.
498
+ *
499
+ * The rewrite also *copies*. pi grows one mutable message object across the whole turn and hands every frame
500
+ * the same reference, so a frame that was filtered while its text was still short would later be read back
501
+ * from the log holding the finished value — which is exactly what a reconnect replays.
502
+ *
503
+ * shortcut: re-snapshots the partial on every delta, so a long streamed answer costs one copy per chunk; if
504
+ * that ever shows up in the timings, copy only from the last text block onwards.
505
+ */
506
+ private redactStream(event: AgentSessionEvent): AgentSessionEvent {
507
+ if (event.type !== "message_update") return event;
508
+ const streamed = event.assistantMessageEvent;
509
+ // A streamed frame carries the in-progress `partial`; pi's own `done`/`error` frame carries the finished
510
+ // `message` under that key instead. Both are copies of the same mutable object, so both are rewritten.
511
+ const partial = "partial" in streamed ? { partial: redactText(streamed.partial) } : {};
512
+ // `text_end`/`thinking_end` carry their block as one string, and `*_delta` carries a chunk: a chunk that
513
+ // happens to hold a whole value would otherwise be the one place it survives the frame.
514
+ const text = "content" in streamed ? { content: redactSecrets(streamed.content) } : {};
515
+ const delta = "delta" in streamed ? { delta: redactSecrets(streamed.delta) } : {};
516
+ return {
517
+ ...event,
518
+ message: redactText(event.message),
519
+ assistantMessageEvent: { ...streamed, ...partial, ...text, ...delta },
520
+ } as AgentSessionEvent;
521
+ }
522
+
523
+ /** The one subscription per conversation: sequence, record, fan out. */
524
+ private watch(conversation: Conversation): void {
525
+ conversation.unsubscribe = conversation.session.subscribe((raw) => {
526
+ const event = this.redactStream(raw);
527
+ if (event.type === "agent_settled") {
528
+ conversation.lastSettledAt = this.now();
529
+ this.markRun(conversation.id, "idle");
530
+ }
531
+ const buffered = conversation.log.push(event);
532
+ if (event.type === "message_end") conversation.messages.set(buffered.seq, event.message);
533
+ for (const listener of conversation.listeners) listener(buffered.seq, event);
534
+ });
535
+ }
536
+
537
+ /** Write the run row, when anything is listening for it. Never lets bookkeeping break a turn. */
538
+ private markRun(conversationId: string, status: "running" | "idle" | "aborted"): void {
539
+ if (!this.onRunStatus) return;
540
+ const conversation = this.conversations.get(conversationId);
541
+ if (!conversation) return;
542
+ try {
543
+ this.onRunStatus(conversationId, status, { seq: conversation.log.lastSeq, sessionFile: conversation.session.sessionFile });
544
+ } catch (error) {
545
+ console.error(`flypi: run status (${status}) not recorded: ${(error as Error).message}`);
546
+ }
547
+ }
548
+
549
+ private teardown(conversation: Conversation): void {
550
+ if (conversation.disposed) return;
551
+ conversation.disposed = true;
552
+ // An extension waiting on a dialog nobody will ever answer would hold its handler open forever.
553
+ conversation.extensionUi.close();
554
+ conversation.unsubscribe();
555
+ conversation.session.dispose();
556
+ }
557
+ }
558
+
559
+ /**
560
+ * The slash commands this conversation answers to, in the order pi resolves them: extension commands first
561
+ * (under pi's own *invocation* name, so one that collides with a built-in is still reachable as `name:2`),
562
+ * then prompt templates. Skills are left out — flypi loads none.
563
+ */
564
+ function commandsOf(session: FlypiSession): CommandRow[] {
565
+ return [
566
+ ...session.extensionRunner.getRegisteredCommands().map((command) => ({
567
+ name: command.invocationName,
568
+ description: command.description ?? "",
569
+ source: "extension" as const,
570
+ })),
571
+ ...session.promptTemplates.map((template) => ({
572
+ name: template.name,
573
+ description: template.description,
574
+ source: "prompt" as const,
575
+ })),
576
+ ];
577
+ }
578
+
579
+ /**
580
+ * A copy of an in-progress assistant message with its text and thinking blocks redacted. The copy is made even
581
+ * when nothing matched, because the object pi gave us is mutable and shared by every frame of the turn — see
582
+ * `redactStream`. Anything that is not such a message (no `content` array) comes back as it was.
583
+ */
584
+ function redactText(message: unknown): unknown {
585
+ const content = (message as { content?: unknown } | undefined)?.content;
586
+ if (!Array.isArray(content)) return message;
587
+ return { ...(message as object), content: content.map((block) => {
588
+ const part = block as { type?: string; text?: string; thinking?: string };
589
+ const field = part?.type === "text" ? "text" : part?.type === "thinking" ? "thinking" : undefined;
590
+ if (field === undefined || typeof part[field] !== "string") return block;
591
+ return { ...part, [field]: redactSecrets(part[field]!) };
592
+ }) };
593
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * A pasted file on its way to the agent.
3
+ *
4
+ * The bytes ride along inside the `prompt` command as base64: the browser holds the file and the server
5
+ * holds the disk, and one JSON frame is cheaper than adding an upload endpoint to a protocol that has no
6
+ * HTTP body (docs/protocol.md). What arrives splits in two: images go to pi as image content, because
7
+ * that is what the model looks at; everything else is written to `.flypi/uploads/` and named by its path
8
+ * in the prompt text, so the agent opens it with its own read tools instead of flypi guessing what a
9
+ * PDF or a spreadsheet means.
10
+ */
11
+
12
+ export interface FlypiAttachment {
13
+ /** The name the user pasted it under — shown in the UI, kept as the file's name on disk. */
14
+ name: string;
15
+ mimeType: string;
16
+ /** The file's bytes, base64. */
17
+ data: string;
18
+ }
19
+
20
+ /** One pasted file's worth of bytes. Clipboard pastes are ad-hoc, so this is a courtesy limit, not a policy. */
21
+ export const MAX_ATTACHMENT_BYTES = 4 * 1024 * 1024;
22
+ export const MAX_ATTACHMENTS = 8;
23
+
24
+ /** Images belong to the model; every other file belongs to the file tools. */
25
+ export function isImage(file: { mimeType: string }): boolean {
26
+ return file.mimeType.startsWith("image/");
27
+ }
28
+
29
+ /**
30
+ * base64 of a file's bytes. Chunked because `String.fromCharCode(...bytes)` on a megabyte overflows the
31
+ * argument list, and a silently truncated attachment is worse than a rejected one.
32
+ */
33
+ export function base64Of(buffer: ArrayBuffer): string {
34
+ const bytes = new Uint8Array(buffer);
35
+ let binary = "";
36
+ for (let offset = 0; offset < bytes.length; offset += 0x8000) {
37
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
38
+ }
39
+ return btoa(binary);
40
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * `FLYPI_CACHE_PROBE=1` — the message hash A3 deferred and C1 needs (docs/prompt-cache.md). A prefix cache
3
+ * keys on request bytes, so a cache miss has exactly two possible causes: the bytes changed, or the route
4
+ * dropped what it already had. That is the difference between "flypi has something to fix" and "this route
5
+ * is not worth optimising for", and no amount of prefix reasoning settles it — two runs of the same probe,
6
+ * diffed, do. So this prints one hash per request and one per message inside it, in wire order.
7
+ *
8
+ * It hashes what the provider was actually given (pi's `agent.onPayload` seam), not pi's internal context:
9
+ * the adapter drops the fields a prefix cache never sees (timestamps, usage) and adds the ones it does.
10
+ */
11
+ import { createHash } from "node:crypto";
12
+
13
+ /** The flag. Only `1` counts, so a stray `FLYPI_CACHE_PROBE=` in a shell cannot turn it on. */
14
+ export const cacheProbeEnabled = (env: NodeJS.ProcessEnv = process.env): boolean => env.FLYPI_CACHE_PROBE === "1";
15
+
16
+ /** sha256, first 12 hex: identical for identical bytes across runs, short enough to read side by side. */
17
+ export function hashOf(value: unknown): string {
18
+ return createHash("sha256").update(JSON.stringify(value) ?? "undefined").digest("hex").slice(0, 12);
19
+ }
20
+
21
+ /**
22
+ * The lines for one provider request. `payload` is the whole thing — its hash answers the run-to-run
23
+ * question on one line. Then the messages array in wire order, which is the region a prefix runs over: the
24
+ * first index whose hash differs from the other run's is where the reusable prefix ended. Then a hash for
25
+ * every other top-level key (`tools`, `model`, sampling), so a change outside the messages is named rather
26
+ * than only implied. `chars` is the JSON length — the size C1 prices the break in.
27
+ */
28
+ export function cacheProbeLines(payload: unknown): string[] {
29
+ const body = JSON.stringify(payload) ?? "undefined";
30
+ const lines = [`payload sha=${hashOf(payload)} chars=${body.length}`];
31
+ const messages = (payload as { messages?: unknown } | undefined | null)?.messages;
32
+ if (Array.isArray(messages)) {
33
+ for (const [index, message] of messages.entries()) {
34
+ const role = (message as { role?: unknown } | undefined | null)?.role ?? "?";
35
+ const json = JSON.stringify(message) ?? "undefined";
36
+ lines.push(`message ${index} role=${role} sha=${hashOf(message)} chars=${json.length}`);
37
+ }
38
+ }
39
+ if (payload && typeof payload === "object" && !Array.isArray(payload)) {
40
+ for (const [key, value] of Object.entries(payload)) {
41
+ // `undefined` fields are what JSON.stringify does with them: absent. Printing a hash for them would
42
+ // be two lines that look identical and mean nothing.
43
+ if (key !== "messages" && value !== undefined) lines.push(`${key} sha=${hashOf(value)}`);
44
+ }
45
+ }
46
+ return lines;
47
+ }