@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,528 @@
1
+ /**
2
+ * Automatic recall — what flypi's own agent does with the memory server in `.flypi/agent/mcp.json`
3
+ * (docs/memory.md Phase 4, "retrieve once per turn"; docs/prompt-cache.md Phase 4.7 C2 for why the block
4
+ * is in the session rather than only in the request).
5
+ *
6
+ * Each turn asks the memory server what matches the prompt, and the answer is put in front of the model as
7
+ * one durable session message: searched once per turn, one block per conversation, and the *same bytes* in
8
+ * every later request (risk 2 stays; the turn-boundary re-payment C1 measured is what goes away). Two rules
9
+ * keep risk 4 true — the digest gate says nothing when the same set of memories is already in this
10
+ * conversation, and a superseded block is dropped from model context by a `context_edit` while its entry
11
+ * stays in the file.
12
+ *
13
+ * It speaks MCP rather than opening the SQLite file, so the memory server stays the only writer
14
+ * (risk 3). Saving stays the model's job: `memory_write` is declared to the model by mcp.json
15
+ * (`exposure: "direct"`), and `memoryInstruction` below is what tells the model when to call that tool —
16
+ * in the session prompt, once, not in the block it would otherwise have to re-send every turn
17
+ * (docs/prompt-cache.md Phase 4.6 B).
18
+ *
19
+ * What happens after a turn is also this file's business, because this is the one place that knows what a
20
+ * turn was shown: the citations are reported (`memory_cite`), the turn is recorded with what it retrieved
21
+ * and what it cited (`memory_turn`, the hit-rate metrics in docs/memory.md Phase 4), and a fact the user
22
+ * stated in their own words is captured without asking a model to notice it (src/memory/capture.ts).
23
+ */
24
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
25
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
26
+ import { createHash } from "node:crypto";
27
+ import type { ExtensionContext, ExtensionFactory, McpServerEntry, SessionEntry } from "@earendil-works/pi-coding-agent";
28
+ import { addUsage, emptyUsage, usageOf, type RenderedUsage } from "../shared/transcript.ts";
29
+ import { captureFromTurn, type ToolOutcome } from "./capture.ts";
30
+ import type { ReviewFork } from "./review.ts";
31
+
32
+ /**
33
+ * The mcp.json entry flypi ships a template for, and the one `src/memory/mcp.ts` names itself.
34
+ * A memory server under another name is still usable by the model — only the automatic search
35
+ * follows this name, which keeps a second, unrelated MCP server from being searched every turn.
36
+ */
37
+ export const RECALL_SERVER = "flypi-memory";
38
+
39
+ /**
40
+ * The `customType` of the session entry a recalled block is persisted as (Phase 4.7 C2). It marks the block
41
+ * in the transcript — risk 4's "tag the block" — and it is what the digest gate and the supersede pass look
42
+ * for, including on a session reopened from its file, where nothing of the last process survived.
43
+ */
44
+ export const RECALL_ENTRY = "flypi-memory-block";
45
+
46
+ /**
47
+ * A block's set of memories as something comparable: sorted unique ids, so the same rows fetched in a
48
+ * different order are one set (C1 measured a turn that did exactly that), and nothing about ranking, token
49
+ * budgets or trimming leaks in. A new row, a retired row or a changed scope filter is a different digest.
50
+ */
51
+ export function setDigest(ids: Iterable<number>): string {
52
+ return createHash("sha256")
53
+ .update([...new Set(ids)].sort((a, b) => a - b).join(","))
54
+ .digest("hex")
55
+ .slice(0, 16);
56
+ }
57
+
58
+ /** The bounds `memory_search` itself declares (src/memory/mcp.ts). */
59
+ const QUERY_CHARS = 500;
60
+ const MEMORY_ROWS = 8;
61
+ /** Hard cap on injected text; a memory is dropped whole, never cut mid-sentence. */
62
+ const MEMORY_TOKENS = 1200;
63
+ /** Failed memory calls one turn may make before the answer stops being worth retrying (docs/memory.md). */
64
+ const RETRY_CAP = 3;
65
+ /** The turn's own text, as far as the store's body limit (src/memory/mcp.ts). */
66
+ const TURN_CHARS = 8000;
67
+
68
+ /** pi's own tool naming for a connected server: `mcp__<server>__<tool>` (docs/mcp.md). */
69
+ export const memoryTool = (server: string, tool: string): string =>
70
+ `mcp__${server.replace(/[^A-Za-z0-9_]/g, "_")}__${tool}`;
71
+
72
+ /**
73
+ * The first `${NAME}` in an entry that this process does not have. pi expands these when it connects
74
+ * (docs/mcp.md), and a name that is missing means the entry cannot authenticate: its tools never reach the
75
+ * session. flypi has to know that, because the alternative is a prompt that orders the model to call
76
+ * `mcp__flypi_memory__memory_write` — and a recall note that says "call memory_search yourself" — over a
77
+ * server that was never connected and a `tool_search` that finds nothing (docs/risks.md risk 8).
78
+ */
79
+ function unresolvedEnvName(entry: McpServerEntry): string | undefined {
80
+ const config = entry.config as { url?: string; headers?: Record<string, string>; env?: Record<string, string> };
81
+ for (const value of [config.url, ...Object.values(config.headers ?? {}), ...Object.values(config.env ?? {})]) {
82
+ if (typeof value !== "string") continue;
83
+ const name = [...value.matchAll(/\$\{([A-Za-z0-9_]+)\}/g)].map((match) => match[1]!).find((env) => !process.env[env]);
84
+ if (name) return name;
85
+ }
86
+ return undefined;
87
+ }
88
+
89
+ /**
90
+ * The server to search before the model runs: an enabled HTTP entry named `flypi-memory` that this process
91
+ * can actually authenticate to. An entry pointing at an env var nobody exported is treated as no memory
92
+ * server at all — the same thing a missing entry does (`src/agent/runtime.ts` builds the standing
93
+ * instruction, the recall extension and the review fork from this one answer).
94
+ */
95
+ export function recallServer(config: { servers: McpServerEntry[] }): McpServerEntry | undefined {
96
+ return config.servers.find((server) => server.name === RECALL_SERVER && server.config.enabled !== false && !unresolvedEnvName(server));
97
+ }
98
+
99
+ /**
100
+ * The standing memory instruction: when to write, when to supersede, and to cite as `#id`.
101
+ *
102
+ * It says the same thing on every turn, so it belongs in the prompt rather than in the block: the prompt is
103
+ * byte-stable and sits at the front of every request, while the block exists only when a turn retrieved
104
+ * something and changes whenever the retrieved set does (docs/prompt-cache.md Phase 4.6 B1/B2, and C2 made
105
+ * the block a session entry, which does not make it a place to put a standing instruction — a constant inside
106
+ * a per-turn message is re-sent or dropped by whatever the retrieval did). It stands on its own: "a memory
107
+ * listed here" became "a memory you were given", since it is no longer inside the block it described.
108
+ * Deliberately the same length as the sentence it replaces —
109
+ * a move that made the prompt bigger than the block it trims would cost bytes on a route that re-pays the
110
+ * whole prompt — and it never spells out the block's tag, which has to stay unique to the injected block so
111
+ * the A3 prefix test can tell a leaked block from a sentence that merely mentions one. Built from the
112
+ * server that is really configured, so a session with no memory server is never asked to call tools it does
113
+ * not have (B1); `src/agent/runtime.ts` puts it in `appendSystemPrompt` under the same condition.
114
+ */
115
+ export function memoryInstruction(serverName: string): string {
116
+ const write = memoryTool(serverName, "memory_write");
117
+ const supersede = memoryTool(serverName, "memory_supersede");
118
+ return `When the user tells you to remember something, call ${write} with the one durable fact; call ${supersede} instead when a memory you were given is no longer true. Cite any memory you use as #id.`;
119
+ }
120
+
121
+ /**
122
+ * `onInjected` hands the rendered block to the host as the turn starts. The debug panel asks what a turn was
123
+ * really shown, and a turn that said nothing because of the digest gate has no block of its own — this is the
124
+ * one place that knows the difference.
125
+ *
126
+ * `conversationId` is flypi's id for the conversation, which only the registry holds. Without it there is
127
+ * no row to record a turn against, so the turn log and auto-capture stay off — a bare session (probe)
128
+ * still gets its recall block.
129
+ */
130
+ export function createMemoryRecallExtension(
131
+ server: McpServerEntry | undefined,
132
+ onInjected?: (block: string) => void,
133
+ options: { conversationId?: string; review?: { interval: number; fork: ReviewFork } } = {},
134
+ ): ExtensionFactory {
135
+ return (pi) => {
136
+ if (!server || server.config.enabled === false) return;
137
+ /** The digest of the set this turn retrieved, "" when it retrieved none. `turn_end` uses it to tell
138
+ * which block in the session is the current one. */
139
+ let digest = "";
140
+ /** What this turn's search could not do, in a sentence. Transient on purpose, and the only thing in the
141
+ * prompt that still is: it is about this turn's call to the store, not a fact to keep (C2). */
142
+ let note: string | null = null;
143
+ /** Ids this turn was really shown — the block, or a memory tool result. A citation only counts for
144
+ * one of these, so a number the model invented cannot inflate a count that means something. */
145
+ const seen = new Set<number>();
146
+ /** Failed memory-tool calls this turn. Reset per turn: last turn's failures are not this turn's. */
147
+ let failures = 0;
148
+ /** Ids already cited this turn, so a run that repeats itself cannot count a sentence twice. */
149
+ const reported = new Set<number>();
150
+ /** What the user asked, and what the turn's tools ran — the two inputs auto-capture reads. */
151
+ let prompt = "";
152
+ const tools: ToolOutcome[] = [];
153
+ /** The model stored something itself this turn, so capturing the same instruction again is a copy. */
154
+ let modelWroteMemory = false;
155
+ /** One turn log per turn: a retried run re-ends the loop, and a second row would lie about the count. */
156
+ let logged = false;
157
+ /**
158
+ * Delivered answers this conversation has produced — what the review fork counts (Phase 4.8). Hydrated
159
+ * from the session file on reopen, so closing the tab or restarting the daemon does not restart the
160
+ * clock: the file already knows how many turns this conversation had.
161
+ */
162
+ let delivered = 0;
163
+ /** One fork at a time per conversation: a fast run of turns must not stack forks. */
164
+ let reviewing = false;
165
+
166
+ if (options.review) {
167
+ pi.on("session_start", (_event, ctx) => {
168
+ delivered = countUserTurns(ctx.sessionManager.getEntries());
169
+ });
170
+ }
171
+
172
+ pi.on("before_agent_start", async (event, ctx) => {
173
+ failures = 0;
174
+ seen.clear();
175
+ reported.clear();
176
+ prompt = event.prompt;
177
+ tools.length = 0;
178
+ modelWroteMemory = false;
179
+ logged = false;
180
+ const outcome = await search(server, event.prompt);
181
+ for (const row of outcome.rows) seen.add(row.id);
182
+ onInjected?.(renderBlock(outcome));
183
+ // A search that could not do all it was asked says so. When it brought rows with it the reason goes in
184
+ // the block, because it qualifies those rows and is worth keeping with them; when it brought nothing the
185
+ // reason is about this one call, so it rides on the newest user message for that turn only — which is
186
+ // new content at the boundary anyway, so it costs the prefix nothing (risk 8, docs/prompt-cache.md C1).
187
+ note = outcome.rows.length === 0 && outcome.note ? renderBlock({ rows: [], note: outcome.note }) : null;
188
+ // A search that brought nothing back has nothing to keep, and takes nothing away: an outage is not a
189
+ // reason to stop showing the model what an earlier turn found.
190
+ digest = outcome.rows.length === 0 ? "" : setDigest(outcome.rows.map((row) => row.id));
191
+ if (!digest) return;
192
+ // The digest gate: this conversation already holds these memories, so say nothing. Saying the same set
193
+ // twice is the duplication risk 4 is about, and it is what makes the bytes before a later turn stop
194
+ // being the bytes a route has already read (docs/prompt-cache.md C1). The digest is the set of rows, not
195
+ // the wording about how they were found: a turn that searched the same rows a weaker way does not get a
196
+ // second copy of them to read.
197
+ if (liveBlocks(ctx).some((entry) => entry.digest === digest)) return;
198
+ // A message of its own, right after the user's, rather than part of the user's text: that is what makes
199
+ // it carry through the next boundary. `display: false` says it is context, not something to show.
200
+ return { message: { customType: RECALL_ENTRY, content: renderBlock(outcome), display: false, details: { digest } } };
201
+ });
202
+
203
+ /**
204
+ * The one thing that is still added at the last moment: what this turn's search could not do. It is not a
205
+ * memory, and it is not kept, so it never reaches the file — and a turn that had nothing to say about its
206
+ * own search pays nothing here.
207
+ */
208
+ pi.on("context", (event) => {
209
+ if (!note) return;
210
+ const messages = [...event.messages];
211
+ for (let i = messages.length - 1; i >= 0; i--) {
212
+ const message = messages[i];
213
+ if (!message || message.role !== "user") continue;
214
+ const existing = typeof message.content === "string" ? [] : message.content;
215
+ // Never twice in one turn: this handler re-runs on every model call of the turn, and risk 2 is
216
+ // about a prompt that says the same thing once.
217
+ if (existing[0]?.type === "text" && existing[0].text === note) return;
218
+ const appended = { type: "text" as const, text: note };
219
+ const content = typeof message.content === "string" ? [appended, { type: "text" as const, text: message.content }] : [appended, ...existing];
220
+ messages[i] = { ...message, content };
221
+ break;
222
+ }
223
+ return { messages };
224
+ });
225
+
226
+ /**
227
+ * A block belongs to the turn that retrieved it. Once this turn's block is in the conversation, any other
228
+ * one is superseded: `replacement: null` omits it from what the model is shown, while the entry and the
229
+ * edit both stay in the file, so what a turn was told remains readable history and the pair is auditable
230
+ * (docs/prompt-cache.md C2, risk 4). A turn that changed nothing appends nothing, and a turn that found
231
+ * nothing takes anything away: an outage is not a reason to stop showing the model what was already found.
232
+ */
233
+ pi.on("turn_end", (_event, ctx) => {
234
+ if (!digest) return;
235
+ const stale = liveBlocks(ctx).filter((entry) => entry.digest !== digest);
236
+ if (stale.length === 0) return;
237
+ return { entries: stale.map((entry) => ({ type: "context_edit", targetId: entry.id, replacement: null })) };
238
+ });
239
+
240
+ /**
241
+ * A memory call that fails is the model's business for three tries, then it stops being one. The
242
+ * store is not going to come back mid-turn, and a fourth error costs a model call to learn it again,
243
+ * so the answer it gets names what is missing and tells it to carry on (docs/memory.md, retry cap).
244
+ * The rows this turn found stay in the text: the facts it already has are not lost with the call.
245
+ */
246
+ pi.on("tool_result", (event) => {
247
+ if (event.toolName.startsWith(memoryTool(server.name, ""))) {
248
+ if (!event.isError) {
249
+ // A row the model fetched for itself is exactly as citable as one it was handed.
250
+ for (const id of idsIn(event.content)) seen.add(id);
251
+ // A write it made itself is a fact it already kept. Capturing the same instruction again would
252
+ // be a second copy, and the near-duplicate gate is not reliable enough to be trusted with it.
253
+ const stored = memoryTool(server.name, "memory_write");
254
+ const replaced = memoryTool(server.name, "memory_supersede");
255
+ if (event.toolName === stored || event.toolName === replaced) modelWroteMemory = true;
256
+ return;
257
+ }
258
+ failures += 1;
259
+ if (failures <= RETRY_CAP) return;
260
+ return {
261
+ content: [
262
+ {
263
+ type: "text" as const,
264
+ text: `${event.toolName} has failed ${failures} times this turn; the store is unchanged. ${
265
+ seen.size ? `The memories you were given are ${[...seen].map((id) => `#${id}`).join(", ")} — use or cite those.` : "No memories were found for this turn."
266
+ } Leave memory unchanged and continue your reply.`,
267
+ },
268
+ ],
269
+ };
270
+ }
271
+ // A command that failed, followed by one that worked, is the one fact worth keeping about a tool
272
+ // call — and it is only knowable while the turn is still running (src/memory/capture.ts).
273
+ const command = typeof event.input?.command === "string" ? event.input.command : undefined;
274
+ if (command) tools.push({ name: event.toolName, command, isError: event.isError });
275
+ });
276
+
277
+ /**
278
+ * A citation is the model writing `#id` about a row it was shown. Its own words are what is read —
279
+ * not its tool calls, which would be the model grading itself — and the count only moves for rows it
280
+ * actually had, so `cited_count` keeps meaning "this fact earned its place" (docs/memory.md).
281
+ */
282
+ pi.on("agent_end", async (event, ctx) => {
283
+ const quoted = new Set<number>();
284
+ const answer: string[] = [];
285
+ for (const message of event.messages) {
286
+ if (message.role !== "assistant") continue;
287
+ for (const part of Array.isArray(message.content) ? message.content : []) {
288
+ if (typeof part !== "object" || part === null) continue;
289
+ const textPart = part as { type?: unknown; text?: unknown };
290
+ if (textPart.type !== "text" || typeof textPart.text !== "string") continue;
291
+ answer.push(textPart.text);
292
+ for (const match of textPart.text.matchAll(/#(\d+)\b/g)) quoted.add(Number(match[1]));
293
+ }
294
+ }
295
+ // Only ids this turn was really shown, and only once each: a retried run re-emits the answer it
296
+ // already gave, and one sentence citing a row twice is one citation.
297
+ const ids = [...quoted].filter((id) => seen.has(id) && !reported.has(id));
298
+ if (ids.length > 0) {
299
+ for (const id of ids) reported.add(id);
300
+ await call(server, "memory_cite", { ids });
301
+ }
302
+
303
+ // Auto-capture, and only after the model had its chance: this is for the fact the user handed over
304
+ // that no tool call was made about, not a second copy of what the model just stored.
305
+ if (!modelWroteMemory) {
306
+ for (const fact of captureFromTurn(prompt, tools)) {
307
+ // The user's own words go to `memory_capture`, the mount's only path to `owner`; something the
308
+ // agent watched happen is the agent's, and goes through the ordinary write tool.
309
+ const args = { body: fact.body, kind: fact.kind, reason: "captured from this turn by flypi" };
310
+ await call(server, fact.origin === "owner" ? "memory_capture" : "memory_write", args);
311
+ }
312
+ }
313
+
314
+ // The turn log: what this turn was shown against what it actually cited. That pair is the only way
315
+ // the hit-rate metrics exist (docs/memory.md Phase 4), and one row per turn is what makes a rate
316
+ // mean anything.
317
+ if (options.conversationId && !logged) {
318
+ logged = true;
319
+ // The turn's own numbers, read off what the provider answered with (docs/prompt-cache.md A1).
320
+ // Nothing else can report them: a model was never told its cache hit rate. A turn the provider
321
+ // gave no numbers for records no usage at all — the absence has to stay visible.
322
+ const usage = turnUsage(event.messages);
323
+ await call(server, "memory_turn", {
324
+ conversation_id: options.conversationId,
325
+ user_text: prompt.slice(0, TURN_CHARS),
326
+ assistant_text: answer.join("\n").slice(0, TURN_CHARS),
327
+ retrieved: [...seen],
328
+ cited: ids,
329
+ ...(usage ? { usage } : {}),
330
+ });
331
+ }
332
+
333
+ /**
334
+ * The review fork (docs/memory.md Phase 4.8), last: the answer is delivered and reported, and the
335
+ * fork runs detached, so nothing here waits for it. It counts only answers really delivered — an
336
+ * aborted or errored turn is not one to learn from. A probe, a daemon job, a subagent and a fork
337
+ * itself never reach here: they are created with no review runner at all.
338
+ */
339
+ const review = options.review;
340
+ if (!review || reviewing || !answerDelivered(event.messages)) return;
341
+ delivered += 1;
342
+ if (review.interval <= 0 || delivered % review.interval !== 0) return;
343
+ reviewing = true;
344
+ // Fire and forget: a fork that could not run is a fact not saved, not an error in the conversation
345
+ // somebody is reading — and an unhandled rejection there would take the process down over it.
346
+ void review
347
+ .fork({ entries: ctx.sessionManager.getEntries() })
348
+ .catch(() => undefined)
349
+ .finally(() => {
350
+ reviewing = false;
351
+ });
352
+ });
353
+ };
354
+ }
355
+
356
+ /**
357
+ * How many turns this conversation has asked for an answer. The recall block is a `custom_message`, not a
358
+ * user message, so it is not counted; a reopened session counts exactly what it was told before, which is the
359
+ * point — the fork's clock belongs to the conversation, not to the process that ran it.
360
+ */
361
+ function countUserTurns(entries: SessionEntry[]): number {
362
+ return entries.filter((entry) => entry.type === "message" && entry.message.role === "user").length;
363
+ }
364
+
365
+ /**
366
+ * Whether this turn ended with an answer. pi derives the run's outcome from the last assistant message's
367
+ * `stopReason`, and `agent_end` fires before the final `turn_end` boundary, so the messages are the only
368
+ * place that knows it in time: an aborted run's last assistant message says `aborted`, and a run that never
369
+ * reached one produced no answer to learn from either.
370
+ */
371
+ function answerDelivered(messages: unknown[]): boolean {
372
+ const last = [...messages].reverse().find((message) => (message as { role?: string }).role === "assistant");
373
+ if (!last) return false;
374
+ const stopReason = (last as { stopReason?: string }).stopReason;
375
+ return stopReason !== "aborted" && stopReason !== "error";
376
+ }
377
+
378
+ /**
379
+ * The provider's own numbers for one turn, summed across its model calls. `usageOf`/`addUsage` are the
380
+ * same functions the chat UI uses to print "N cached", so a `turns` row and the number on screen are one
381
+ * accounting and not two (docs/prompt-cache.md Phase 4.6 A1). Messages with no usage (the user's, tool
382
+ * results) carry no numbers and add nothing.
383
+ *
384
+ * Null when the provider reported nothing at all — a route with `supportsUsageInStreaming: false`, or a
385
+ * turn that never reached a model. Storing zeros there would read back as a turn that cost nothing, which
386
+ * is the lie the column exists to avoid; the absence of a number has to stay visible.
387
+ */
388
+ export function turnUsage(messages: unknown[]): RenderedUsage | null {
389
+ let total = emptyUsage();
390
+ let reported = false;
391
+ for (const message of messages) {
392
+ const usage = usageOf((message as { usage?: unknown })?.usage);
393
+ if (!usage) continue;
394
+ reported = true;
395
+ total = addUsage(total, usage);
396
+ }
397
+ return reported ? total : null;
398
+ }
399
+
400
+ interface MemoryRow {
401
+ id: number;
402
+ kind: string;
403
+ body: string;
404
+ }
405
+
406
+ interface SearchOutcome {
407
+ rows: MemoryRow[];
408
+ /** Why there is nothing to show: keyword-only, unreachable server. Never left silent (risk 8). */
409
+ note?: string;
410
+ }
411
+
412
+ /**
413
+ * The recall blocks this conversation is being shown right now. The session projection has already dropped
414
+ * anything a `context_edit` omitted, so an entry here is in front of the model — which is what the digest gate
415
+ * and the supersede pass need. Reading the session rather than remembering anything is what makes a session
416
+ * reopened from its file behave like the same conversation: it still has the memories it was already given.
417
+ */
418
+ function liveBlocks(ctx: ExtensionContext): { id: string; digest: string }[] {
419
+ return ctx.sessionManager.buildSessionProjection().entries.flatMap((entry) => {
420
+ const source = entry.sourceEntry;
421
+ if (source.type !== "custom_message" || source.customType !== RECALL_ENTRY || entry.messages.length === 0) return [];
422
+ return [{ id: source.id, digest: String((source.details as { digest?: string } | undefined)?.digest ?? "") }];
423
+ });
424
+ }
425
+
426
+ /**
427
+ * Connect, search, close. The memory server is stateless per request (src/memory/mcp.ts), so there is
428
+ * no connection worth keeping across a turn and nothing to release on session shutdown.
429
+ */
430
+ async function search(server: McpServerEntry, prompt: string): Promise<SearchOutcome> {
431
+ if (!("url" in server.config)) {
432
+ return { rows: [], note: `${server.name} is not an HTTP server, so flypi cannot search it before the model runs` };
433
+ }
434
+ const client = new Client({ name: "flypi-recall", version: "0.0.0" });
435
+ try {
436
+ await client.connect(
437
+ new StreamableHTTPClientTransport(new URL(server.config.url), { requestInit: { headers: headersOf(server) } }),
438
+ );
439
+ const result = await client.callTool({
440
+ name: "memory_search",
441
+ arguments: {
442
+ query: prompt.slice(0, QUERY_CHARS),
443
+ limit: MEMORY_ROWS,
444
+ max_tokens: MEMORY_TOKENS,
445
+ // Only what the store trusts enough to be said without being asked for: facts the user stated
446
+ // or this agent wrote. Everything else the model can still find by searching for itself.
447
+ injectable_only: true,
448
+ },
449
+ });
450
+ if (result.isError) return { rows: [], note: text(result.content) };
451
+ const payload = JSON.parse(text(result.content) || "{}") as { results?: MemoryRow[]; partial?: boolean; note?: string };
452
+ // `partial` means the vector half did not run; keyword hits are still shown, but never as if they
453
+ // were the whole answer (risk 8).
454
+ const note = payload.note ?? (payload.partial ? "keyword-only: the query could not be embedded" : undefined);
455
+ return { rows: payload.results ?? [], ...(note ? { note } : {}) };
456
+ } catch (error) {
457
+ return { rows: [], note: `${(error as Error).message} — call ${memoryTool(server.name, "memory_search")} yourself` };
458
+ } finally {
459
+ await client.close().catch(() => undefined);
460
+ }
461
+ }
462
+
463
+ const text = (content: unknown): string =>
464
+ (Array.isArray(content) ? content : [])
465
+ .filter((block): block is { type: string; text: string } => typeof block === "object" && block !== null && block.type === "text")
466
+ .map((block) => block.text)
467
+ .join("");
468
+
469
+ /**
470
+ * The ids a successful memory tool result put in front of the model. The two shapes it knows are read
471
+ * explicitly rather than any JSON with an `id` in it, so a write's row id never enters the citable set.
472
+ */
473
+ function idsIn(content: unknown): number[] {
474
+ try {
475
+ const payload = JSON.parse(text(content) || "{}") as { results?: { id: number }[]; memory?: { id: number } };
476
+ return [...(payload.results ?? []).map((row) => row.id), ...(payload.memory ? [payload.memory.id] : [])].filter(
477
+ (id) => Number.isInteger(id),
478
+ );
479
+ } catch {
480
+ return [];
481
+ }
482
+ }
483
+
484
+ /**
485
+ * A memory call whose answer nobody reads. A citation, a capture or a turn log that failed to record is a
486
+ * metric missing a data point, not something to tell the model, and the next turn that reports will try
487
+ * again. One client per call, because the memory server is stateless per request (src/memory/mcp.ts).
488
+ */
489
+ async function call(server: McpServerEntry, tool: string, args: unknown): Promise<void> {
490
+ if (!("url" in server.config)) return;
491
+ const client = new Client({ name: "flypi-recall", version: "0.0.0" });
492
+ try {
493
+ await client.connect(new StreamableHTTPClientTransport(new URL(server.config.url), { requestInit: { headers: headersOf(server) } }));
494
+ await client.callTool({ name: tool, arguments: args as Record<string, unknown> });
495
+ } catch {
496
+ // ignored: see above
497
+ } finally {
498
+ await client.close().catch(() => undefined);
499
+ }
500
+ }
501
+
502
+ /**
503
+ * pi expands `${NAME}` in headers itself when it connects (docs/mcp.md); this is a second client, so it
504
+ * does the same. shortcut: no `!command` header values here, add them if an entry ever uses one.
505
+ */
506
+ function headersOf(server: McpServerEntry): Record<string, string> {
507
+ const raw = "headers" in server.config ? server.config.headers : undefined;
508
+ return Object.fromEntries(
509
+ Object.entries(raw ?? {}).map(([name, value]) => [
510
+ name,
511
+ value.replace(/\$\{([A-Za-z0-9_]+)\}/g, (_, env: string) => process.env[env] ?? ""),
512
+ ]),
513
+ );
514
+ }
515
+
516
+ /**
517
+ * What one turn was shown: tags, the rows, and why if the search was degraded (risk 8). No instruction —
518
+ * that is `memoryInstruction` in the prompt now, and its bytes were otherwise re-paid every turn
519
+ * (docs/prompt-cache.md Phase 4.6 B3).
520
+ */
521
+ function renderBlock({ rows, note }: SearchOutcome): string {
522
+ return [
523
+ "<relevant_memories>",
524
+ ...(note ? [`Memory search: ${note}`] : []),
525
+ ...rows.map((row) => `#${row.id} (${row.kind}): ${row.body}`),
526
+ "</relevant_memories>",
527
+ ].join("\n");
528
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The review fork (docs/memory.md Phase 4.8): learning that does not wait for a sweep.
3
+ *
4
+ * Every `memory.reviewInterval` delivered answers, flypi forks a detached agent over a *snapshot* of the
5
+ * conversation and asks it one question — save what is worth saving, or say so. It has to exist because
6
+ * capture is otherwise a word list (src/memory/capture.ts), and a correction phrased unusually, a convention
7
+ * earned over a long tool run, or a preference never typed as a sentence are invisible to it.
8
+ *
9
+ * The rule everything else follows from: **nothing a fork says reaches a conversation a human reads.** The
10
+ * fork runs on pi's own non-persisting session (`SessionManager.inMemory`, seeded with the parent's entries),
11
+ * so it can write memories and cannot write the session file. hermes hand-built that isolation and its comment
12
+ * names the bug it prevents: a fork that writes its harness turn into the real session has it re-read by the
13
+ * next live turn as a standing instruction — risk 4 in a new costume.
14
+ *
15
+ * Why it is cheap: same provider and model, the same system-prompt bytes (byte-stable since Phase 4.6 B), the
16
+ * same advertised `tools[]`, and the memory block already in the seeded history (Phase 4.7 C2) — so the fork's
17
+ * request *extends a prefix the route has already read* instead of paying cold. That is why the write gate
18
+ * lives at the store (src/memory/mcp.ts) rather than in a smaller tool list: shrinking `tools[]` is a
19
+ * different request, and the cache is gone.
20
+ *
21
+ * This file holds the parts that need no session: what the fork is asked, and how its session is configured.
22
+ * The fork itself is created by `createFlypiSession` — the one place flypi creates an `AgentSession`, so the
23
+ * config-isolation invariant (risk 1) is enforced once (src/agent/runtime.ts).
24
+ */
25
+ import type { LoadedMcpConfig, SessionEntry } from "@earendil-works/pi-coding-agent";
26
+ import { memoryTool } from "./recall.ts";
27
+
28
+ /**
29
+ * What the recall extension hands the fork runner when it fires: the conversation exactly as the parent holds
30
+ * it, entries and all. The runner seeds a non-persisting session with them, so the fork sees the same
31
+ * transcript — including the memory block — and writes to none of it.
32
+ */
33
+ export type ReviewParent = { entries: SessionEntry[] };
34
+
35
+ /** The fork, as the recall extension calls it: fire and forget, the answer was already delivered. */
36
+ export type ReviewFork = (parent: ReviewParent) => Promise<void>;
37
+
38
+ /**
39
+ * The fork's prompt. Three things hermes learned the hard way (`agent/background_review.py`), each of which
40
+ * fails open if left out:
41
+ *
42
+ * - **one fact per row, filed by what it is about** — hermes has two stores and a routing rule; flypi has one
43
+ * store and a free-form `kind`, so the rule becomes "never put a fact about the user in the same row as a
44
+ * fact about this machine", because a mixed row is filed wrong for half of itself.
45
+ * - **the do-not-capture list** — environment-dependent failures, negative claims about tools, errors that
46
+ * already resolved, attempts that never worked. These harden into constraints the agent cites against
47
+ * itself long after the problem was fixed.
48
+ * - **an explicit "say so and stop"** — a review that finds nothing must write nothing, or the store fills
49
+ * with summaries of small talk.
50
+ */
51
+ export function reviewPrompt(serverName: string): string {
52
+ const write = memoryTool(serverName, "memory_write");
53
+ return [
54
+ "Review the conversation above. It has already been answered — you are not replying to it.",
55
+ "",
56
+ `Save what is worth keeping and nothing else: one call to ${write} per durable fact, with a kind that names what the fact is about.`,
57
+ "One fact per call. Never put a fact about the user in the same row as a fact about this machine or repository: a mixed row is filed wrong for half of itself.",
58
+ "Do not save a failure that only happens in this environment, a claim that some tool does not work, an error that already resolved inside this conversation, or an attempt that never worked. Those turn into rules you will cite against yourself long after the problem was fixed.",
59
+ "Do not save what the memories above already say, and do not restate one in different words.",
60
+ "You may add memories. You may not replace, delete, or claim the user's own words for one — if an existing memory is now wrong, say so in your reply instead.",
61
+ 'If nothing stands out, reply exactly "Nothing to save" and stop.',
62
+ ].join("\n");
63
+ }
64
+
65
+ /**
66
+ * The same MCP config with the memory server on the background mount (src/memory/mcp.ts `MOUNTS`), which is
67
+ * what makes its writes land `session_kind='background'`. The entry keeps its name, exposure and
68
+ * `toolExposure`, so the tools the fork is offered are the tools the parent was offered — byte-identical,
69
+ * which is the cache-parity claim.
70
+ *
71
+ * shortcut: only the HTTP memory server gets rewritten, because that is the only mount flypi's own template
72
+ * configures; a memory server on a stdio entry or a path that is not `/mcp` keeps its URL and its writes land
73
+ * `interactive`. Move the mount onto the entry itself if a non-standard one ever ships.
74
+ */
75
+ export function backgroundMount(config: LoadedMcpConfig, serverName: string): LoadedMcpConfig {
76
+ return {
77
+ ...config,
78
+ servers: config.servers.map((entry) => {
79
+ if (entry.name !== serverName || !("url" in entry.config)) return entry;
80
+ const url = new URL(entry.config.url);
81
+ if (!url.pathname.endsWith("/mcp")) return entry;
82
+ url.pathname = `${url.pathname}/background`;
83
+ return { ...entry, config: { ...entry.config, url: url.href } };
84
+ }),
85
+ };
86
+ }