@huseynsnmz/pi-suite 0.0.0-stage → 0.1.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.
- package/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/README.md +87 -2
- package/SECURITY.md +5 -0
- package/package.json +113 -4
- package/packages/board/CHANGELOG.md +340 -0
- package/packages/board/CONTRIBUTING.md +7 -0
- package/packages/board/LICENSE +21 -0
- package/packages/board/README.md +88 -0
- package/packages/board/SECURITY.md +19 -0
- package/packages/board/docs/how-it-works.md +31 -0
- package/packages/board/docs/reference.md +278 -0
- package/packages/board/package.json +83 -0
- package/packages/board/runner/job-runner-legacy.mjs +68 -0
- package/packages/board/runner/job-runner.mjs +834 -0
- package/packages/board/runner/pty-runner-legacy.mjs +50 -0
- package/packages/board/runner/pty-runner.mjs +2175 -0
- package/packages/board/runner/state-coordinator.mjs +673 -0
- package/packages/board/runner/state-runner.mjs +203 -0
- package/packages/board/runner/title-runner.mjs +80 -0
- package/packages/board/src/commands/agent-board.ts +394 -0
- package/packages/board/src/commands/attach-decision.mjs +66 -0
- package/packages/board/src/commands/attach-flow.ts +251 -0
- package/packages/board/src/commands/bg.ts +103 -0
- package/packages/board/src/commands/board-model.ts +21 -0
- package/packages/board/src/core/atomic.mjs +184 -0
- package/packages/board/src/core/attach-delete.mjs +24 -0
- package/packages/board/src/core/attach-frame-proof.mjs +870 -0
- package/packages/board/src/core/attach-frame-reporter.mjs +648 -0
- package/packages/board/src/core/attach-frame-stream.mjs +266 -0
- package/packages/board/src/core/attach-view-proof.mjs +709 -0
- package/packages/board/src/core/auto-state.mjs +377 -0
- package/packages/board/src/core/background-runs.mjs +273 -0
- package/packages/board/src/core/code-refs-store.mjs +315 -0
- package/packages/board/src/core/code-refs.mjs +913 -0
- package/packages/board/src/core/config.mjs +77 -0
- package/packages/board/src/core/control-protocol.mjs +482 -0
- package/packages/board/src/core/coordinator-client.mjs +633 -0
- package/packages/board/src/core/coordinator-journal.mjs +282 -0
- package/packages/board/src/core/coordinator-protocol.mjs +14 -0
- package/packages/board/src/core/cwd-stats.mjs +114 -0
- package/packages/board/src/core/dashboard-render.mjs +10 -0
- package/packages/board/src/core/derive.mjs +117 -0
- package/packages/board/src/core/diagnostics.mjs +133 -0
- package/packages/board/src/core/double-press.mjs +26 -0
- package/packages/board/src/core/editor-state-reporter.mjs +112 -0
- package/packages/board/src/core/events.mjs +286 -0
- package/packages/board/src/core/evidence.mjs +245 -0
- package/packages/board/src/core/follow-up-queue.mjs +211 -0
- package/packages/board/src/core/foreground-preview-cache.mjs +117 -0
- package/packages/board/src/core/foreground-projection-cache.mjs +99 -0
- package/packages/board/src/core/frame-witness-update.mjs +35 -0
- package/packages/board/src/core/heuristics.mjs +313 -0
- package/packages/board/src/core/hold-input.mjs +27 -0
- package/packages/board/src/core/host-coordination.mjs +300 -0
- package/packages/board/src/core/host-crash.mjs +79 -0
- package/packages/board/src/core/host-probe.mjs +207 -0
- package/packages/board/src/core/host-protocol.mjs +73 -0
- package/packages/board/src/core/ids.mjs +35 -0
- package/packages/board/src/core/ime-cursor-coalesce.mjs +193 -0
- package/packages/board/src/core/invocation.mjs +43 -0
- package/packages/board/src/core/launch-options.mjs +418 -0
- package/packages/board/src/core/launch.mjs +157 -0
- package/packages/board/src/core/load-config.mjs +4 -0
- package/packages/board/src/core/locks.mjs +530 -0
- package/packages/board/src/core/paths.mjs +183 -0
- package/packages/board/src/core/pid.mjs +75 -0
- package/packages/board/src/core/prewarm-schedule.mjs +41 -0
- package/packages/board/src/core/process-identity-probe.mjs +228 -0
- package/packages/board/src/core/prompt-transport.mjs +13 -0
- package/packages/board/src/core/pty-attach-jiggle-controller.mjs +356 -0
- package/packages/board/src/core/pty-attach-jiggle-retry.mjs +109 -0
- package/packages/board/src/core/pty-attach-reconnect.mjs +61 -0
- package/packages/board/src/core/pty-attach-render.mjs +229 -0
- package/packages/board/src/core/pty-links.mjs +81 -0
- package/packages/board/src/core/pty-scroll.mjs +522 -0
- package/packages/board/src/core/pty-support.mjs +327 -0
- package/packages/board/src/core/repo.mjs +103 -0
- package/packages/board/src/core/rows.mjs +398 -0
- package/packages/board/src/core/screen-log-gc.mjs +198 -0
- package/packages/board/src/core/screen-log.mjs +160 -0
- package/packages/board/src/core/session-view.mjs +174 -0
- package/packages/board/src/core/slash-commands.mjs +52 -0
- package/packages/board/src/core/state-commands.mjs +757 -0
- package/packages/board/src/core/status-consistency.mjs +98 -0
- package/packages/board/src/core/steering-prompts.mjs +34 -0
- package/packages/board/src/core/steering.mjs +133 -0
- package/packages/board/src/core/stop-latch.mjs +41 -0
- package/packages/board/src/core/store.mjs +523 -0
- package/packages/board/src/core/suspend.mjs +36 -0
- package/packages/board/src/core/terminal-attach-client.mjs +981 -0
- package/packages/board/src/core/terminal-attach-protocol.mjs +460 -0
- package/packages/board/src/core/terminal-model.mjs +222 -0
- package/packages/board/src/core/terminal-query-sequences.mjs +239 -0
- package/packages/board/src/core/terminal-reply-guard.mjs +222 -0
- package/packages/board/src/core/terminal-snapshot.mjs +440 -0
- package/packages/board/src/core/test-knobs.mjs +28 -0
- package/packages/board/src/core/title.mjs +43 -0
- package/packages/board/src/core/types.mjs +414 -0
- package/packages/board/src/core/warm-host-sweeper.mjs +168 -0
- package/packages/board/src/core/worktree.mjs +65 -0
- package/packages/board/src/index.ts +303 -0
- package/packages/board/src/runtime/service.mjs +3222 -0
- package/packages/board/src/ui/chrome.ts +20 -0
- package/packages/board/src/ui/dashboard-decisions.mjs +55 -0
- package/packages/board/src/ui/dashboard-evidence.mjs +85 -0
- package/packages/board/src/ui/dashboard.ts +2221 -0
- package/packages/board/src/ui/double-left.ts +30 -0
- package/packages/board/src/ui/external-editor.ts +25 -0
- package/packages/board/src/ui/focus.ts +32 -0
- package/packages/board/src/ui/pty-attach.ts +3184 -0
- package/packages/board/src/ui/waiting.ts +30 -0
- package/packages/frame/CHANGELOG.md +113 -0
- package/packages/frame/CONTRIBUTING.md +7 -0
- package/packages/frame/LICENSE +22 -0
- package/packages/frame/README.md +105 -0
- package/packages/frame/SECURITY.md +16 -0
- package/packages/frame/docs/how-it-works.md +53 -0
- package/packages/frame/docs/reference.md +78 -0
- package/packages/frame/package.json +75 -0
- package/packages/frame/src/data/git.ts +90 -0
- package/packages/frame/src/data/github.ts +140 -0
- package/packages/frame/src/data/gitlab.ts +63 -0
- package/packages/frame/src/data/mode.ts +23 -0
- package/packages/frame/src/data/past-prompts.ts +57 -0
- package/packages/frame/src/data/runner.ts +32 -0
- package/packages/frame/src/data/snapshot.ts +223 -0
- package/packages/frame/src/data/start-notes.ts +104 -0
- package/packages/frame/src/data/telemetry.ts +337 -0
- package/packages/frame/src/data/todos.ts +162 -0
- package/packages/frame/src/data/totals.ts +94 -0
- package/packages/frame/src/data/visits.ts +45 -0
- package/packages/frame/src/index.ts +676 -0
- package/packages/frame/src/render/diff.ts +104 -0
- package/packages/frame/src/render/fit.ts +86 -0
- package/packages/frame/src/render/format.ts +108 -0
- package/packages/frame/src/render/frame.ts +106 -0
- package/packages/frame/src/render/glyphs.ts +189 -0
- package/packages/frame/src/render/keys.ts +43 -0
- package/packages/frame/src/render/lead.ts +37 -0
- package/packages/frame/src/render/paint.ts +135 -0
- package/packages/frame/src/render/safe.ts +19 -0
- package/packages/frame/src/render/segments.ts +735 -0
- package/packages/frame/src/render/streaming.ts +223 -0
- package/packages/frame/src/render/timing.ts +106 -0
- package/packages/frame/src/render/tools.ts +167 -0
- package/packages/frame/src/settings.ts +105 -0
- package/packages/frame/src/ui/editor.ts +272 -0
- package/packages/frame/src/ui/effort.ts +36 -0
- package/packages/frame/src/ui/footer.ts +165 -0
- package/packages/frame/src/ui/header.ts +362 -0
- package/packages/frame/src/ui/history-search.ts +23 -0
- package/packages/frame/src/ui/interrupt.ts +21 -0
- package/packages/frame/src/ui/outputs.ts +170 -0
- package/packages/frame/src/ui/pi-entries.ts +276 -0
- package/packages/frame/src/ui/previews.ts +72 -0
- package/packages/frame/src/ui/queue.ts +75 -0
- package/packages/frame/src/ui/screen.ts +59 -0
- package/packages/frame/src/ui/timings.ts +48 -0
- package/packages/frame/src/ui/todos.ts +205 -0
- package/packages/frame/src/ui/tool-frame.ts +595 -0
- package/packages/frame/src/ui/working.ts +25 -0
- package/packages/frame/src/view/state.ts +97 -0
- package/packages/frame/src/view/transcript.ts +175 -0
- package/packages/frame/src/view/view.ts +162 -0
- package/packages/memory/CONTRIBUTING.md +7 -0
- package/packages/memory/LICENSE +21 -0
- package/packages/memory/README.md +44 -0
- package/packages/memory/SECURITY.md +20 -0
- package/packages/memory/package.json +55 -0
- package/packages/memory/skills/review-memory/SKILL.md +48 -0
- package/packages/memory/src/index.ts +163 -0
- package/packages/memory/src/prompt.ts +25 -0
- package/packages/memory/src/review.ts +150 -0
- package/packages/memory/src/secrets.ts +22 -0
- package/packages/memory/src/store.ts +173 -0
- package/packages/modes/CHANGELOG.md +104 -0
- package/packages/modes/CONTRIBUTING.md +7 -0
- package/packages/modes/LICENSE +21 -0
- package/packages/modes/README.md +174 -0
- package/packages/modes/SECURITY.md +17 -0
- package/packages/modes/package.json +55 -0
- package/packages/modes/skills/review-permissions/SKILL.md +54 -0
- package/packages/modes/src/bash.ts +218 -0
- package/packages/modes/src/deny-off.ts +62 -0
- package/packages/modes/src/git-safety.ts +72 -0
- package/packages/modes/src/index.ts +338 -0
- package/packages/modes/src/interview.ts +206 -0
- package/packages/modes/src/judge-gate.ts +113 -0
- package/packages/modes/src/modes.ts +121 -0
- package/packages/modes/src/review/apply.mjs +156 -0
- package/packages/modes/src/review/collect.mjs +208 -0
- package/packages/modes/src/review/tools.ts +71 -0
- package/packages/modes/src/sandbox.ts +37 -0
- package/packages/router/CHANGELOG.md +8 -0
- package/packages/router/LICENSE +21 -0
- package/packages/router/README.md +90 -0
- package/packages/router/package.json +51 -0
- package/packages/router/src/config.ts +65 -0
- package/packages/router/src/index.ts +172 -0
- package/packages/router/src/judge.ts +51 -0
- package/packages/router/src/route.ts +67 -0
- package/packages/web/CHANGELOG.md +36 -0
- package/packages/web/LICENSE +21 -0
- package/packages/web/README.md +87 -0
- package/packages/web/SECURITY.md +13 -0
- package/packages/web/package.json +61 -0
- package/packages/web/src/config.ts +109 -0
- package/packages/web/src/dnsfilter.ts +97 -0
- package/packages/web/src/extract.ts +65 -0
- package/packages/web/src/index.ts +217 -0
- package/packages/web/src/net.ts +109 -0
- package/packages/web/src/reader.ts +73 -0
- package/packages/web/src/sanitize.ts +50 -0
- package/packages/web/src/search.ts +76 -0
- package/packages/web/src/secrets.ts +22 -0
- package/packages/web/src/validate.ts +186 -0
- package/src/index.ts +156 -0
- package/src/settings.ts +78 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { STALL_MS } from "../render/timing.ts";
|
|
4
|
+
import {
|
|
5
|
+
emptyTotals,
|
|
6
|
+
perSecond,
|
|
7
|
+
promptOf,
|
|
8
|
+
REQUEST_CHANGES,
|
|
9
|
+
type RequestRecord,
|
|
10
|
+
type Totals,
|
|
11
|
+
usageTotals,
|
|
12
|
+
} from "./totals.ts";
|
|
13
|
+
|
|
14
|
+
const MIN_SAMPLE_MS = 250;
|
|
15
|
+
/** Measured over 16,889 assistant messages: 2.88 characters per output token. */
|
|
16
|
+
const DEFAULT_CHARS_PER_TOKEN = 2.9;
|
|
17
|
+
|
|
18
|
+
/** So the wait for the first thought can be told from the first word. */
|
|
19
|
+
export type DeltaKind = "thinking" | "writing";
|
|
20
|
+
|
|
21
|
+
export interface RequestView {
|
|
22
|
+
streaming: boolean;
|
|
23
|
+
waiting: boolean;
|
|
24
|
+
elapsedMs: number;
|
|
25
|
+
/** From the request leaving to the first token of any kind. */
|
|
26
|
+
waitMs: number | undefined;
|
|
27
|
+
/** The same wait while it runs. */
|
|
28
|
+
waitingMs: number;
|
|
29
|
+
/** To the response headers: the provider's own latency. */
|
|
30
|
+
serverMs: number | undefined;
|
|
31
|
+
/** From the headers to the first token: the model reading the prompt. */
|
|
32
|
+
prefillMs: number | undefined;
|
|
33
|
+
/** Unset when it wrote without thinking. */
|
|
34
|
+
thoughtMs: number | undefined;
|
|
35
|
+
decode: number | undefined;
|
|
36
|
+
prefill: number | undefined;
|
|
37
|
+
usage: Totals;
|
|
38
|
+
estimated: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One call to the model, for the line under its response. */
|
|
42
|
+
export interface Timing {
|
|
43
|
+
/** When it left. */
|
|
44
|
+
at: number;
|
|
45
|
+
streaming: boolean;
|
|
46
|
+
waitMs: number | undefined;
|
|
47
|
+
waitingMs: number;
|
|
48
|
+
serverMs: number | undefined;
|
|
49
|
+
thoughtMs: number | undefined;
|
|
50
|
+
totalMs: number;
|
|
51
|
+
writingMs: number;
|
|
52
|
+
/** Pauses of `STALL_MS` or more while it wrote. */
|
|
53
|
+
stallMs: number;
|
|
54
|
+
output: number;
|
|
55
|
+
estimated: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface RunView {
|
|
59
|
+
running: boolean;
|
|
60
|
+
elapsedMs: number;
|
|
61
|
+
requests: number;
|
|
62
|
+
cost: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface Request {
|
|
66
|
+
startedAt: number;
|
|
67
|
+
sentAt: number | undefined;
|
|
68
|
+
headersAt: number | undefined;
|
|
69
|
+
firstTokenAt: number | undefined;
|
|
70
|
+
firstWritingAt: number | undefined;
|
|
71
|
+
endedAt: number | undefined;
|
|
72
|
+
messageAt?: number;
|
|
73
|
+
usage: Totals;
|
|
74
|
+
characters: number;
|
|
75
|
+
lastDeltaAt: number | undefined;
|
|
76
|
+
stallMs: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface Run {
|
|
80
|
+
startedAt: number;
|
|
81
|
+
endedAt: number | undefined;
|
|
82
|
+
requests: number;
|
|
83
|
+
cost: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface UsageLike {
|
|
87
|
+
input: number;
|
|
88
|
+
output: number;
|
|
89
|
+
cacheRead: number;
|
|
90
|
+
cacheWrite: number;
|
|
91
|
+
cost: { total: number };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function span(from: number | undefined, to: number | undefined): number | undefined {
|
|
95
|
+
return from === undefined || to === undefined ? undefined : to - from;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export class Telemetry {
|
|
99
|
+
readonly #now: () => number;
|
|
100
|
+
#run: Run | undefined;
|
|
101
|
+
#request: Request | undefined;
|
|
102
|
+
#last: RequestView | undefined;
|
|
103
|
+
/** By the response's timestamp, which stays the same while it's written. */
|
|
104
|
+
#byMessage = new Map<number, Request>();
|
|
105
|
+
#restored = new Map<number, Timing>();
|
|
106
|
+
/** They arrive before the pi message they belong to. */
|
|
107
|
+
#sentAt: number | undefined;
|
|
108
|
+
#headersAt: number | undefined;
|
|
109
|
+
/** So the live estimate uses a real ratio. */
|
|
110
|
+
#sampled = { characters: 0, output: 0 };
|
|
111
|
+
|
|
112
|
+
constructor(now: () => number = Date.now) {
|
|
113
|
+
this.#now = now;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
runStarted(): void {
|
|
117
|
+
this.#run = { startedAt: this.#now(), endedAt: undefined, requests: 0, cost: 0 };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
runEnded(): void {
|
|
121
|
+
if (this.#run !== undefined && this.#run.endedAt === undefined) this.#run.endedAt = this.#now();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Before every call to the model: the start of the wait. */
|
|
125
|
+
providerRequestSent(): void {
|
|
126
|
+
this.#sentAt = this.#now();
|
|
127
|
+
this.#headersAt = undefined;
|
|
128
|
+
this.#attach();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Before the stream is read. */
|
|
132
|
+
providerResponseArrived(): void {
|
|
133
|
+
this.#headersAt = this.#now();
|
|
134
|
+
this.#attach();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
requestStarted(): void {
|
|
138
|
+
this.#sentAt = undefined;
|
|
139
|
+
this.#headersAt = undefined;
|
|
140
|
+
this.#begin();
|
|
141
|
+
if (this.#run === undefined || this.#run.endedAt !== undefined) this.runStarted();
|
|
142
|
+
if (this.#run !== undefined) this.#run.requests++;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Later requests of a turn: the next call after a tool ran. */
|
|
146
|
+
answerStarted(messageAt?: number): void {
|
|
147
|
+
if (this.#request === undefined || this.#request.endedAt !== undefined) this.#begin();
|
|
148
|
+
if (messageAt !== undefined && this.#request !== undefined) {
|
|
149
|
+
this.#byMessage.set(messageAt, this.#request);
|
|
150
|
+
this.#request.messageAt = messageAt;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
answerGrew(delta: string, usage: UsageLike, kind: DeltaKind): void {
|
|
155
|
+
const request = this.#request;
|
|
156
|
+
if (request === undefined || request.endedAt !== undefined) return;
|
|
157
|
+
request.usage = usageTotals(usage);
|
|
158
|
+
if (delta.length === 0) return;
|
|
159
|
+
const grewAt = this.#now();
|
|
160
|
+
const gap = grewAt - (request.lastDeltaAt ?? grewAt);
|
|
161
|
+
if (gap >= STALL_MS) request.stallMs += gap;
|
|
162
|
+
request.lastDeltaAt = grewAt;
|
|
163
|
+
request.characters += delta.length;
|
|
164
|
+
request.firstTokenAt ??= this.#now();
|
|
165
|
+
if (kind === "writing") request.firstWritingAt ??= this.#now();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
answerEnded(usage: UsageLike): RequestRecord | undefined {
|
|
169
|
+
const request = this.#request;
|
|
170
|
+
if (request === undefined || request.endedAt !== undefined) return undefined;
|
|
171
|
+
|
|
172
|
+
const now = this.#now();
|
|
173
|
+
request.endedAt = now;
|
|
174
|
+
request.usage = usageTotals(usage);
|
|
175
|
+
if (this.#run !== undefined) this.#run.cost += usage.cost.total;
|
|
176
|
+
if (request.usage.output === 0 && request.firstTokenAt === undefined) return undefined;
|
|
177
|
+
|
|
178
|
+
if (request.characters > 0 && request.usage.output > 0) {
|
|
179
|
+
this.#sampled.characters += request.characters;
|
|
180
|
+
this.#sampled.output += request.usage.output;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const firstTokenAt = request.firstTokenAt ?? now;
|
|
184
|
+
const from = request.sentAt ?? request.startedAt;
|
|
185
|
+
this.#last = this.#view(request);
|
|
186
|
+
const timing = this.#timing(request);
|
|
187
|
+
return {
|
|
188
|
+
output: request.usage.output,
|
|
189
|
+
prompt: promptOf(request.usage),
|
|
190
|
+
firstTokenMs: firstTokenAt - from,
|
|
191
|
+
generationMs: now - firstTokenAt,
|
|
192
|
+
at: from,
|
|
193
|
+
messageAt: request.messageAt,
|
|
194
|
+
serverMs: span(request.sentAt, request.headersAt),
|
|
195
|
+
thoughtMs: timing.thoughtMs,
|
|
196
|
+
totalMs: now - from,
|
|
197
|
+
stallMs: request.stallMs,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
timingOf(messageAt: number): Timing | undefined {
|
|
202
|
+
const request = this.#byMessage.get(messageAt);
|
|
203
|
+
return request === undefined ? this.#restored.get(messageAt) : this.#timing(request);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The calls an earlier run of this session recorded. */
|
|
207
|
+
restore(entries: readonly SessionEntry[]): void {
|
|
208
|
+
for (const entry of entries) {
|
|
209
|
+
if (entry.type !== "custom" || entry.customType !== REQUEST_CHANGES) continue;
|
|
210
|
+
const record = entry.data as Partial<RequestRecord> | undefined;
|
|
211
|
+
if (record?.messageAt === undefined || record.at === undefined) continue;
|
|
212
|
+
this.#restored.set(record.messageAt, {
|
|
213
|
+
at: record.at,
|
|
214
|
+
streaming: false,
|
|
215
|
+
waitMs: record.firstTokenMs,
|
|
216
|
+
waitingMs: record.firstTokenMs ?? 0,
|
|
217
|
+
serverMs: record.serverMs,
|
|
218
|
+
thoughtMs: record.thoughtMs,
|
|
219
|
+
totalMs: record.totalMs ?? 0,
|
|
220
|
+
writingMs: record.generationMs ?? 0,
|
|
221
|
+
stallMs: record.stallMs ?? 0,
|
|
222
|
+
output: record.output ?? 0,
|
|
223
|
+
estimated: false,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
request(): RequestView | undefined {
|
|
229
|
+
const request = this.#request;
|
|
230
|
+
return request === undefined ? undefined : this.#view(request);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
last(): RequestView | undefined {
|
|
234
|
+
return this.#last;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
run(): RunView | undefined {
|
|
238
|
+
const run = this.#run;
|
|
239
|
+
if (run === undefined) return undefined;
|
|
240
|
+
const running = run.endedAt === undefined;
|
|
241
|
+
return {
|
|
242
|
+
running,
|
|
243
|
+
elapsedMs: (run.endedAt ?? this.#now()) - run.startedAt,
|
|
244
|
+
requests: run.requests,
|
|
245
|
+
cost: run.cost,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
reset(): void {
|
|
250
|
+
this.#run = undefined;
|
|
251
|
+
this.#request = undefined;
|
|
252
|
+
this.#last = undefined;
|
|
253
|
+
this.#byMessage.clear();
|
|
254
|
+
this.#restored.clear();
|
|
255
|
+
this.#sentAt = undefined;
|
|
256
|
+
this.#headersAt = undefined;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
#begin(): void {
|
|
260
|
+
this.#request = {
|
|
261
|
+
startedAt: this.#now(),
|
|
262
|
+
sentAt: this.#sentAt,
|
|
263
|
+
headersAt: this.#headersAt,
|
|
264
|
+
firstTokenAt: undefined,
|
|
265
|
+
firstWritingAt: undefined,
|
|
266
|
+
endedAt: undefined,
|
|
267
|
+
usage: emptyTotals(),
|
|
268
|
+
characters: 0,
|
|
269
|
+
lastDeltaAt: undefined,
|
|
270
|
+
stallMs: 0,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Pi sends before it opens the message, so the marks may arrive first. */
|
|
275
|
+
#attach(): void {
|
|
276
|
+
const request = this.#request;
|
|
277
|
+
if (request === undefined || request.endedAt !== undefined) return;
|
|
278
|
+
request.sentAt = this.#sentAt;
|
|
279
|
+
request.headersAt = this.#headersAt;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
#timing(request: Request): Timing {
|
|
283
|
+
const view = this.#view(request);
|
|
284
|
+
const end = request.endedAt ?? this.#now();
|
|
285
|
+
const from = request.sentAt ?? request.startedAt;
|
|
286
|
+
// While it's paused, the pause so far counts too.
|
|
287
|
+
const pause = request.endedAt === undefined ? end - (request.lastDeltaAt ?? end) : 0;
|
|
288
|
+
return {
|
|
289
|
+
at: from,
|
|
290
|
+
streaming: view.streaming,
|
|
291
|
+
waitMs: view.waitMs,
|
|
292
|
+
waitingMs: view.waitingMs,
|
|
293
|
+
serverMs: view.serverMs,
|
|
294
|
+
thoughtMs: view.thoughtMs,
|
|
295
|
+
totalMs: end - from,
|
|
296
|
+
writingMs: request.firstTokenAt === undefined ? 0 : end - request.firstTokenAt,
|
|
297
|
+
stallMs: request.stallMs + (pause >= STALL_MS ? pause : 0),
|
|
298
|
+
output: view.usage.output,
|
|
299
|
+
estimated: view.estimated,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
#view(request: Request): RequestView {
|
|
304
|
+
const now = this.#now();
|
|
305
|
+
const streaming = request.endedAt === undefined;
|
|
306
|
+
const from = request.sentAt ?? request.startedAt;
|
|
307
|
+
const waitMs = span(from, request.firstTokenAt);
|
|
308
|
+
const estimated = streaming && request.usage.output === 0;
|
|
309
|
+
const output = estimated
|
|
310
|
+
? Math.round(request.characters / this.#charsPerToken())
|
|
311
|
+
: request.usage.output;
|
|
312
|
+
const writingMs =
|
|
313
|
+
request.firstTokenAt === undefined ? 0 : (request.endedAt ?? now) - request.firstTokenAt;
|
|
314
|
+
const thoughtMs = span(request.firstTokenAt, request.firstWritingAt);
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
streaming,
|
|
318
|
+
waiting: streaming && request.firstTokenAt === undefined,
|
|
319
|
+
elapsedMs: (request.endedAt ?? now) - request.startedAt,
|
|
320
|
+
waitMs,
|
|
321
|
+
waitingMs: (request.endedAt ?? now) - from,
|
|
322
|
+
serverMs: span(request.sentAt, request.headersAt),
|
|
323
|
+
prefillMs: span(request.headersAt ?? request.sentAt, request.firstTokenAt),
|
|
324
|
+
thoughtMs: thoughtMs !== undefined && thoughtMs > 0 ? thoughtMs : undefined,
|
|
325
|
+
decode: writingMs < MIN_SAMPLE_MS && streaming ? undefined : perSecond(output, writingMs),
|
|
326
|
+
prefill: waitMs === undefined ? undefined : perSecond(promptOf(request.usage), waitMs),
|
|
327
|
+
usage: { ...request.usage, output },
|
|
328
|
+
estimated,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** This session's ratio, or the measured default. */
|
|
333
|
+
#charsPerToken(): number {
|
|
334
|
+
const { characters, output } = this.#sampled;
|
|
335
|
+
return output > 0 ? characters / output : DEFAULT_CHARS_PER_TOKEN;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// The agent's checklist. Pure: the tool, the panel and the session all go through these.
|
|
2
|
+
import { plainText } from "../render/safe.ts";
|
|
3
|
+
|
|
4
|
+
export const TODO_ENTRY = "pi-frame:todo";
|
|
5
|
+
const MAX_ITEMS = 50;
|
|
6
|
+
const MAX_TEXT = 200;
|
|
7
|
+
|
|
8
|
+
const INVISIBLE = /[\u00ad\u034f\u061c\u180e\u200b-\u200f\u2060-\u206f\u3164\ufeff\ufff9-\ufffb\u{e0000}-\u{e007f}]/gu;
|
|
9
|
+
|
|
10
|
+
export type TodoStatus = "open" | "active" | "done";
|
|
11
|
+
|
|
12
|
+
export interface Todo {
|
|
13
|
+
id: number;
|
|
14
|
+
text: string;
|
|
15
|
+
status: TodoStatus;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TodoList {
|
|
19
|
+
items: readonly Todo[];
|
|
20
|
+
nextId: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface TodoChange {
|
|
24
|
+
plan?: readonly string[];
|
|
25
|
+
add?: readonly string[];
|
|
26
|
+
drop?: readonly number[];
|
|
27
|
+
done?: readonly number[];
|
|
28
|
+
start?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const NO_TODOS: TodoList = { items: [], nextId: 1 };
|
|
32
|
+
|
|
33
|
+
/** Model text, drawn on one row, so it can't drive the terminal or the layout. */
|
|
34
|
+
function cleanText(text: string): string {
|
|
35
|
+
const clean = plainText(text).replace(INVISIBLE, "").replace(/\s+/g, " ").trim();
|
|
36
|
+
return clean.length > MAX_TEXT ? `${clean.slice(0, MAX_TEXT - 3)}...` : clean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function added(texts: readonly string[], from: number): Todo[] {
|
|
40
|
+
return texts.map((text, index) => {
|
|
41
|
+
const clean = cleanText(text);
|
|
42
|
+
if (clean === "") throw new Error("A todo item needs text.");
|
|
43
|
+
return { id: from + index, text: clean, status: "open" };
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function checkedIds(list: TodoList, ids: readonly number[]): void {
|
|
48
|
+
const known = new Set(list.items.map((item) => item.id));
|
|
49
|
+
const missing = ids.filter((id) => !known.has(id));
|
|
50
|
+
if (missing.length > 0) throw new Error(`No todo item ${missing.join(", ")}.`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** All or nothing: a bad id or text throws, with the list, which stays as it was. No arguments just reads it. */
|
|
54
|
+
export function changedTodos(list: TodoList, change: TodoChange): TodoList {
|
|
55
|
+
try {
|
|
56
|
+
return applied(list, change);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
throw new Error(`${(error as Error).message} The list has not changed.\n${todoText(list)}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function applied(list: TodoList, change: TodoChange): TodoList {
|
|
63
|
+
let items: readonly Todo[] = list.items;
|
|
64
|
+
let nextId = list.nextId;
|
|
65
|
+
if (change.plan !== undefined) {
|
|
66
|
+
items = added(change.plan, 1);
|
|
67
|
+
nextId = items.length + 1;
|
|
68
|
+
}
|
|
69
|
+
if (change.add !== undefined) {
|
|
70
|
+
items = [...items, ...added(change.add, nextId)];
|
|
71
|
+
nextId += change.add.length;
|
|
72
|
+
}
|
|
73
|
+
if (items.length > MAX_ITEMS) throw new Error(`A todo list holds at most ${MAX_ITEMS} items.`);
|
|
74
|
+
|
|
75
|
+
const next = (): TodoList => ({ items, nextId });
|
|
76
|
+
checkedIds(next(), change.drop ?? []);
|
|
77
|
+
items = items.filter((item) => !change.drop?.includes(item.id));
|
|
78
|
+
checkedIds(next(), change.done ?? []);
|
|
79
|
+
items = items.map((item) => (change.done?.includes(item.id) ? { ...item, status: "done" } : item));
|
|
80
|
+
|
|
81
|
+
const start = change.start;
|
|
82
|
+
if (start !== undefined) {
|
|
83
|
+
checkedIds(next(), [start]);
|
|
84
|
+
// Only one at a time, so starting one puts back whichever was active.
|
|
85
|
+
items = items.map((item) => {
|
|
86
|
+
if (item.id === start) return { ...item, status: "active" };
|
|
87
|
+
return item.status === "active" ? { ...item, status: "open" } : item;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return next();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function hasOpenTodos(list: TodoList): boolean {
|
|
94
|
+
return list.items.some((item) => item.status !== "done");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function doneCount(list: TodoList): number {
|
|
98
|
+
return list.items.filter((item) => item.status === "done").length;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const MARKS: Record<TodoStatus, string> = { open: "[ ]", active: "[>]", done: "[x]" };
|
|
102
|
+
|
|
103
|
+
/** What the model reads. */
|
|
104
|
+
export function todoText(list: TodoList): string {
|
|
105
|
+
if (list.items.length === 0) return "The todo list is empty.";
|
|
106
|
+
const rows = list.items.map((item) => `${MARKS[item.status]} ${item.id} ${item.text}`);
|
|
107
|
+
return [`${doneCount(list)}/${list.items.length} done`, ...rows].join("\n");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The transcript's one line: what's being worked on, or how far it got. */
|
|
111
|
+
export function todoSummary(list: TodoList): string {
|
|
112
|
+
if (list.items.length === 0) return "list empty";
|
|
113
|
+
const active = list.items.find((item) => item.status === "active");
|
|
114
|
+
const count = `${doneCount(list)}/${list.items.length} done`;
|
|
115
|
+
return active === undefined ? count : `${count}, now: ${active.text}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function decoded(data: unknown): TodoList | undefined {
|
|
119
|
+
const list = data as { items?: unknown; nextId?: unknown } | null;
|
|
120
|
+
if (!Array.isArray(list?.items) || !Number.isSafeInteger(list.nextId)) return undefined;
|
|
121
|
+
if (list.items.length > MAX_ITEMS) return undefined;
|
|
122
|
+
const items: Todo[] = [];
|
|
123
|
+
const nextId = list.nextId as number;
|
|
124
|
+
const seen = new Set<number>();
|
|
125
|
+
for (const raw of list.items as Partial<Todo>[]) {
|
|
126
|
+
if (!Number.isSafeInteger(raw?.id) || typeof raw?.text !== "string") return undefined;
|
|
127
|
+
if (raw.status !== "open" && raw.status !== "active" && raw.status !== "done") return undefined;
|
|
128
|
+
const id = raw.id as number;
|
|
129
|
+
const text = cleanText(raw.text);
|
|
130
|
+
if (text === "" || id >= nextId || seen.has(id)) return undefined;
|
|
131
|
+
seen.add(id);
|
|
132
|
+
items.push({ id, text, status: raw.status });
|
|
133
|
+
}
|
|
134
|
+
return { items, nextId };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The last list saved on this branch. A session can come from anyone, so a bad entry is skipped. */
|
|
138
|
+
export function todosOnBranch(entries: Iterable<unknown>): TodoList {
|
|
139
|
+
let list = NO_TODOS;
|
|
140
|
+
for (const entry of entries) {
|
|
141
|
+
const custom = entry as { type?: string; customType?: string; data?: unknown };
|
|
142
|
+
if (custom.type !== "custom" || custom.customType !== TODO_ENTRY) continue;
|
|
143
|
+
list = decoded(custom.data) ?? list;
|
|
144
|
+
}
|
|
145
|
+
return list;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Compaction can take the tool's last answer out of the model's context while the list goes on.
|
|
150
|
+
* Then the list comes first, where it doesn't move between calls, which keeps most of the prompt cache.
|
|
151
|
+
* A failed call may not show the list, so only an answer that worked counts.
|
|
152
|
+
*/
|
|
153
|
+
export function withTodoReminder<M extends { role: string; toolName?: string; isError?: boolean }>(
|
|
154
|
+
messages: readonly M[],
|
|
155
|
+
list: TodoList,
|
|
156
|
+
reminder: (text: string) => M,
|
|
157
|
+
): M[] | undefined {
|
|
158
|
+
if (!hasOpenTodos(list)) return undefined;
|
|
159
|
+
const seen = (message: M) => message.role === "toolResult" && message.toolName === "todo" && message.isError !== true;
|
|
160
|
+
if (messages.some(seen)) return undefined;
|
|
161
|
+
return [reminder(`Your todo list, kept from earlier in this session:\n${todoText(list)}`), ...messages];
|
|
162
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { type UsageLike } from "@adeildo/pi-kit";
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
emptyTotals,
|
|
5
|
+
promptOf,
|
|
6
|
+
tokensOf,
|
|
7
|
+
totalsOf,
|
|
8
|
+
type Totals,
|
|
9
|
+
usageTotals,
|
|
10
|
+
} from "@adeildo/pi-kit";
|
|
11
|
+
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
|
|
13
|
+
export interface RequestRecord {
|
|
14
|
+
output: number;
|
|
15
|
+
prompt: number;
|
|
16
|
+
firstTokenMs: number;
|
|
17
|
+
generationMs: number;
|
|
18
|
+
/** For the line under the response. Older records don't have them. */
|
|
19
|
+
at?: number;
|
|
20
|
+
messageAt?: number;
|
|
21
|
+
serverMs?: number;
|
|
22
|
+
thoughtMs?: number;
|
|
23
|
+
totalMs?: number;
|
|
24
|
+
stallMs?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const REQUEST_CHANGES = "pi-frame:request";
|
|
28
|
+
/** Entries from this package's old name. They have no `prompt`, so they count only for decode. */
|
|
29
|
+
const LEGACY_CHANGES = "pi-statusline:turn";
|
|
30
|
+
|
|
31
|
+
export interface Averages {
|
|
32
|
+
decode: number | undefined;
|
|
33
|
+
prefill: number | undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function lastAssistantUsage(entries: readonly SessionEntry[]): UsageLike | undefined {
|
|
37
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
38
|
+
const entry = entries[index];
|
|
39
|
+
if (entry?.type !== "message" || entry.message.role !== "assistant") continue;
|
|
40
|
+
return entry.message.usage;
|
|
41
|
+
}
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function cacheHitPercent(entries: readonly SessionEntry[]): number | undefined {
|
|
46
|
+
const usage = lastAssistantUsage(entries);
|
|
47
|
+
if (usage === undefined) return undefined;
|
|
48
|
+
const prompt = usage.input + usage.cacheRead + usage.cacheWrite;
|
|
49
|
+
return prompt === 0 ? undefined : (usage.cacheRead / prompt) * 100;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function cacheWarming(entries: readonly SessionEntry[]): boolean {
|
|
53
|
+
const usage = lastAssistantUsage(entries);
|
|
54
|
+
return usage !== undefined && usage.cacheRead === 0 && usage.cacheWrite > 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function averagesOf(entries: readonly SessionEntry[]): Averages {
|
|
58
|
+
let output = 0;
|
|
59
|
+
let generationMs = 0;
|
|
60
|
+
let prompt = 0;
|
|
61
|
+
let waitingMs = 0;
|
|
62
|
+
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
if (entry.type !== "custom") continue;
|
|
65
|
+
if (entry.customType !== REQUEST_CHANGES && entry.customType !== LEGACY_CHANGES) continue;
|
|
66
|
+
const record = entry.data;
|
|
67
|
+
if (!isRecord(record)) continue;
|
|
68
|
+
|
|
69
|
+
output += record.output;
|
|
70
|
+
generationMs += record.generationMs;
|
|
71
|
+
if (typeof record.prompt === "number" && record.prompt > 0) {
|
|
72
|
+
prompt += record.prompt;
|
|
73
|
+
waitingMs += record.firstTokenMs;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { decode: perSecond(output, generationMs), prefill: perSecond(prompt, waitingMs) };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function perSecond(tokens: number, milliseconds: number): number | undefined {
|
|
81
|
+
return tokens > 0 && milliseconds > 0 ? tokens / (milliseconds / 1000) : undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isRecord(
|
|
85
|
+
data: unknown,
|
|
86
|
+
): data is Omit<RequestRecord, "prompt"> & { prompt?: number | undefined } {
|
|
87
|
+
if (typeof data !== "object" || data === null) return false;
|
|
88
|
+
const record = data as Partial<RequestRecord>;
|
|
89
|
+
return (
|
|
90
|
+
typeof record.output === "number" &&
|
|
91
|
+
typeof record.firstTokenMs === "number" &&
|
|
92
|
+
typeof record.generationMs === "number"
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// The start screen says what changed since the last session in a folder, so it remembers when that was.
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { isObject } from "@adeildo/pi-kit";
|
|
6
|
+
|
|
7
|
+
import type { LastVisit } from "./start-notes.ts";
|
|
8
|
+
|
|
9
|
+
type Visits = Record<string, LastVisit>;
|
|
10
|
+
|
|
11
|
+
/** Records this visit too. */
|
|
12
|
+
export function visit(
|
|
13
|
+
file: string,
|
|
14
|
+
cwd: string,
|
|
15
|
+
version: string,
|
|
16
|
+
now: number,
|
|
17
|
+
): LastVisit | undefined {
|
|
18
|
+
const visits = read(file);
|
|
19
|
+
const last = visits[cwd];
|
|
20
|
+
visits[cwd] = { at: now, version };
|
|
21
|
+
// Two sessions starting together must not read each other's half-written file. It lists your folders.
|
|
22
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
23
|
+
try {
|
|
24
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
25
|
+
writeFileSync(temporary, JSON.stringify(visits), { mode: 0o600 });
|
|
26
|
+
renameSync(temporary, file);
|
|
27
|
+
} catch {
|
|
28
|
+
// A start screen that cannot remember only loses "since when".
|
|
29
|
+
rmSync(temporary, { force: true });
|
|
30
|
+
}
|
|
31
|
+
return isVisit(last) ? last : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isVisit(value: unknown): value is LastVisit {
|
|
35
|
+
return isObject(value) && Number.isFinite(value.at) && typeof value.version === "string";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function read(file: string): Visits {
|
|
39
|
+
try {
|
|
40
|
+
const value = JSON.parse(readFileSync(file, "utf8")) as unknown;
|
|
41
|
+
return isObject(value) ? (value as Visits) : {};
|
|
42
|
+
} catch {
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
}
|