opencode-top 3.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 +35 -0
- package/bin/octop.js +13 -0
- package/bin/octop.mjs +13 -0
- package/package.json +48 -0
- package/src/cli.ts +60 -0
- package/src/core/agents.ts +78 -0
- package/src/core/session.ts +315 -0
- package/src/core/types.ts +156 -0
- package/src/data/pricing.ts +82 -0
- package/src/data/sqlite.ts +347 -0
- package/src/index.ts +6 -0
- package/src/ui/App.tsx +141 -0
- package/src/ui/components/AgentChainGraph.tsx +95 -0
- package/src/ui/components/AgentTree.tsx +98 -0
- package/src/ui/components/DetailsPanel.tsx +210 -0
- package/src/ui/components/MessagesPanel.tsx +188 -0
- package/src/ui/components/SparkLine.tsx +18 -0
- package/src/ui/components/StatusBar.tsx +24 -0
- package/src/ui/components/TabBar.tsx +42 -0
- package/src/ui/screens/OverviewScreen.tsx +327 -0
- package/src/ui/screens/SessionsScreen.tsx +191 -0
- package/src/ui/screens/TimelineScreen.tsx +222 -0
- package/src/ui/screens/ToolsScreen.tsx +260 -0
- package/src/ui/theme.ts +21 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 OpenCode Monitor Contributors
|
|
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,35 @@
|
|
|
1
|
+
# OCMonitor
|
|
2
|
+
|
|
3
|
+
Monitor OpenCode AI coding sessions with a modern TUI.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install
|
|
9
|
+
npm run build
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm run start live # Live monitoring dashboard
|
|
16
|
+
npm run start sessions # List all sessions
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### Keyboard shortcuts (live mode)
|
|
20
|
+
|
|
21
|
+
- `j/k` or arrows: Navigate
|
|
22
|
+
- `e` or enter: Expand/collapse
|
|
23
|
+
- `h/l`: Resize panes
|
|
24
|
+
- `r`: Refresh
|
|
25
|
+
- `q`: Quit
|
|
26
|
+
|
|
27
|
+
## Structure
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
src/
|
|
31
|
+
├── core/ # Domain models & business logic
|
|
32
|
+
├── data/ # SQLite loader, pricing data
|
|
33
|
+
├── ui/ # Ink components (React TUI)
|
|
34
|
+
└── cli.ts # Entry point
|
|
35
|
+
```
|
package/bin/octop.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from "module";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
import { dirname, join } from "path";
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const pkgRoot = join(__dirname, "..");
|
|
8
|
+
|
|
9
|
+
// Use tsx to run the TypeScript source
|
|
10
|
+
const { register } = await import("tsx/esm/api");
|
|
11
|
+
register();
|
|
12
|
+
|
|
13
|
+
await import(join(pkgRoot, "src", "cli.ts"));
|
package/bin/octop.mjs
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from "module";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
import { dirname, join } from "path";
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const pkgRoot = join(__dirname, "..");
|
|
8
|
+
|
|
9
|
+
// Use tsx to run the TypeScript source
|
|
10
|
+
const { register } = await import("tsx/esm/api");
|
|
11
|
+
register();
|
|
12
|
+
|
|
13
|
+
await import(join(pkgRoot, "src", "cli.ts"));
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-top",
|
|
3
|
+
"version": "3.0.0",
|
|
4
|
+
"description": "Monitor OpenCode AI coding sessions - Token usage, costs, and agent analytics",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"opencode-top": "./bin/octop.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "node build.mjs",
|
|
11
|
+
"dev": "tsc --watch",
|
|
12
|
+
"start": "node --import tsx src/cli.ts",
|
|
13
|
+
"lint": "biome check src",
|
|
14
|
+
"format": "biome format src --write",
|
|
15
|
+
"test": "vitest",
|
|
16
|
+
"typecheck": "tsc --noEmit"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"better-sqlite3": "^11.7.0",
|
|
20
|
+
"commander": "^12.1.0",
|
|
21
|
+
"decimal.js": "^10.4.3",
|
|
22
|
+
"ink": "^5.0.1",
|
|
23
|
+
"ink-text-input": "^6.0.0",
|
|
24
|
+
"react": "^18.3.1",
|
|
25
|
+
"zod": "^3.24.1"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@biomejs/biome": "^1.9.4",
|
|
29
|
+
"@types/better-sqlite3": "^7.6.11",
|
|
30
|
+
"@types/node": "^22.10.5",
|
|
31
|
+
"@types/react": "^18.3.3",
|
|
32
|
+
"esbuild": "^0.27.4",
|
|
33
|
+
"tsx": "^4.19.2",
|
|
34
|
+
"typescript": "^5.7.2",
|
|
35
|
+
"vitest": "^2.1.8"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=20"
|
|
39
|
+
},
|
|
40
|
+
"keywords": [
|
|
41
|
+
"opencode",
|
|
42
|
+
"ai",
|
|
43
|
+
"monitoring",
|
|
44
|
+
"tui",
|
|
45
|
+
"cli"
|
|
46
|
+
],
|
|
47
|
+
"license": "MIT"
|
|
48
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { render } from "ink";
|
|
4
|
+
import { program } from "commander";
|
|
5
|
+
import { App } from "./ui/App";
|
|
6
|
+
import { loadSessions, getDbPath } from "./data/sqlite";
|
|
7
|
+
import { groupSessionsToWorkflows } from "./core/agents";
|
|
8
|
+
import { getWorkflowCostSingle, getSessionDuration } from "./core/session";
|
|
9
|
+
import { getPricing } from "./data/pricing";
|
|
10
|
+
|
|
11
|
+
const pkg = { version: "3.0.0", name: "ocmonitor" };
|
|
12
|
+
|
|
13
|
+
program
|
|
14
|
+
.name(pkg.name)
|
|
15
|
+
.version(pkg.version)
|
|
16
|
+
.description("Monitor OpenCode AI coding sessions");
|
|
17
|
+
|
|
18
|
+
program
|
|
19
|
+
.command("live")
|
|
20
|
+
.description("Start live monitoring dashboard")
|
|
21
|
+
.option("-i, --interval <ms>", "Refresh interval in milliseconds", "2000")
|
|
22
|
+
.action((options) => {
|
|
23
|
+
const refreshInterval = Number.parseInt(options.interval, 10);
|
|
24
|
+
render(React.createElement(App, { refreshInterval }));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
program
|
|
28
|
+
.command("sessions")
|
|
29
|
+
.description("List all sessions")
|
|
30
|
+
.option("-l, --limit <n>", "Limit number of sessions", "50")
|
|
31
|
+
.action((options) => {
|
|
32
|
+
const limit = Number.parseInt(options.limit, 10);
|
|
33
|
+
try {
|
|
34
|
+
const sessions = loadSessions(getDbPath());
|
|
35
|
+
const workflows = groupSessionsToWorkflows(sessions);
|
|
36
|
+
|
|
37
|
+
const shown = workflows.slice(0, limit);
|
|
38
|
+
console.log(`\nOCMonitor — ${shown.length}/${workflows.length} workflows\n`);
|
|
39
|
+
console.log(`${"Title".padEnd(40)} ${"Project".padEnd(20)} ${"Cost".padEnd(10)} Dur`);
|
|
40
|
+
console.log("─".repeat(80));
|
|
41
|
+
|
|
42
|
+
for (const workflow of shown) {
|
|
43
|
+
const { mainSession } = workflow;
|
|
44
|
+
const pricing = getPricing(mainSession.interactions[0]?.modelId ?? "");
|
|
45
|
+
const cost = getWorkflowCostSingle(workflow, pricing);
|
|
46
|
+
const dur = getSessionDuration(mainSession);
|
|
47
|
+
const title = (mainSession.title ?? "Untitled").slice(0, 38).padEnd(40);
|
|
48
|
+
const project = (mainSession.projectName ?? "—").slice(0, 18).padEnd(20);
|
|
49
|
+
const costStr = `$${cost.toFixed(3)}`.padEnd(10);
|
|
50
|
+
const durStr = dur > 0 ? `${Math.floor(dur / 60000)}m${Math.floor((dur % 60000) / 1000)}s` : "—";
|
|
51
|
+
console.log(`${title} ${project} ${costStr} ${durStr}`);
|
|
52
|
+
}
|
|
53
|
+
console.log();
|
|
54
|
+
} catch (e) {
|
|
55
|
+
console.error("Error:", e instanceof Error ? e.message : e);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
program.parse();
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { Session, Workflow, AgentNode } from "./types";
|
|
2
|
+
|
|
3
|
+
export class AgentRegistry {
|
|
4
|
+
private mainAgents = new Set(["plan", "build"]);
|
|
5
|
+
private subAgents = new Set(["explore"]);
|
|
6
|
+
|
|
7
|
+
isMainAgent(agent: string | null): boolean {
|
|
8
|
+
if (!agent) return true;
|
|
9
|
+
return this.mainAgents.has(agent);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
isSubAgent(agent: string | null): boolean {
|
|
13
|
+
if (!agent) return false;
|
|
14
|
+
return this.subAgents.has(agent);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
addMainAgent(agent: string): void {
|
|
18
|
+
this.mainAgents.add(agent);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
addSubAgent(agent: string): void {
|
|
22
|
+
this.subAgents.add(agent);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function buildAgentNode(
|
|
27
|
+
session: Session,
|
|
28
|
+
sessionMap: Map<string, Session>,
|
|
29
|
+
depth: number
|
|
30
|
+
): AgentNode {
|
|
31
|
+
// Find all direct children of this session
|
|
32
|
+
const children: AgentNode[] = [];
|
|
33
|
+
for (const [, s] of sessionMap) {
|
|
34
|
+
if (s.parentId === session.id) {
|
|
35
|
+
children.push(buildAgentNode(s, sessionMap, depth + 1));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Sort children by timeCreated
|
|
39
|
+
children.sort(
|
|
40
|
+
(a, b) => (a.session.timeCreated ?? 0) - (b.session.timeCreated ?? 0)
|
|
41
|
+
);
|
|
42
|
+
return { session, children, depth };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function collectAllDescendants(node: AgentNode): Session[] {
|
|
46
|
+
const result: Session[] = [];
|
|
47
|
+
for (const child of node.children) {
|
|
48
|
+
result.push(child.session);
|
|
49
|
+
result.push(...collectAllDescendants(child));
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function groupSessionsToWorkflows(
|
|
55
|
+
sessions: Session[],
|
|
56
|
+
_registry?: AgentRegistry
|
|
57
|
+
): Workflow[] {
|
|
58
|
+
const sessionMap = new Map<string, Session>(sessions.map((s) => [s.id, s]));
|
|
59
|
+
|
|
60
|
+
// Find root sessions (no parentId)
|
|
61
|
+
const roots = sessions.filter((s) => s.parentId === null);
|
|
62
|
+
|
|
63
|
+
const workflows: Workflow[] = [];
|
|
64
|
+
|
|
65
|
+
for (const root of roots) {
|
|
66
|
+
const agentTree = buildAgentNode(root, sessionMap, 0);
|
|
67
|
+
const subAgentSessions = collectAllDescendants(agentTree);
|
|
68
|
+
|
|
69
|
+
workflows.push({
|
|
70
|
+
id: root.id,
|
|
71
|
+
mainSession: root,
|
|
72
|
+
subAgentSessions,
|
|
73
|
+
agentTree,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return workflows;
|
|
78
|
+
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import Decimal from "decimal.js";
|
|
2
|
+
import { TokenUsage } from "./types";
|
|
3
|
+
import type { Session, Workflow, ToolUsage, ModelPricing, OverviewStats } from "./types";
|
|
4
|
+
import type { MessagePart } from "./types";
|
|
5
|
+
|
|
6
|
+
export function getSessionTokens(session: Session): TokenUsage {
|
|
7
|
+
return session.interactions.reduce((acc, i) => acc.add(i.tokens), new TokenUsage());
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function getSessionCost(session: Session, pricing: Map<string, ModelPricing>): Decimal {
|
|
11
|
+
return session.interactions.reduce((acc, i) => {
|
|
12
|
+
const p = pricing.get(i.modelId);
|
|
13
|
+
if (!p) return acc;
|
|
14
|
+
return acc.plus(i.tokens.calculateCost(p));
|
|
15
|
+
}, new Decimal(0));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getSessionCostSingle(session: Session, pricing: ModelPricing): Decimal {
|
|
19
|
+
return session.interactions.reduce((acc, i) => {
|
|
20
|
+
return acc.plus(i.tokens.calculateCost(pricing));
|
|
21
|
+
}, new Decimal(0));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getSessionDuration(session: Session): number {
|
|
25
|
+
const interactions = session.interactions;
|
|
26
|
+
if (interactions.length === 0) return 0;
|
|
27
|
+
|
|
28
|
+
// Use real time.completed when available
|
|
29
|
+
const times: number[] = [];
|
|
30
|
+
for (const i of interactions) {
|
|
31
|
+
if (i.time.created !== null) times.push(i.time.created);
|
|
32
|
+
if (i.time.completed !== null) times.push(i.time.completed);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (times.length === 0) return 0;
|
|
36
|
+
return Math.max(...times) - Math.min(...times);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getWorkflowTokens(workflow: Workflow): TokenUsage {
|
|
40
|
+
const main = getSessionTokens(workflow.mainSession);
|
|
41
|
+
const subs = workflow.subAgentSessions.reduce(
|
|
42
|
+
(acc, s) => acc.add(getSessionTokens(s)),
|
|
43
|
+
new TokenUsage()
|
|
44
|
+
);
|
|
45
|
+
return main.add(subs);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function getWorkflowCost(workflow: Workflow, pricing: Map<string, ModelPricing>): Decimal {
|
|
49
|
+
const main = getSessionCost(workflow.mainSession, pricing);
|
|
50
|
+
const subs = workflow.subAgentSessions.reduce(
|
|
51
|
+
(acc, s) => acc.plus(getSessionCost(s, pricing)),
|
|
52
|
+
new Decimal(0)
|
|
53
|
+
);
|
|
54
|
+
return main.plus(subs);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getWorkflowCostSingle(workflow: Workflow, pricing: ModelPricing): Decimal {
|
|
58
|
+
const main = getSessionCostSingle(workflow.mainSession, pricing);
|
|
59
|
+
const subs = workflow.subAgentSessions.reduce(
|
|
60
|
+
(acc, s) => acc.plus(getSessionCostSingle(s, pricing)),
|
|
61
|
+
new Decimal(0)
|
|
62
|
+
);
|
|
63
|
+
return main.plus(subs);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function getToolUsage(session: Session): ToolUsage[] {
|
|
67
|
+
const tools = new Map<
|
|
68
|
+
string,
|
|
69
|
+
{ calls: number; successes: number; failures: number; totalDurationMs: number; recentErrors: string[] }
|
|
70
|
+
>();
|
|
71
|
+
|
|
72
|
+
for (const interaction of session.interactions) {
|
|
73
|
+
for (const part of interaction.parts) {
|
|
74
|
+
if (part.type !== "tool") continue;
|
|
75
|
+
|
|
76
|
+
const existing = tools.get(part.toolName) ?? {
|
|
77
|
+
calls: 0,
|
|
78
|
+
successes: 0,
|
|
79
|
+
failures: 0,
|
|
80
|
+
totalDurationMs: 0,
|
|
81
|
+
recentErrors: [],
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
existing.calls++;
|
|
85
|
+
if (part.status === "completed") {
|
|
86
|
+
existing.successes++;
|
|
87
|
+
} else if (part.status === "error") {
|
|
88
|
+
existing.failures++;
|
|
89
|
+
// Keep last 3 errors
|
|
90
|
+
if (existing.recentErrors.length < 3) {
|
|
91
|
+
existing.recentErrors.push(part.output?.slice(0, 200) ?? "unknown error");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const durationMs = part.timeEnd > 0 && part.timeStart > 0 ? part.timeEnd - part.timeStart : 0;
|
|
96
|
+
existing.totalDurationMs += durationMs;
|
|
97
|
+
|
|
98
|
+
tools.set(part.toolName, existing);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return Array.from(tools.entries()).map(([name, stats]) => ({
|
|
103
|
+
name,
|
|
104
|
+
calls: stats.calls,
|
|
105
|
+
successes: stats.successes,
|
|
106
|
+
failures: stats.failures,
|
|
107
|
+
totalDurationMs: stats.totalDurationMs,
|
|
108
|
+
avgDurationMs: stats.calls > 0 ? stats.totalDurationMs / stats.calls : 0,
|
|
109
|
+
recentErrors: stats.recentErrors,
|
|
110
|
+
}));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function getWorkflowToolUsage(workflow: Workflow): ToolUsage[] {
|
|
114
|
+
const allSessions = [workflow.mainSession, ...workflow.subAgentSessions];
|
|
115
|
+
const merged = new Map<
|
|
116
|
+
string,
|
|
117
|
+
{ calls: number; successes: number; failures: number; totalDurationMs: number; recentErrors: string[] }
|
|
118
|
+
>();
|
|
119
|
+
|
|
120
|
+
for (const session of allSessions) {
|
|
121
|
+
const usage = getToolUsage(session);
|
|
122
|
+
for (const tool of usage) {
|
|
123
|
+
const existing = merged.get(tool.name) ?? {
|
|
124
|
+
calls: 0,
|
|
125
|
+
successes: 0,
|
|
126
|
+
failures: 0,
|
|
127
|
+
totalDurationMs: 0,
|
|
128
|
+
recentErrors: [],
|
|
129
|
+
};
|
|
130
|
+
existing.calls += tool.calls;
|
|
131
|
+
existing.successes += tool.successes;
|
|
132
|
+
existing.failures += tool.failures;
|
|
133
|
+
existing.totalDurationMs += tool.totalDurationMs;
|
|
134
|
+
for (const err of tool.recentErrors) {
|
|
135
|
+
if (existing.recentErrors.length < 3) existing.recentErrors.push(err);
|
|
136
|
+
}
|
|
137
|
+
merged.set(tool.name, existing);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return Array.from(merged.entries()).map(([name, stats]) => ({
|
|
142
|
+
name,
|
|
143
|
+
calls: stats.calls,
|
|
144
|
+
successes: stats.successes,
|
|
145
|
+
failures: stats.failures,
|
|
146
|
+
totalDurationMs: stats.totalDurationMs,
|
|
147
|
+
avgDurationMs: stats.calls > 0 ? stats.totalDurationMs / stats.calls : 0,
|
|
148
|
+
recentErrors: stats.recentErrors,
|
|
149
|
+
}));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function getOutputRate(session: Session): number {
|
|
153
|
+
const rates = session.interactions
|
|
154
|
+
.map((i) => i.outputRate)
|
|
155
|
+
.filter((r) => r > 0)
|
|
156
|
+
.sort((a, b) => a - b);
|
|
157
|
+
|
|
158
|
+
if (rates.length === 0) return 0;
|
|
159
|
+
|
|
160
|
+
const mid = Math.floor(rates.length / 2);
|
|
161
|
+
return rates.length % 2 !== 0 ? rates[mid] : (rates[mid - 1] + rates[mid]) / 2;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function computeOverviewStats(
|
|
165
|
+
workflows: Workflow[],
|
|
166
|
+
pricing: Map<string, ModelPricing>
|
|
167
|
+
): OverviewStats {
|
|
168
|
+
const totalTokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 };
|
|
169
|
+
let totalCost = new Decimal(0);
|
|
170
|
+
const modelBreakdown = new Map<string, { cost: Decimal; tokens: number; calls: number }>();
|
|
171
|
+
const projectBreakdown = new Map<string, { cost: Decimal; sessions: number }>();
|
|
172
|
+
const agentBreakdown = new Map<string, { cost: Decimal; calls: number }>();
|
|
173
|
+
const agentToolErrors = new Map<string, { calls: number; errors: number }>();
|
|
174
|
+
const toolCallCounts = new Map<string, { calls: number; errors: number; totalDurationMs: number }>();
|
|
175
|
+
const weeklyTokenMap = new Map<string, number>();
|
|
176
|
+
const weeklySessionMap = new Map<string, number>();
|
|
177
|
+
const hourlyActivity = new Array(24).fill(0);
|
|
178
|
+
|
|
179
|
+
const now = Date.now();
|
|
180
|
+
const sevenDaysAgo = now - 7 * 86_400_000;
|
|
181
|
+
|
|
182
|
+
for (const workflow of workflows) {
|
|
183
|
+
const allSessions = [workflow.mainSession, ...workflow.subAgentSessions];
|
|
184
|
+
|
|
185
|
+
for (const session of allSessions) {
|
|
186
|
+
const projName = session.projectName ?? "Unknown";
|
|
187
|
+
const projEntry = projectBreakdown.get(projName) ?? { cost: new Decimal(0), sessions: 0 };
|
|
188
|
+
projEntry.sessions++;
|
|
189
|
+
|
|
190
|
+
// Weekly session count
|
|
191
|
+
const sessionTs = session.timeCreated;
|
|
192
|
+
if (sessionTs && sessionTs >= sevenDaysAgo) {
|
|
193
|
+
const day = new Date(sessionTs).toISOString().slice(5, 10); // MM-DD
|
|
194
|
+
weeklySessionMap.set(day, (weeklySessionMap.get(day) ?? 0) + 1);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
for (const interaction of session.interactions) {
|
|
198
|
+
const p = pricing.get(interaction.modelId);
|
|
199
|
+
const cost = p ? interaction.tokens.calculateCost(p) : new Decimal(0);
|
|
200
|
+
|
|
201
|
+
totalCost = totalCost.plus(cost);
|
|
202
|
+
projEntry.cost = projEntry.cost.plus(cost);
|
|
203
|
+
|
|
204
|
+
totalTokens.input += interaction.tokens.input;
|
|
205
|
+
totalTokens.output += interaction.tokens.output;
|
|
206
|
+
totalTokens.cacheRead += interaction.tokens.cacheRead;
|
|
207
|
+
totalTokens.cacheWrite += interaction.tokens.cacheWrite;
|
|
208
|
+
totalTokens.reasoning += interaction.tokens.reasoning;
|
|
209
|
+
|
|
210
|
+
// Model breakdown
|
|
211
|
+
const modelEntry = modelBreakdown.get(interaction.modelId) ?? {
|
|
212
|
+
cost: new Decimal(0),
|
|
213
|
+
tokens: 0,
|
|
214
|
+
calls: 0,
|
|
215
|
+
};
|
|
216
|
+
modelEntry.cost = modelEntry.cost.plus(cost);
|
|
217
|
+
modelEntry.tokens += interaction.tokens.total;
|
|
218
|
+
modelEntry.calls++;
|
|
219
|
+
modelBreakdown.set(interaction.modelId, modelEntry);
|
|
220
|
+
|
|
221
|
+
// Agent breakdown
|
|
222
|
+
const agentKey = interaction.agent ?? "main";
|
|
223
|
+
const agentEntry = agentBreakdown.get(agentKey) ?? { cost: new Decimal(0), calls: 0 };
|
|
224
|
+
agentEntry.cost = agentEntry.cost.plus(cost);
|
|
225
|
+
agentEntry.calls++;
|
|
226
|
+
agentBreakdown.set(agentKey, agentEntry);
|
|
227
|
+
|
|
228
|
+
// Hourly activity
|
|
229
|
+
const ts = interaction.time.created;
|
|
230
|
+
if (ts) {
|
|
231
|
+
hourlyActivity[new Date(ts).getHours()]++;
|
|
232
|
+
|
|
233
|
+
// Weekly token trend
|
|
234
|
+
if (ts >= sevenDaysAgo) {
|
|
235
|
+
const day = new Date(ts).toISOString().slice(5, 10);
|
|
236
|
+
weeklyTokenMap.set(day, (weeklyTokenMap.get(day) ?? 0) + interaction.tokens.total);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Tool stats: per-tool call counts and agent tool errors
|
|
241
|
+
for (const part of interaction.parts) {
|
|
242
|
+
if (part.type !== "tool") continue;
|
|
243
|
+
|
|
244
|
+
const isError = part.status === "error";
|
|
245
|
+
const dur = part.timeEnd > 0 && part.timeStart > 0 ? part.timeEnd - part.timeStart : 0;
|
|
246
|
+
|
|
247
|
+
// Tool call counts
|
|
248
|
+
const toolEntry = toolCallCounts.get(part.toolName) ?? { calls: 0, errors: 0, totalDurationMs: 0 };
|
|
249
|
+
toolEntry.calls++;
|
|
250
|
+
if (isError) toolEntry.errors++;
|
|
251
|
+
toolEntry.totalDurationMs += dur;
|
|
252
|
+
toolCallCounts.set(part.toolName, toolEntry);
|
|
253
|
+
|
|
254
|
+
// Agent tool errors
|
|
255
|
+
const agentToolEntry = agentToolErrors.get(agentKey) ?? { calls: 0, errors: 0 };
|
|
256
|
+
agentToolEntry.calls++;
|
|
257
|
+
if (isError) agentToolEntry.errors++;
|
|
258
|
+
agentToolErrors.set(agentKey, agentToolEntry);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
projectBreakdown.set(projName, projEntry);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Build 7-day arrays (last 7 days, MM-DD labels)
|
|
267
|
+
const today = new Date();
|
|
268
|
+
const weeklyTokens: { date: string; tokens: number }[] = [];
|
|
269
|
+
const weeklySessions: { date: string; sessions: number }[] = [];
|
|
270
|
+
for (let i = 6; i >= 0; i--) {
|
|
271
|
+
const d = new Date(today);
|
|
272
|
+
d.setDate(d.getDate() - i);
|
|
273
|
+
const day = d.toISOString().slice(5, 10);
|
|
274
|
+
weeklyTokens.push({ date: day, tokens: weeklyTokenMap.get(day) ?? 0 });
|
|
275
|
+
weeklySessions.push({ date: day, sessions: weeklySessionMap.get(day) ?? 0 });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
totalCost,
|
|
280
|
+
totalTokens: new TokenUsage(
|
|
281
|
+
totalTokens.input,
|
|
282
|
+
totalTokens.output,
|
|
283
|
+
totalTokens.cacheRead,
|
|
284
|
+
totalTokens.cacheWrite,
|
|
285
|
+
totalTokens.reasoning
|
|
286
|
+
),
|
|
287
|
+
modelBreakdown,
|
|
288
|
+
projectBreakdown,
|
|
289
|
+
agentBreakdown,
|
|
290
|
+
agentToolErrors,
|
|
291
|
+
toolCallCounts,
|
|
292
|
+
weeklyTokens,
|
|
293
|
+
weeklySessions,
|
|
294
|
+
hourlyActivity,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Build spark series (8 levels) from numeric array */
|
|
299
|
+
export function buildSparkSeries(values: number[]): string {
|
|
300
|
+
if (values.length === 0) return "";
|
|
301
|
+
const max = Math.max(...values);
|
|
302
|
+
if (max === 0) return "▁".repeat(values.length);
|
|
303
|
+
const chars = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
|
|
304
|
+
return values
|
|
305
|
+
.map((v) => {
|
|
306
|
+
const idx = Math.min(7, Math.floor((v / max) * 8));
|
|
307
|
+
return chars[idx];
|
|
308
|
+
})
|
|
309
|
+
.join("");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** All parts across all interactions in a session */
|
|
313
|
+
export function getAllParts(session: Session): MessagePart[] {
|
|
314
|
+
return session.interactions.flatMap((i) => i.parts);
|
|
315
|
+
}
|