@makerstufferupper/flypi 0.0.0-stage → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +44 -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 +303 -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 +35 -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,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one transcript mapper (docs/architecture.md Phase 2).
|
|
3
|
+
*
|
|
4
|
+
* `renderMessage()` maps a single pi message to view blocks, and `applyEvent()` folds a pi
|
|
5
|
+
* session event stream into a transcript view model. The browser, `scripts/smoke.mjs` and the
|
|
6
|
+
* golden test all go through these two functions — if the UI and the snapshot disagree, one of
|
|
7
|
+
* them is wrong, which is the whole reason they share a mapper.
|
|
8
|
+
*
|
|
9
|
+
* It is pure and DOM-free on purpose: the streaming reducer can then be fed the recorded event
|
|
10
|
+
* log (tests/fixtures/transcript-log) and asserted outside a browser, and the component tests
|
|
11
|
+
* assert what the reducer produced rather than re-testing the reducer.
|
|
12
|
+
*
|
|
13
|
+
* Streaming needs no delta arithmetic: every `message_update` carries `assistantMessageEvent.partial`,
|
|
14
|
+
* the message as far as it has got, so the streaming view is `renderMessage(partial)` and
|
|
15
|
+
* `message_end` replaces it with the final message. Delta counts are timing-dependent (docs/pi-api.md)
|
|
16
|
+
* and this design never looks at them.
|
|
17
|
+
*/
|
|
18
|
+
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
|
|
20
|
+
/** Derived from pi's own event types, so no transitive pi-ai / pi-agent-core import is needed here. */
|
|
21
|
+
type MessageEndEvent = Extract<AgentSessionEvent, { type: "message_end" }>;
|
|
22
|
+
type MessageStartEvent = Extract<AgentSessionEvent, { type: "message_start" }>;
|
|
23
|
+
type MessageUpdateEvent = Extract<AgentSessionEvent, { type: "message_update" }>;
|
|
24
|
+
type ToolStartEvent = Extract<AgentSessionEvent, { type: "tool_execution_start" }>;
|
|
25
|
+
type ToolUpdateEvent = Extract<AgentSessionEvent, { type: "tool_execution_update" }>;
|
|
26
|
+
type ToolEndEvent = Extract<AgentSessionEvent, { type: "tool_execution_end" }>;
|
|
27
|
+
type QueueUpdateEvent = Extract<AgentSessionEvent, { type: "queue_update" }>;
|
|
28
|
+
|
|
29
|
+
export type FlypiMessage = MessageEndEvent["message"];
|
|
30
|
+
|
|
31
|
+
/** pi's `Usage`, flattened to the two numbers a chat UI shows. */
|
|
32
|
+
export interface RenderedUsage {
|
|
33
|
+
input: number;
|
|
34
|
+
output: number;
|
|
35
|
+
cacheRead: number;
|
|
36
|
+
cacheWrite: number;
|
|
37
|
+
totalTokens: number;
|
|
38
|
+
cost: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type RenderedBlock =
|
|
42
|
+
| { kind: "text"; text: string }
|
|
43
|
+
| { kind: "image"; data: string; mimeType: string }
|
|
44
|
+
| { kind: "thinking"; text: string }
|
|
45
|
+
| { kind: "tool-call"; id: string; name: string; args: unknown }
|
|
46
|
+
| { kind: "tool-result"; id: string; name: string; output: string; isError: boolean };
|
|
47
|
+
|
|
48
|
+
export interface RenderedMessage {
|
|
49
|
+
role: string;
|
|
50
|
+
blocks: RenderedBlock[];
|
|
51
|
+
usage?: RenderedUsage;
|
|
52
|
+
/**
|
|
53
|
+
* The event `seq` this message arrived on, when the caller passed one. It is what the UI sends back
|
|
54
|
+
* to name the message (fork), so the server resolves the very message on screen instead of a position
|
|
55
|
+
* both sides counted separately. `renderMessage()` never sets it: only the socket knows the seq.
|
|
56
|
+
*/
|
|
57
|
+
seq?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A tool card's live state, keyed by pi's toolCallId. */
|
|
61
|
+
export interface ToolCard {
|
|
62
|
+
id: string;
|
|
63
|
+
name: string;
|
|
64
|
+
args: unknown;
|
|
65
|
+
output: string;
|
|
66
|
+
state: "pending" | "running" | "done" | "error";
|
|
67
|
+
/** Set when the run starts. A card created from a tool call in a message has none yet. */
|
|
68
|
+
startedAt?: number;
|
|
69
|
+
endedAt?: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface Transcript {
|
|
73
|
+
messages: RenderedMessage[];
|
|
74
|
+
/** The assistant message as far as it has streamed; rendered exactly like a finished one. */
|
|
75
|
+
streaming: RenderedMessage | null;
|
|
76
|
+
tools: Record<string, ToolCard>;
|
|
77
|
+
/** pi's `queue_update`: what the user steered in, and what is waiting for the turn. */
|
|
78
|
+
queue: { steering: string[]; followUp: string[] };
|
|
79
|
+
running: boolean;
|
|
80
|
+
/** Usage/cost for the current turn, and for the whole conversation. */
|
|
81
|
+
turn: RenderedUsage;
|
|
82
|
+
total: RenderedUsage;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function emptyTranscript(): Transcript {
|
|
86
|
+
return {
|
|
87
|
+
messages: [],
|
|
88
|
+
streaming: null,
|
|
89
|
+
tools: {},
|
|
90
|
+
queue: { steering: [], followUp: [] },
|
|
91
|
+
running: false,
|
|
92
|
+
turn: emptyUsage(),
|
|
93
|
+
total: emptyUsage(),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function emptyUsage(): RenderedUsage {
|
|
98
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: 0 };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The single message mapper. Handles every role pi puts in the stream — user, assistant
|
|
103
|
+
* (text/images/thinking/tool calls) and tool results — and ignores what Phase 2 has no view for
|
|
104
|
+
* (extension-authored message types) rather than inventing one.
|
|
105
|
+
*/
|
|
106
|
+
export function renderMessage(message: FlypiMessage): RenderedMessage {
|
|
107
|
+
const record = message as unknown as Record<string, unknown>;
|
|
108
|
+
const role = typeof record.role === "string" ? record.role : "unknown";
|
|
109
|
+
const blocks: RenderedBlock[] = [];
|
|
110
|
+
|
|
111
|
+
if (role === "toolResult") {
|
|
112
|
+
blocks.push({
|
|
113
|
+
kind: "tool-result",
|
|
114
|
+
id: typeof record.toolCallId === "string" ? record.toolCallId : "",
|
|
115
|
+
name: typeof record.toolName === "string" ? record.toolName : "",
|
|
116
|
+
output: textOf(record.content),
|
|
117
|
+
isError: record.isError === true,
|
|
118
|
+
});
|
|
119
|
+
} else {
|
|
120
|
+
const content = record.content;
|
|
121
|
+
if (typeof content === "string") {
|
|
122
|
+
if (content) blocks.push({ kind: "text", text: content });
|
|
123
|
+
} else if (Array.isArray(content)) {
|
|
124
|
+
for (const part of content) blocks.push(...renderBlock(part));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const rendered: RenderedMessage = { role, blocks };
|
|
129
|
+
const usage = usageOf(record.usage);
|
|
130
|
+
if (usage) rendered.usage = usage;
|
|
131
|
+
return rendered;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function renderBlock(part: unknown): RenderedBlock[] {
|
|
135
|
+
if (!part || typeof part !== "object") return [];
|
|
136
|
+
const block = part as Record<string, unknown>;
|
|
137
|
+
switch (block.type) {
|
|
138
|
+
case "text":
|
|
139
|
+
return typeof block.text === "string" && block.text ? [{ kind: "text", text: block.text }] : [];
|
|
140
|
+
// A pasted image: pi keeps the bytes in the message, so the chat can show what was sent without
|
|
141
|
+
// holding a second copy of it.
|
|
142
|
+
case "image":
|
|
143
|
+
return typeof block.data === "string" && typeof block.mimeType === "string"
|
|
144
|
+
? [{ kind: "image", data: block.data, mimeType: block.mimeType }]
|
|
145
|
+
: [];
|
|
146
|
+
case "thinking":
|
|
147
|
+
return typeof block.thinking === "string" && block.thinking
|
|
148
|
+
? [{ kind: "thinking", text: block.thinking }]
|
|
149
|
+
: [];
|
|
150
|
+
case "toolCall":
|
|
151
|
+
return [
|
|
152
|
+
{
|
|
153
|
+
kind: "tool-call",
|
|
154
|
+
id: typeof block.id === "string" ? block.id : "",
|
|
155
|
+
name: typeof block.name === "string" ? block.name : "",
|
|
156
|
+
args: block.arguments,
|
|
157
|
+
},
|
|
158
|
+
];
|
|
159
|
+
default:
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Flattens pi's `Usage`, or returns undefined for a message that has none (user, tool results). */
|
|
165
|
+
export function usageOf(usage: unknown): RenderedUsage | undefined {
|
|
166
|
+
if (!usage || typeof usage !== "object") return undefined;
|
|
167
|
+
const u = usage as Record<string, unknown>;
|
|
168
|
+
return {
|
|
169
|
+
input: num(u.input),
|
|
170
|
+
output: num(u.output),
|
|
171
|
+
cacheRead: num(u.cacheRead),
|
|
172
|
+
cacheWrite: num(u.cacheWrite),
|
|
173
|
+
totalTokens: num(u.totalTokens),
|
|
174
|
+
cost: costOf(u.cost),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Usage fields are optional in some providers' partial messages; a missing number is 0, never NaN. */
|
|
179
|
+
function num(value: unknown): number {
|
|
180
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function costOf(cost: unknown): number {
|
|
184
|
+
if (typeof cost === "number") return cost;
|
|
185
|
+
if (cost && typeof cost === "object") return num((cost as Record<string, unknown>).total);
|
|
186
|
+
return 0;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Tool output arrives as a result object, a content array, or a plain string depending on the tool. */
|
|
190
|
+
function textOf(value: unknown): string {
|
|
191
|
+
if (typeof value === "string") return value;
|
|
192
|
+
if (Array.isArray(value)) return value.map(textOf).filter(Boolean).join("\n");
|
|
193
|
+
if (value && typeof value === "object") {
|
|
194
|
+
const record = value as Record<string, unknown>;
|
|
195
|
+
if ("content" in record) return textOf(record.content);
|
|
196
|
+
if ("text" in record && typeof record.text === "string") return record.text;
|
|
197
|
+
return JSON.stringify(value, null, 2);
|
|
198
|
+
}
|
|
199
|
+
return "";
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function addUsage(base: RenderedUsage, addition: RenderedUsage): RenderedUsage {
|
|
203
|
+
return {
|
|
204
|
+
input: base.input + addition.input,
|
|
205
|
+
output: base.output + addition.output,
|
|
206
|
+
cacheRead: base.cacheRead + addition.cacheRead,
|
|
207
|
+
cacheWrite: base.cacheWrite + addition.cacheWrite,
|
|
208
|
+
totalTokens: base.totalTokens + addition.totalTokens,
|
|
209
|
+
cost: base.cost + addition.cost,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Fold one pi session event into the transcript. Returns a new object (never mutates), so a
|
|
215
|
+
* Svelte 5 `$state` assignment and a test's equality check both work off the same function.
|
|
216
|
+
* Unknown event types are ignored: pi may add events, and an unknown one must never blank a
|
|
217
|
+
* transcript.
|
|
218
|
+
*
|
|
219
|
+
* `now` is injectable so tool-card durations are testable; the UI passes the real clock.
|
|
220
|
+
* `seq` is the number the event carried on the wire, recorded on the message it appends (see `RenderedMessage`).
|
|
221
|
+
*/
|
|
222
|
+
export function applyEvent(state: Transcript, event: AgentSessionEvent, now: () => number = Date.now, seq?: number): Transcript {
|
|
223
|
+
switch (event.type) {
|
|
224
|
+
case "agent_start":
|
|
225
|
+
return { ...state, running: true, turn: emptyUsage() };
|
|
226
|
+
case "agent_settled":
|
|
227
|
+
return { ...state, running: false, streaming: null, queue: { steering: [], followUp: [] } };
|
|
228
|
+
case "message_start":
|
|
229
|
+
return isAssistant((event as MessageStartEvent).message)
|
|
230
|
+
? { ...state, streaming: renderMessage((event as MessageStartEvent).message) }
|
|
231
|
+
: state;
|
|
232
|
+
case "message_update": {
|
|
233
|
+
const partial = (event as MessageUpdateEvent).assistantMessageEvent as { partial?: FlypiMessage } | undefined;
|
|
234
|
+
if (!partial?.partial) return state;
|
|
235
|
+
return { ...state, streaming: renderMessage(partial.partial) };
|
|
236
|
+
}
|
|
237
|
+
case "message_end": {
|
|
238
|
+
const rendered = renderMessage((event as MessageEndEvent).message);
|
|
239
|
+
// A system message is not part of the conversation; the system prompt belongs in the debug panel.
|
|
240
|
+
if (rendered.role === "system") return state;
|
|
241
|
+
// Neither is a recalled memory block (src/memory/recall.ts, Phase 4.7 C2): it is context the model was
|
|
242
|
+
// given, not something anyone said. What a turn was shown is on the debug panel, and the session file
|
|
243
|
+
// holds the bytes themselves.
|
|
244
|
+
if (rendered.role === "custom") return state;
|
|
245
|
+
if (rendered.role === "toolResult") {
|
|
246
|
+
// A tool result is a card's output, not a message. `renderMessage()` always puts exactly one
|
|
247
|
+
// tool-result block here, so the card is looked up by that block's id: it lands on the card
|
|
248
|
+
// when the card exists, and stays a message of its own when it does not (a replay gap).
|
|
249
|
+
const result = rendered.blocks[0] as Extract<RenderedBlock, { kind: "tool-result" }>;
|
|
250
|
+
const card = state.tools[result.id];
|
|
251
|
+
if (card) {
|
|
252
|
+
return {
|
|
253
|
+
...state,
|
|
254
|
+
tools: { ...state.tools, [result.id]: { ...card, output: result.output, state: result.isError ? "error" : "done" } },
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (seq !== undefined) rendered.seq = seq;
|
|
259
|
+
const usage = rendered.usage ? addUsage(state.turn, rendered.usage) : state.turn;
|
|
260
|
+
// A tool call in a message is a card too, not only a `tool_execution_start`: a session opened
|
|
261
|
+
// from disk is seeded with messages only, and its cards must still take their results.
|
|
262
|
+
const tools = { ...state.tools };
|
|
263
|
+
for (const block of rendered.blocks) {
|
|
264
|
+
if (block.kind === "tool-call" && !tools[block.id]) {
|
|
265
|
+
tools[block.id] = { id: block.id, name: block.name, args: block.args, output: "", state: "pending" };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
...state,
|
|
270
|
+
messages: [...state.messages, rendered],
|
|
271
|
+
streaming: null,
|
|
272
|
+
tools,
|
|
273
|
+
turn: usage,
|
|
274
|
+
total: rendered.usage ? addUsage(state.total, rendered.usage) : state.total,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
case "tool_execution_start": {
|
|
278
|
+
const start = event as ToolStartEvent;
|
|
279
|
+
const existing = state.tools[start.toolCallId];
|
|
280
|
+
return {
|
|
281
|
+
...state,
|
|
282
|
+
tools: {
|
|
283
|
+
...state.tools,
|
|
284
|
+
[start.toolCallId]: {
|
|
285
|
+
id: start.toolCallId,
|
|
286
|
+
name: start.toolName,
|
|
287
|
+
args: start.args,
|
|
288
|
+
output: existing?.output ?? "",
|
|
289
|
+
state: "running",
|
|
290
|
+
startedAt: existing?.startedAt ?? now(),
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
case "tool_execution_update": {
|
|
296
|
+
const update = event as ToolUpdateEvent;
|
|
297
|
+
const card = state.tools[update.toolCallId];
|
|
298
|
+
if (!card) return state;
|
|
299
|
+
return { ...state, tools: { ...state.tools, [update.toolCallId]: { ...card, output: textOf(update.partialResult) } } };
|
|
300
|
+
}
|
|
301
|
+
case "tool_execution_end": {
|
|
302
|
+
const end = event as ToolEndEvent;
|
|
303
|
+
const card = state.tools[end.toolCallId];
|
|
304
|
+
const finished: ToolCard = {
|
|
305
|
+
id: end.toolCallId,
|
|
306
|
+
name: end.toolName,
|
|
307
|
+
args: card?.args,
|
|
308
|
+
output: textOf(end.result),
|
|
309
|
+
state: end.isError ? "error" : "done",
|
|
310
|
+
startedAt: card?.startedAt ?? now(),
|
|
311
|
+
endedAt: now(),
|
|
312
|
+
};
|
|
313
|
+
return { ...state, tools: { ...state.tools, [end.toolCallId]: finished } };
|
|
314
|
+
}
|
|
315
|
+
case "queue_update": {
|
|
316
|
+
const queue = event as QueueUpdateEvent;
|
|
317
|
+
return { ...state, queue: { steering: [...queue.steering], followUp: [...queue.followUp] } };
|
|
318
|
+
}
|
|
319
|
+
default:
|
|
320
|
+
return state;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Replay a whole recorded event log. This is what the fixture tests and `smoke.mjs` call. */
|
|
325
|
+
export function reduceEvents(events: AgentSessionEvent[], now: () => number = () => 0): Transcript {
|
|
326
|
+
return events.reduce((state, event) => applyEvent(state, event, now), emptyTranscript());
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Whether a message from the stream is one the model produced. */
|
|
330
|
+
export function isAssistant(message: unknown): boolean {
|
|
331
|
+
return (message as { role?: string } | undefined)?.role === "assistant";
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Compact token count for the UI: "14.2k". One formatter, so no two places round differently. */
|
|
335
|
+
export function compact(value: number): string {
|
|
336
|
+
return value >= 1000 ? `${(value / 1000).toFixed(1)}k` : String(value);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** "1.2k in / 340 out · $0.02" — one formatter, so the sidebar and the debug panel cannot disagree. */
|
|
340
|
+
export function formatUsage(usage: RenderedUsage): string {
|
|
341
|
+
const tokens = `${compact(usage.input)} in / ${compact(usage.output)} out`;
|
|
342
|
+
const cache = usage.cacheRead ? ` / ${compact(usage.cacheRead)} cached` : "";
|
|
343
|
+
return `${tokens}${cache} · $${usage.cost.toFixed(4)}`;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** A prompt is everything the request carried: uncached input, cache reads and cache writes. */
|
|
347
|
+
export function promptTokensOf(usage: RenderedUsage): number {
|
|
348
|
+
return usage.input + usage.cacheRead + usage.cacheWrite;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* The prompt-cache numbers for one turn or one whole conversation (docs/prompt-cache.md Phase 4.6 A2).
|
|
353
|
+
* `formatUsage` hides a zero cache read because it is describing one answer; this is describing the
|
|
354
|
+
* cache, so the zeros stay and the share of the prompt that was served from cache is spelled out — a
|
|
355
|
+
* bare count is not a measurement anyone can read. A turn whose provider reported no usage at all says
|
|
356
|
+
* so, because "nothing reported" is a different fact from a cold turn that reported zero hits.
|
|
357
|
+
*/
|
|
358
|
+
export function formatCache(usage: RenderedUsage): string {
|
|
359
|
+
const prompt = promptTokensOf(usage);
|
|
360
|
+
if (!prompt) return "the provider reported no usage";
|
|
361
|
+
return `${compact(usage.cacheRead)} read / ${compact(usage.cacheWrite)} write · ${Math.round((usage.cacheRead / prompt) * 100)}% of the prompt from cache`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* The text form of a transcript: what `scripts/smoke.mjs` prints and what the golden fixture
|
|
366
|
+
* compares against. Deliberately structurally identical to what the DOM renders.
|
|
367
|
+
*/
|
|
368
|
+
export function renderTranscriptText(transcript: Transcript): string {
|
|
369
|
+
const lines: string[] = [];
|
|
370
|
+
for (const message of transcript.messages) {
|
|
371
|
+
lines.push(`${message.role}:`);
|
|
372
|
+
for (const block of message.blocks) lines.push(...describeBlock(block, transcript));
|
|
373
|
+
if (message.usage) lines.push(` usage ${formatUsage(message.usage)}`);
|
|
374
|
+
}
|
|
375
|
+
if (transcript.streaming) {
|
|
376
|
+
lines.push("assistant (streaming):");
|
|
377
|
+
for (const block of transcript.streaming.blocks) lines.push(...describeBlock(block, transcript));
|
|
378
|
+
}
|
|
379
|
+
if (transcript.queue.steering.length || transcript.queue.followUp.length) {
|
|
380
|
+
lines.push(`queued steering: ${transcript.queue.steering.join(" | ")}`);
|
|
381
|
+
lines.push(`queued followUp: ${transcript.queue.followUp.join(" | ")}`);
|
|
382
|
+
}
|
|
383
|
+
lines.push(`total ${formatUsage(transcript.total)}`);
|
|
384
|
+
return `${lines.join("\n")}\n`;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function describeBlock(block: RenderedBlock, transcript: Transcript): string[] {
|
|
388
|
+
switch (block.kind) {
|
|
389
|
+
case "text":
|
|
390
|
+
return [` text: ${block.text}`];
|
|
391
|
+
case "image":
|
|
392
|
+
// The bytes are not part of a text rendering; that a paste reached the model is (docs/testing.md).
|
|
393
|
+
return [` image: ${block.mimeType}`];
|
|
394
|
+
case "thinking":
|
|
395
|
+
return [" thinking: (collapsed)"];
|
|
396
|
+
case "tool-call": {
|
|
397
|
+
// A tool call with no card (a seeded history, a replay gap) renders as pending, not blank.
|
|
398
|
+
const card = transcript.tools[block.id] ?? { state: "pending" as const, output: "" };
|
|
399
|
+
return [` tool ${block.name} [${card.state}] ${JSON.stringify(block.args)}`, ...(card.output ? [` output: ${card.output}`] : [])];
|
|
400
|
+
}
|
|
401
|
+
case "tool-result":
|
|
402
|
+
return [` tool-result ${block.name} [${block.isError ? "error" : "done"}]: ${block.output}`];
|
|
403
|
+
}
|
|
404
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* C1's measurement (docs/prompt-cache.md Phase 4.7): what a turn boundary costs in the conversations that
|
|
3
|
+
* really happened.
|
|
4
|
+
*
|
|
5
|
+
* `npm run probe:turn` measures one synthetic conversation on a live route. This reads what the app already
|
|
6
|
+
* recorded: pi's session log carries the provider's usage on every assistant message, and `turns.usage_json`
|
|
7
|
+
* is the same accounting summed per turn (Phase 4.6 A1). The only place the prompt cache is allowed to break
|
|
8
|
+
* is the first model call of a turn, so the calls are split there and counted — no model, no server, and no
|
|
9
|
+
* new token counting anywhere.
|
|
10
|
+
*/
|
|
11
|
+
import { usageOf } from "./transcript.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* One model call, as pi recorded it. The numbers are the provider's own (`usageOf`), never re-counted.
|
|
15
|
+
*/
|
|
16
|
+
export interface ModelCall {
|
|
17
|
+
/** The first model call of a turn — the point where the prefix is allowed to break. Turn 1's first call
|
|
18
|
+
* has no earlier turn to break away from, which is what `carry` separates it by. */
|
|
19
|
+
firstCallOfTurn: boolean;
|
|
20
|
+
model: string;
|
|
21
|
+
/** What the request cost the route: served from cache plus read again. */
|
|
22
|
+
prompt: number;
|
|
23
|
+
/** Served from the provider's prefix cache. */
|
|
24
|
+
reused: number;
|
|
25
|
+
/** Read again. `input + cacheWrite`: routes differ about which field they report (A0 — llama_a leaves
|
|
26
|
+
* `cacheWrite` at 0, llama_d left `input` at 0), and the sum is the same number either way. */
|
|
27
|
+
repaid: number;
|
|
28
|
+
/** prompt + completion of the previous model call: the region this request would have carried unchanged
|
|
29
|
+
* had nothing transient been dropped from it. 0 for the first call in the file, so a call with `carry`
|
|
30
|
+
* above 0 is a turn boundary with something to lose. */
|
|
31
|
+
carry: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* pi's session log (append-only JSONL, one entry per line) as its model calls. Entries that are not
|
|
36
|
+
* messages, and assistant messages a provider gave no numbers for, carry no numbers and are left out.
|
|
37
|
+
*/
|
|
38
|
+
export function modelCalls(jsonl: string): ModelCall[] {
|
|
39
|
+
const calls: ModelCall[] = [];
|
|
40
|
+
let previousRole = "";
|
|
41
|
+
let carry = 0;
|
|
42
|
+
for (const line of jsonl.split("\n")) {
|
|
43
|
+
if (!line.trim()) continue;
|
|
44
|
+
const entry = JSON.parse(line) as { type?: string; message?: { role?: string; model?: string; usage?: unknown } };
|
|
45
|
+
const message = entry.type === "message" ? entry.message : undefined;
|
|
46
|
+
if (!message?.role) continue;
|
|
47
|
+
if (message.role === "assistant") {
|
|
48
|
+
const usage = usageOf(message.usage);
|
|
49
|
+
if (usage) {
|
|
50
|
+
// A turn's later calls follow a tool result; only a user message starts one.
|
|
51
|
+
calls.push({
|
|
52
|
+
firstCallOfTurn: previousRole === "user",
|
|
53
|
+
model: message.model ?? "unknown",
|
|
54
|
+
prompt: usage.input + usage.cacheRead + usage.cacheWrite,
|
|
55
|
+
reused: usage.cacheRead,
|
|
56
|
+
repaid: usage.input + usage.cacheWrite,
|
|
57
|
+
carry,
|
|
58
|
+
});
|
|
59
|
+
carry = usage.totalTokens;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
previousRole = message.role;
|
|
63
|
+
}
|
|
64
|
+
return calls;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** What one conversation's boundaries cost, as the doc quotes it: the break's tokens are what was read
|
|
68
|
+
* again where the previous turn's context was there to be reused. */
|
|
69
|
+
export interface BoundarySummary {
|
|
70
|
+
boundaries: number;
|
|
71
|
+
/** The boundary calls after the first turn — the ones that had a prefix to lose. */
|
|
72
|
+
paid: number[];
|
|
73
|
+
/** What the route could have served from cache at those boundaries instead. */
|
|
74
|
+
carry: number[];
|
|
75
|
+
/** The same turns' later calls, for contrast: inside a turn the only new bytes are the tool result. */
|
|
76
|
+
withinTurn: number[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function summarise(calls: ModelCall[]): BoundarySummary {
|
|
80
|
+
const boundaries = calls.filter((call) => call.firstCallOfTurn && call.carry > 0);
|
|
81
|
+
return {
|
|
82
|
+
boundaries: boundaries.length,
|
|
83
|
+
paid: boundaries.map((call) => call.repaid),
|
|
84
|
+
carry: boundaries.map((call) => call.carry),
|
|
85
|
+
withinTurn: calls.filter((call) => !call.firstCallOfTurn).map((call) => call.repaid),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** min / median / max, which is the only honest way to state a number a route varies run to run by.
|
|
90
|
+
* An even count averages the two middle values: `sorted[Math.floor(n / 2)]` on its own takes the
|
|
91
|
+
* upper-middle element, which shifts every even-length set the probe prints (code-review.md finding 3). */
|
|
92
|
+
export function spread(values: number[]): string {
|
|
93
|
+
if (values.length === 0) return "none";
|
|
94
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
95
|
+
const mid = Math.floor(sorted.length / 2);
|
|
96
|
+
const median = sorted.length % 2 === 0 ? (sorted[mid - 1]! + sorted[mid]!) / 2 : sorted[mid]!;
|
|
97
|
+
return `${sorted[0]!} / ${median} / ${sorted[sorted.length - 1]!}`;
|
|
98
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app's addresses that are not files: the views `/settings`, `/secrets`, `/memory`, and the conversation
|
|
3
|
+
* `/sessions/<session file name>`. The chat with no session named is the one at `/`. Both halves need the
|
|
4
|
+
* same names — the UI routes to them (`ui/lib/route.ts`) and the server answers them with the one page it
|
|
5
|
+
* already serves (`src/server/index.ts`) — so a link that works in one cannot drift away from the other.
|
|
6
|
+
*/
|
|
7
|
+
export const VIEW_PATHS = ["settings", "secrets", "memory"] as const;
|
|
8
|
+
|
|
9
|
+
/** Which of the page's views is on screen. `chat` is the one that names nothing. */
|
|
10
|
+
export type View = "chat" | (typeof VIEW_PATHS)[number];
|
|
11
|
+
|
|
12
|
+
/** The address that names a conversation: `/sessions/<session file name>`. */
|
|
13
|
+
export const SESSIONS_SEGMENT = "sessions";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Reads an address the way both halves need it — the view it names, and the session it names. A view owns
|
|
17
|
+
* the whole address (`/settings`) and a session owns it too (`/sessions/<id>`); the view is the segment the
|
|
18
|
+
* address ends with, or the one before `/sessions` if a session follows, so a page served under a subpath
|
|
19
|
+
* still names its view and an address that carries both is read without losing either. The session is the
|
|
20
|
+
* file name alone: the server knows where its sessions live (`sessionFileIn`, src/server/session-registry.ts),
|
|
21
|
+
* so no path from this checkout is ever put in a URL.
|
|
22
|
+
*/
|
|
23
|
+
export function parsePath(path: string): { view: View; session: string | null } {
|
|
24
|
+
const segments = path.split("/").filter(Boolean);
|
|
25
|
+
const at = segments.lastIndexOf(SESSIONS_SEGMENT);
|
|
26
|
+
const named = at >= 0 ? (segments[at - 1] ?? "") : (segments.at(-1) ?? "");
|
|
27
|
+
return {
|
|
28
|
+
view: (VIEW_PATHS as readonly string[]).includes(named) ? (named as View) : "chat",
|
|
29
|
+
session: at >= 0 ? (segments[at + 1] ?? null) : null,
|
|
30
|
+
};
|
|
31
|
+
}
|