agent-profiler 1.0.0 → 1.0.1
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/README.md +33 -3
- package/assets/dashboard.png +0 -0
- package/dist/adapters/cursor.js +5 -1
- package/dist/cli.js +23 -0
- package/dist/commands/dashboard.js +17 -0
- package/dist/commands/init.js +26 -0
- package/dist/commands/last.js +3 -191
- package/dist/core/dashboardServer.js +294 -0
- package/dist/core/db.js +164 -54
- package/dist/core/eventMetadata.js +7 -2
- package/dist/core/gitWorkspace.js +16 -4
- package/dist/core/sessionAnalytics.js +204 -0
- package/dist/dashboard/app.js +329 -0
- package/dist/dashboard/index.html +89 -0
- package/dist/dashboard/styles.css +389 -0
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -1,8 +1,32 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Agent Profiler
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+

|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
**Agent Profiler** is a **local-first** tool for understanding how AI coding agents spend observable effort in your workspace. It records Cursor and Codex hook traffic into **SQLite** so you can review session shape, estimated token usage, tool and shell noise, always-on context weight, and simple efficiency signals—**without sending telemetry to a remote service.**
|
|
6
|
+
|
|
7
|
+
Today the workflow is intentionally scoped **project-by-project**: you run commands from a repo root, store profiler config and data under **`.agent-profiler/`** beside that project, and inspect sessions with the CLI or the optional dashboard. **A future direction** is to offer a clearer opt-in path to operate primarily from the **home-directory** profile (for example global installs and multi-repo DB layout) when users want that; the current release optimizes for **per-repo isolation** and predictable paths.
|
|
8
|
+
|
|
9
|
+
On **`agent-profiler init`** in **dev** mode, when the profiler directory resolves to **`<project>/.agent-profiler`**, the tool **appends a `.gitignore` rule** so the local store (including SQLite and synced dashboard assets) is **not committed** by mistake.
|
|
10
|
+
|
|
11
|
+
## Dashboard (early version)
|
|
12
|
+
|
|
13
|
+
Run a minimal **localhost-only** web UI over the same database and context audit used by `last`:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
agent-profiler dashboard
|
|
17
|
+
# open http://127.0.0.1:3737/ — use Refresh to reload metrics
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The dashboard (**early version**) surfaces:
|
|
21
|
+
|
|
22
|
+
- **Observable usage** — estimated input, output, tool/MCP result, and shell output tokens with simple bar proportions
|
|
23
|
+
- **Efficiency score** — heuristic score with a small sparkline over recent keyed sessions (when present)
|
|
24
|
+
- **Session timeline** — chronological strip colored by role (user, assistant, tool, shell, other)
|
|
25
|
+
- **Tool result sizes** — histogram buckets for large tool payloads
|
|
26
|
+
- **Context audit** — estimated tokens for common always-on instruction paths in the repo
|
|
27
|
+
- **Red flags and recommendations** — aligned with the `last` command logic
|
|
28
|
+
|
|
29
|
+
Static assets are copied into **`.agent-profiler/dashboard/`** when the server starts so the UI stays next to your project data. Expect **rough edges and UI iteration** in future releases.
|
|
6
30
|
|
|
7
31
|
## Requirements
|
|
8
32
|
|
|
@@ -47,6 +71,11 @@ agent-profiler last
|
|
|
47
71
|
agent-profiler audit context
|
|
48
72
|
```
|
|
49
73
|
|
|
74
|
+
### Hook approval and restarts
|
|
75
|
+
|
|
76
|
+
- **Codex**: Hooks must be **approved** before they run—confirm when prompted or enable them in the **Codex plugin settings**.
|
|
77
|
+
- **Cursor**: **Restart Cursor** after `init` so hook configuration reliably takes effect.
|
|
78
|
+
|
|
50
79
|
## `npx` vs `--mode prod`
|
|
51
80
|
|
|
52
81
|
- `npx agent-profiler ...` is great for one-off inspection commands.
|
|
@@ -59,6 +88,7 @@ agent-profiler audit context
|
|
|
59
88
|
- `agent-profiler hook <source> <eventName>`: ingest one hook payload from stdin
|
|
60
89
|
- `agent-profiler status`: inspect local setup and ingest state
|
|
61
90
|
- `agent-profiler last`: summarize the most recent observed session
|
|
91
|
+
- `agent-profiler dashboard`: serve the local dashboard (SQLite + context audit)
|
|
62
92
|
- `agent-profiler audit context`: estimate always-on context token footprint
|
|
63
93
|
|
|
64
94
|
## Releases
|
|
Binary file
|
package/dist/adapters/cursor.js
CHANGED
|
@@ -62,7 +62,11 @@ function extractObservableText(payload) {
|
|
|
62
62
|
const toolResponse = stringifyJsonish(payload.tool_response ?? payload.toolResponse ?? payload.result);
|
|
63
63
|
if (toolResponse)
|
|
64
64
|
return toolResponse;
|
|
65
|
-
const toolInput = stringifyJsonish(payload.tool_input ??
|
|
65
|
+
const toolInput = stringifyJsonish(payload.tool_input ??
|
|
66
|
+
payload.toolInput ??
|
|
67
|
+
payload.input ??
|
|
68
|
+
payload.args ??
|
|
69
|
+
payload.arguments);
|
|
66
70
|
if (toolInput)
|
|
67
71
|
return toolInput;
|
|
68
72
|
return JSON.stringify(payload);
|
package/dist/cli.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { Command } from "commander";
|
|
3
3
|
import { getAuditContextReport, runAuditContext, } from "./commands/auditContext.js";
|
|
4
4
|
import { runHook } from "./commands/hook.js";
|
|
5
|
+
import { runDashboard } from "./commands/dashboard.js";
|
|
5
6
|
import { runInit } from "./commands/init.js";
|
|
6
7
|
import { getLastReport, runLast } from "./commands/last.js";
|
|
7
8
|
import { getStatusReport, runStatus } from "./commands/status.js";
|
|
@@ -75,6 +76,28 @@ program
|
|
|
75
76
|
}
|
|
76
77
|
runStatus(options.mode);
|
|
77
78
|
});
|
|
79
|
+
program
|
|
80
|
+
.command("dashboard")
|
|
81
|
+
.description("Serve a local dashboard (SQLite + context audit). Copies UI into .agent-profiler/dashboard/.")
|
|
82
|
+
.option("--host <host>", "Bind address", "127.0.0.1")
|
|
83
|
+
.option("--port <port>", "HTTP port", "3737")
|
|
84
|
+
.action((opts) => {
|
|
85
|
+
const port = parseInt(opts.port ?? "3737", 10);
|
|
86
|
+
if (!Number.isFinite(port) || port < 1 || port > 65535) {
|
|
87
|
+
console.error(`Invalid port: ${opts.port}`);
|
|
88
|
+
process.exitCode = 1;
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const host = opts.host ?? "127.0.0.1";
|
|
92
|
+
try {
|
|
93
|
+
runDashboard({ host, port });
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
console.error("Failed to start dashboard.");
|
|
97
|
+
console.error(error);
|
|
98
|
+
process.exitCode = 1;
|
|
99
|
+
}
|
|
100
|
+
});
|
|
78
101
|
program
|
|
79
102
|
.command("last")
|
|
80
103
|
.description("Generate a report for the most recent observed session.")
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createDashboardServer, listenDashboardServer, resolvePackagedDashboardDir, resolveProjectDashboardDir, syncDashboardAssets, } from "../core/dashboardServer.js";
|
|
2
|
+
export function runDashboard(options) {
|
|
3
|
+
const cwd = process.cwd();
|
|
4
|
+
const packaged = resolvePackagedDashboardDir();
|
|
5
|
+
const target = resolveProjectDashboardDir(cwd);
|
|
6
|
+
syncDashboardAssets(packaged, target);
|
|
7
|
+
const server = createDashboardServer({
|
|
8
|
+
host: options.host,
|
|
9
|
+
port: options.port,
|
|
10
|
+
staticRoot: target,
|
|
11
|
+
cwd,
|
|
12
|
+
});
|
|
13
|
+
void listenDashboardServer(server, options.host, options.port).then(({ url }) => {
|
|
14
|
+
console.log(`Agent Profiler dashboard: ${url}`);
|
|
15
|
+
console.log("Press Ctrl+C to stop.");
|
|
16
|
+
});
|
|
17
|
+
}
|
package/dist/commands/init.js
CHANGED
|
@@ -3,6 +3,26 @@ import path from "node:path";
|
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { openDb, resolveUsableDbPath } from "../core/db.js";
|
|
5
5
|
import { getHomeProfileDir, getLocalProfileDir } from "../core/profile.js";
|
|
6
|
+
const GITIGNORE_MARKER = "# Agent Profiler local store";
|
|
7
|
+
const GITIGNORE_ENTRY = ".agent-profiler/";
|
|
8
|
+
function ensureProfilerGitignore(projectRoot) {
|
|
9
|
+
const gitignorePath = path.join(projectRoot, ".gitignore");
|
|
10
|
+
const block = `${GITIGNORE_MARKER}\n${GITIGNORE_ENTRY}\n`;
|
|
11
|
+
let existing = "";
|
|
12
|
+
try {
|
|
13
|
+
existing = fs.readFileSync(gitignorePath, "utf8");
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
fs.writeFileSync(gitignorePath, block, "utf8");
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const lines = existing.split(/\r?\n/);
|
|
20
|
+
const hasEntry = lines.some((line) => line.trim() === GITIGNORE_ENTRY || line.trim() === ".agent-profiler");
|
|
21
|
+
if (hasEntry)
|
|
22
|
+
return;
|
|
23
|
+
const suffix = existing.endsWith("\n") || existing.length === 0 ? "" : "\n";
|
|
24
|
+
fs.appendFileSync(gitignorePath, `${suffix}\n${block}`, "utf8");
|
|
25
|
+
}
|
|
6
26
|
const CURSOR_EVENTS = [
|
|
7
27
|
"beforeSubmitPrompt",
|
|
8
28
|
"afterAgentResponse",
|
|
@@ -190,6 +210,12 @@ export function runInit(source, mode = "dev") {
|
|
|
190
210
|
const resolvedDbPath = resolveUsableDbPath(configuredDbPath);
|
|
191
211
|
const db = openDb(resolvedDbPath);
|
|
192
212
|
db.close();
|
|
213
|
+
if (mode === "dev") {
|
|
214
|
+
const localProfiler = path.resolve(getLocalProfileDir(process.cwd()));
|
|
215
|
+
if (path.resolve(profilerDir) === localProfiler) {
|
|
216
|
+
ensureProfilerGitignore(process.cwd());
|
|
217
|
+
}
|
|
218
|
+
}
|
|
193
219
|
let hooksPath = "";
|
|
194
220
|
let configPath = path.join(profilerDir, "config.json");
|
|
195
221
|
let usedFallbackConfigPath = false;
|
package/dist/commands/last.js
CHANGED
|
@@ -1,201 +1,13 @@
|
|
|
1
|
-
import { getDefaultDbPath, getEventsForLatestSession, openDb } from "../core/db.js";
|
|
2
|
-
import {
|
|
3
|
-
function parsePayload(rawPayload) {
|
|
4
|
-
try {
|
|
5
|
-
const parsed = JSON.parse(rawPayload);
|
|
6
|
-
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
7
|
-
return parsed;
|
|
8
|
-
}
|
|
9
|
-
}
|
|
10
|
-
catch {
|
|
11
|
-
// ignore malformed payloads
|
|
12
|
-
}
|
|
13
|
-
return {};
|
|
14
|
-
}
|
|
15
|
-
function formatTokens(value) {
|
|
16
|
-
return `~${new Intl.NumberFormat("en-US").format(value)} tokens`;
|
|
17
|
-
}
|
|
1
|
+
import { getDefaultDbPath, getEventsForLatestSession, openDb, } from "../core/db.js";
|
|
2
|
+
import { analyzeSession, formatTokens, LARGEST_EVENTS_LIMIT, } from "../core/sessionAnalytics.js";
|
|
18
3
|
function formatDuration(mins) {
|
|
19
4
|
return mins < 1 ? "<1 minute" : `${mins} minute${mins === 1 ? "" : "s"}`;
|
|
20
5
|
}
|
|
21
|
-
function normalizeSnippet(value) {
|
|
22
|
-
return value.toLowerCase().replace(/\s+/g, " ").trim().slice(0, 160);
|
|
23
|
-
}
|
|
24
|
-
const LARGEST_EVENTS_LIMIT = 10;
|
|
25
6
|
export function getLastReport() {
|
|
26
7
|
const db = openDb(getDefaultDbPath());
|
|
27
8
|
const events = getEventsForLatestSession(db);
|
|
28
9
|
db.close();
|
|
29
|
-
|
|
30
|
-
return null;
|
|
31
|
-
}
|
|
32
|
-
const first = events[0];
|
|
33
|
-
const last = events[events.length - 1];
|
|
34
|
-
const start = new Date(first.createdAt).getTime();
|
|
35
|
-
const end = new Date(last.createdAt).getTime();
|
|
36
|
-
const durationMinutes = Math.max(0, Math.round((end - start) / 60000));
|
|
37
|
-
let input = 0;
|
|
38
|
-
let output = 0;
|
|
39
|
-
let total = 0;
|
|
40
|
-
let shellOutput = 0;
|
|
41
|
-
let toolResults = 0;
|
|
42
|
-
const turnIds = new Set();
|
|
43
|
-
let fileEdits = 0;
|
|
44
|
-
let shellCalls = 0;
|
|
45
|
-
let toolCalls = 0;
|
|
46
|
-
const fileEditCounts = new Map();
|
|
47
|
-
const redFlags = [];
|
|
48
|
-
let recommendations = [];
|
|
49
|
-
const shellFailureBuckets = new Map();
|
|
50
|
-
for (const event of events) {
|
|
51
|
-
input += event.estimatedInputTokens;
|
|
52
|
-
output += event.estimatedOutputTokens;
|
|
53
|
-
total += event.estimatedTotalTokens;
|
|
54
|
-
if (event.turnId)
|
|
55
|
-
turnIds.add(event.turnId);
|
|
56
|
-
if (event.role === "file_edit") {
|
|
57
|
-
fileEdits += 1;
|
|
58
|
-
const payload = parsePayload(event.rawPayload);
|
|
59
|
-
const file = (payload.filePath ?? payload.path ?? payload.relativePath);
|
|
60
|
-
if (file) {
|
|
61
|
-
fileEditCounts.set(file, (fileEditCounts.get(file) ?? 0) + 1);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
if (event.role === "shell_command")
|
|
65
|
-
shellCalls += 1;
|
|
66
|
-
if (event.role === "shell_output") {
|
|
67
|
-
shellCalls += 1;
|
|
68
|
-
shellOutput += event.estimatedTotalTokens;
|
|
69
|
-
const payload = parsePayload(event.rawPayload);
|
|
70
|
-
const command = typeof payload.command === "string" ? payload.command : null;
|
|
71
|
-
const stderr = typeof payload.stderr === "string" ? payload.stderr : "";
|
|
72
|
-
const stdout = typeof payload.stdout === "string" ? payload.stdout : "";
|
|
73
|
-
const outputSnippet = normalizeSnippet(stderr || stdout);
|
|
74
|
-
const looksFailed = outputSnippet.includes("error") ||
|
|
75
|
-
outputSnippet.includes("failed") ||
|
|
76
|
-
outputSnippet.includes("exception") ||
|
|
77
|
-
outputSnippet.includes("traceback");
|
|
78
|
-
if (command && looksFailed) {
|
|
79
|
-
const key = `${normalizeSnippet(command)}|${outputSnippet}`;
|
|
80
|
-
const prev = shellFailureBuckets.get(key) ?? { runs: 0, tokenTotal: 0 };
|
|
81
|
-
shellFailureBuckets.set(key, {
|
|
82
|
-
runs: prev.runs + 1,
|
|
83
|
-
tokenTotal: prev.tokenTotal + event.estimatedTotalTokens,
|
|
84
|
-
});
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
if (event.role === "tool_call")
|
|
88
|
-
toolCalls += 1;
|
|
89
|
-
if (event.role === "tool_result" || event.role === "tool_failure")
|
|
90
|
-
toolResults += event.estimatedTotalTokens;
|
|
91
|
-
}
|
|
92
|
-
let score = 100;
|
|
93
|
-
const topChurn = [...fileEditCounts.entries()].sort((a, b) => b[1] - a[1])[0];
|
|
94
|
-
if (topChurn && topChurn[1] >= 5) {
|
|
95
|
-
redFlags.push({
|
|
96
|
-
severity: "HIGH",
|
|
97
|
-
title: "same-file churn",
|
|
98
|
-
detail: `${topChurn[0]} was edited ${topChurn[1]} times.`,
|
|
99
|
-
recommendation: "Add a focused repo rule or skill note for this file's recurring failure pattern.",
|
|
100
|
-
penalty: 15,
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
if (shellOutput > 4000) {
|
|
104
|
-
redFlags.push({
|
|
105
|
-
severity: "HIGH",
|
|
106
|
-
title: "shell output noise",
|
|
107
|
-
detail: `Shell output produced ${formatTokens(shellOutput)} in this session.`,
|
|
108
|
-
recommendation: "Capture key test/build failures once, then summarize repeated output.",
|
|
109
|
-
penalty: 12,
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
|
-
if (toolResults > 4000) {
|
|
113
|
-
redFlags.push({
|
|
114
|
-
severity: "MEDIUM",
|
|
115
|
-
title: "large tool result",
|
|
116
|
-
detail: `Tool results produced ${formatTokens(toolResults)} in this session.`,
|
|
117
|
-
recommendation: "Request narrower tool queries and summarize oversized tool responses.",
|
|
118
|
-
penalty: 10,
|
|
119
|
-
});
|
|
120
|
-
}
|
|
121
|
-
const worstFailureLoop = [...shellFailureBuckets.entries()].sort((a, b) => b[1].runs - a[1].runs || b[1].tokenTotal - a[1].tokenTotal)[0];
|
|
122
|
-
if (worstFailureLoop && worstFailureLoop[1].runs >= 3) {
|
|
123
|
-
redFlags.push({
|
|
124
|
-
severity: "HIGH",
|
|
125
|
-
title: "thrashing loop",
|
|
126
|
-
detail: `A similar failing shell command looped ${worstFailureLoop[1].runs} times (${formatTokens(worstFailureLoop[1].tokenTotal)}).`,
|
|
127
|
-
recommendation: "Pause after repeated failures, capture one root error, then adjust strategy.",
|
|
128
|
-
penalty: 14,
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
const largestPrompt = events
|
|
132
|
-
.filter((e) => e.role === "user_prompt")
|
|
133
|
-
.reduce((max, cur) => Math.max(max, cur.estimatedTotalTokens), 0);
|
|
134
|
-
if (largestPrompt > 8000) {
|
|
135
|
-
redFlags.push({
|
|
136
|
-
severity: "MEDIUM",
|
|
137
|
-
title: "oversized prompt",
|
|
138
|
-
detail: `Largest prompt was ${formatTokens(largestPrompt)}.`,
|
|
139
|
-
recommendation: "Split goals into smaller requests and load reference docs on demand.",
|
|
140
|
-
penalty: 8,
|
|
141
|
-
});
|
|
142
|
-
}
|
|
143
|
-
if (total > 12000 && fileEdits === 0) {
|
|
144
|
-
redFlags.push({
|
|
145
|
-
severity: "MEDIUM",
|
|
146
|
-
title: "low-signal session",
|
|
147
|
-
detail: `High observable usage (${formatTokens(total)}) with no file edits.`,
|
|
148
|
-
recommendation: "Push for earlier implementation checkpoints instead of extended analysis.",
|
|
149
|
-
penalty: 10,
|
|
150
|
-
});
|
|
151
|
-
}
|
|
152
|
-
const contextAudit = runContextAudit(process.cwd());
|
|
153
|
-
if (contextAudit.totalEstimatedTokens > 6000) {
|
|
154
|
-
redFlags.push({
|
|
155
|
-
severity: "MEDIUM",
|
|
156
|
-
title: "context bloat",
|
|
157
|
-
detail: `Always-on instruction files estimate ${formatTokens(contextAudit.totalEstimatedTokens)}.`,
|
|
158
|
-
recommendation: "Move large static references into on-demand skills or targeted commands.",
|
|
159
|
-
penalty: 10,
|
|
160
|
-
});
|
|
161
|
-
}
|
|
162
|
-
for (const flag of redFlags)
|
|
163
|
-
score -= flag.penalty;
|
|
164
|
-
score = Math.max(0, score);
|
|
165
|
-
recommendations = [...new Set(redFlags.map((r) => r.recommendation))];
|
|
166
|
-
if (recommendations.length === 0) {
|
|
167
|
-
recommendations = [
|
|
168
|
-
"No major waste pattern detected. Keep prompts scoped and continue tracking trends.",
|
|
169
|
-
];
|
|
170
|
-
}
|
|
171
|
-
const largestEvents = [...events]
|
|
172
|
-
.sort((a, b) => b.estimatedTotalTokens - a.estimatedTotalTokens)
|
|
173
|
-
.slice(0, LARGEST_EVENTS_LIMIT)
|
|
174
|
-
.map((e) => ({
|
|
175
|
-
role: e.role,
|
|
176
|
-
sourceEvent: e.sourceEvent,
|
|
177
|
-
estimatedTotalTokens: e.estimatedTotalTokens,
|
|
178
|
-
}));
|
|
179
|
-
return {
|
|
180
|
-
source: first.source,
|
|
181
|
-
repo: first.repoPath ?? process.cwd(),
|
|
182
|
-
durationMinutes,
|
|
183
|
-
usage: { input, output, toolResults, shellOutput, total },
|
|
184
|
-
sessionShape: {
|
|
185
|
-
turns: turnIds.size,
|
|
186
|
-
fileEdits,
|
|
187
|
-
shellCalls,
|
|
188
|
-
toolCalls,
|
|
189
|
-
},
|
|
190
|
-
largestEvents,
|
|
191
|
-
efficiencyScore: score,
|
|
192
|
-
redFlags: redFlags.map((flag) => ({
|
|
193
|
-
severity: flag.severity,
|
|
194
|
-
title: flag.title,
|
|
195
|
-
detail: flag.detail,
|
|
196
|
-
})),
|
|
197
|
-
recommendations,
|
|
198
|
-
};
|
|
10
|
+
return analyzeSession(events, { contextAuditRoot: process.cwd() });
|
|
199
11
|
}
|
|
200
12
|
export function runLast() {
|
|
201
13
|
const report = getLastReport();
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { getDefaultDbPath, getEventsForLegacyRepoWindow, getEventsForSession, getLatestSessionDescriptor, getLegacyRepoTimeline, getSessionTimeline, listRecentSessions, openDb, } from "./db.js";
|
|
6
|
+
import { analyzeSession } from "./sessionAnalytics.js";
|
|
7
|
+
import { runContextAudit } from "./contextAudit.js";
|
|
8
|
+
import { getLocalProfileDir } from "./profile.js";
|
|
9
|
+
/** Bump when bundled dashboard assets change so consumers pick up updates. */
|
|
10
|
+
export const DASHBOARD_ASSETS_VERSION = "2";
|
|
11
|
+
/**
|
|
12
|
+
* Built CLI reads `dist/dashboard/` (flat). `tsx src/...` resolves under `src/core/`, where sources live in
|
|
13
|
+
* `src/dashboard/public/` — prefer whichever layout contains `index.html`.
|
|
14
|
+
*/
|
|
15
|
+
export function resolvePackagedDashboardDir() {
|
|
16
|
+
const coreDir = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const dashboardRoot = path.join(coreDir, "..", "dashboard");
|
|
18
|
+
const nestedPublic = path.join(dashboardRoot, "public");
|
|
19
|
+
if (fs.existsSync(path.join(dashboardRoot, "index.html"))) {
|
|
20
|
+
return dashboardRoot;
|
|
21
|
+
}
|
|
22
|
+
if (fs.existsSync(path.join(nestedPublic, "index.html"))) {
|
|
23
|
+
return nestedPublic;
|
|
24
|
+
}
|
|
25
|
+
return dashboardRoot;
|
|
26
|
+
}
|
|
27
|
+
export function resolveProjectDashboardDir(cwd = process.cwd()) {
|
|
28
|
+
return path.join(getLocalProfileDir(cwd), "dashboard");
|
|
29
|
+
}
|
|
30
|
+
export function syncDashboardAssets(packageDashboardDir, targetDir) {
|
|
31
|
+
if (!fs.existsSync(packageDashboardDir)) {
|
|
32
|
+
throw new Error(`Dashboard assets missing at ${packageDashboardDir}. Run npm run build.`);
|
|
33
|
+
}
|
|
34
|
+
if (!fs.existsSync(path.join(packageDashboardDir, "index.html"))) {
|
|
35
|
+
throw new Error(`Dashboard assets incomplete at ${packageDashboardDir} (missing index.html). Run npm run build.`);
|
|
36
|
+
}
|
|
37
|
+
fs.mkdirSync(path.dirname(targetDir), { recursive: true });
|
|
38
|
+
if (fs.existsSync(targetDir)) {
|
|
39
|
+
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
40
|
+
}
|
|
41
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
42
|
+
fs.cpSync(packageDashboardDir, targetDir, { recursive: true, force: true });
|
|
43
|
+
fs.writeFileSync(path.join(targetDir, ".agent-profiler-dashboard-version"), `${DASHBOARD_ASSETS_VERSION}\n`, "utf8");
|
|
44
|
+
}
|
|
45
|
+
const MIME = {
|
|
46
|
+
".html": "text/html; charset=utf-8",
|
|
47
|
+
".js": "text/javascript; charset=utf-8",
|
|
48
|
+
".css": "text/css; charset=utf-8",
|
|
49
|
+
".json": "application/json; charset=utf-8",
|
|
50
|
+
".svg": "image/svg+xml",
|
|
51
|
+
".ico": "image/x-icon",
|
|
52
|
+
".png": "image/png",
|
|
53
|
+
".woff2": "font/woff2",
|
|
54
|
+
};
|
|
55
|
+
function safeJoinStatic(root, requestPath) {
|
|
56
|
+
const trimmed = requestPath.replace(/^\/+/, "") || "index.html";
|
|
57
|
+
const segments = trimmed.split("/").filter((s) => s.length > 0 && s !== ".");
|
|
58
|
+
if (segments.some((s) => s === ".."))
|
|
59
|
+
return null;
|
|
60
|
+
const rootNorm = path.normalize(path.resolve(root));
|
|
61
|
+
const resolved = path.normalize(path.resolve(rootNorm, ...segments));
|
|
62
|
+
const rel = path.relative(rootNorm, resolved);
|
|
63
|
+
if (rel.startsWith("..") || path.isAbsolute(rel))
|
|
64
|
+
return null;
|
|
65
|
+
return resolved;
|
|
66
|
+
}
|
|
67
|
+
function sendJson(res, status, body) {
|
|
68
|
+
const payload = JSON.stringify(body);
|
|
69
|
+
res.writeHead(status, {
|
|
70
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
71
|
+
"Cache-Control": "no-store",
|
|
72
|
+
});
|
|
73
|
+
res.end(payload);
|
|
74
|
+
}
|
|
75
|
+
function readQuery(url) {
|
|
76
|
+
const out = {};
|
|
77
|
+
url.searchParams.forEach((value, key) => {
|
|
78
|
+
out[key] = value;
|
|
79
|
+
});
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
function resolveLatestEvents(db) {
|
|
83
|
+
const desc = getLatestSessionDescriptor(db);
|
|
84
|
+
if (!desc)
|
|
85
|
+
return [];
|
|
86
|
+
if (desc.sessionId && desc.sessionId.trim().length > 0) {
|
|
87
|
+
return getEventsForSession(db, desc.source, desc.sessionId);
|
|
88
|
+
}
|
|
89
|
+
return getEventsForLegacyRepoWindow(db, desc.source, desc.repoPath);
|
|
90
|
+
}
|
|
91
|
+
function resolveSessionEvents(db, source, sessionId, repoPath, legacy) {
|
|
92
|
+
if (legacy || !sessionId || sessionId.trim() === "") {
|
|
93
|
+
return getEventsForLegacyRepoWindow(db, source, repoPath ?? null);
|
|
94
|
+
}
|
|
95
|
+
return getEventsForSession(db, source, sessionId);
|
|
96
|
+
}
|
|
97
|
+
/** Defaults to latest session when `source` omitted from query. */
|
|
98
|
+
function resolveSessionQueryParams(db, q) {
|
|
99
|
+
let source = q.source ?? "";
|
|
100
|
+
let sessionId = q.sessionId;
|
|
101
|
+
let repoPath = q.repoPath !== undefined
|
|
102
|
+
? q.repoPath.length > 0
|
|
103
|
+
? q.repoPath
|
|
104
|
+
: null
|
|
105
|
+
: undefined;
|
|
106
|
+
let legacy = q.legacy === "1" || q.legacy === "true";
|
|
107
|
+
if (!source) {
|
|
108
|
+
const desc = getLatestSessionDescriptor(db);
|
|
109
|
+
if (!desc)
|
|
110
|
+
return null;
|
|
111
|
+
source = desc.source;
|
|
112
|
+
sessionId = desc.sessionId ?? undefined;
|
|
113
|
+
repoPath = desc.repoPath ?? null;
|
|
114
|
+
legacy = !sessionId || sessionId.trim() === "";
|
|
115
|
+
}
|
|
116
|
+
else if ((legacy || !sessionId || sessionId.trim() === "") &&
|
|
117
|
+
repoPath === undefined) {
|
|
118
|
+
const desc = getLatestSessionDescriptor(db);
|
|
119
|
+
if (desc && desc.source === source) {
|
|
120
|
+
repoPath = desc.repoPath ?? null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
source,
|
|
125
|
+
sessionId,
|
|
126
|
+
repoPath: repoPath ?? null,
|
|
127
|
+
legacy,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function toolHistogramFromEvents(events) {
|
|
131
|
+
const sizes = events
|
|
132
|
+
.filter((e) => e.role === "tool_result" || e.role === "tool_failure")
|
|
133
|
+
.map((e) => e.estimatedTotalTokens);
|
|
134
|
+
const buckets = [
|
|
135
|
+
{ label: "0–2k", min: 0, max: 2000 },
|
|
136
|
+
{ label: "2k–10k", min: 2001, max: 10000 },
|
|
137
|
+
{ label: "10k–50k", min: 10001, max: 50000 },
|
|
138
|
+
{ label: "50k+", min: 50001, max: Infinity },
|
|
139
|
+
];
|
|
140
|
+
const counts = buckets.map((b) => ({ bucket: b.label, count: 0 }));
|
|
141
|
+
for (const t of sizes) {
|
|
142
|
+
const idx = buckets.findIndex((b) => t >= b.min && t <= b.max);
|
|
143
|
+
if (idx >= 0)
|
|
144
|
+
counts[idx].count += 1;
|
|
145
|
+
}
|
|
146
|
+
return counts;
|
|
147
|
+
}
|
|
148
|
+
export function createDashboardServer(options) {
|
|
149
|
+
const { staticRoot, cwd } = options;
|
|
150
|
+
const server = http.createServer((req, res) => {
|
|
151
|
+
const urlRaw = req.url ?? "/";
|
|
152
|
+
let pathname = urlRaw;
|
|
153
|
+
try {
|
|
154
|
+
const u = new URL(urlRaw, `http://${req.headers.host ?? "localhost"}`);
|
|
155
|
+
pathname = u.pathname;
|
|
156
|
+
const url = u;
|
|
157
|
+
if (pathname.startsWith("/api/")) {
|
|
158
|
+
const dbPath = getDefaultDbPath();
|
|
159
|
+
let db;
|
|
160
|
+
try {
|
|
161
|
+
db = openDb(dbPath);
|
|
162
|
+
}
|
|
163
|
+
catch (e) {
|
|
164
|
+
sendJson(res, 500, { error: String(e) });
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
if (pathname === "/api/overview") {
|
|
169
|
+
const events = resolveLatestEvents(db);
|
|
170
|
+
const report = analyzeSession(events, { contextAuditRoot: cwd });
|
|
171
|
+
const desc = getLatestSessionDescriptor(db);
|
|
172
|
+
sendJson(res, 200, {
|
|
173
|
+
databasePath: dbPath,
|
|
174
|
+
descriptor: desc,
|
|
175
|
+
report,
|
|
176
|
+
});
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (pathname === "/api/session/report") {
|
|
180
|
+
const resolved = resolveSessionQueryParams(db, readQuery(url));
|
|
181
|
+
if (!resolved) {
|
|
182
|
+
sendJson(res, 200, { report: null });
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const events = resolveSessionEvents(db, resolved.source, resolved.sessionId, resolved.repoPath, resolved.legacy);
|
|
186
|
+
const report = analyzeSession(events, { contextAuditRoot: cwd });
|
|
187
|
+
sendJson(res, 200, { report });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (pathname === "/api/sessions") {
|
|
191
|
+
const q = readQuery(url);
|
|
192
|
+
const limit = Math.min(100, Math.max(1, parseInt(q.limit ?? "20", 10) || 20));
|
|
193
|
+
const rows = listRecentSessions(db, limit);
|
|
194
|
+
sendJson(res, 200, { sessions: rows });
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (pathname === "/api/session/timeline") {
|
|
198
|
+
const q = readQuery(url);
|
|
199
|
+
const resolved = resolveSessionQueryParams(db, q);
|
|
200
|
+
if (!resolved) {
|
|
201
|
+
sendJson(res, 200, { timeline: [] });
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const { source, sessionId, repoPath, legacy } = resolved;
|
|
205
|
+
const timeline = legacy || !sessionId || sessionId.trim() === ""
|
|
206
|
+
? getLegacyRepoTimeline(db, source, repoPath)
|
|
207
|
+
: getSessionTimeline(db, source, sessionId);
|
|
208
|
+
sendJson(res, 200, { timeline });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (pathname === "/api/tool-histogram") {
|
|
212
|
+
const q = readQuery(url);
|
|
213
|
+
const resolved = resolveSessionQueryParams(db, q);
|
|
214
|
+
if (!resolved) {
|
|
215
|
+
sendJson(res, 200, { histogram: [] });
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const { source, sessionId, repoPath, legacy } = resolved;
|
|
219
|
+
const events = resolveSessionEvents(db, source, sessionId, repoPath, legacy);
|
|
220
|
+
sendJson(res, 200, { histogram: toolHistogramFromEvents(events) });
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (pathname === "/api/context-audit") {
|
|
224
|
+
sendJson(res, 200, runContextAudit(cwd));
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (pathname === "/api/score-history") {
|
|
228
|
+
const q = readQuery(url);
|
|
229
|
+
const limit = Math.min(50, Math.max(1, parseInt(q.limit ?? "15", 10) || 15));
|
|
230
|
+
const sessions = listRecentSessions(db, limit);
|
|
231
|
+
const points = [];
|
|
232
|
+
for (const s of sessions) {
|
|
233
|
+
const ev = getEventsForSession(db, s.source, s.sessionId);
|
|
234
|
+
const report = analyzeSession(ev, { contextAuditRoot: cwd });
|
|
235
|
+
if (report) {
|
|
236
|
+
points.push({
|
|
237
|
+
endedAt: s.endedAt,
|
|
238
|
+
efficiencyScore: report.efficiencyScore,
|
|
239
|
+
sessionId: s.sessionId,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
points.sort((a, b) => a.endedAt.localeCompare(b.endedAt));
|
|
244
|
+
sendJson(res, 200, { points });
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
sendJson(res, 404, { error: "not found" });
|
|
248
|
+
}
|
|
249
|
+
finally {
|
|
250
|
+
db.close();
|
|
251
|
+
}
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const filePath = safeJoinStatic(staticRoot, pathname);
|
|
255
|
+
if (!filePath) {
|
|
256
|
+
res.writeHead(403).end();
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
let diskPath = filePath;
|
|
260
|
+
if (fs.existsSync(diskPath) && fs.statSync(diskPath).isDirectory()) {
|
|
261
|
+
diskPath = path.join(diskPath, "index.html");
|
|
262
|
+
}
|
|
263
|
+
if (!fs.existsSync(diskPath) || !fs.statSync(diskPath).isFile()) {
|
|
264
|
+
res.writeHead(404).end("Not found");
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const ext = path.extname(diskPath).toLowerCase();
|
|
268
|
+
const type = MIME[ext] ?? "application/octet-stream";
|
|
269
|
+
const stream = fs.createReadStream(diskPath);
|
|
270
|
+
res.writeHead(200, {
|
|
271
|
+
"Content-Type": type,
|
|
272
|
+
// Local dashboard: always revalidate so fixes to app.js/CSS show up without fighting browser cache.
|
|
273
|
+
"Cache-Control": "no-store",
|
|
274
|
+
});
|
|
275
|
+
stream.pipe(res);
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
res.writeHead(400).end();
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
return server;
|
|
282
|
+
}
|
|
283
|
+
export function listenDashboardServer(server, host, port) {
|
|
284
|
+
return new Promise((resolve, reject) => {
|
|
285
|
+
server.once("error", reject);
|
|
286
|
+
server.listen(port, host, () => {
|
|
287
|
+
const addr = server.address();
|
|
288
|
+
const resolvedPort = typeof addr === "object" && addr !== null && "port" in addr
|
|
289
|
+
? addr.port
|
|
290
|
+
: port;
|
|
291
|
+
resolve({ url: `http://${host}:${resolvedPort}` });
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
}
|