webseek 0.3.0 → 0.5.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/README.md CHANGED
@@ -19,6 +19,41 @@ The Gemini provider supports two backends that share the same request/response
19
19
  shape: the **Gemini Developer API** (`gemini-api`, default) and **Vertex AI
20
20
  express mode** (`vertex-express`).
21
21
 
22
+ ### Usage and cost
23
+
24
+ Every search reports what it consumed and an estimated cost in USD. The text
25
+ output ends with a line such as:
26
+
27
+ ```
28
+ Usage: 1,100 tokens (1,000 in, 100 out), 1 search, ~$0.0180
29
+ ```
30
+
31
+ JSON output (and the MCP tool result) carries the same data as `usage` and
32
+ `cost`, plus the `model` that served the search:
33
+
34
+ ```jsonc
35
+ {
36
+ "model": "gpt-5.5",
37
+ "usage": { "inputTokens": 1000, "outputTokens": 100, "totalTokens": 1100, "searchCalls": 1 },
38
+ "cost": { "totalUsd": 0.018, "tokensUsd": 0.008, "searchUsd": 0.01 },
39
+ }
40
+ ```
41
+
42
+ - `usage.inputTokens` includes cached tokens (`usage.cachedInputTokens`) and the
43
+ search content the model read; `usage.outputTokens` includes reasoning/thinking
44
+ tokens. SERP providers (`google`) report no tokens.
45
+ - `usage.searchCalls` counts OpenAI `web_search` calls, Gemini grounding queries,
46
+ or Google Custom Search requests (one per page of 10 results).
47
+ - `cost` is computed from built-in list prices: token rates for the model plus
48
+ the provider's search fee (OpenAI $10 per 1k calls, Gemini 3.x $14 per 1k
49
+ queries, Gemini 2.5 $35 per 1k grounded prompts, Google Custom Search $5 per 1k
50
+ queries). It is a list-price estimate, not your bill: free allowances,
51
+ discounts and long-context rates are **not** applied. `cost` is omitted when the model has no known price
52
+ or the provider reported no token counts.
53
+
54
+ The CLI and the MCP server also append every successful search to a local usage
55
+ log, which [`webseek stats`](#usage-stats) totals.
56
+
22
57
  ## Install
23
58
 
24
59
  ```bash
@@ -61,6 +96,54 @@ webseek "summarize the latest TypeScript release" -p openai
61
96
  webseek "who won euro 2024" -p gemini --gemini-backend vertex-express --json
62
97
  ```
63
98
 
99
+ `mcp` and `stats` are subcommands: a query whose first word is `mcp` or
100
+ `stats` must be quoted as a whole (`webseek "stats for the nba" -p openai`), and
101
+ a query of just that one word cannot be searched from the CLI.
102
+
103
+ ### Usage stats
104
+
105
+ Every successful search — from the CLI or the MCP server — is appended as one
106
+ JSON line to `usage.jsonl` in the data directory: the time, provider, model,
107
+ `usage` and `cost`. The query text is **not** stored. `webseek stats` totals
108
+ the log, with flags modelled on `opencode stats`:
109
+
110
+ ```bash
111
+ webseek stats # this year so far
112
+ webseek stats --days 7 --full # the last 7 days with every section
113
+ webseek stats --all -p openai --models
114
+ ```
115
+
116
+ ```
117
+ webseek stats · 2026 so far · all providers
118
+
119
+ 3 searches · 4 search calls · 12.9k tokens
120
+ ~$0.0550 estimated · 1 active day
121
+ ```
122
+
123
+ | Flag | Description |
124
+ | ----------------------- | --------------------------------------------------- |
125
+ | `--days <n>` | The last N days; `0` means today |
126
+ | `--year <year>` | A calendar year (default: this year so far) |
127
+ | `--all` | Lifetime statistics |
128
+ | `-p, --provider <name>` | Only count one provider |
129
+ | `--cost` | Cost split (tokens / search fees) and token details |
130
+ | `--models` | Searches, tokens and cost per model |
131
+ | `--full` | Every detailed section |
132
+ | `--limit <n>` | Number of rows in the models section |
133
+ | `--json` | Statistics as JSON |
134
+
135
+ `--days`, `--year` and `--all` cannot be combined. Costs are the same list-price
136
+ estimates as in each search's output; searches whose model has no known price
137
+ are counted but left out of the cost (and reported as unpriced).
138
+
139
+ | Environment variable | Effect |
140
+ | -------------------- | -------------------------------------------------------------------------------- |
141
+ | `WEBSEEK_DATA_DIR` | Data directory (default `$XDG_DATA_HOME/webseek`, else `~/.local/share/webseek`) |
142
+ | `WEBSEEK_USAGE_LOG` | Set to `0`, `false` or `off` to stop recording searches |
143
+
144
+ Delete `usage.jsonl` to reset the statistics. A failure to write the log is
145
+ reported as a warning on stderr and never fails the search.
146
+
64
147
  ## MCP server mode
65
148
 
66
149
  Start a [Model Context Protocol](https://modelcontextprotocol.io) server over
@@ -105,8 +188,30 @@ CommonJS consumers can `require` it the same way:
105
188
  const { runSearch } = require("webseek");
106
189
  ```
107
190
 
191
+ Each result carries `usage` and, when it can be priced, an estimated `cost` (see
192
+ [Usage and cost](#usage-and-cost)). To price usage yourself — for example to sum
193
+ several searches — call `estimateCost` with the provider, the model and a
194
+ `usage` object; it returns `undefined` when the model has no known price:
195
+
196
+ ```ts
197
+ import { estimateCost } from "webseek";
198
+
199
+ const cost = estimateCost({
200
+ provider: "openai",
201
+ model: "gpt-5.5",
202
+ usage: { inputTokens: 1000, outputTokens: 100, searchCalls: 1 },
203
+ });
204
+ console.log(cost?.totalUsd.toFixed(3)); // "0.018"
205
+ ```
206
+
207
+ Prices are list prices built into the package and change with its releases.
208
+
108
209
  To embed the `web_search` tool into your own MCP server, use the
109
- `createWebSearchTool` factory exported from the same entry point.
210
+ `createWebSearchTool` factory exported from the same entry point. Its optional
211
+ `onResult` callback receives every successful result (the `webseek mcp` server
212
+ uses it to write the usage log).
213
+
214
+ The library itself never writes the usage log; only the CLI and `webseek mcp` do.
110
215
 
111
216
  ## Authentication
112
217
 
@@ -135,10 +240,12 @@ they don't leak into shell history or process listings.
135
240
  ```
136
241
  src/
137
242
  index.ts public library entry (runSearch, types, createWebSearchTool)
138
- cli/ commander program: root search command and `mcp` command
243
+ cli/ commander program: root search command, `mcp` and `stats` commands
139
244
  mcp/ MCP server + the web_search tool
140
245
  lib/ runSearch — the shared core called by both CLI and MCP
141
246
  providers/ per-provider implementations (openai, google-cse, gemini)
247
+ pricing/ list-price table and cost estimation
248
+ stats/ local usage log, aggregation and the `stats` report
142
249
  config/ credential + base-URL resolution from env
143
250
  output/ text / JSON formatting
144
251
  utils/ logger, error formatter
@@ -1,10 +1,122 @@
1
1
  #!/usr/bin/env node
2
- const require_tools = require("../tools-Ci1EDrCT.cjs");
2
+ const require_tools = require("../tools-Brr43TiA.cjs");
3
+ let zod = require("zod");
3
4
  let node_fs = require("node:fs");
4
5
  let node_path = require("node:path");
5
6
  let commander = require("commander");
6
7
  let _modelcontextprotocol_sdk_server_mcp_js = require("@modelcontextprotocol/sdk/server/mcp.js");
7
8
  let _modelcontextprotocol_sdk_server_stdio_js = require("@modelcontextprotocol/sdk/server/stdio.js");
9
+ let node_fs_promises = require("node:fs/promises");
10
+ let node_os = require("node:os");
11
+ //#region src/stats/usage-log.ts
12
+ /**
13
+ * The local usage log: one JSON line per successful search, appended by the
14
+ * CLI and the MCP server and read back by `webseek stats`.
15
+ *
16
+ * Records hold what a search consumed and cost — never the query text — so the
17
+ * log is safe to keep around. The library entry point never writes to it.
18
+ */
19
+ const USAGE_LOG_FILE = "usage.jsonl";
20
+ const RECORD_VERSION = 1;
21
+ /**
22
+ * The directory the usage log lives in: `WEBSEEK_DATA_DIR`, else
23
+ * `$XDG_DATA_HOME/webseek`, else `~/.local/share/webseek`.
24
+ */
25
+ function resolveDataDir(params = {}) {
26
+ const env = params.env ?? process.env;
27
+ if (env.WEBSEEK_DATA_DIR) return env.WEBSEEK_DATA_DIR;
28
+ if (env.XDG_DATA_HOME) return (0, node_path.join)(env.XDG_DATA_HOME, "webseek");
29
+ return (0, node_path.join)((0, node_os.homedir)(), ".local", "share", "webseek");
30
+ }
31
+ function resolveUsageLogPath(params = {}) {
32
+ return (0, node_path.join)(resolveDataDir(params), USAGE_LOG_FILE);
33
+ }
34
+ /** Logging is on unless `WEBSEEK_USAGE_LOG` is `0`, `false` or `off`. */
35
+ function isUsageLogEnabled(params = {}) {
36
+ const value = (params.env ?? process.env).WEBSEEK_USAGE_LOG?.trim().toLowerCase();
37
+ return value !== "0" && value !== "false" && value !== "off";
38
+ }
39
+ function toUsageRecord(params) {
40
+ const { result } = params;
41
+ return {
42
+ v: RECORD_VERSION,
43
+ at: params.now ?? Date.now(),
44
+ provider: result.provider,
45
+ model: result.model,
46
+ usage: result.usage,
47
+ cost: result.cost
48
+ };
49
+ }
50
+ /**
51
+ * Append a search to the usage log. Never throws: a failed write is reported
52
+ * through `onError` so it cannot fail (or corrupt the output of) the search.
53
+ */
54
+ async function appendUsageRecord(params) {
55
+ if (!isUsageLogEnabled({ env: params.env })) return;
56
+ try {
57
+ const dir = resolveDataDir({ env: params.env });
58
+ await (0, node_fs_promises.mkdir)(dir, {
59
+ recursive: true,
60
+ mode: 448
61
+ });
62
+ const record = toUsageRecord({
63
+ result: params.result,
64
+ now: params.now
65
+ });
66
+ await (0, node_fs_promises.appendFile)((0, node_path.join)(dir, USAGE_LOG_FILE), `${JSON.stringify(record)}\n`, {
67
+ encoding: "utf8",
68
+ mode: 384
69
+ });
70
+ } catch (error) {
71
+ params.onError?.(error);
72
+ }
73
+ }
74
+ /** Read every valid record from the usage log; a missing log reads as empty. */
75
+ async function readUsageRecords(params = {}) {
76
+ let text;
77
+ try {
78
+ text = await (0, node_fs_promises.readFile)(resolveUsageLogPath(params), "utf8");
79
+ } catch (error) {
80
+ if (error.code === "ENOENT") return [];
81
+ throw error;
82
+ }
83
+ return parseUsageRecords(text);
84
+ }
85
+ const usageRecordSchema = zod.z.looseObject({
86
+ v: zod.z.literal(RECORD_VERSION),
87
+ at: zod.z.number(),
88
+ provider: zod.z.enum(require_tools.PROVIDER_NAMES),
89
+ model: zod.z.string().optional(),
90
+ usage: zod.z.looseObject({
91
+ inputTokens: zod.z.number().optional(),
92
+ cachedInputTokens: zod.z.number().optional(),
93
+ outputTokens: zod.z.number().optional(),
94
+ totalTokens: zod.z.number().optional(),
95
+ searchCalls: zod.z.number()
96
+ }).optional(),
97
+ cost: zod.z.looseObject({
98
+ totalUsd: zod.z.number(),
99
+ tokensUsd: zod.z.number(),
100
+ searchUsd: zod.z.number()
101
+ }).optional()
102
+ });
103
+ /** Parse JSONL, skipping blank, malformed and unknown-version lines. */
104
+ function parseUsageRecords(text) {
105
+ const records = [];
106
+ for (const line of text.split("\n")) {
107
+ if (!line.trim()) continue;
108
+ let json;
109
+ try {
110
+ json = JSON.parse(line);
111
+ } catch {
112
+ continue;
113
+ }
114
+ const parsed = usageRecordSchema.safeParse(json);
115
+ if (parsed.success) records.push(parsed.data);
116
+ }
117
+ return records;
118
+ }
119
+ //#endregion
8
120
  //#region src/mcp/server.ts
9
121
  /**
10
122
  * MCP server mode: exposes the `web_search` tool over the stdio transport so
@@ -17,7 +129,10 @@ async function startMcpServer(params) {
17
129
  name: "webseek",
18
130
  version: params.version
19
131
  });
20
- const tool = require_tools.createWebSearchTool();
132
+ const tool = require_tools.createWebSearchTool({ onResult: (result) => appendUsageRecord({
133
+ result,
134
+ onError: (error) => params.logger.warn(`could not record usage: ${require_tools.formatError(error)}`)
135
+ }) });
21
136
  server.registerTool(tool.name, tool.config, tool.handler);
22
137
  const transport = new _modelcontextprotocol_sdk_server_stdio_js.StdioServerTransport();
23
138
  await server.connect(transport);
@@ -102,7 +217,10 @@ function formatJson(result) {
102
217
  results: result.results,
103
218
  answer: result.answer,
104
219
  citations: result.citations,
105
- searchQueries: result.searchQueries
220
+ searchQueries: result.searchQueries,
221
+ model: result.model,
222
+ usage: result.usage,
223
+ cost: result.cost
106
224
  };
107
225
  if (result.raw !== void 0) payload.raw = result.raw;
108
226
  return JSON.stringify(payload, null, 2);
@@ -128,8 +246,29 @@ function formatText(result) {
128
246
  lines.push("");
129
247
  }
130
248
  if (result.searchQueries.length > 0) lines.push(`Searches: ${result.searchQueries.join(" | ")}`);
249
+ if (result.usage) lines.push(formatUsage({
250
+ usage: result.usage,
251
+ cost: result.cost
252
+ }));
131
253
  return lines.join("\n").trimEnd();
132
254
  }
255
+ function formatUsage(params) {
256
+ const { usage, cost } = params;
257
+ const parts = [];
258
+ if (usage.inputTokens !== void 0 || usage.outputTokens !== void 0) {
259
+ const input = usage.inputTokens ?? 0;
260
+ const output = usage.outputTokens ?? 0;
261
+ const total = usage.totalTokens ?? input + output;
262
+ parts.push(`${total.toLocaleString("en-US")} tokens (${input.toLocaleString("en-US")} in, ${output.toLocaleString("en-US")} out)`);
263
+ }
264
+ parts.push(`${usage.searchCalls} search${usage.searchCalls === 1 ? "" : "es"}`);
265
+ parts.push(cost === void 0 ? "cost unknown" : `~${formatUsd(cost.totalUsd)}`);
266
+ return `Usage: ${parts.join(", ")}`;
267
+ }
268
+ function formatUsd(value) {
269
+ if (value > 0 && value < 5e-5) return "<$0.0001";
270
+ return `$${value.toFixed(4)}`;
271
+ }
133
272
  //#endregion
134
273
  //#region src/cli/commands/search.ts
135
274
  /**
@@ -152,10 +291,15 @@ function toSearchRequest(params) {
152
291
  };
153
292
  }
154
293
  async function runSearchCommand(params) {
155
- const result = await require_tools.runSearch(toSearchRequest({
294
+ const request = toSearchRequest({
156
295
  queryParts: params.queryParts,
157
296
  options: params.options
158
- }));
297
+ });
298
+ const result = await require_tools.runSearch(request);
299
+ await appendUsageRecord({
300
+ result,
301
+ onError: (error) => params.logger.warn(`could not record usage: ${require_tools.formatError(error)}`)
302
+ });
159
303
  params.logger.result(formatResult({
160
304
  result,
161
305
  json: Boolean(params.options.json)
@@ -175,6 +319,293 @@ function registerSearchCommand(program) {
175
319
  }));
176
320
  }
177
321
  //#endregion
322
+ //#region src/stats/aggregate.ts
323
+ /**
324
+ * `--all` → all time; `--days N` → since local midnight N-1 days ago (0 or 1
325
+ * mean today); `--year Y` → that calendar year; default → this year so far.
326
+ */
327
+ function resolveStatsRange(params = {}) {
328
+ const now = params.now ?? /* @__PURE__ */ new Date();
329
+ const to = now.getTime() + 1;
330
+ if (params.all) return {
331
+ from: void 0,
332
+ to,
333
+ label: "all time"
334
+ };
335
+ if (params.days !== void 0) {
336
+ const from = new Date(now.getFullYear(), now.getMonth(), now.getDate());
337
+ from.setDate(from.getDate() - Math.max(0, params.days - 1));
338
+ return {
339
+ from: from.getTime(),
340
+ to,
341
+ label: params.days <= 1 ? "today" : `last ${params.days} days`
342
+ };
343
+ }
344
+ const year = params.year ?? now.getFullYear();
345
+ const isCurrentYear = year === now.getFullYear();
346
+ return {
347
+ from: new Date(year, 0, 1).getTime(),
348
+ to: isCurrentYear ? to : new Date(year + 1, 0, 1).getTime(),
349
+ label: isCurrentYear ? `${year} so far` : String(year)
350
+ };
351
+ }
352
+ function aggregateUsage(params) {
353
+ const { range } = params;
354
+ const stats = {
355
+ range: {
356
+ from: range.from,
357
+ to: range.to
358
+ },
359
+ searches: 0,
360
+ searchCalls: 0,
361
+ tokens: {
362
+ input: 0,
363
+ cachedInput: 0,
364
+ output: 0,
365
+ total: 0
366
+ },
367
+ cost: {
368
+ totalUsd: 0,
369
+ tokensUsd: 0,
370
+ searchUsd: 0
371
+ },
372
+ unpricedSearches: 0,
373
+ activeDays: 0,
374
+ models: []
375
+ };
376
+ const days = /* @__PURE__ */ new Set();
377
+ const models = /* @__PURE__ */ new Map();
378
+ for (const record of params.records) {
379
+ if (!((range.from === void 0 || record.at >= range.from) && record.at < range.to) || params.provider !== void 0 && record.provider !== params.provider) continue;
380
+ addRecord({
381
+ stats,
382
+ models,
383
+ record
384
+ });
385
+ const at = new Date(record.at);
386
+ days.add(`${at.getFullYear()}-${at.getMonth()}-${at.getDate()}`);
387
+ }
388
+ stats.activeDays = days.size;
389
+ stats.models = [...models.values()].toSorted((a, b) => b.costUsd - a.costUsd || b.searches - a.searches);
390
+ return stats;
391
+ }
392
+ function addRecord(params) {
393
+ const { stats, record } = params;
394
+ const usage = record.usage;
395
+ const input = usage?.inputTokens ?? 0;
396
+ const output = usage?.outputTokens ?? 0;
397
+ const total = usage?.totalTokens ?? input + output;
398
+ const searchCalls = usage?.searchCalls ?? 0;
399
+ stats.searches += 1;
400
+ stats.searchCalls += searchCalls;
401
+ stats.tokens.input += input;
402
+ stats.tokens.cachedInput += usage?.cachedInputTokens ?? 0;
403
+ stats.tokens.output += output;
404
+ stats.tokens.total += total;
405
+ if (record.cost) {
406
+ stats.cost.totalUsd += record.cost.totalUsd;
407
+ stats.cost.tokensUsd += record.cost.tokensUsd;
408
+ stats.cost.searchUsd += record.cost.searchUsd;
409
+ } else stats.unpricedSearches += 1;
410
+ const key = `${record.provider}/${record.model ?? ""}`;
411
+ const model = params.models.get(key) ?? {
412
+ provider: record.provider,
413
+ model: record.model,
414
+ searches: 0,
415
+ searchCalls: 0,
416
+ tokens: 0,
417
+ costUsd: 0,
418
+ unpricedSearches: 0
419
+ };
420
+ model.searches += 1;
421
+ model.searchCalls += searchCalls;
422
+ model.tokens += total;
423
+ model.costUsd += record.cost?.totalUsd ?? 0;
424
+ if (!record.cost) model.unpricedSearches += 1;
425
+ params.models.set(key, model);
426
+ }
427
+ //#endregion
428
+ //#region src/stats/render.ts
429
+ /**
430
+ * Renders aggregated usage as the `webseek stats` text report, laid out after
431
+ * `opencode stats`: a summary by default, plus COST & TOKENS and MODELS
432
+ * sections on request.
433
+ */
434
+ function renderStats(params) {
435
+ const { stats } = params;
436
+ const detailed = params.cost || params.models;
437
+ const header = `webseek stats · ${params.label} · ${params.scope}`;
438
+ const lines = [];
439
+ if (detailed) lines.push(`${params.label} · ${params.scope}`);
440
+ else if (stats.searches === 0) lines.push(header, "", "no searches in this range");
441
+ else {
442
+ lines.push(header, "", `${plural({
443
+ value: stats.searches,
444
+ singular: "search",
445
+ pluralForm: "searches"
446
+ })} · ${plural({
447
+ value: stats.searchCalls,
448
+ singular: "search call"
449
+ })} · ${plural({
450
+ value: stats.tokens.total,
451
+ singular: "token"
452
+ })}`, `~${formatUsd(stats.cost.totalUsd)} estimated · ${plural({
453
+ value: stats.activeDays,
454
+ singular: "active day"
455
+ })}`);
456
+ if (stats.unpricedSearches > 0) lines.push(`${plural({
457
+ value: stats.unpricedSearches,
458
+ singular: "search",
459
+ pluralForm: "searches"
460
+ })} without a price estimate`);
461
+ }
462
+ if (params.cost) lines.push("", ...renderCost(stats));
463
+ if (params.models) lines.push("", ...renderModels({
464
+ models: stats.models,
465
+ limit: params.limit
466
+ }));
467
+ return lines.join("\n");
468
+ }
469
+ function renderCost(stats) {
470
+ const rows = [
471
+ ["cost", `~${formatUsd(stats.cost.totalUsd)}`],
472
+ [" tokens", formatUsd(stats.cost.tokensUsd)],
473
+ [" search fees", formatUsd(stats.cost.searchUsd)],
474
+ ["input", compact(stats.tokens.input)],
475
+ ["cached input", compact(stats.tokens.cachedInput)],
476
+ ["output", compact(stats.tokens.output)],
477
+ ["search calls", compact(stats.searchCalls)]
478
+ ];
479
+ if (stats.unpricedSearches > 0) rows.push(["unpriced searches", compact(stats.unpricedSearches)]);
480
+ return ["COST & TOKENS", ...rows.map(([label, value]) => ` ${label.padEnd(20)}${value}`)];
481
+ }
482
+ function renderModels(params) {
483
+ const { models, limit } = params;
484
+ if (models.length === 0) return ["MODELS", " no searches"];
485
+ const shown = limit === void 0 ? models : models.slice(0, limit);
486
+ const rest = models.length - shown.length;
487
+ return [
488
+ "MODELS",
489
+ tableRow({
490
+ name: "model",
491
+ searches: "searches",
492
+ tokens: "tokens",
493
+ cost: "cost"
494
+ }),
495
+ ...shown.map((model) => tableRow({
496
+ name: modelName(model),
497
+ searches: compact(model.searches),
498
+ tokens: compact(model.tokens),
499
+ cost: model.unpricedSearches === model.searches ? "-" : formatUsd(model.costUsd)
500
+ })),
501
+ ...rest > 0 ? ["", `+${rest.toLocaleString("en-US")} more model${rest === 1 ? "" : "s"}`] : []
502
+ ];
503
+ }
504
+ function modelName(model) {
505
+ const name = model.model?.replace(/\p{Cc}/gu, "");
506
+ return name ? `${model.provider}/${name}` : model.provider;
507
+ }
508
+ function tableRow(cells) {
509
+ return `${(cells.name.length <= 34 ? cells.name : `${cells.name.slice(0, 33)}…`).padEnd(34)}${cells.searches.padStart(10)}${cells.tokens.padStart(10)}${cells.cost.padStart(12)}`;
510
+ }
511
+ /** 1234 → "1.2k", 2_000_000 → "2m". */
512
+ function compact(value) {
513
+ if (value >= 1e9) return `${oneDecimal(value / 1e9)}b`;
514
+ if (value >= 1e6) return `${oneDecimal(value / 1e6)}m`;
515
+ if (value >= 1e3) return `${oneDecimal(value / 1e3)}k`;
516
+ return Math.round(value).toLocaleString("en-US");
517
+ }
518
+ function oneDecimal(value) {
519
+ return value.toFixed(1).replace(/\.0$/, "");
520
+ }
521
+ function plural(params) {
522
+ const noun = params.value === 1 ? params.singular : params.pluralForm ?? `${params.singular}s`;
523
+ return `${compact(params.value)} ${noun}`;
524
+ }
525
+ //#endregion
526
+ //#region src/cli/commands/stats.ts
527
+ /** Validate CLI options into a stats request. Pure apart from reading `now`. */
528
+ function toStatsRequest(params) {
529
+ const { options } = params;
530
+ if ([
531
+ options.days !== void 0,
532
+ options.year !== void 0,
533
+ options.all
534
+ ].filter(Boolean).length > 1) throw new require_tools.WebseekError({
535
+ code: "invalid_usage",
536
+ message: "--days, --year, and --all cannot be combined."
537
+ });
538
+ return {
539
+ range: resolveStatsRange({
540
+ days: options.days === void 0 ? void 0 : parseInteger({
541
+ name: "days",
542
+ value: options.days,
543
+ min: 0,
544
+ max: 36500
545
+ }),
546
+ year: options.year === void 0 ? void 0 : parseInteger({
547
+ name: "year",
548
+ value: options.year,
549
+ min: 1970,
550
+ max: 9999
551
+ }),
552
+ all: options.all,
553
+ now: params.now
554
+ }),
555
+ provider: options.provider === void 0 ? void 0 : require_tools.coerceProvider(options.provider),
556
+ cost: Boolean(options.cost || options.full),
557
+ models: Boolean(options.models || options.full),
558
+ limit: options.limit === void 0 ? void 0 : parseInteger({
559
+ name: "limit",
560
+ value: options.limit,
561
+ min: 1,
562
+ max: Number.MAX_SAFE_INTEGER
563
+ }),
564
+ json: Boolean(options.json)
565
+ };
566
+ }
567
+ function parseInteger(params) {
568
+ const { name, value, min, max } = params;
569
+ const parsed = Number(value);
570
+ if (!/^\d+$/.test(value.trim()) || !Number.isSafeInteger(parsed) || parsed < min || parsed > max) throw new require_tools.WebseekError({
571
+ code: "invalid_usage",
572
+ message: `--${name} must be an integer between ${min} and ${max}.`
573
+ });
574
+ return parsed;
575
+ }
576
+ async function runStatsCommand(params) {
577
+ const request = toStatsRequest({ options: params.options });
578
+ const stats = aggregateUsage({
579
+ records: await readUsageRecords({ env: params.env }),
580
+ range: request.range,
581
+ provider: request.provider
582
+ });
583
+ if (request.json) {
584
+ const models = request.limit === void 0 ? stats.models : stats.models.slice(0, request.limit);
585
+ params.logger.result(JSON.stringify({
586
+ ...stats,
587
+ models
588
+ }, null, 2));
589
+ return;
590
+ }
591
+ params.logger.result(renderStats({
592
+ stats,
593
+ label: request.range.label,
594
+ scope: request.provider ?? "all providers",
595
+ cost: request.cost,
596
+ models: request.models,
597
+ limit: request.limit
598
+ }));
599
+ }
600
+ function registerStatsCommand(program) {
601
+ program.command("stats").description("Show usage and estimated cost of the searches recorded locally").option("--days <n>", "show the last N days; 0 means today").option("--year <year>", "show a calendar year").option("--all", "show lifetime statistics").option("-p, --provider <name>", "only count one provider: openai | google | gemini").option("--cost", "show cost and token details").option("--models", "show usage per model").option("--full", "show every detailed section").option("--limit <n>", "number of rows in the models section").option("--json", "output statistics as JSON").action(wrapCommand(async ({ logger }, options) => {
602
+ await runStatsCommand({
603
+ logger,
604
+ options
605
+ });
606
+ }));
607
+ }
608
+ //#endregion
178
609
  //#region src/cli/index.ts
179
610
  /**
180
611
  * webseek CLI entry point.
@@ -182,6 +613,7 @@ function registerSearchCommand(program) {
182
613
  * Two modes:
183
614
  * webseek <query> --provider <name> run a one-off web search (root command)
184
615
  * webseek mcp start the MCP server (stdio)
616
+ * webseek stats summarize the locally recorded usage
185
617
  */
186
618
  function readPackageVersion(dir) {
187
619
  try {
@@ -203,12 +635,13 @@ function getVersion() {
203
635
  function buildProgram() {
204
636
  const program = new commander.Command();
205
637
  const version = getVersion();
206
- program.name("webseek").description("Unified multi-provider web search (CLI + MCP server)").version(version, "-v, --version", "Show version");
638
+ program.name("webseek").description("Unified multi-provider web search (CLI + MCP server)").version(version, "-v, --version", "Show version").enablePositionalOptions();
207
639
  registerSearchCommand(program);
208
640
  registerMcpCommand({
209
641
  program,
210
642
  version
211
643
  });
644
+ registerStatsCommand(program);
212
645
  return program;
213
646
  }
214
647
  async function main() {