@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,398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The flypi server: `node:http` + `ws` on 127.0.0.1 behind a token
|
|
3
|
+
* (docs/architecture.md Phase 1–2, docs/protocol.md). No framework; one WS endpoint and the
|
|
4
|
+
* built UI's static files.
|
|
5
|
+
*
|
|
6
|
+
* The daemon options (`health`, `scheduler`, `interruptedRun`) are all optional: `npm run serve` stays a
|
|
7
|
+
* foreground server with no job queue and no memory store, and answers 404 for `/healthz`.
|
|
8
|
+
*/
|
|
9
|
+
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
10
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
12
|
+
import { extname, resolve } from "node:path";
|
|
13
|
+
import { WebSocketServer, type WebSocket } from "ws";
|
|
14
|
+
import { MAX_ATTACHMENTS, MAX_ATTACHMENT_BYTES } from "../shared/attachments.ts";
|
|
15
|
+
import { parsePath, VIEW_PATHS } from "../shared/views.ts";
|
|
16
|
+
import { PROTOCOL_VERSION, encodeFrame, parseClientCommand, type InterruptedRun, type ResultFrame, type SchedulerSnapshot, type ServerFrame } from "./protocol.ts";
|
|
17
|
+
import type { MemoryApi } from "./memory-api.ts";
|
|
18
|
+
import type { SessionRegistry } from "./session-registry.ts";
|
|
19
|
+
import type { SecretsApi } from "./secrets-api.ts";
|
|
20
|
+
|
|
21
|
+
export interface FlypiServerOptions {
|
|
22
|
+
registry: SessionRegistry;
|
|
23
|
+
/** Omit to generate one per run; `start.mjs` prints it in the URL (Phase 7). */
|
|
24
|
+
token?: string;
|
|
25
|
+
port?: number;
|
|
26
|
+
host?: string;
|
|
27
|
+
serverVersion?: string;
|
|
28
|
+
/** How often to sweep idle, unattached conversations. */
|
|
29
|
+
evictIntervalMs?: number;
|
|
30
|
+
/** Built UI to serve (`npm run build`). Omit and the server is headless, as in Phase 1. */
|
|
31
|
+
uiDir?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Answers `GET /healthz` — the daemon's liveness page for `flypi status` and the service manager
|
|
34
|
+
* (Phase 4.5). Absent means this process is not a daemon, and `/healthz` is a 404.
|
|
35
|
+
*/
|
|
36
|
+
health?: () => Record<string, unknown>;
|
|
37
|
+
/**
|
|
38
|
+
* The page's "install now" for a downloaded update (docs/architecture.md Phase 7). Starts the install and
|
|
39
|
+
* the restart that follows it, so it answers before the work is done: null when it started, a reason when
|
|
40
|
+
* it did not. Absent in a process that does not manage its own updates — a checkout, or `npm run serve`.
|
|
41
|
+
*/
|
|
42
|
+
installUpdate?: () => Promise<string | null>;
|
|
43
|
+
/** The scheduler tab: what is queued, when it next runs, what the last runs changed. */
|
|
44
|
+
scheduler?: () => SchedulerSnapshot;
|
|
45
|
+
/**
|
|
46
|
+
* The Secrets tab (docs/secrets.md Phase 5.5). Absent in a process that opened no vault — a client then
|
|
47
|
+
* gets an error frame saying so, the same way the scheduler tab answers in a process without a daemon.
|
|
48
|
+
*/
|
|
49
|
+
secrets?: SecretsApi;
|
|
50
|
+
/**
|
|
51
|
+
* The Memory tab (docs/memory.md Phase 6). Present where the process can reach the memory database: the
|
|
52
|
+
* daemon (which owns it) and a dev/serve process that opened it read-only. Anywhere else the tab answers
|
|
53
|
+
* with an error frame rather than an empty store, which would read as "nothing remembered".
|
|
54
|
+
*/
|
|
55
|
+
memory?: MemoryApi;
|
|
56
|
+
/**
|
|
57
|
+
* Was a turn in this session file cut short when the daemon went away? Asked when a client opens that
|
|
58
|
+
* session, so the chat can say so instead of showing a half-finished answer with no explanation.
|
|
59
|
+
*/
|
|
60
|
+
interruptedRun?: (sessionFile: string) => InterruptedRun | undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface FlypiServerHandle {
|
|
64
|
+
port: number;
|
|
65
|
+
host: string;
|
|
66
|
+
token: string;
|
|
67
|
+
url: string;
|
|
68
|
+
/** How many pages are attached to this process right now. */
|
|
69
|
+
clients(): number;
|
|
70
|
+
close(): Promise<void>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Per-socket state for one conversation. `lastSent` is the dedupe guard between replay and live frames. */
|
|
74
|
+
interface Subscription {
|
|
75
|
+
lastSent: number;
|
|
76
|
+
detach: () => void;
|
|
77
|
+
detachUi: () => void;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const MIME: Record<string, string> = {
|
|
81
|
+
".html": "text/html; charset=utf-8",
|
|
82
|
+
".js": "text/javascript; charset=utf-8",
|
|
83
|
+
".css": "text/css; charset=utf-8",
|
|
84
|
+
".json": "application/json",
|
|
85
|
+
".svg": "image/svg+xml",
|
|
86
|
+
".jpeg": "image/jpeg",
|
|
87
|
+
".jpg": "image/jpeg",
|
|
88
|
+
".ico": "image/x-icon",
|
|
89
|
+
".woff2": "font/woff2",
|
|
90
|
+
".map": "application/json",
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export async function startFlypiServer(options: FlypiServerOptions): Promise<FlypiServerHandle> {
|
|
94
|
+
const host = options.host ?? "127.0.0.1";
|
|
95
|
+
const token = options.token ?? randomUUID();
|
|
96
|
+
const registry = options.registry;
|
|
97
|
+
|
|
98
|
+
const httpServer = createServer((request, response) => {
|
|
99
|
+
const path = new URL(request.url ?? "/", `http://${host}`).pathname;
|
|
100
|
+
// The UI reads its token here instead of parsing it out of a printed URL. Same origin as the
|
|
101
|
+
// page, so dev (via the Vite proxy) and prod use one client.
|
|
102
|
+
if (path === "/__flypi.json") {
|
|
103
|
+
response.writeHead(200, { "content-type": "application/json" });
|
|
104
|
+
response.end(JSON.stringify({ protocolVersion: PROTOCOL_VERSION, token, wsPath: "/ws" }));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
// Unauthenticated, like `/__flypi.json`: it says only that a daemon is up, and `launchd`/systemd
|
|
108
|
+
// have to be able to poll it without being handed the token.
|
|
109
|
+
if (path === "/healthz") {
|
|
110
|
+
if (!options.health) {
|
|
111
|
+
response.writeHead(404, { "content-type": "application/json" });
|
|
112
|
+
response.end('{"ok":false,"reason":"this process is not a daemon"}\n');
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
response.writeHead(200, { "content-type": "application/json" });
|
|
116
|
+
response.end(JSON.stringify({ ok: true, ...options.health() }));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (serveStatic(path, response)) return;
|
|
120
|
+
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
121
|
+
response.end("not found\n");
|
|
122
|
+
});
|
|
123
|
+
// ws' default maxPayload is 1 MiB, which a pasted screenshot comfortably exceeds. A prompt may carry
|
|
124
|
+
// the whole attachment allowance, and base64 is 4/3 of those bytes: deriving the frame limit from the
|
|
125
|
+
// same constants means "too big for the socket" (a close the chat cannot explain) never happens
|
|
126
|
+
// before "too big" (a refusal the chat can show).
|
|
127
|
+
const maxFrameBytes = Math.ceil((MAX_ATTACHMENTS * MAX_ATTACHMENT_BYTES * 4) / 3) + 64 * 1024;
|
|
128
|
+
const webSockets = new WebSocketServer({ server: httpServer, maxPayload: maxFrameBytes });
|
|
129
|
+
// `ws` mirrors the HTTP server's own `error` onto the WebSocketServer. With no listener here that is an
|
|
130
|
+
// unhandled 'error' and the process dies on the spot — which is how a bind failure would skip the caller's
|
|
131
|
+
// chance to report it and retry on another port (src/daemon/index.ts). The bind failure itself is reported
|
|
132
|
+
// through the `listen` promise at the bottom of this function.
|
|
133
|
+
webSockets.on("error", () => {});
|
|
134
|
+
|
|
135
|
+
/** Serve the built UI. Nothing here is dynamic: the app's data all comes over the socket. */
|
|
136
|
+
function serveStatic(path: string, response: ServerResponse): boolean {
|
|
137
|
+
if (!options.uiDir || !existsSync(options.uiDir)) {
|
|
138
|
+
if (path !== "/") return false;
|
|
139
|
+
response.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
|
|
140
|
+
response.end("flypi agent server — connect over WebSocket; see docs/protocol.md\n");
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
// The app's views and its conversations are addresses, not files (ui/lib/route.ts): `/settings` and
|
|
144
|
+
// `/sessions/<id>` are the same page as `/`, so a reload or a bookmark lands on what it names instead of a 404.
|
|
145
|
+
const page =
|
|
146
|
+
path === "/" || parsePath(path).session || (VIEW_PATHS as readonly string[]).includes(path.slice(1)) ? "/index.html" : path;
|
|
147
|
+
const file = resolve(options.uiDir, `.${page}`);
|
|
148
|
+
if (!file.startsWith(resolve(options.uiDir)) || !existsSync(file)) return false;
|
|
149
|
+
response.writeHead(200, { "content-type": MIME[extname(file)] ?? "application/octet-stream" });
|
|
150
|
+
response.end(readFileSync(file));
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
webSockets.on("connection", (socket: WebSocket, request: IncomingMessage) => {
|
|
155
|
+
const url = new URL(request.url ?? "/", `http://${host}`);
|
|
156
|
+
if (url.searchParams.get("token") !== token) {
|
|
157
|
+
socket.close(4401, "unauthorized");
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const subscriptions = new Map<string, Subscription>();
|
|
162
|
+
const send = (frame: ServerFrame) => {
|
|
163
|
+
if (socket.readyState === socket.OPEN) socket.send(encodeFrame(frame));
|
|
164
|
+
};
|
|
165
|
+
const fail = (message: string, extra: { code?: string; requestId?: string; conversationId?: string } = {}) =>
|
|
166
|
+
send({ type: "error", message, ...extra });
|
|
167
|
+
/** Reply to a command that asked for one. `prompt` is deliberately not in that set: its events are the answer. */
|
|
168
|
+
const reply = (requestId: string | undefined, extra: Omit<ResultFrame, "type" | "requestId"> = {}) => {
|
|
169
|
+
if (requestId) send({ type: "result", requestId, ...extra });
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/** Start (or keep) this socket's subscription to a conversation; `afterSeq` replays what it missed. */
|
|
173
|
+
const subscribe = (conversationId: string, afterSeq?: number) => {
|
|
174
|
+
let subscription = subscriptions.get(conversationId);
|
|
175
|
+
if (!subscription) {
|
|
176
|
+
const state: Subscription = { lastSent: 0, detach: () => {}, detachUi: () => {} };
|
|
177
|
+
state.detach = registry.attach(conversationId, (seq, event) => {
|
|
178
|
+
if (seq <= state.lastSent) return;
|
|
179
|
+
state.lastSent = seq;
|
|
180
|
+
send({ type: "event", conversationId, seq, event });
|
|
181
|
+
});
|
|
182
|
+
// Extension output is not a pi event, so it has no seq to dedupe: the page sees each frame once,
|
|
183
|
+
// live, and a dialog it was not connected for is simply missed (src/server/extension-ui.ts).
|
|
184
|
+
state.detachUi = registry.attachUi(conversationId, send);
|
|
185
|
+
subscription = state;
|
|
186
|
+
subscriptions.set(conversationId, state);
|
|
187
|
+
}
|
|
188
|
+
if (afterSeq === undefined) return;
|
|
189
|
+
subscription.lastSent = afterSeq;
|
|
190
|
+
const replay = registry.log(conversationId).replayAfter(afterSeq);
|
|
191
|
+
if (replay.gap) {
|
|
192
|
+
// Tell the client its transcript has a hole rather than pretending it does not.
|
|
193
|
+
fail(`events 1–${registry.log(conversationId).evictedThroughSeq} were dropped; reload the transcript`, {
|
|
194
|
+
code: "replay_gap",
|
|
195
|
+
conversationId,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
for (const entry of replay.events) {
|
|
199
|
+
if (entry.seq <= subscription.lastSent) continue;
|
|
200
|
+
subscription.lastSent = entry.seq;
|
|
201
|
+
send({ type: "event", conversationId, seq: entry.seq, event: entry.event });
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
send({ type: "hello", protocolVersion: PROTOCOL_VERSION, serverVersion: options.serverVersion ?? "dev" });
|
|
206
|
+
|
|
207
|
+
const handle = async (raw: string): Promise<void> => {
|
|
208
|
+
const parsed = parseClientCommand(raw);
|
|
209
|
+
if (!parsed.ok) return fail(parsed.message);
|
|
210
|
+
const command = parsed.command;
|
|
211
|
+
const requestId = command.requestId;
|
|
212
|
+
const conversationId = "conversationId" in command ? command.conversationId : undefined;
|
|
213
|
+
const onError = (error: Error) => {
|
|
214
|
+
// What the page was told, in the dev log: an error frame alone leaves a stuck UI inexplicable.
|
|
215
|
+
console.error(`flypi: ${command.type} failed: ${error.message}${conversationId ? ` (conversation ${conversationId})` : ""}`);
|
|
216
|
+
fail(error.message, { requestId, conversationId });
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
switch (command.type) {
|
|
221
|
+
case "newSession": {
|
|
222
|
+
const conversation = await registry.create();
|
|
223
|
+
subscribe(conversation.id); // attach before the first event can fire: a new conversation loses nothing
|
|
224
|
+
reply(requestId, { conversationId: conversation.id, sessionPath: conversation.session.sessionFile });
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
case "resume":
|
|
228
|
+
subscribe(command.conversationId, command.afterSeq);
|
|
229
|
+
return;
|
|
230
|
+
case "extensionReply":
|
|
231
|
+
// The answer to an extension's question. Nothing waiting on that id means it timed out or
|
|
232
|
+
// another tab answered it, and the page has to stop showing the dialog either way.
|
|
233
|
+
if (!registry.replyExtension(command.conversationId, command.dialogId, command.value)) {
|
|
234
|
+
return fail(`no extension dialog ${command.dialogId} is waiting for an answer`, { requestId });
|
|
235
|
+
}
|
|
236
|
+
return reply(requestId, { conversationId });
|
|
237
|
+
case "prompt":
|
|
238
|
+
subscribe(command.conversationId);
|
|
239
|
+
// Awaits the whole run; that is fine because each message is handled independently and
|
|
240
|
+
// the events are already streaming. A rejection (bad mode, no model) is the error frame.
|
|
241
|
+
await registry.prompt(command.conversationId, command.text, command.mode, command.attachments);
|
|
242
|
+
return;
|
|
243
|
+
case "abort":
|
|
244
|
+
subscribe(command.conversationId);
|
|
245
|
+
await registry.abort(command.conversationId);
|
|
246
|
+
return reply(requestId, { conversationId });
|
|
247
|
+
case "switchSession": {
|
|
248
|
+
const switched = await registry.switchSession(command.conversationId, command.sessionPath);
|
|
249
|
+
// A turn the last process left half-done is reported with the session it belongs to — the
|
|
250
|
+
// client is about to render that chat, and nothing else tells it why the last answer stops short.
|
|
251
|
+
const interrupted = options.interruptedRun?.(switched.session.sessionFile ?? "");
|
|
252
|
+
return reply(requestId, {
|
|
253
|
+
conversationId: switched.id,
|
|
254
|
+
sessionPath: switched.session.sessionFile,
|
|
255
|
+
...(interrupted ? { interrupted } : {}),
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
case "fork": {
|
|
259
|
+
const forked = await registry.fork(command.conversationId, command.messageSeq);
|
|
260
|
+
subscribe(forked.id);
|
|
261
|
+
return reply(requestId, { conversationId: forked.id, sessionPath: forked.session.sessionFile });
|
|
262
|
+
}
|
|
263
|
+
case "setModel":
|
|
264
|
+
subscribe(command.conversationId);
|
|
265
|
+
await registry.setModel(command.conversationId, command.provider, command.modelId);
|
|
266
|
+
return reply(requestId, { conversationId });
|
|
267
|
+
case "setThinkingLevel":
|
|
268
|
+
subscribe(command.conversationId);
|
|
269
|
+
registry.setThinkingLevel(command.conversationId, command.level);
|
|
270
|
+
return reply(requestId, { conversationId });
|
|
271
|
+
case "listSessions":
|
|
272
|
+
// Not conversation-scoped: it lists the session files flypi owns, for the sidebar.
|
|
273
|
+
return reply(requestId, { sessions: await registry.listSessions() });
|
|
274
|
+
case "renameSession":
|
|
275
|
+
registry.rename(command.conversationId, command.name);
|
|
276
|
+
return reply(requestId, { conversationId });
|
|
277
|
+
case "deleteSession":
|
|
278
|
+
// The refreshed list comes back with the ack: the sidebar is the only caller, and what it
|
|
279
|
+
// needs after a delete is the list without the deleted row in it.
|
|
280
|
+
await registry.deleteSession(command.sessionPath);
|
|
281
|
+
return reply(requestId, { sessions: await registry.listSessions() });
|
|
282
|
+
case "debug":
|
|
283
|
+
return reply(requestId, { conversationId, debug: await registry.debugInfo(command.conversationId) });
|
|
284
|
+
case "installUpdate":
|
|
285
|
+
// The daemon's own update path, asked for when the user will not wait for the idle moment.
|
|
286
|
+
// The ack goes out before the restart, because the restart closes this socket.
|
|
287
|
+
if (!options.installUpdate) return fail("this flypi process does not manage its own updates — it is a checkout or a headless server", { requestId });
|
|
288
|
+
return options.installUpdate().then((why) => (why ? fail(why, { requestId }) : reply(requestId)));
|
|
289
|
+
case "scheduler":
|
|
290
|
+
// The queue belongs to the daemon, not to a conversation, so a process without one says so
|
|
291
|
+
// in an error frame the panel can show rather than rendering an empty table.
|
|
292
|
+
if (!options.scheduler) return fail("this flypi process has no scheduler — `flypi start` runs one", { requestId });
|
|
293
|
+
return reply(requestId, { scheduler: options.scheduler() });
|
|
294
|
+
case "getConfig":
|
|
295
|
+
// Config files, not conversations: the settings panel reads what is on disk.
|
|
296
|
+
return reply(requestId, { config: registry.configSnapshot() });
|
|
297
|
+
case "saveConfig":
|
|
298
|
+
// The refreshed files come back with the ack, as with deleteSession: the panel's next
|
|
299
|
+
// thing to show is what the write produced.
|
|
300
|
+
return reply(requestId, { config: registry.saveConfig(command) });
|
|
301
|
+
case "packages":
|
|
302
|
+
// The packages the agent loads, read from the agent dir. No network: what has an update
|
|
303
|
+
// waiting comes from the daemon's daily stamp (src/agent/packages.ts).
|
|
304
|
+
return reply(requestId, { packages: registry.packages() });
|
|
305
|
+
case "savePackages":
|
|
306
|
+
// npm or git does the work, so this is the slow one of the settings commands; the page shows a
|
|
307
|
+
// busy state while it is open. The refreshed list comes back with the ack, as with saveConfig.
|
|
308
|
+
return reply(requestId, { packages: await registry.applyPackages(command) });
|
|
309
|
+
case "mcpStatus":
|
|
310
|
+
// Not config and not a conversation: the servers themselves are asked. Slow by nature (a
|
|
311
|
+
// stdio server has to start), which is why the panel asks once and shows a busy state
|
|
312
|
+
// rather than polling (src/agent/mcp-status.ts).
|
|
313
|
+
return reply(requestId, { mcpStatus: await registry.mcpStatus() });
|
|
314
|
+
case "mcpSignIn":
|
|
315
|
+
// The browser round-trip (risk 5). This request stays open for the whole sign-in: the URL is
|
|
316
|
+
// pushed as an `mcpAuth` frame, the redirect arrives as `mcpRedirect`, and the ack is the
|
|
317
|
+
// refreshed status — the row the user was looking at, answering now that it has a token.
|
|
318
|
+
await registry.mcpSignIn(command.name, (url) => send({ type: "mcpAuth", name: command.name, url }));
|
|
319
|
+
return reply(requestId, { mcpStatus: await registry.mcpStatus() });
|
|
320
|
+
case "mcpRedirect":
|
|
321
|
+
// No ack of its own: the `mcpSignIn` still open is what answers this, and a sign-in that
|
|
322
|
+
// failed is an `error` frame for that request. Here there is only one thing to say: that
|
|
323
|
+
// nothing was waiting for this paste.
|
|
324
|
+
if (!registry.submitMcpRedirect(command.name, command.url)) {
|
|
325
|
+
return fail(`no sign-in is waiting for "${command.name}" — start one with "Sign in"`, { requestId });
|
|
326
|
+
}
|
|
327
|
+
return;
|
|
328
|
+
case "secrets":
|
|
329
|
+
if (!options.secrets) return fail("this flypi process has no vault open", { requestId });
|
|
330
|
+
return reply(requestId, { secrets: options.secrets.snapshot() });
|
|
331
|
+
case "saveSecrets":
|
|
332
|
+
if (!options.secrets) return fail("this flypi process has no vault open", { requestId });
|
|
333
|
+
// The refreshed list comes back with the ack, as with saveConfig: the page should show what
|
|
334
|
+
// the vault holds now, including the mask it chose for a value it never sends back.
|
|
335
|
+
return reply(requestId, { secrets: options.secrets.apply(command.secrets) });
|
|
336
|
+
case "revealSecret": {
|
|
337
|
+
if (!options.secrets) return fail("this flypi process has no vault open", { requestId });
|
|
338
|
+
const revealed = options.secrets.reveal(command.name);
|
|
339
|
+
if (revealed === undefined) return fail(`no secret named "${command.name}"`, { requestId });
|
|
340
|
+
return reply(requestId, { revealed });
|
|
341
|
+
}
|
|
342
|
+
case "memory":
|
|
343
|
+
if (!options.memory) return fail("this flypi process has no memory store open — `flypi start` runs the one that owns it", { requestId });
|
|
344
|
+
return reply(requestId, { memory: options.memory.snapshot() });
|
|
345
|
+
case "saveMemory":
|
|
346
|
+
if (!options.memory) return fail("this flypi process has no memory store open — `flypi start` runs the one that owns it", { requestId });
|
|
347
|
+
// The refreshed store comes back with the ack, as with saveSecrets: the panel's next thing to
|
|
348
|
+
// show is what the write produced, including the audit row it just wrote.
|
|
349
|
+
return reply(requestId, { memory: options.memory.apply(command.memories) });
|
|
350
|
+
}
|
|
351
|
+
} catch (error) {
|
|
352
|
+
onError(error as Error);
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
// Never await here: a prompt must not block the next command on the same socket.
|
|
357
|
+
socket.on("message", (data) => void handle(String(data)));
|
|
358
|
+
|
|
359
|
+
const drop = () => {
|
|
360
|
+
for (const subscription of subscriptions.values()) {
|
|
361
|
+
subscription.detach();
|
|
362
|
+
subscription.detachUi();
|
|
363
|
+
}
|
|
364
|
+
subscriptions.clear();
|
|
365
|
+
};
|
|
366
|
+
socket.on("close", drop);
|
|
367
|
+
socket.on("error", drop);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
const sweep = setInterval(() => registry.evictIdle(), options.evictIntervalMs ?? 60_000);
|
|
371
|
+
sweep.unref();
|
|
372
|
+
|
|
373
|
+
const port = await new Promise<number>((resolvePort, reject) => {
|
|
374
|
+
httpServer.once("error", reject);
|
|
375
|
+
httpServer.listen(options.port ?? 0, host, () => {
|
|
376
|
+
const address = httpServer.address();
|
|
377
|
+
if (address && typeof address === "object") resolvePort(address.port);
|
|
378
|
+
else reject(new Error("could not bind the flypi server socket"));
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
port,
|
|
384
|
+
host,
|
|
385
|
+
token,
|
|
386
|
+
url: `ws://${host}:${port}`,
|
|
387
|
+
/** How many pages are attached. The daemon's sweep trigger reads this: a sweep waits for the quiet a
|
|
388
|
+
* live conversation does not give it (docs/memory.md Phase 6). */
|
|
389
|
+
clients: () => webSockets.clients.size,
|
|
390
|
+
close: () =>
|
|
391
|
+
new Promise<void>((resolveClose) => {
|
|
392
|
+
clearInterval(sweep);
|
|
393
|
+
for (const client of webSockets.clients) client.terminate();
|
|
394
|
+
webSockets.close();
|
|
395
|
+
httpServer.close(() => resolveClose());
|
|
396
|
+
}),
|
|
397
|
+
};
|
|
398
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Memory tab's server side (docs/memory.md Phase 6): the store as a browser needs it — every row in
|
|
3
|
+
* every status, the turns that say what recall was shown and what an answer actually cited, the wiki as it
|
|
4
|
+
* was last rendered, and the hit-rate numbers.
|
|
5
|
+
*
|
|
6
|
+
* Two things this file is responsible for:
|
|
7
|
+
*
|
|
8
|
+
* - It is a **read of the store, not a search**. The browser filters what it was given, so a row a search
|
|
9
|
+
* would rank last is still listed, and a superseded chain is still traceable. `search()` would also move
|
|
10
|
+
* `use_count` for every row the page happens to look at, which would make a promotion gate count browsing.
|
|
11
|
+
* - Every write it applies is one the user asked for, with the actor named `ui`. The store refuses the same
|
|
12
|
+
* call from an unattended actor (`UNATTENDED_ACTORS` in src/memory/store.ts), and nothing here can set
|
|
13
|
+
* `origin_class` — provenance has never been an argument.
|
|
14
|
+
*/
|
|
15
|
+
import type { DatabaseSync } from "node:sqlite";
|
|
16
|
+
import { estimateTokens, type MemoryRow, type MemoryStore } from "../memory/store.ts";
|
|
17
|
+
import { readWiki } from "../memory/wiki.ts";
|
|
18
|
+
import type { MemoryEdit, MemorySnapshot, MemoryTurnRow } from "./protocol.ts";
|
|
19
|
+
|
|
20
|
+
/** How many turns the browser shows. Enough to answer "why was this injected" for anything on screen. */
|
|
21
|
+
const TURN_ROWS = 50;
|
|
22
|
+
|
|
23
|
+
export interface MemoryApi {
|
|
24
|
+
/** The store, the turn log, the wiki and the numbers, as they are right now. */
|
|
25
|
+
snapshot(): MemorySnapshot;
|
|
26
|
+
/** Edit, tombstone or undo. Returns the refreshed snapshot; a refused edit throws, and the panel shows why. */
|
|
27
|
+
apply(edits: MemoryEdit[]): MemorySnapshot;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function memoryApi(store: MemoryStore, db: DatabaseSync, options: { readOnly?: boolean } = {}): MemoryApi {
|
|
31
|
+
const entry = (row: MemoryRow) => ({
|
|
32
|
+
id: row.id,
|
|
33
|
+
kind: row.kind,
|
|
34
|
+
body: row.body,
|
|
35
|
+
tags: row.tags,
|
|
36
|
+
status: row.status,
|
|
37
|
+
originClass: row.origin_class,
|
|
38
|
+
sessionKind: row.session_kind,
|
|
39
|
+
strength: row.strength,
|
|
40
|
+
useCount: row.use_count,
|
|
41
|
+
citedCount: row.cited_count,
|
|
42
|
+
createdAt: row.created_at,
|
|
43
|
+
lastVerifiedAt: row.last_verified_at,
|
|
44
|
+
supersedes: row.supersedes,
|
|
45
|
+
supersededBy: row.superseded_by,
|
|
46
|
+
mergedFrom: row.merged_from,
|
|
47
|
+
history: store.history(row.id).map((audit) => ({
|
|
48
|
+
id: audit.id,
|
|
49
|
+
action: audit.action,
|
|
50
|
+
actor: audit.actor,
|
|
51
|
+
reason: audit.reason ?? "",
|
|
52
|
+
createdAt: audit.created_at,
|
|
53
|
+
// The stored text, not a re-render: it is what the row looked like before the change, and the
|
|
54
|
+
// page has no business guessing at columns it does not own.
|
|
55
|
+
preImage: JSON.stringify(audit.pre_image ?? {}),
|
|
56
|
+
})),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const turns = (): MemoryTurnRow[] =>
|
|
60
|
+
(db
|
|
61
|
+
.prepare("SELECT id, conversation_id, created_at, user_text, retrieved_json, cited_json FROM turns ORDER BY id DESC LIMIT ?")
|
|
62
|
+
.all(TURN_ROWS) as unknown as {
|
|
63
|
+
id: number;
|
|
64
|
+
conversation_id: string;
|
|
65
|
+
created_at: string;
|
|
66
|
+
user_text: string;
|
|
67
|
+
retrieved_json: string | null;
|
|
68
|
+
cited_json: string | null;
|
|
69
|
+
}[]
|
|
70
|
+
).map((turn) => {
|
|
71
|
+
const retrieved = turn.retrieved_json ? (JSON.parse(turn.retrieved_json) as number[]) : [];
|
|
72
|
+
return {
|
|
73
|
+
id: turn.id,
|
|
74
|
+
conversationId: turn.conversation_id,
|
|
75
|
+
createdAt: turn.created_at,
|
|
76
|
+
userText: turn.user_text,
|
|
77
|
+
retrieved,
|
|
78
|
+
cited: turn.cited_json ? (JSON.parse(turn.cited_json) as number[]) : [],
|
|
79
|
+
// What the rows shown to this turn would have cost, estimated the same way the injection budget
|
|
80
|
+
// is estimated (src/memory/store.ts `estimateTokens`) — so the number on screen is the number
|
|
81
|
+
// the recall budget was working against.
|
|
82
|
+
injectedTokens: retrieved.reduce((total, id) => total + estimateTokens(store.memory(id)?.body ?? ""), 0),
|
|
83
|
+
};
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const snapshot = (): MemorySnapshot => {
|
|
87
|
+
const hit = store.memoryHitRate();
|
|
88
|
+
// The summary, not `indexIdentity()`: looking at the store must not queue a re-embed, and a process
|
|
89
|
+
// holding the database read-only could not write the queue row it would try to make.
|
|
90
|
+
const index = store.indexSummary();
|
|
91
|
+
return {
|
|
92
|
+
// Newest first: the browser is where you check what the agent learned *today*.
|
|
93
|
+
memories: (db.prepare("SELECT id FROM memories ORDER BY id DESC").all() as unknown as { id: number }[])
|
|
94
|
+
.map((row) => store.memory(row.id))
|
|
95
|
+
.filter((row): row is MemoryRow => row !== undefined)
|
|
96
|
+
.map(entry),
|
|
97
|
+
turns: turns(),
|
|
98
|
+
wiki: readWiki(db).map((page) => ({
|
|
99
|
+
slug: page.slug,
|
|
100
|
+
title: page.title,
|
|
101
|
+
topicKey: page.topic_key,
|
|
102
|
+
body: page.body,
|
|
103
|
+
claimIds: page.claim_ids,
|
|
104
|
+
model: page.model,
|
|
105
|
+
generatedAt: page.generated_at,
|
|
106
|
+
sections: page.sections.map((section) => ({ heading: section.heading, body: section.body, claimIds: section.claim_ids })),
|
|
107
|
+
})),
|
|
108
|
+
stats: {
|
|
109
|
+
turns: hit.turns,
|
|
110
|
+
turnsShownMemories: hit.turns_shown_memories,
|
|
111
|
+
turnsCiting: hit.turns_citing,
|
|
112
|
+
memoriesRetrieved: hit.memories_retrieved,
|
|
113
|
+
memoriesCited: hit.memories_cited,
|
|
114
|
+
rate: hit.rate,
|
|
115
|
+
indexRows: index.rows,
|
|
116
|
+
indexStale: index.stale,
|
|
117
|
+
embedModel: index.expected?.model ?? null,
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
snapshot,
|
|
124
|
+
apply: (edits) => {
|
|
125
|
+
// A second process may read the store; only the one that owns the writer may change it. Said as a
|
|
126
|
+
// sentence rather than left to SQLite, whose "attempting to write a readonly database" is not a
|
|
127
|
+
// thing a reader can act on — the same rule the Secrets panel follows for a read-only vault.
|
|
128
|
+
if (options.readOnly === true) {
|
|
129
|
+
throw new Error("this process has the memory database open read-only — `flypi start` is the one that can change it");
|
|
130
|
+
}
|
|
131
|
+
for (const edit of edits) {
|
|
132
|
+
// Undo first: it names the audited change to reverse, so the page never has to hold what the
|
|
133
|
+
// row looked like before (that is what the audit row is for).
|
|
134
|
+
if (edit.undoAudit !== undefined) {
|
|
135
|
+
store.undo(edit.id, edit.undoAudit, "ui");
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (edit.remove === true) {
|
|
139
|
+
store.forget(edit.id, "ui", "deleted in the memory browser");
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (edit.body === undefined && edit.kind === undefined && edit.tags === undefined) {
|
|
143
|
+
throw new Error(`memory #${edit.id}: nothing to change`);
|
|
144
|
+
}
|
|
145
|
+
store.editMemory(
|
|
146
|
+
edit.id,
|
|
147
|
+
{
|
|
148
|
+
...(edit.body === undefined ? {} : { body: edit.body }),
|
|
149
|
+
...(edit.kind === undefined ? {} : { kind: edit.kind }),
|
|
150
|
+
...(edit.tags === undefined ? {} : { tags: edit.tags }),
|
|
151
|
+
},
|
|
152
|
+
"ui",
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
return snapshot();
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|