@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,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown for the chat (docs/architecture.md Phase 2: "Chat rendering: markdown (marked)").
|
|
3
|
+
*
|
|
4
|
+
* `marked` is the one exception to flypi's no-framework rule, and it is a rendering exception only —
|
|
5
|
+
* it renders model output, it is not the app's HTML. Tests assert the structure it produces
|
|
6
|
+
* (docs/testing.md), so swapping the renderer is a test diff, not a rewrite.
|
|
7
|
+
*/
|
|
8
|
+
import { marked } from "marked";
|
|
9
|
+
|
|
10
|
+
marked.setOptions({ gfm: true, breaks: false });
|
|
11
|
+
|
|
12
|
+
export function renderMarkdown(text: string): string {
|
|
13
|
+
// The agent and its tool outputs may contain HTML; escaping `<` keeps a tool result from
|
|
14
|
+
// injecting live markup into the page, and `javascript:`/`data:` hrefs are the other half of it.
|
|
15
|
+
const html = marked.parse(text.replace(/</g, "<"), { async: false });
|
|
16
|
+
return html.replace(/href=["']\s*(?:javascript|data|vbscript):/gi, 'href="#blocked:');
|
|
17
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live model speed metrics, read out of the same pi event stream the transcript mapper reads
|
|
3
|
+
* (`src/shared/transcript.ts`).
|
|
4
|
+
*
|
|
5
|
+
* pi reports tokens, never tokens/second, so the only clock available is when the events arrived here.
|
|
6
|
+
* That decides what each number honestly means:
|
|
7
|
+
*
|
|
8
|
+
* - **prefill** — the provider request up to the first content token, over the prompt tokens that
|
|
9
|
+
* request carried. pi emits `turn_start` once per provider request, which is the closest the stream
|
|
10
|
+
* gets to "request sent". The window also contains pi's request preparation and the network, so this
|
|
11
|
+
* is a ceiling on the model's prompt-eval rate, not the number a benchmark would print.
|
|
12
|
+
* - **decode** — the response's output tokens over the time from its first token to its end.
|
|
13
|
+
*
|
|
14
|
+
* A question is usually answered by several responses with tool calls between them (pi runs the tools
|
|
15
|
+
* after `message_end` and before the next `turn_start`), so the rates shown are the **average over the
|
|
16
|
+
* answers that streamed**: their windows are summed, tokens over milliseconds. Nothing that happens
|
|
17
|
+
* outside a response's own stream is in a window, so a long tool call, a compaction or an idle pause
|
|
18
|
+
* cannot pull either rate down.
|
|
19
|
+
*
|
|
20
|
+
* `now` is injectable for the same reason the transcript reducer takes one: the numbers are testable
|
|
21
|
+
* without a clock. A response with no measurable window (both events inside one ms, which a local model
|
|
22
|
+
* does) reports `null`, not a huge number — an unknown rate is shown as unknown.
|
|
23
|
+
*
|
|
24
|
+
* A response that is still streaming is counted only from what the provider reported about it. Providers
|
|
25
|
+
* differ in when they report: some carry usage in every chunk, so those numbers move as the answer streams;
|
|
26
|
+
* a local server (LM Studio and vLLM do this) reports usage with the last chunk, so nothing of that answer
|
|
27
|
+
* is known until it ends and the strip keeps showing the last thing it actually measured. Guessing what the
|
|
28
|
+
* text so far is worth would make the rates drift while the app was waiting — which is what this replaced.
|
|
29
|
+
*
|
|
30
|
+
* A half-window joins an average only once it has a token count next to it: milliseconds with an empty
|
|
31
|
+
* numerator (the request is timed, its prompt size is not reported yet) would slide the rate down on the way
|
|
32
|
+
* to being measured.
|
|
33
|
+
*/
|
|
34
|
+
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
35
|
+
import { isAssistant, promptTokensOf, usageOf, type RenderedUsage } from "./transcript.ts";
|
|
36
|
+
|
|
37
|
+
type MessageStartEvent = Extract<AgentSessionEvent, { type: "message_start" }>;
|
|
38
|
+
type MessageUpdateEvent = Extract<AgentSessionEvent, { type: "message_update" }>;
|
|
39
|
+
type MessageEndEvent = Extract<AgentSessionEvent, { type: "message_end" }>;
|
|
40
|
+
|
|
41
|
+
/** What the sub-events of `message_update` look like, flattened to what a rate needs. */
|
|
42
|
+
type AssistantSubEvent = { type?: string; partial?: { usage?: Record<string, unknown> } };
|
|
43
|
+
|
|
44
|
+
/** The sub-events that mean "the model has produced something". */
|
|
45
|
+
const FIRST_TOKEN = new Set(["text_start", "text_delta", "thinking_start", "thinking_delta", "toolcall_start", "toolcall_delta"]);
|
|
46
|
+
|
|
47
|
+
/** A completed provider response, and how long each half of it took. */
|
|
48
|
+
export interface ResponsePerf {
|
|
49
|
+
promptTokens: number;
|
|
50
|
+
outputTokens: number;
|
|
51
|
+
prefillMs: number;
|
|
52
|
+
decodeMs: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The response currently streaming. `firstTokenAt` is null while the request is still being answered. */
|
|
56
|
+
export interface LivePerf {
|
|
57
|
+
requestStart: number;
|
|
58
|
+
firstTokenAt: number | null;
|
|
59
|
+
promptTokens: number;
|
|
60
|
+
outputTokens: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface Perf {
|
|
64
|
+
live: LivePerf | null;
|
|
65
|
+
/** The most recent completed response — kept so a reopened chat can still show its own numbers. */
|
|
66
|
+
last: ResponsePerf | null;
|
|
67
|
+
/** Every answer of the current question, summed. The rates on screen are this sum's rates. */
|
|
68
|
+
run: ResponsePerf;
|
|
69
|
+
/** When the last `turn_start` arrived; becomes the request start of the next response. */
|
|
70
|
+
pendingStart?: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Rates for whatever the UI should be showing right now: the response streaming, or the last one. */
|
|
74
|
+
export interface Speeds {
|
|
75
|
+
prefill: number | null;
|
|
76
|
+
decode: number | null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const NO_WINDOWS: ResponsePerf = { promptTokens: 0, outputTokens: 0, prefillMs: 0, decodeMs: 0 };
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Whether a set of windows measured anything at all: a turn that never got a response, or one from a
|
|
83
|
+
* provider that reports no usage, leaves zeros — and so does a chat that has never measured one. Zeros are
|
|
84
|
+
* not kept, are not averaged into the next answer's numbers, and must not be recorded as belonging to the
|
|
85
|
+
* model on screen.
|
|
86
|
+
*/
|
|
87
|
+
export function hasMeasurement(windows: ResponsePerf | null): boolean {
|
|
88
|
+
return windows !== null && (windows.promptTokens > 0 || windows.outputTokens > 0);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function emptyPerf(): Perf {
|
|
92
|
+
return { live: null, last: null, run: { ...NO_WINDOWS } };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Fold one pi event into the metrics. Returns a new object, never mutates — as the transcript reducer does. */
|
|
96
|
+
export function applyPerfEvent(state: Perf, event: AgentSessionEvent, now: () => number = Date.now): Perf {
|
|
97
|
+
switch (event.type) {
|
|
98
|
+
// A new question starts its average from nothing. `last` stays, so the strip keeps showing the
|
|
99
|
+
// previous answer until this question has a window worth showing.
|
|
100
|
+
case "agent_start":
|
|
101
|
+
return { ...state, pendingStart: undefined, live: null, run: { ...NO_WINDOWS } };
|
|
102
|
+
// The start of a provider request. Recorded rather than made live: a turn can emit tool-result
|
|
103
|
+
// messages before it asks the model anything, and those are not a response being streamed.
|
|
104
|
+
case "turn_start":
|
|
105
|
+
return { ...state, pendingStart: now() };
|
|
106
|
+
case "message_start": {
|
|
107
|
+
if (!isAssistant((event as MessageStartEvent).message)) return state; return {
|
|
108
|
+
...state,
|
|
109
|
+
pendingStart: undefined,
|
|
110
|
+
live: { requestStart: state.pendingStart ?? now(), firstTokenAt: null, promptTokens: 0, outputTokens: 0 },
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
case "message_update": {
|
|
114
|
+
if (!state.live) return state;
|
|
115
|
+
const sub = (event as MessageUpdateEvent).assistantMessageEvent as AssistantSubEvent | undefined;
|
|
116
|
+
const usage = usageOf(sub?.partial?.usage);
|
|
117
|
+
// Only the provider's own counts are kept: an answer whose usage has not arrived yet contributes
|
|
118
|
+
// nothing, and the strip holds the last measured numbers (see the note above).
|
|
119
|
+
return {
|
|
120
|
+
...state,
|
|
121
|
+
live: {
|
|
122
|
+
...state.live,
|
|
123
|
+
firstTokenAt: state.live.firstTokenAt ?? (FIRST_TOKEN.has(sub?.type ?? "") ? now() : null),
|
|
124
|
+
promptTokens: usage ? promptTokensOf(usage) : state.live.promptTokens,
|
|
125
|
+
outputTokens: usage && usage.output > 0 ? usage.output : state.live.outputTokens,
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
case "message_end": {
|
|
130
|
+
const message = (event as MessageEndEvent).message;
|
|
131
|
+
if (!isAssistant(message) || !state.live) return state;
|
|
132
|
+
const usage = usageOf((message as { usage?: unknown }).usage);
|
|
133
|
+
const first = state.live.firstTokenAt;
|
|
134
|
+
const prompt = usage ? promptTokensOf(usage) : 0;
|
|
135
|
+
const output = usage?.output ?? 0;
|
|
136
|
+
const window: ResponsePerf = {
|
|
137
|
+
promptTokens: prompt,
|
|
138
|
+
outputTokens: output,
|
|
139
|
+
prefillMs: first === null ? 0 : timed(prompt, first - state.live.requestStart),
|
|
140
|
+
decodeMs: first === null ? 0 : timed(output, now() - first),
|
|
141
|
+
};
|
|
142
|
+
// A response that never produced a token (aborted, errored, or a provider that reports no usage at
|
|
143
|
+
// all) has no windows and joins no average: recording it as zero tokens/second would be a lie. It
|
|
144
|
+
// does not replace the last measured answer either — that is what a reopened chat restores.
|
|
145
|
+
return {
|
|
146
|
+
...state,
|
|
147
|
+
pendingStart: undefined,
|
|
148
|
+
live: null,
|
|
149
|
+
last: hasMeasurement(window) ? window : state.last,
|
|
150
|
+
run: first === null ? state.run : sumWindows(state.run, window),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
// A turn that ends without an assistant message_end (aborted mid-request, a dropped stream) must
|
|
154
|
+
// not leave a live gauge counting against a response that stopped existing.
|
|
155
|
+
case "agent_settled":
|
|
156
|
+
// The question is over; its average stays on screen. Only the live gauge closes.
|
|
157
|
+
return { ...state, pendingStart: undefined, live: null };
|
|
158
|
+
default:
|
|
159
|
+
return state;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* What to show: the average over every answer this question streamed, counting the answer currently
|
|
165
|
+
* streaming as far as its provider has reported about it. `at` is the UI's tick, so a live decode window
|
|
166
|
+
* keeps growing between events. A question that has measured nothing yet falls back to the last answer it
|
|
167
|
+
* measured, which is all a reopened chat has (ui/lib/last-answer.ts).
|
|
168
|
+
*/
|
|
169
|
+
export function speedsOf(perf: Perf, at: number): Speeds {
|
|
170
|
+
const live = perf.live;
|
|
171
|
+
// A live answer has no prefill window before its first token and no decode window to end yet, so its
|
|
172
|
+
// unfinished prefill counts as nothing rather than as a slow prefill.
|
|
173
|
+
const totals =
|
|
174
|
+
live?.firstTokenAt == null
|
|
175
|
+
? perf.run
|
|
176
|
+
: sumWindows(perf.run, {
|
|
177
|
+
promptTokens: live.promptTokens,
|
|
178
|
+
outputTokens: live.outputTokens,
|
|
179
|
+
prefillMs: timed(live.promptTokens, live.firstTokenAt - live.requestStart),
|
|
180
|
+
decodeMs: timed(live.outputTokens, at - live.firstTokenAt),
|
|
181
|
+
});
|
|
182
|
+
if (totals.promptTokens === 0 && totals.outputTokens === 0) {
|
|
183
|
+
const last = perf.last;
|
|
184
|
+
if (!last) return { prefill: null, decode: null };
|
|
185
|
+
return { prefill: rate(last.promptTokens, last.prefillMs), decode: rate(last.outputTokens, last.decodeMs) };
|
|
186
|
+
}
|
|
187
|
+
return { prefill: rate(totals.promptTokens, totals.prefillMs), decode: rate(totals.outputTokens, totals.decodeMs) };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function sumWindows(a: ResponsePerf, b: ResponsePerf): ResponsePerf {
|
|
191
|
+
return {
|
|
192
|
+
promptTokens: a.promptTokens + b.promptTokens,
|
|
193
|
+
outputTokens: a.outputTokens + b.outputTokens,
|
|
194
|
+
prefillMs: a.prefillMs + b.prefillMs,
|
|
195
|
+
decodeMs: a.decodeMs + b.decodeMs,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** A window only counts when it has tokens to average; see the note above about empty numerators. */
|
|
200
|
+
function timed(tokens: number, ms: number): number {
|
|
201
|
+
return tokens > 0 ? ms : 0;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** tokens/second, or null when the window is too short to mean anything. */
|
|
205
|
+
export function rate(tokens: number, ms: number): number | null {
|
|
206
|
+
return ms > 0 && tokens > 0 ? (tokens * 1000) / ms : null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** "1.2k" for a rate — whole numbers where they are big enough to be, one decimal where they are not. */
|
|
210
|
+
export function formatRate(value: number): string {
|
|
211
|
+
return value >= 100 ? String(Math.round(value)) : value.toFixed(1);
|
|
212
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SQLite setup every flypi-owned database shares (docs/decisions.md: one writer per file). WAL,
|
|
3
|
+
* foreign keys, a busy timeout, and forward-only migrations that are never edited or re-run. `node:sqlite`
|
|
4
|
+
* provides none of that by itself, and two copies of it is how two databases drift apart.
|
|
5
|
+
*
|
|
6
|
+
* There are two databases on purpose (the backend's run record and the memory store), and each is opened
|
|
7
|
+
* by exactly one component. Nothing here joins across them — a cross-file join would need an `ATTACH`,
|
|
8
|
+
* which is how a split quietly becomes one database again.
|
|
9
|
+
*/
|
|
10
|
+
import { mkdirSync } from "node:fs";
|
|
11
|
+
import { dirname } from "node:path";
|
|
12
|
+
import { DatabaseSync } from "node:sqlite";
|
|
13
|
+
|
|
14
|
+
/** One statement batch per version. Versions are never reused or edited after release. */
|
|
15
|
+
export interface Migration {
|
|
16
|
+
version: number;
|
|
17
|
+
sql: string;
|
|
18
|
+
/**
|
|
19
|
+
* Run with `foreign_keys` off. Only a migration that rebuilds a table another one references needs it —
|
|
20
|
+
* `PRAGMA foreign_keys` is a no-op inside a transaction, so it has to be set around the whole batch.
|
|
21
|
+
* Such a migration checks its own work: see `assertForeignKeys`.
|
|
22
|
+
*/
|
|
23
|
+
deferForeignKeys?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Open (creating the parent directories) a flypi-owned SQLite file with the pragmas every one needs. */
|
|
27
|
+
export function openFlypiDb(file: string): DatabaseSync {
|
|
28
|
+
if (file !== ":memory:") mkdirSync(dirname(file), { recursive: true });
|
|
29
|
+
const db = new DatabaseSync(file);
|
|
30
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
31
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
32
|
+
// A second connection (risk 15) must wait for the writer, not fail on it.
|
|
33
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
34
|
+
return db;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The current schema version as recorded in the file, or 0 for an empty database. */
|
|
38
|
+
export function schemaVersion(db: DatabaseSync): number {
|
|
39
|
+
const row = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'").get();
|
|
40
|
+
if (!row) return 0;
|
|
41
|
+
return (db.prepare("SELECT max(version) AS version FROM schema_migrations").get() as { version: number }).version ?? 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Rows that no longer satisfy their foreign keys — the check a deferred migration owes. */
|
|
45
|
+
export function assertForeignKeys(db: DatabaseSync): void {
|
|
46
|
+
const broken = db.prepare("PRAGMA foreign_key_check").all() as unknown[];
|
|
47
|
+
if (broken.length > 0) throw new Error(`foreign keys are broken after migrating: ${JSON.stringify(broken)}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Bring a database up to `migrations`, one version per transaction. A newer file is an error, not a downgrade. */
|
|
51
|
+
export function migrate(db: DatabaseSync, migrations: Migration[]): void {
|
|
52
|
+
db.exec("CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)");
|
|
53
|
+
const current = schemaVersion(db);
|
|
54
|
+
if (current > migrations.length) {
|
|
55
|
+
throw new Error(`database schema v${current} is newer than this build knows (v${migrations.length})`);
|
|
56
|
+
}
|
|
57
|
+
for (const migration of migrations) {
|
|
58
|
+
if (migration.version <= current) continue;
|
|
59
|
+
if (migration.deferForeignKeys) db.exec("PRAGMA foreign_keys = OFF");
|
|
60
|
+
db.exec("BEGIN");
|
|
61
|
+
try {
|
|
62
|
+
db.exec(migration.sql);
|
|
63
|
+
db.prepare("INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)").run(migration.version, new Date().toISOString());
|
|
64
|
+
db.exec("COMMIT");
|
|
65
|
+
} catch (error) {
|
|
66
|
+
db.exec("ROLLBACK");
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
if (migration.deferForeignKeys) {
|
|
70
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
71
|
+
assertForeignKeys(db);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|