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 CHANGED
@@ -1,8 +1,32 @@
1
- # agent-profiler
1
+ # Agent Profiler
2
2
 
3
- Local-first profiling for AI coding agents.
3
+ ![Agent Profiler Dashboard](assets/dashboard.png)
4
4
 
5
- `agent-profiler` captures local Cursor and Codex hook events into SQLite so you can inspect recent sessions, setup state, and always-on context overhead without sending data to a remote service.
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
@@ -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 ?? payload.toolInput ?? payload.input ?? payload.args ?? payload.arguments);
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
+ }
@@ -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;
@@ -1,201 +1,13 @@
1
- import { getDefaultDbPath, getEventsForLatestSession, openDb } from "../core/db.js";
2
- import { runContextAudit } from "../core/contextAudit.js";
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
- if (events.length === 0) {
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
+ }