localpi 0.5.2 → 0.6.0

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.
@@ -1,15 +1,23 @@
1
- export function tokenStatusExtensionSource(options = {}) {
2
- const includeContext = options.includeContext ?? true;
3
- return `import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import { settingsFileSource } from "./settings-file.js";
2
+ export function tokenStatusExtensionSource(config) {
3
+ const initialModeSource = JSON.stringify(config.mode);
4
+ const slotsUrlSource = JSON.stringify(slotsUrl(config));
5
+ return `import { mkdir, readFile, writeFile } from "node:fs/promises";
6
+ import { dirname } from "node:path";
7
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
8
 
5
- const includeContext = ${JSON.stringify(includeContext)};
9
+ type StatsMode = "off" | "line" | "full";
6
10
 
7
11
  type Usage = {
8
- input?: number;
9
- output?: number;
10
- cacheRead?: number;
11
- cacheWrite?: number;
12
- totalTokens?: number;
12
+ input?: number | undefined;
13
+ output?: number | undefined;
14
+ cacheRead?: number | undefined;
15
+ cacheWrite?: number | undefined;
16
+ };
17
+
18
+ type PrefillProgress = {
19
+ processed: number;
20
+ total: number;
13
21
  };
14
22
 
15
23
  type TurnState = {
@@ -17,165 +25,582 @@ type TurnState = {
17
25
  firstOutputAt?: number;
18
26
  outputText: string;
19
27
  estimatedOutputTokens: number;
20
- lastStatusAt: number;
28
+ prefill?: PrefillProgress;
29
+ lastRenderAt: number;
30
+ };
31
+
32
+ type StatsEntry = {
33
+ rate?: number | undefined;
34
+ output?: number | undefined;
35
+ input?: number | undefined;
36
+ cacheRead?: number | undefined;
37
+ cacheWrite?: number | undefined;
38
+ prefillSeconds?: number | undefined;
39
+ elapsedSeconds?: number | undefined;
40
+ contextTokens?: number | undefined;
41
+ contextWindow?: number | undefined;
42
+ contextPercent?: number | undefined;
43
+ };
44
+
45
+ // The status line extension renders the footer, and Pi loads every extension into one process, so
46
+ // the last completed turn travels through this typed global. A missing bridge only hides the rate.
47
+ type TurnSummary = {
48
+ rate?: number | undefined;
49
+ output?: number | undefined;
50
+ elapsedSeconds?: number | undefined;
21
51
  };
22
52
 
53
+ type StatsBridge = { lastTurn?: TurnSummary | undefined };
54
+
55
+ type ContextUsage = {
56
+ tokens: number | null;
57
+ contextWindow: number;
58
+ percent: number | null;
59
+ };
60
+
61
+ type ThemeLike = {
62
+ fg(color: string, text: string): string;
63
+ bold(text: string): string;
64
+ };
65
+
66
+ type Segment = {
67
+ text: string;
68
+ color: string;
69
+ };
70
+
71
+ type StatsContext = {
72
+ readonly hasUI: boolean;
73
+ readonly ui: {
74
+ setWorkingMessage(message?: string): void;
75
+ notify(message: string, type?: "info" | "warning" | "error"): void;
76
+ theme: ThemeLike;
77
+ };
78
+ getContextUsage(): ContextUsage | undefined;
79
+ };
80
+
81
+ // Stats mode is remembered per localpi launch. /stats updates both this session and the setting.
82
+ // Pi owns Pi's own footer facts; localpi adds the live working line, one transcript entry per turn,
83
+ // and the rate of the last completed turn in the status line.
84
+ ${settingsFileSource(config.settingsPath)}
85
+ const initialMode: StatsMode = ${initialModeSource};
86
+ // llama.cpp exposes live prefill progress on /slots. Other engines have no equivalent endpoint.
87
+ const slotsUrl: string | undefined = ${slotsUrlSource};
88
+
89
+ const entryType = "localpi-stats";
90
+ const modes: readonly StatsMode[] = ["off", "line", "full"];
91
+ const renderIntervalMs = 200;
92
+ const slotsIntervalMs = 300;
93
+ const slotsTimeoutMs = 1500;
94
+ const renderThrottleMs = 100;
95
+
23
96
  export default function localpiTokenStatus(pi: ExtensionAPI): void {
24
- let currentTurn: TurnState | undefined;
97
+ let mode: StatsMode = initialMode;
98
+ let state: TurnState | undefined;
99
+ let renderTimer: ReturnType<typeof setInterval> | undefined;
100
+ let slotsTimer: ReturnType<typeof setInterval> | undefined;
101
+ let slotsAvailable = slotsUrl !== undefined;
102
+
103
+ pi.registerEntryRenderer(entryType, (entry, _options, theme) => {
104
+ const data = readEntry(entry.data);
105
+ return {
106
+ render: (width: number) => [entryLine(data, theme, width)],
107
+ invalidate: () => undefined
108
+ };
109
+ });
110
+
111
+ pi.registerCommand("stats", {
112
+ description: "Set localpi stats mode",
113
+ getArgumentCompletions: (prefix: string) => {
114
+ const trimmed = prefix.trim().toLowerCase();
115
+ const matches = modes.filter((value) => value.startsWith(trimmed));
116
+ return matches.length === 0 ? null : matches.map((value) => ({ value, label: value }));
117
+ },
118
+ handler: async (args: string, ctx) => {
119
+ const requested = parseMode(args);
120
+ const next = requested === undefined ? await promptMode(mode, ctx) : requested;
121
+ if (next === undefined) {
122
+ return;
123
+ }
124
+ mode = next;
125
+ await persistMode(next);
126
+ if (ctx.hasUI) {
127
+ ctx.ui.setWorkingMessage();
128
+ }
129
+ ctx.ui.notify("stats: " + next, "info");
130
+ }
131
+ });
25
132
 
26
- pi.on("turn_start", () => {
27
- currentTurn = {
133
+ pi.on("turn_start", (_event, ctx) => {
134
+ stopTimers();
135
+ state = {
28
136
  startedAt: Date.now(),
29
137
  outputText: "",
30
138
  estimatedOutputTokens: 0,
31
- lastStatusAt: 0
139
+ lastRenderAt: 0
32
140
  };
141
+ if (!ctx.hasUI || mode === "off") {
142
+ return;
143
+ }
144
+ render(ctx, Date.now());
145
+ renderTimer = setInterval(() => {
146
+ render(ctx, Date.now());
147
+ }, renderIntervalMs);
148
+ if (mode === "full" && slotsAvailable) {
149
+ slotsTimer = setInterval(() => {
150
+ void pollPrefill();
151
+ }, slotsIntervalMs);
152
+ }
33
153
  });
34
154
 
35
155
  pi.on("message_update", (event, ctx) => {
36
- const state = currentTurn;
37
- if (!ctx.hasUI || state === undefined) {
156
+ const current = state;
157
+ if (current === undefined) {
38
158
  return;
39
159
  }
40
- const now = Date.now();
41
- const update = textUpdateFromUnknown(event.assistantMessageEvent ?? event.message ?? event);
42
- if (update.kind === "delta") {
43
- state.outputText += update.text;
44
- } else if (update.text.length > state.outputText.length) {
45
- state.outputText = update.text;
160
+ const delta = textDelta(event.assistantMessageEvent);
161
+ if (delta === undefined) {
162
+ const snapshot = messageText(event.message);
163
+ if (snapshot.length > current.outputText.length) {
164
+ current.outputText = snapshot;
165
+ }
166
+ } else {
167
+ current.outputText += delta;
46
168
  }
47
- state.estimatedOutputTokens = Math.ceil(state.outputText.length / 4);
48
- if (state.firstOutputAt === undefined && state.outputText.length > 0) {
49
- state.firstOutputAt = now;
50
- }
51
- if (now - state.lastStatusAt < 250) {
52
- return;
169
+ current.estimatedOutputTokens = Math.ceil(current.outputText.length / 4);
170
+ if (current.firstOutputAt === undefined && current.outputText.length > 0) {
171
+ current.firstOutputAt = Date.now();
172
+ stopSlotsTimer();
53
173
  }
54
- state.lastStatusAt = now;
55
- ctx.ui.setStatus("localpi-perf", ctx.ui.theme.fg("dim", statusText(state, now)));
174
+ render(ctx, Date.now());
56
175
  });
57
176
 
58
177
  pi.on("turn_end", (event, ctx) => {
59
- const state = currentTurn ?? {
60
- startedAt: Date.now(),
61
- outputText: "",
62
- estimatedOutputTokens: 0,
63
- lastStatusAt: 0
64
- };
65
- currentTurn = undefined;
66
-
67
- if (!ctx.hasUI || event.message.role !== "assistant") {
178
+ const current = state ?? emptyState();
179
+ state = undefined;
180
+ stopTimers();
181
+ if (!ctx.hasUI) {
68
182
  return;
69
183
  }
70
-
71
- const usage = event.message.usage as Usage | undefined;
72
- const output = usage?.output ?? state.estimatedOutputTokens;
73
- const input = usage?.input ?? 0;
74
- const cacheRead = usage?.cacheRead ?? 0;
75
- const cacheWrite = usage?.cacheWrite ?? 0;
76
- const now = Date.now();
77
- const elapsedSeconds = elapsed(state, now);
78
- const decodeSeconds = generationElapsed(state, now);
79
- const prefillText = prefillStatusText(state, input, cacheWrite);
80
- const context = includeContext ? ctx.getContextUsage() : undefined;
81
- const contextText = !includeContext
82
- ? undefined
83
- : context && context.percent !== null
84
- ? \`ctx \${Math.round(context.percent)}%/\${Math.round(context.contextWindow / 1000)}k\`
85
- : "ctx ?";
86
-
87
- ctx.ui.setStatus(
88
- "localpi-perf",
89
- ctx.ui.theme.fg(
90
- "dim",
91
- [
92
- \`gen \${(output / decodeSeconds).toFixed(1)} tok/s\`,
93
- prefillText,
94
- \`out \${output}\`,
95
- \`in \${input}\`,
96
- cacheRead > 0 ? \`cache \${cacheRead}\` : undefined,
97
- cacheWrite > 0 ? \`cw \${cacheWrite}\` : undefined,
98
- \`\${elapsedSeconds.toFixed(1)}s\`,
99
- contextText
100
- ]
101
- .filter(Boolean)
102
- .join(" | ")
103
- )
104
- );
184
+ ctx.ui.setWorkingMessage();
185
+ if (event.message.role !== "assistant") {
186
+ return;
187
+ }
188
+ const data = turnEntry(current, usageOf(event.message), contextUsage(ctx), Date.now());
189
+ publishTurn(data);
190
+ if (mode !== "full") {
191
+ return;
192
+ }
193
+ pi.appendEntry(entryType, data);
105
194
  });
106
195
 
107
196
  pi.on("session_shutdown", (_event, ctx) => {
108
- if (ctx.hasUI) {
109
- ctx.ui.setStatus("localpi-perf", "");
197
+ stopTimers();
198
+ state = undefined;
199
+ if (!ctx.hasUI) {
200
+ return;
110
201
  }
202
+ ctx.ui.setWorkingMessage();
111
203
  });
204
+
205
+ function stopTimers(): void {
206
+ stopRenderTimer();
207
+ stopSlotsTimer();
208
+ }
209
+
210
+ function stopRenderTimer(): void {
211
+ if (renderTimer === undefined) {
212
+ return;
213
+ }
214
+ clearInterval(renderTimer);
215
+ renderTimer = undefined;
216
+ }
217
+
218
+ function stopSlotsTimer(): void {
219
+ if (slotsTimer === undefined) {
220
+ return;
221
+ }
222
+ clearInterval(slotsTimer);
223
+ slotsTimer = undefined;
224
+ }
225
+
226
+ function render(ctx: StatsContext, now: number): void {
227
+ const current = state;
228
+ if (current === undefined || !ctx.hasUI || mode === "off") {
229
+ return;
230
+ }
231
+ if (now - current.lastRenderAt < renderThrottleMs) {
232
+ return;
233
+ }
234
+ current.lastRenderAt = now;
235
+ ctx.ui.setWorkingMessage(workingLine(current, now, contextUsage(ctx), ctx.ui.theme));
236
+ }
237
+
238
+ async function pollPrefill(): Promise<void> {
239
+ const current = state;
240
+ if (current === undefined || slotsUrl === undefined || !slotsAvailable) {
241
+ return;
242
+ }
243
+ if (current.firstOutputAt !== undefined) {
244
+ stopSlotsTimer();
245
+ return;
246
+ }
247
+ try {
248
+ const response = await fetch(slotsUrl, { signal: AbortSignal.timeout(slotsTimeoutMs) });
249
+ if (!response.ok) {
250
+ throw new Error("llama.cpp slots request failed");
251
+ }
252
+ const progress = prefillProgress(await response.json());
253
+ if (progress !== undefined) {
254
+ current.prefill = progress;
255
+ }
256
+ } catch {
257
+ // The endpoint is missing or slow. Fall back to elapsed-time prefill display for this session.
258
+ slotsAvailable = false;
259
+ stopSlotsTimer();
260
+ }
261
+ }
262
+ }
263
+
264
+ function emptyState(): TurnState {
265
+ return { startedAt: Date.now(), outputText: "", estimatedOutputTokens: 0, lastRenderAt: 0 };
266
+ }
267
+
268
+ function workingLine(
269
+ state: TurnState,
270
+ now: number,
271
+ usage: ContextUsage | undefined,
272
+ theme: ThemeLike
273
+ ): string {
274
+ return (
275
+ theme.bold("Working") +
276
+ theme.fg("dim", " (") +
277
+ renderParts(workingParts(state, now, usage), theme) +
278
+ theme.fg("dim", ")")
279
+ );
112
280
  }
113
281
 
114
- function statusText(state: TurnState, now: number): string {
115
- const elapsedSeconds = elapsed(state, now);
282
+ function workingParts(state: TurnState, now: number, usage: ContextUsage | undefined): Segment[][] {
283
+ const parts: Segment[][] = [];
116
284
  if (state.firstOutputAt === undefined) {
117
- return \`prefill \${elapsedSeconds.toFixed(1)}s | out ~\${state.estimatedOutputTokens}\`;
285
+ parts.push(...prefillParts(state, now));
286
+ } else {
287
+ const seconds = secondsBetween(state.firstOutputAt, now);
288
+ parts.push([{ text: formatElapsed(seconds), color: "dim" }]);
289
+ parts.push([
290
+ { text: formatTokenCount(state.estimatedOutputTokens) + " out", color: "dim" }
291
+ ]);
292
+ parts.push([
293
+ { text: formatRate(state.estimatedOutputTokens / seconds) + " tok/s", color: "accent" }
294
+ ]);
295
+ }
296
+ parts.push(...contextParts(usage));
297
+ return parts;
298
+ }
299
+
300
+ function prefillParts(state: TurnState, now: number): Segment[][] {
301
+ const seconds = secondsBetween(state.startedAt, now);
302
+ const progress = state.prefill;
303
+ if (progress === undefined || progress.total <= 0) {
304
+ return [[{ text: "prefill " + formatElapsed(seconds), color: "dim" }]];
118
305
  }
119
- const decodeSeconds = generationElapsed(state, now);
120
- const prefillSeconds = secondsBetween(state.startedAt, state.firstOutputAt);
306
+ const percent = Math.min(100, Math.max(0, Math.round((progress.processed / progress.total) * 100)));
121
307
  return [
122
- \`gen \${(state.estimatedOutputTokens / decodeSeconds).toFixed(1)} tok/s\`,
123
- \`out ~\${state.estimatedOutputTokens}\`,
124
- \`prefill \${prefillSeconds.toFixed(1)}s\`,
125
- \`total \${elapsedSeconds.toFixed(1)}s\`
126
- ].join(" | ");
308
+ [
309
+ { text: "prefill ", color: "dim" },
310
+ { text: percent + "%", color: "accent" }
311
+ ],
312
+ [
313
+ {
314
+ text: formatTokenCount(progress.processed) + "/" + formatTokenCount(progress.total) + " tok",
315
+ color: "dim"
316
+ }
317
+ ],
318
+ [{ text: formatElapsed(seconds), color: "dim" }]
319
+ ];
127
320
  }
128
321
 
129
- function prefillStatusText(
130
- state: TurnState,
131
- input: number,
132
- cacheWrite: number
133
- ): string | undefined {
134
- const tokens = prefillTokenCount(input, cacheWrite);
135
- if (state.firstOutputAt === undefined || tokens <= 0) {
322
+ function entryLine(data: StatsEntry, theme: ThemeLike, width: number): string {
323
+ const parts = entryParts(data);
324
+ const plain = parts.map((part) => part.map((segment) => segment.text).join("")).join(" · ");
325
+ if (width > 0 && plain.length > width) {
326
+ return theme.fg("dim", plain.slice(0, Math.max(0, width - 1)) + "…");
327
+ }
328
+ return renderParts(parts, theme);
329
+ }
330
+
331
+ function entryParts(data: StatsEntry): Segment[][] {
332
+ const parts: Segment[][] = [];
333
+ if (data.elapsedSeconds !== undefined) {
334
+ parts.push([{ text: formatElapsed(data.elapsedSeconds), color: "dim" }]);
335
+ }
336
+ if (data.output !== undefined) {
337
+ parts.push([{ text: formatTokenCount(data.output) + " out", color: "dim" }]);
338
+ }
339
+ if (data.rate !== undefined) {
340
+ parts.push([{ text: formatRate(data.rate) + " tok/s", color: "accent" }]);
341
+ }
342
+ if (data.input !== undefined && data.input > 0) {
343
+ parts.push([{ text: formatTokenCount(data.input) + " in", color: "dim" }]);
344
+ }
345
+ if (data.cacheRead !== undefined && data.cacheRead > 0) {
346
+ parts.push([{ text: "cache " + formatTokenCount(data.cacheRead), color: "dim" }]);
347
+ }
348
+ if (data.cacheWrite !== undefined && data.cacheWrite > 0) {
349
+ parts.push([{ text: "cache write " + formatTokenCount(data.cacheWrite), color: "dim" }]);
350
+ }
351
+ if (data.prefillSeconds !== undefined) {
352
+ parts.push([{ text: "prefill " + formatElapsed(data.prefillSeconds), color: "dim" }]);
353
+ }
354
+ parts.push(...contextParts(entryContext(data)));
355
+ return parts;
356
+ }
357
+
358
+ function entryContext(data: StatsEntry): ContextUsage | undefined {
359
+ if (data.contextPercent === undefined) {
136
360
  return undefined;
137
361
  }
138
- const seconds = secondsBetween(state.startedAt, state.firstOutputAt);
139
- return \`prefill \${(tokens / seconds).toFixed(1)} tok/s\`;
362
+ return {
363
+ tokens: data.contextTokens ?? null,
364
+ contextWindow: data.contextWindow ?? 0,
365
+ percent: data.contextPercent
366
+ };
140
367
  }
141
368
 
142
- function prefillTokenCount(input: number, cacheWrite: number): number {
143
- return Math.max(input + cacheWrite, 0);
369
+ function contextParts(usage: ContextUsage | undefined): Segment[][] {
370
+ if (usage === undefined || usage.percent === null) {
371
+ return [];
372
+ }
373
+ const color = contextColor(usage.percent);
374
+ const measured = usage.tokens !== null && usage.contextWindow > 0;
375
+ const text = measured
376
+ ? formatTokenCount(usage.tokens as number) +
377
+ "/" +
378
+ formatTokenCount(usage.contextWindow) +
379
+ " (" +
380
+ Math.round(usage.percent) +
381
+ "%)"
382
+ : Math.round(usage.percent) + "%";
383
+ return [
384
+ [
385
+ { text: "ctx ", color: "dim" },
386
+ { text, color }
387
+ ]
388
+ ];
144
389
  }
145
390
 
146
- function generationElapsed(state: TurnState, now: number): number {
147
- return secondsBetween(state.firstOutputAt ?? state.startedAt, now);
391
+ function contextColor(percent: number): string {
392
+ if (percent >= 95) {
393
+ return "error";
394
+ }
395
+ return percent >= 80 ? "warning" : "accent";
148
396
  }
149
397
 
150
- function elapsed(state: TurnState, now: number): number {
151
- return secondsBetween(state.startedAt, now);
398
+ function renderParts(parts: readonly Segment[][], theme: ThemeLike): string {
399
+ return parts
400
+ .map((part) => part.map((segment) => theme.fg(segment.color, segment.text)).join(""))
401
+ .join(theme.fg("dim", " · "));
152
402
  }
153
403
 
154
- function secondsBetween(start: number, end: number): number {
155
- return Math.max((end - start) / 1000, 0.001);
404
+ function statsBridge(): StatsBridge {
405
+ const holder = globalThis as unknown as { localpiStats?: StatsBridge };
406
+ holder.localpiStats ??= {};
407
+ return holder.localpiStats;
156
408
  }
157
409
 
158
- type TextUpdate = {
159
- kind: "delta" | "snapshot";
160
- text: string;
161
- };
410
+ function publishTurn(entry: StatsEntry): void {
411
+ statsBridge().lastTurn = {
412
+ rate: entry.rate,
413
+ output: entry.output,
414
+ elapsedSeconds: entry.elapsedSeconds
415
+ };
416
+ }
417
+
418
+ function turnEntry(
419
+ state: TurnState,
420
+ usage: Usage | undefined,
421
+ context: ContextUsage | undefined,
422
+ now: number
423
+ ): StatsEntry {
424
+ const firstOutputAt = state.firstOutputAt;
425
+ const output = usage?.output ?? state.estimatedOutputTokens;
426
+ return withoutUndefined({
427
+ rate: firstOutputAt === undefined ? undefined : output / secondsBetween(firstOutputAt, now),
428
+ output,
429
+ input: usage?.input,
430
+ cacheRead: usage?.cacheRead,
431
+ cacheWrite: usage?.cacheWrite,
432
+ prefillSeconds:
433
+ firstOutputAt === undefined ? undefined : secondsBetween(state.startedAt, firstOutputAt),
434
+ elapsedSeconds: secondsBetween(state.startedAt, now),
435
+ contextTokens: context?.tokens ?? undefined,
436
+ contextWindow: context?.contextWindow,
437
+ contextPercent: context?.percent ?? undefined
438
+ });
439
+ }
440
+
441
+ function contextUsage(ctx: StatsContext): ContextUsage | undefined {
442
+ return ctx.getContextUsage();
443
+ }
444
+
445
+ function readEntry(data: unknown): StatsEntry {
446
+ if (!isRecord(data)) {
447
+ return {};
448
+ }
449
+ return withoutUndefined({
450
+ rate: numberOrUndefined(data["rate"]),
451
+ output: numberOrUndefined(data["output"]),
452
+ input: numberOrUndefined(data["input"]),
453
+ cacheRead: numberOrUndefined(data["cacheRead"]),
454
+ cacheWrite: numberOrUndefined(data["cacheWrite"]),
455
+ prefillSeconds: numberOrUndefined(data["prefillSeconds"]),
456
+ elapsedSeconds: numberOrUndefined(data["elapsedSeconds"]),
457
+ contextTokens: numberOrUndefined(data["contextTokens"]),
458
+ contextWindow: numberOrUndefined(data["contextWindow"]),
459
+ contextPercent: numberOrUndefined(data["contextPercent"])
460
+ });
461
+ }
462
+
463
+ function usageOf(message: unknown): Usage | undefined {
464
+ if (!isRecord(message) || !isRecord(message["usage"])) {
465
+ return undefined;
466
+ }
467
+ const usage = message["usage"] as Record<string, unknown>;
468
+ return {
469
+ input: numberOrUndefined(usage["input"]),
470
+ output: numberOrUndefined(usage["output"]),
471
+ cacheRead: numberOrUndefined(usage["cacheRead"]),
472
+ cacheWrite: numberOrUndefined(usage["cacheWrite"])
473
+ };
474
+ }
475
+
476
+ function textDelta(value: unknown): string | undefined {
477
+ if (!isRecord(value)) {
478
+ return undefined;
479
+ }
480
+ const type = value["type"];
481
+ if (type !== "text_delta" && type !== "thinking_delta" && type !== "toolcall_delta") {
482
+ return undefined;
483
+ }
484
+ return typeof value["delta"] === "string" ? value["delta"] : undefined;
485
+ }
162
486
 
163
- function textUpdateFromUnknown(value: unknown): TextUpdate {
164
- if (typeof value === "string") {
165
- return { kind: "snapshot", text: value };
487
+ function messageText(message: unknown): string {
488
+ if (!isRecord(message)) {
489
+ return "";
166
490
  }
167
- if (value && typeof value === "object") {
168
- const object = value as Record<string, unknown>;
169
- const delta = object["delta"];
170
- const text = object["text"] ?? object["content"];
171
- if (typeof delta === "string") {
172
- return { kind: "delta", text: delta };
491
+ const content = message["content"];
492
+ if (typeof content === "string") {
493
+ return content;
494
+ }
495
+ if (!Array.isArray(content)) {
496
+ return "";
497
+ }
498
+ return content
499
+ .map((part) => (isRecord(part) && typeof part["text"] === "string" ? part["text"] : ""))
500
+ .join("");
501
+ }
502
+
503
+ function prefillProgress(slots: unknown): PrefillProgress | undefined {
504
+ if (!Array.isArray(slots)) {
505
+ return undefined;
506
+ }
507
+ let best: PrefillProgress | undefined;
508
+ for (const slot of slots) {
509
+ if (!isRecord(slot) || slot["is_processing"] !== true) {
510
+ continue;
511
+ }
512
+ const total = numberOrUndefined(slot["n_prompt_tokens"]);
513
+ if (total === undefined || total <= 0) {
514
+ continue;
173
515
  }
174
- if (typeof text === "string") {
175
- return { kind: "snapshot", text };
516
+ const candidate = { total, processed: numberOrUndefined(slot["n_prompt_tokens_processed"]) ?? 0 };
517
+ if (best === undefined || candidate.total > best.total) {
518
+ best = candidate;
176
519
  }
177
520
  }
178
- return { kind: "snapshot", text: "" };
521
+ return best;
522
+ }
523
+
524
+ function parseMode(value: string): StatsMode | undefined {
525
+ const normalized = value.trim().split(/\\s+/u)[0]?.toLowerCase();
526
+ return modes.find((mode) => mode === normalized);
527
+ }
528
+
529
+ async function promptMode(current: StatsMode, ctx: StatsContext): Promise<StatsMode | undefined> {
530
+ const selectable = ctx as StatsContext & {
531
+ ui: { select(title: string, options: string[]): Promise<string | undefined> };
532
+ };
533
+ const selected = await selectable.ui.select(
534
+ "Stats mode",
535
+ modes.map((mode) => (mode === current ? mode + " (current)" : mode))
536
+ );
537
+ return selected === undefined ? undefined : parseMode(selected);
538
+ }
539
+
540
+ async function persistMode(mode: StatsMode): Promise<void> {
541
+ const settings = await readSettings();
542
+ settings["stats"] = mode;
543
+ await writeSettings(settings);
544
+ }
545
+
546
+ function formatElapsed(seconds: number): string {
547
+ const value = Math.max(0, seconds);
548
+ if (value < 10) {
549
+ return value.toFixed(1) + "s";
550
+ }
551
+ const total = Math.floor(value);
552
+ const minutes = Math.floor(total / 60);
553
+ const rest = total % 60;
554
+ return minutes > 0 ? minutes + "m" + String(rest).padStart(2, "0") + "s" : total + "s";
555
+ }
556
+
557
+ function formatRate(rate: number | undefined): string {
558
+ if (rate === undefined || !Number.isFinite(rate)) {
559
+ return "—";
560
+ }
561
+ const oneDecimal = Number(rate.toFixed(1));
562
+ return oneDecimal < 100 ? oneDecimal.toFixed(1) : String(Math.round(oneDecimal));
563
+ }
564
+
565
+ function formatTokenCount(tokens: number): string {
566
+ const value = Math.max(0, tokens);
567
+ if (value < 1000) {
568
+ return String(Math.round(value));
569
+ }
570
+ if (value < 1000000) {
571
+ return compact(value / 1000, "k");
572
+ }
573
+ return compact(value / 1000000, "M");
574
+ }
575
+
576
+ function compact(value: number, suffix: string): string {
577
+ const decimals = value < 10 ? 1 : 0;
578
+ return value.toFixed(decimals).replace(/\\.0$/u, "") + suffix;
579
+ }
580
+
581
+ function secondsBetween(start: number, end: number): number {
582
+ return Math.max((end - start) / 1000, 0.001);
583
+ }
584
+
585
+ function numberOrUndefined(value: unknown): number | undefined {
586
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
587
+ }
588
+
589
+ function isRecord(value: unknown): value is Record<string, unknown> {
590
+ return value !== null && typeof value === "object" && !Array.isArray(value);
591
+ }
592
+
593
+ function withoutUndefined<T extends Record<string, unknown>>(value: T): Partial<T> {
594
+ const entries = Object.entries(value).filter(([, entry]) => entry !== undefined);
595
+ return Object.fromEntries(entries) as Partial<T>;
179
596
  }
180
597
  `;
181
598
  }
599
+ function slotsUrl(config) {
600
+ if (config.engine !== "llama-cpp" || config.baseUrl === undefined) {
601
+ return undefined;
602
+ }
603
+ const root = config.baseUrl.replace(/\/+$/u, "").replace(/\/v1$/u, "");
604
+ const model = config.modelId === undefined ? "" : `?model=${encodeURIComponent(config.modelId)}`;
605
+ return `${root}/slots${model}`;
606
+ }