pi-observability 1.0.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/LICENSE +21 -0
- package/README.md +78 -0
- package/extensions/observability.ts +482 -0
- package/package.json +41 -0
- package/tsconfig.json +14 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# 🔭 pi-observability
|
|
2
|
+
|
|
3
|
+
A [pi](https://github.com/mariozechner/pi) extension that replaces the default footer with a live observability bar and provides a full dashboard command.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Live footer bar** showing:
|
|
8
|
+
- Session input/output tokens & estimated cost
|
|
9
|
+
- Live TPS (tokens per second) during streaming
|
|
10
|
+
- Session runtime
|
|
11
|
+
- Current model & git branch
|
|
12
|
+
- Git diff stats (added/removed lines)
|
|
13
|
+
- Context usage (current / max)
|
|
14
|
+
|
|
15
|
+
- **`/obs` command** — Print a full observability dashboard with per-turn breakdowns and last 10 session history
|
|
16
|
+
|
|
17
|
+
- **`/obs-toggle` command** — Toggle the live footer on/off
|
|
18
|
+
|
|
19
|
+
## Preview
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
~/projects/my-app (main) +42 -7
|
|
23
|
+
⏱ 12:34 ctx 4.2k/200k ↑1.2k ↓3.4k $0.0042 ⚡ 45.2 tok/s claude-sonnet-4
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
### Via npm
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pi install npm:pi-observability
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Via git
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pi install git:github.com/imran-vz/pi-observability
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Manual
|
|
41
|
+
|
|
42
|
+
Copy `extensions/observability.ts` to `~/.pi/agent/extensions/observability.ts` (or `.pi/extensions/observability.ts` for project-local).
|
|
43
|
+
|
|
44
|
+
## Commands
|
|
45
|
+
|
|
46
|
+
| Command | Description |
|
|
47
|
+
|---------|-------------|
|
|
48
|
+
| `/obs` | Print full observability dashboard + last 10 sessions history |
|
|
49
|
+
| `/obs-toggle` | Toggle the observability footer on/off |
|
|
50
|
+
|
|
51
|
+
## Dashboard Output
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
╔══════════════════════════════════════════════════════════════╗
|
|
55
|
+
║ 🕵️ Agent Observability Dashboard ║
|
|
56
|
+
╠══════════════════════════════════════════════════════════════╣
|
|
57
|
+
║ Runtime: 12:34 ║
|
|
58
|
+
║ Dir: ~/projects/my-app ║
|
|
59
|
+
║ Branch: main ║
|
|
60
|
+
║ Model: claude-sonnet-4 ║
|
|
61
|
+
╠══════════════════════════════════════════════════════════════╣
|
|
62
|
+
║ Tokens: ↑1.2k ↓3.4k ║
|
|
63
|
+
║ Cost: $0.004200 ║
|
|
64
|
+
╠══════════════════════════════════════════════════════════════╣
|
|
65
|
+
║ Turns: ║
|
|
66
|
+
║ #1 ↑450 ↓1200 0:45 26.7/s $0.0015 claude-sonne ║
|
|
67
|
+
║ #2 ↑320 ↓900 0:32 28.1/s $0.0012 claude-sonne ║
|
|
68
|
+
╚══════════════════════════════════════════════════════════════╝
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Requirements
|
|
72
|
+
|
|
73
|
+
- [pi](https://github.com/mariozechner/pi) coding agent
|
|
74
|
+
- Git (for branch & diff stats)
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT
|
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Observability Extension
|
|
3
|
+
*
|
|
4
|
+
* Replaces the default footer with a live observability bar showing:
|
|
5
|
+
* - Session input/output tokens & cost
|
|
6
|
+
* - Live TPS during streaming (chunk-based estimate)
|
|
7
|
+
* - Session runtime
|
|
8
|
+
* - Current model & git branch
|
|
9
|
+
* - Git diff stats (added/removed lines)
|
|
10
|
+
* - Context usage (current/max)
|
|
11
|
+
*
|
|
12
|
+
* Commands:
|
|
13
|
+
* /obs - Print full observability dashboard + last 10 sessions
|
|
14
|
+
* /obs-toggle - Toggle the observability footer on/off
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
18
|
+
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
|
19
|
+
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
|
|
24
|
+
/* ───── Types ───── */
|
|
25
|
+
|
|
26
|
+
interface TurnRecord {
|
|
27
|
+
turnIndex: number;
|
|
28
|
+
inputTokens: number;
|
|
29
|
+
outputTokens: number;
|
|
30
|
+
cost: number;
|
|
31
|
+
durationMs: number;
|
|
32
|
+
tps: number;
|
|
33
|
+
model: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface PersistedTurn {
|
|
37
|
+
customType: "obs-turn";
|
|
38
|
+
data: TurnRecord;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface SessionState {
|
|
42
|
+
startTime: number;
|
|
43
|
+
turns: TurnRecord[];
|
|
44
|
+
currentTurnStartTime: number | null;
|
|
45
|
+
currentTurnUpdateCount: number;
|
|
46
|
+
isStreaming: boolean;
|
|
47
|
+
footerEnabled: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface SessionSummary {
|
|
51
|
+
endedAt: number;
|
|
52
|
+
runtimeMs: number;
|
|
53
|
+
turns: number;
|
|
54
|
+
inputTokens: number;
|
|
55
|
+
outputTokens: number;
|
|
56
|
+
cost: number;
|
|
57
|
+
model: string;
|
|
58
|
+
cwd: string;
|
|
59
|
+
branch: string | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* ───── Helpers ───── */
|
|
63
|
+
|
|
64
|
+
function fmtDuration(ms: number): string {
|
|
65
|
+
if (!Number.isFinite(ms) || ms < 0) ms = 0;
|
|
66
|
+
const s = Math.floor(ms / 1000);
|
|
67
|
+
const h = Math.floor(s / 3600);
|
|
68
|
+
const m = Math.floor((s % 3600) / 60);
|
|
69
|
+
const sec = s % 60;
|
|
70
|
+
if (h > 0) return `${h}:${m.toString().padStart(2, "0")}:${sec.toString().padStart(2, "0")}`;
|
|
71
|
+
return `${m}:${sec.toString().padStart(2, "0")}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function fmtTokens(n: number): string {
|
|
75
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
|
|
76
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
|
77
|
+
return `${n}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function shortenPath(p: string): string {
|
|
81
|
+
const home = homedir();
|
|
82
|
+
if (home && p.startsWith(home)) return p.replace(home, "~");
|
|
83
|
+
return p;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function scanHistoricalTurns(ctx: ExtensionContext): TurnRecord[] {
|
|
87
|
+
const turns: TurnRecord[] = [];
|
|
88
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
89
|
+
if (entry.type === "custom" && entry.customType === "obs-turn") {
|
|
90
|
+
turns.push((entry as unknown as PersistedTurn).data);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return turns;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function getSessionStartTime(ctx: ExtensionContext): number {
|
|
97
|
+
const entries = ctx.sessionManager.getBranch();
|
|
98
|
+
for (const e of entries) {
|
|
99
|
+
if (typeof e.timestamp === "number" && Number.isFinite(e.timestamp)) {
|
|
100
|
+
return e.timestamp;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return Date.now();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* ───── History persistence ───── */
|
|
107
|
+
|
|
108
|
+
const HISTORY_DIR = join(homedir(), ".pi", "agent", "observability");
|
|
109
|
+
const HISTORY_FILE = join(HISTORY_DIR, "history.jsonl");
|
|
110
|
+
|
|
111
|
+
async function loadHistory(): Promise<SessionSummary[]> {
|
|
112
|
+
try {
|
|
113
|
+
const text = await readFile(HISTORY_FILE, "utf8");
|
|
114
|
+
const lines = text.split("\n").filter((l) => l.trim());
|
|
115
|
+
return lines.map((l) => JSON.parse(l));
|
|
116
|
+
} catch {
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function saveHistory(sessions: SessionSummary[]): Promise<void> {
|
|
122
|
+
await mkdir(HISTORY_DIR, { recursive: true });
|
|
123
|
+
const text = sessions.map((s) => JSON.stringify(s)).join("\n") + "\n";
|
|
124
|
+
await writeFile(HISTORY_FILE, text, "utf8");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* ───── Dashboard formatting ───── */
|
|
128
|
+
|
|
129
|
+
const BOX_W = 64; // total outer width including ║ borders
|
|
130
|
+
const IN_W = BOX_W - 4; // inner width: "║ " + content + " ║"
|
|
131
|
+
|
|
132
|
+
function boxTop(): string {
|
|
133
|
+
return "╔" + "═".repeat(BOX_W - 2) + "╗";
|
|
134
|
+
}
|
|
135
|
+
function boxMid(): string {
|
|
136
|
+
return "╠" + "═".repeat(BOX_W - 2) + "╣";
|
|
137
|
+
}
|
|
138
|
+
function boxBot(): string {
|
|
139
|
+
return "╚" + "═".repeat(BOX_W - 2) + "╝";
|
|
140
|
+
}
|
|
141
|
+
function boxLine(text: string): string {
|
|
142
|
+
const visible = visibleWidth(text);
|
|
143
|
+
let pad = IN_W - visible;
|
|
144
|
+
if (pad < 0) pad = 0;
|
|
145
|
+
return "║ " + text + " ".repeat(pad) + " ║";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/* ───── Extension ───── */
|
|
149
|
+
|
|
150
|
+
export default function (pi: ExtensionAPI) {
|
|
151
|
+
const state: SessionState = {
|
|
152
|
+
startTime: Date.now(),
|
|
153
|
+
turns: [],
|
|
154
|
+
currentTurnStartTime: null,
|
|
155
|
+
currentTurnUpdateCount: 0,
|
|
156
|
+
isStreaming: false,
|
|
157
|
+
footerEnabled: true,
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/* ─── Lifecycle ─── */
|
|
161
|
+
|
|
162
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
163
|
+
state.startTime = getSessionStartTime(ctx);
|
|
164
|
+
state.turns = scanHistoricalTurns(ctx);
|
|
165
|
+
state.currentTurnStartTime = null;
|
|
166
|
+
state.currentTurnUpdateCount = 0;
|
|
167
|
+
state.isStreaming = false;
|
|
168
|
+
|
|
169
|
+
if (state.footerEnabled && ctx.hasUI) {
|
|
170
|
+
setupFooter(ctx);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
pi.on("turn_start", async (_event, _ctx) => {
|
|
175
|
+
state.currentTurnStartTime = Date.now();
|
|
176
|
+
state.currentTurnUpdateCount = 0;
|
|
177
|
+
state.isStreaming = true;
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
pi.on("message_update", async (_event, _ctx) => {
|
|
181
|
+
state.currentTurnUpdateCount++;
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
pi.on("turn_end", async (event, ctx) => {
|
|
185
|
+
const duration = state.currentTurnStartTime
|
|
186
|
+
? Date.now() - state.currentTurnStartTime
|
|
187
|
+
: 0;
|
|
188
|
+
|
|
189
|
+
let inputTokens = 0;
|
|
190
|
+
let outputTokens = 0;
|
|
191
|
+
let cost = 0;
|
|
192
|
+
|
|
193
|
+
const branch = ctx.sessionManager.getBranch();
|
|
194
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
195
|
+
const entry = branch[i];
|
|
196
|
+
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
197
|
+
const m = entry.message as AssistantMessage;
|
|
198
|
+
inputTokens = m.usage?.input ?? 0;
|
|
199
|
+
outputTokens = m.usage?.output ?? 0;
|
|
200
|
+
cost = m.usage?.cost?.total ?? 0;
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const tps = duration > 0 && outputTokens >= 0 ? outputTokens / (duration / 1000) : 0;
|
|
206
|
+
|
|
207
|
+
const record: TurnRecord = {
|
|
208
|
+
turnIndex: event.turnIndex,
|
|
209
|
+
inputTokens,
|
|
210
|
+
outputTokens,
|
|
211
|
+
cost,
|
|
212
|
+
durationMs: duration,
|
|
213
|
+
tps,
|
|
214
|
+
model: ctx.model?.id ?? "unknown",
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
state.turns.push(record);
|
|
218
|
+
state.isStreaming = false;
|
|
219
|
+
state.currentTurnStartTime = null;
|
|
220
|
+
state.currentTurnUpdateCount = 0;
|
|
221
|
+
|
|
222
|
+
pi.appendEntry("obs-turn", record);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
pi.on("agent_end", async (_event, _ctx) => {
|
|
226
|
+
state.isStreaming = false;
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
230
|
+
const totalIn = state.turns.reduce((s, t) => s + t.inputTokens, 0);
|
|
231
|
+
const totalOut = state.turns.reduce((s, t) => s + t.outputTokens, 0);
|
|
232
|
+
const totalCost = state.turns.reduce((s, t) => s + t.cost, 0);
|
|
233
|
+
const runtime = Date.now() - state.startTime;
|
|
234
|
+
|
|
235
|
+
let branch: string | null = null;
|
|
236
|
+
try {
|
|
237
|
+
const result = await pi.exec("git", ["branch", "--show-current"], {
|
|
238
|
+
cwd: ctx.cwd,
|
|
239
|
+
throwOnError: false,
|
|
240
|
+
});
|
|
241
|
+
branch = result.stdout?.trim() || null;
|
|
242
|
+
} catch {
|
|
243
|
+
branch = null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const summary: SessionSummary = {
|
|
247
|
+
endedAt: Date.now(),
|
|
248
|
+
runtimeMs: runtime,
|
|
249
|
+
turns: state.turns.length,
|
|
250
|
+
inputTokens: totalIn,
|
|
251
|
+
outputTokens: totalOut,
|
|
252
|
+
cost: totalCost,
|
|
253
|
+
model: ctx.model?.id ?? "unknown",
|
|
254
|
+
cwd: ctx.cwd,
|
|
255
|
+
branch,
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const history = await loadHistory();
|
|
259
|
+
history.push(summary);
|
|
260
|
+
if (history.length > 10) history.splice(0, history.length - 10);
|
|
261
|
+
await saveHistory(history);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
/* ─── Footer ─── */
|
|
265
|
+
|
|
266
|
+
function setupFooter(ctx: ExtensionContext) {
|
|
267
|
+
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
268
|
+
let diffAdded = 0;
|
|
269
|
+
let diffRemoved = 0;
|
|
270
|
+
|
|
271
|
+
async function refreshDiff() {
|
|
272
|
+
try {
|
|
273
|
+
const result = await pi.exec("git", ["diff", "--numstat"], {
|
|
274
|
+
cwd: ctx.cwd,
|
|
275
|
+
throwOnError: false,
|
|
276
|
+
});
|
|
277
|
+
if (result.code !== 0 || !result.stdout) {
|
|
278
|
+
diffAdded = 0;
|
|
279
|
+
diffRemoved = 0;
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
let added = 0;
|
|
283
|
+
let removed = 0;
|
|
284
|
+
for (const line of result.stdout.split("\n")) {
|
|
285
|
+
const parts = line.trim().split(/\s+/);
|
|
286
|
+
if (parts.length >= 2) {
|
|
287
|
+
const a = parseInt(parts[0], 10);
|
|
288
|
+
const b = parseInt(parts[1], 10);
|
|
289
|
+
if (!Number.isNaN(a)) added += a;
|
|
290
|
+
if (!Number.isNaN(b)) removed += b;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
diffAdded = added;
|
|
294
|
+
diffRemoved = removed;
|
|
295
|
+
} catch {
|
|
296
|
+
diffAdded = 0;
|
|
297
|
+
diffRemoved = 0;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
refreshDiff();
|
|
302
|
+
|
|
303
|
+
const unsubBranch = footerData.onBranchChange(() => {
|
|
304
|
+
refreshDiff();
|
|
305
|
+
tui.requestRender();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const timer = setInterval(() => {
|
|
309
|
+
refreshDiff();
|
|
310
|
+
tui.requestRender();
|
|
311
|
+
}, 1000);
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
dispose() {
|
|
315
|
+
unsubBranch();
|
|
316
|
+
clearInterval(timer);
|
|
317
|
+
},
|
|
318
|
+
invalidate() {},
|
|
319
|
+
render(width: number): string[] {
|
|
320
|
+
const totalIn = state.turns.reduce((s, t) => s + t.inputTokens, 0);
|
|
321
|
+
const totalOut = state.turns.reduce((s, t) => s + t.outputTokens, 0);
|
|
322
|
+
const totalCost = state.turns.reduce((s, t) => s + t.cost, 0);
|
|
323
|
+
let runtime = Date.now() - state.startTime;
|
|
324
|
+
if (!Number.isFinite(runtime) || runtime < 0) runtime = 0;
|
|
325
|
+
|
|
326
|
+
const branch = footerData.getGitBranch();
|
|
327
|
+
const model = ctx.model?.id ?? "no-model";
|
|
328
|
+
const cwd = shortenPath(ctx.cwd);
|
|
329
|
+
|
|
330
|
+
// ── Line 1: folder + branch + git diff stats ──
|
|
331
|
+
const branchPart = branch ? ` (${branch})` : "";
|
|
332
|
+
const diffPart =
|
|
333
|
+
diffAdded > 0 || diffRemoved > 0
|
|
334
|
+
? ` ${theme.fg("success", `+${diffAdded}`)} ${theme.fg("error", `-${diffRemoved}`)}`
|
|
335
|
+
: "";
|
|
336
|
+
const line1Raw = theme.fg("dim", `${cwd}${branchPart}`) + diffPart;
|
|
337
|
+
const line1 = truncateToWidth(line1Raw, width);
|
|
338
|
+
|
|
339
|
+
// ── Line 2: runtime, context, tokens, cost, TPS, model ──
|
|
340
|
+
const segRuntime = theme.fg("dim", `⏱ ${fmtDuration(runtime)}`);
|
|
341
|
+
|
|
342
|
+
const ctxUsage = ctx.getContextUsage();
|
|
343
|
+
const segCtx = ctxUsage
|
|
344
|
+
? theme.fg("dim", `ctx ${fmtTokens(ctxUsage.tokens)}/${fmtTokens(ctxUsage.contextWindow)}`)
|
|
345
|
+
: "";
|
|
346
|
+
|
|
347
|
+
const segTokens = theme.fg("dim", `↑${fmtTokens(totalIn)} ↓${fmtTokens(totalOut)}`);
|
|
348
|
+
const segCost = theme.fg("dim", `$${totalCost.toFixed(4)}`);
|
|
349
|
+
|
|
350
|
+
let segTps = "";
|
|
351
|
+
if (state.isStreaming && state.currentTurnStartTime) {
|
|
352
|
+
const elapsed = (Date.now() - state.currentTurnStartTime) / 1000;
|
|
353
|
+
const liveTps = elapsed > 0 ? state.currentTurnUpdateCount / elapsed : 0;
|
|
354
|
+
segTps = theme.fg("accent", `⚡ ${liveTps.toFixed(1)} tok/s`);
|
|
355
|
+
} else if (state.turns.length > 0) {
|
|
356
|
+
const last = state.turns[state.turns.length - 1];
|
|
357
|
+
segTps = theme.fg("accent", `⚡ ${last.tps.toFixed(1)} tok/s`);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const segModel = theme.fg("dim", model);
|
|
361
|
+
|
|
362
|
+
const leftRaw = [segRuntime, segCtx, segTokens, segCost, segTps].filter(Boolean).join(" ");
|
|
363
|
+
const leftW = visibleWidth(leftRaw);
|
|
364
|
+
const rightW = visibleWidth(segModel);
|
|
365
|
+
|
|
366
|
+
const gap = width - leftW - rightW;
|
|
367
|
+
let line2: string;
|
|
368
|
+
if (gap >= 1) {
|
|
369
|
+
line2 = leftRaw + " ".repeat(gap) + segModel;
|
|
370
|
+
} else {
|
|
371
|
+
line2 = leftRaw + " " + segModel;
|
|
372
|
+
}
|
|
373
|
+
line2 = truncateToWidth(line2, width);
|
|
374
|
+
|
|
375
|
+
return [line1, line2];
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function teardownFooter(ctx: ExtensionContext) {
|
|
382
|
+
ctx.ui.setFooter(undefined);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/* ─── Commands ─── */
|
|
386
|
+
|
|
387
|
+
pi.registerCommand("obs", {
|
|
388
|
+
description: "Show observability dashboard (tokens, cost, TPS, runtime, history)",
|
|
389
|
+
handler: async (_args, ctx) => {
|
|
390
|
+
const lines: string[] = [];
|
|
391
|
+
const runtime = Date.now() - state.startTime;
|
|
392
|
+
|
|
393
|
+
const branchResult = await pi.exec("git", ["branch", "--show-current"], {
|
|
394
|
+
cwd: ctx.cwd,
|
|
395
|
+
throwOnError: false,
|
|
396
|
+
});
|
|
397
|
+
const branch = branchResult.stdout?.trim() || null;
|
|
398
|
+
|
|
399
|
+
const totalIn = state.turns.reduce((s, t) => s + t.inputTokens, 0);
|
|
400
|
+
const totalOut = state.turns.reduce((s, t) => s + t.outputTokens, 0);
|
|
401
|
+
const totalCost = state.turns.reduce((s, t) => s + t.cost, 0);
|
|
402
|
+
|
|
403
|
+
// ── Current Session ──
|
|
404
|
+
lines.push("");
|
|
405
|
+
lines.push(boxTop());
|
|
406
|
+
lines.push(boxLine("🕵️ Agent Observability Dashboard"));
|
|
407
|
+
lines.push(boxMid());
|
|
408
|
+
lines.push(boxLine(`Runtime: ${fmtDuration(runtime)}`));
|
|
409
|
+
lines.push(boxLine(`Dir: ${shortenPath(ctx.cwd)}`));
|
|
410
|
+
if (branch) lines.push(boxLine(`Branch: ${branch}`));
|
|
411
|
+
lines.push(boxLine(`Model: ${ctx.model?.id ?? "none"}`));
|
|
412
|
+
lines.push(boxMid());
|
|
413
|
+
lines.push(boxLine(`Tokens: ↑${fmtTokens(totalIn)} ↓${fmtTokens(totalOut)}`));
|
|
414
|
+
lines.push(boxLine(`Cost: $${totalCost.toFixed(6)}`));
|
|
415
|
+
|
|
416
|
+
if (state.turns.length > 0) {
|
|
417
|
+
lines.push(boxMid());
|
|
418
|
+
lines.push(boxLine("Turns:"));
|
|
419
|
+
for (let i = 0; i < state.turns.length; i++) {
|
|
420
|
+
const t = state.turns[i];
|
|
421
|
+
const parts = [
|
|
422
|
+
`#${i + 1}`,
|
|
423
|
+
`↑${fmtTokens(t.inputTokens)}`,
|
|
424
|
+
`↓${fmtTokens(t.outputTokens)}`,
|
|
425
|
+
fmtDuration(t.durationMs),
|
|
426
|
+
`${t.tps.toFixed(1)}/s`,
|
|
427
|
+
`$${t.cost.toFixed(2)}`,
|
|
428
|
+
t.model.slice(0, 14),
|
|
429
|
+
];
|
|
430
|
+
lines.push(boxLine(parts.join(" ")));
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
lines.push(boxBot());
|
|
434
|
+
|
|
435
|
+
// ── History ──
|
|
436
|
+
const history = await loadHistory();
|
|
437
|
+
if (history.length > 0) {
|
|
438
|
+
lines.push("");
|
|
439
|
+
lines.push(boxTop());
|
|
440
|
+
lines.push(boxLine("📜 Last 10 Sessions"));
|
|
441
|
+
lines.push(boxMid());
|
|
442
|
+
for (const h of history.slice().reverse()) {
|
|
443
|
+
const date = new Date(h.endedAt).toLocaleDateString("en-US", {
|
|
444
|
+
month: "short",
|
|
445
|
+
day: "numeric",
|
|
446
|
+
hour: "2-digit",
|
|
447
|
+
minute: "2-digit",
|
|
448
|
+
});
|
|
449
|
+
const parts = [
|
|
450
|
+
date,
|
|
451
|
+
fmtDuration(h.runtimeMs),
|
|
452
|
+
`${h.turns}t`,
|
|
453
|
+
`↑${fmtTokens(h.inputTokens)}`,
|
|
454
|
+
`↓${fmtTokens(h.outputTokens)}`,
|
|
455
|
+
`$${h.cost.toFixed(2)}`,
|
|
456
|
+
h.model.slice(0, 10),
|
|
457
|
+
];
|
|
458
|
+
lines.push(boxLine(parts.join(" ")));
|
|
459
|
+
}
|
|
460
|
+
lines.push(boxBot());
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
lines.push("");
|
|
464
|
+
console.log(lines.join("\n"));
|
|
465
|
+
ctx.ui.notify("Observability dashboard printed to console", "info");
|
|
466
|
+
},
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
pi.registerCommand("obs-toggle", {
|
|
470
|
+
description: "Toggle the observability footer on/off",
|
|
471
|
+
handler: async (_args, ctx) => {
|
|
472
|
+
state.footerEnabled = !state.footerEnabled;
|
|
473
|
+
if (state.footerEnabled) {
|
|
474
|
+
setupFooter(ctx);
|
|
475
|
+
ctx.ui.notify("Observability footer enabled", "success");
|
|
476
|
+
} else {
|
|
477
|
+
teardownFooter(ctx);
|
|
478
|
+
ctx.ui.notify("Observability footer disabled", "info");
|
|
479
|
+
}
|
|
480
|
+
},
|
|
481
|
+
});
|
|
482
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-observability",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Live observability dashboard for pi coding agent sessions — tokens, cost, TPS, runtime, git stats, and context usage",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi-extension",
|
|
8
|
+
"observability",
|
|
9
|
+
"dashboard",
|
|
10
|
+
"tokens",
|
|
11
|
+
"cost-tracking",
|
|
12
|
+
"tps",
|
|
13
|
+
"cli"
|
|
14
|
+
],
|
|
15
|
+
"author": "",
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/imran-vz/pi-observability.git"
|
|
20
|
+
},
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/imran-vz/pi-observability/issues"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/imran-vz/pi-observability#readme",
|
|
25
|
+
"pi": {
|
|
26
|
+
"extensions": [
|
|
27
|
+
"./extensions/observability.ts"
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
32
|
+
"@mariozechner/pi-ai": "*",
|
|
33
|
+
"@mariozechner/pi-tui": "*"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@mariozechner/pi-coding-agent": "^0.1.0",
|
|
37
|
+
"@mariozechner/pi-ai": "^0.1.0",
|
|
38
|
+
"@mariozechner/pi-tui": "^0.1.0",
|
|
39
|
+
"typescript": "^5.4.0"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"forceConsistentCasingInFileNames": true,
|
|
10
|
+
"noEmit": true,
|
|
11
|
+
"types": ["node"]
|
|
12
|
+
},
|
|
13
|
+
"include": ["extensions/**/*.ts"]
|
|
14
|
+
}
|