@gajae-code/stats 0.18.0 → 0.18.5

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.
Files changed (3) hide show
  1. package/README.md +77 -5
  2. package/package.json +3 -3
  3. package/src/server.ts +28 -7
package/README.md CHANGED
@@ -56,13 +56,22 @@ console.log(stats.byAgent.find(agent => agent.agent === "executor")?.totalCost);
56
56
 
57
57
  ## API Endpoints
58
58
 
59
+ Endpoints marked *range* accept `?range=1h|24h|7d|30d|90d|all` (default `24h`; an unknown value falls back to `24h`). Time series use hourly buckets for `1h` and `24h` and daily buckets for longer ranges.
60
+
59
61
  | Endpoint | Description |
60
62
  |----------|-------------|
61
- | `GET /api/stats` | Overall stats with all breakdowns |
62
- | `GET /api/stats/models` | Per-model statistics |
63
- | `GET /api/stats/folders` | Per-folder/project statistics |
64
- | `GET /api/stats/timeseries` | Hourly time series data |
65
- | `POST /api/sync` | Trigger sync and return counts |
63
+ | `GET /api/stats` | The full `DashboardStats` object: overall, failures, per-model/folder/agent breakdowns, the time, model, model-performance, and cost series, and the cache-miss attribution (*range*) |
64
+ | `GET /api/stats/overview` | `overall` and `timeSeries` only (*range*) |
65
+ | `GET /api/stats/models` | Per-model statistics, ordered by request count (*range*) |
66
+ | `GET /api/stats/model-dashboard` | `byModel`, `modelSeries`, and `modelPerformanceSeries` (*range*) |
67
+ | `GET /api/stats/folders` | Per-folder/project statistics (*range*) |
68
+ | `GET /api/stats/timeseries` | Requests, errors, tokens, and cost per time bucket (*range*) |
69
+ | `GET /api/stats/costs` | Daily cost per model and provider, split into input/output/cache (*range*) |
70
+ | `GET /api/stats/behavior` | User-message behavior signals (yelling, profanity, repetition, and others): overall, per model, and over time (*range*) |
71
+ | `GET /api/stats/recent` | Most recent requests, newest first (`?limit=`, default 100) |
72
+ | `GET /api/stats/errors` | Most recent requests that stopped with an error (`?limit=`, default 100) |
73
+ | `GET /api/request/:id` | One request with its stored messages and output; `404` if unknown |
74
+ | `POST /api/sync` | Sync session files and return `{ processed, files, totalMessages }`; `409` while a sync is already running |
66
75
 
67
76
  ## Local server security
68
77
 
@@ -85,6 +94,69 @@ The web dashboard provides:
85
94
  - Per-folder breakdown table
86
95
  - Auto-refresh every 30 seconds
87
96
 
97
+ ## Tips & Common Patterns
98
+
99
+ ### Sync and print a summary
100
+
101
+ Every `gjc stats` invocation syncs session files before it does anything else, so there is no separate sync flag.
102
+
103
+ ```bash
104
+ gjc stats --summary
105
+ ```
106
+
107
+ ### JSON output for scripting
108
+
109
+ With `--json`, stdout carries only the JSON document; the sync progress and the `Synced N new entries ...` summary go to stderr.
110
+
111
+ ```bash
112
+ gjc stats --json | jq '.overall.totalCost'
113
+ ```
114
+
115
+ ### Dashboard on a custom port
116
+
117
+ ```bash
118
+ gjc stats --port 3000
119
+ ```
120
+
121
+ `--summary` and `--json` print and exit without starting the server, so `--port` has no effect when combined with them.
122
+
123
+ ### Programmatic: highest-cost folder
124
+
125
+ ```typescript
126
+ import { getDashboardStats, syncAllSessions } from "@gajae-code/stats";
127
+
128
+ await syncAllSessions();
129
+ const stats = await getDashboardStats();
130
+
131
+ const [first, ...rest] = stats.byFolder;
132
+ if (first) {
133
+ const topFolder = rest.reduce((a, b) => (b.totalCost > a.totalCost ? b : a), first);
134
+ console.log(`Highest cost folder: ${topFolder.folder} ($${topFolder.totalCost.toFixed(2)})`);
135
+ } else {
136
+ console.log("No folder data available");
137
+ }
138
+ ```
139
+
140
+ ### Programmatic: most-requested model
141
+
142
+ ```typescript
143
+ import { getDashboardStats, syncAllSessions } from "@gajae-code/stats";
144
+
145
+ await syncAllSessions();
146
+ const stats = await getDashboardStats();
147
+
148
+ // byModel is ordered by request count, highest first.
149
+ const topModel = stats.byModel[0];
150
+ if (topModel) {
151
+ console.log(`Most requested model: ${topModel.model} (${topModel.totalRequests} requests)`);
152
+ }
153
+ ```
154
+
155
+ ### Troubleshooting
156
+
157
+ - **No data shown?** Check that session logs exist under `~/.gjc/agent/sessions/` (`~/<GJC_CONFIG_DIR>/agent/sessions/` if that variable is set). With a custom `GJC_CODING_AGENT_DIR`, sessions are in `$GJC_CODING_AGENT_DIR/sessions/`. Otherwise, on Linux and macOS, if `XDG_DATA_HOME` is set and `$XDG_DATA_HOME/gjc` exists (created by `gjc config init-xdg`), sessions are in `$XDG_DATA_HOME/gjc/sessions/`. These variables must come from your shell environment. A project `.env` cannot set them, and if the `.env` entry uses `$` expansion it blocks the shell value as well; in both cases the default path applies.
158
+ - **Dashboard not starting?** Check that port 3847 (or the port passed to `--port`) is free.
159
+
88
160
  ## License
89
161
 
90
162
  MIT
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/stats",
4
- "version": "0.18.0",
4
+ "version": "0.18.5",
5
5
  "description": "Local observability dashboard for pi AI usage statistics",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo",
@@ -38,8 +38,8 @@
38
38
  "fmt": "biome format --write ."
39
39
  },
40
40
  "dependencies": {
41
- "@gajae-code/ai": "0.18.0",
42
- "@gajae-code/utils": "0.18.0",
41
+ "@gajae-code/ai": "0.18.5",
42
+ "@gajae-code/utils": "0.18.5",
43
43
  "@tailwindcss/node": "^4.2.4",
44
44
  "chart.js": "^4.5.1",
45
45
  "date-fns": "^4.1.0",
package/src/server.ts CHANGED
@@ -167,14 +167,16 @@ async function handleApi(req: Request, context: ApiContext): Promise<Response> {
167
167
  }
168
168
 
169
169
  if (path === "/api/stats/recent") {
170
- const limit = url.searchParams.get("limit");
171
- const stats = await getRecentRequests(limit ? parseInt(limit, 10) : undefined);
170
+ const limit = parseLimitParam(url.searchParams.get("limit"));
171
+ if (limit === null) return badRequest();
172
+ const stats = await getRecentRequests(limit);
172
173
  return Response.json(stats);
173
174
  }
174
175
 
175
176
  if (path === "/api/stats/errors") {
176
- const limit = url.searchParams.get("limit");
177
- const stats = await getRecentErrors(limit ? parseInt(limit, 10) : undefined);
177
+ const limit = parseLimitParam(url.searchParams.get("limit"));
178
+ if (limit === null) return badRequest();
179
+ const stats = await getRecentErrors(limit);
178
180
  return Response.json(stats);
179
181
  }
180
182
 
@@ -194,9 +196,9 @@ async function handleApi(req: Request, context: ApiContext): Promise<Response> {
194
196
  }
195
197
 
196
198
  if (path.startsWith("/api/request/")) {
197
- const id = path.split("/").pop();
198
- if (!id) return new Response("Bad Request", { status: 400 });
199
- const details = await getRequestDetails(parseInt(id, 10));
199
+ const id = parseIntegerParam(path.slice("/api/request/".length));
200
+ if (id === null) return badRequest();
201
+ const details = await getRequestDetails(id);
200
202
  if (!details) return new Response("Not Found", { status: 404 });
201
203
  return Response.json(details);
202
204
  }
@@ -218,6 +220,25 @@ async function handleApi(req: Request, context: ApiContext): Promise<Response> {
218
220
  return new Response("Not Found", { status: 404 });
219
221
  }
220
222
 
223
+ /**
224
+ * A request parameter that must be a whole decimal number. `parseInt` alone
225
+ * accepts "1abc" and "-1", and SQLite rejects NaN with a datatype mismatch and
226
+ * treats a negative LIMIT as unlimited, so anything but plain digits is refused.
227
+ */
228
+ function parseIntegerParam(value: string): number | null {
229
+ if (!/^\d{1,15}$/.test(value)) return null;
230
+ return Number(value);
231
+ }
232
+
233
+ /** `?limit=`: absent keeps the query's own default; present must be a whole number. */
234
+ function parseLimitParam(value: string | null): number | undefined | null {
235
+ return value === null ? undefined : parseIntegerParam(value);
236
+ }
237
+
238
+ function badRequest(): Response {
239
+ return new Response("Bad Request", { status: 400 });
240
+ }
241
+
221
242
  function forbidden(): Response {
222
243
  return new Response("Forbidden", { status: 403 });
223
244
  }