@makerstufferupper/flypi 0.0.0-stage → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +64 -2
- package/assets/screenshot.png +0 -0
- package/bin/flypi.mjs +28 -0
- package/dist/ui/assets/index-CG1CuGxu.js +105 -0
- package/dist/ui/assets/index-qaKjH5fH.css +1 -0
- package/dist/ui/flypi-logo-dark.jpeg +0 -0
- package/dist/ui/flypi-logo-light.jpeg +0 -0
- package/dist/ui/index.html +17 -0
- package/package.json +71 -5
- package/scripts/benchmark-nli.ts +273 -0
- package/scripts/flypi.mjs +310 -0
- package/scripts/probe-turn-boundary.ts +97 -0
- package/scripts/probe.ts +119 -0
- package/scripts/record-transcript.ts +63 -0
- package/scripts/reembed-memory.ts +41 -0
- package/scripts/release.mjs +38 -0
- package/scripts/secret.ts +24 -0
- package/scripts/serve-daemon.ts +101 -0
- package/scripts/serve-memory.ts +78 -0
- package/scripts/serve.ts +89 -0
- package/scripts/smoke-memory.mjs +239 -0
- package/scripts/smoke-secrets.mjs +153 -0
- package/scripts/smoke.mjs +59 -0
- package/scripts/start.mjs +294 -0
- package/scripts/turn-boundary.ts +52 -0
- package/scripts/update.ts +92 -0
- package/scripts/warm-models.ts +41 -0
- package/src/agent/config-files.ts +529 -0
- package/src/agent/mcp-signin.ts +137 -0
- package/src/agent/mcp-status.ts +168 -0
- package/src/agent/onboarding.ts +74 -0
- package/src/agent/packages.ts +150 -0
- package/src/agent/paths.ts +80 -0
- package/src/agent/permission-gate.ts +70 -0
- package/src/agent/pi-mcp.ts +97 -0
- package/src/agent/runtime.ts +347 -0
- package/src/daemon/index.ts +442 -0
- package/src/daemon/jobs.ts +202 -0
- package/src/daemon/update.ts +248 -0
- package/src/memory/capture.ts +109 -0
- package/src/memory/config.ts +90 -0
- package/src/memory/consolidate.ts +218 -0
- package/src/memory/db.ts +393 -0
- package/src/memory/embed.ts +154 -0
- package/src/memory/judge.ts +167 -0
- package/src/memory/mcp.ts +496 -0
- package/src/memory/media.ts +135 -0
- package/src/memory/progress.ts +39 -0
- package/src/memory/recall.ts +528 -0
- package/src/memory/review.ts +86 -0
- package/src/memory/store.ts +1034 -0
- package/src/memory/wiki.ts +140 -0
- package/src/secrets/cli.ts +248 -0
- package/src/secrets/config.ts +133 -0
- package/src/secrets/crypto.ts +180 -0
- package/src/secrets/extension.ts +253 -0
- package/src/secrets/redact.ts +132 -0
- package/src/secrets/vault.ts +396 -0
- package/src/server/event-log.ts +49 -0
- package/src/server/extension-ui.ts +118 -0
- package/src/server/index.ts +398 -0
- package/src/server/memory-api.ts +158 -0
- package/src/server/protocol.ts +947 -0
- package/src/server/run-store.ts +136 -0
- package/src/server/secrets-api.ts +88 -0
- package/src/server/session-registry.ts +593 -0
- package/src/shared/attachments.ts +40 -0
- package/src/shared/cache-probe.ts +47 -0
- package/src/shared/markdown.ts +17 -0
- package/src/shared/metrics.ts +212 -0
- package/src/shared/sqlite.ts +74 -0
- package/src/shared/transcript.ts +404 -0
- package/src/shared/turn-boundary.ts +98 -0
- 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
|
+
}
|