webseek 0.4.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
@@ -51,6 +51,9 @@ JSON output (and the MCP tool result) carries the same data as `usage` and
51
51
  discounts and long-context rates are **not** applied. `cost` is omitted when the model has no known price
52
52
  or the provider reported no token counts.
53
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
+
54
57
  ## Install
55
58
 
56
59
  ```bash
@@ -93,6 +96,54 @@ webseek "summarize the latest TypeScript release" -p openai
93
96
  webseek "who won euro 2024" -p gemini --gemini-backend vertex-express --json
94
97
  ```
95
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
+
96
147
  ## MCP server mode
97
148
 
98
149
  Start a [Model Context Protocol](https://modelcontextprotocol.io) server over
@@ -156,7 +207,11 @@ console.log(cost?.totalUsd.toFixed(3)); // "0.018"
156
207
  Prices are list prices built into the package and change with its releases.
157
208
 
158
209
  To embed the `web_search` tool into your own MCP server, use the
159
- `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.
160
215
 
161
216
  ## Authentication
162
217
 
@@ -185,11 +240,12 @@ they don't leak into shell history or process listings.
185
240
  ```
186
241
  src/
187
242
  index.ts public library entry (runSearch, types, createWebSearchTool)
188
- cli/ commander program: root search command and `mcp` command
243
+ cli/ commander program: root search command, `mcp` and `stats` commands
189
244
  mcp/ MCP server + the web_search tool
190
245
  lib/ runSearch — the shared core called by both CLI and MCP
191
246
  providers/ per-provider implementations (openai, google-cse, gemini)
192
247
  pricing/ list-price table and cost estimation
248
+ stats/ local usage log, aggregation and the `stats` report
193
249
  config/ credential + base-URL resolution from env
194
250
  output/ text / JSON formatting
195
251
  utils/ logger, error formatter
@@ -1,10 +1,122 @@
1
1
  #!/usr/bin/env node
2
- const require_tools = require("../tools-40nYFbvw.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);
@@ -181,6 +296,10 @@ async function runSearchCommand(params) {
181
296
  options: params.options
182
297
  });
183
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
+ });
184
303
  params.logger.result(formatResult({
185
304
  result,
186
305
  json: Boolean(params.options.json)
@@ -200,6 +319,293 @@ function registerSearchCommand(program) {
200
319
  }));
201
320
  }
202
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
203
609
  //#region src/cli/index.ts
204
610
  /**
205
611
  * webseek CLI entry point.
@@ -207,6 +613,7 @@ function registerSearchCommand(program) {
207
613
  * Two modes:
208
614
  * webseek <query> --provider <name> run a one-off web search (root command)
209
615
  * webseek mcp start the MCP server (stdio)
616
+ * webseek stats summarize the locally recorded usage
210
617
  */
211
618
  function readPackageVersion(dir) {
212
619
  try {
@@ -228,12 +635,13 @@ function getVersion() {
228
635
  function buildProgram() {
229
636
  const program = new commander.Command();
230
637
  const version = getVersion();
231
- 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();
232
639
  registerSearchCommand(program);
233
640
  registerMcpCommand({
234
641
  program,
235
642
  version
236
643
  });
644
+ registerStatsCommand(program);
237
645
  return program;
238
646
  }
239
647
  async function main() {
@@ -1,10 +1,122 @@
1
1
  #!/usr/bin/env node
2
- import { a as coerceGeminiBackend, c as runSearch, d as errorExitCode, f as formatError, o as coerceMaxResults, s as coerceProvider, t as createWebSearchTool, u as WebseekError } from "../tools-B4VbXLvm.mjs";
2
+ import { a as coerceGeminiBackend, c as runSearch, d as errorExitCode, f as formatError, i as PROVIDER_NAMES, o as coerceMaxResults, s as coerceProvider, t as createWebSearchTool, u as WebseekError } from "../tools-1ZllUYBx.mjs";
3
+ import { z } from "zod";
3
4
  import { readFileSync } from "node:fs";
4
5
  import { dirname, join } from "node:path";
5
6
  import { Command } from "commander";
6
7
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
+ import { appendFile, mkdir, readFile } from "node:fs/promises";
10
+ import { homedir } from "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 join(env.XDG_DATA_HOME, "webseek");
29
+ return join(homedir(), ".local", "share", "webseek");
30
+ }
31
+ function resolveUsageLogPath(params = {}) {
32
+ return 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 mkdir(dir, {
59
+ recursive: true,
60
+ mode: 448
61
+ });
62
+ const record = toUsageRecord({
63
+ result: params.result,
64
+ now: params.now
65
+ });
66
+ await appendFile(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 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 = z.looseObject({
86
+ v: z.literal(RECORD_VERSION),
87
+ at: z.number(),
88
+ provider: z.enum(PROVIDER_NAMES),
89
+ model: z.string().optional(),
90
+ usage: z.looseObject({
91
+ inputTokens: z.number().optional(),
92
+ cachedInputTokens: z.number().optional(),
93
+ outputTokens: z.number().optional(),
94
+ totalTokens: z.number().optional(),
95
+ searchCalls: z.number()
96
+ }).optional(),
97
+ cost: z.looseObject({
98
+ totalUsd: z.number(),
99
+ tokensUsd: z.number(),
100
+ searchUsd: 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 = createWebSearchTool();
132
+ const tool = createWebSearchTool({ onResult: (result) => appendUsageRecord({
133
+ result,
134
+ onError: (error) => params.logger.warn(`could not record usage: ${formatError(error)}`)
135
+ }) });
21
136
  server.registerTool(tool.name, tool.config, tool.handler);
22
137
  const transport = new StdioServerTransport();
23
138
  await server.connect(transport);
@@ -181,6 +296,10 @@ async function runSearchCommand(params) {
181
296
  options: params.options
182
297
  });
183
298
  const result = await runSearch(request);
299
+ await appendUsageRecord({
300
+ result,
301
+ onError: (error) => params.logger.warn(`could not record usage: ${formatError(error)}`)
302
+ });
184
303
  params.logger.result(formatResult({
185
304
  result,
186
305
  json: Boolean(params.options.json)
@@ -200,6 +319,293 @@ function registerSearchCommand(program) {
200
319
  }));
201
320
  }
202
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 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 : 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 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
203
609
  //#region src/cli/index.ts
204
610
  /**
205
611
  * webseek CLI entry point.
@@ -207,6 +613,7 @@ function registerSearchCommand(program) {
207
613
  * Two modes:
208
614
  * webseek <query> --provider <name> run a one-off web search (root command)
209
615
  * webseek mcp start the MCP server (stdio)
616
+ * webseek stats summarize the locally recorded usage
210
617
  */
211
618
  function readPackageVersion(dir) {
212
619
  try {
@@ -228,12 +635,13 @@ function getVersion() {
228
635
  function buildProgram() {
229
636
  const program = new Command();
230
637
  const version = getVersion();
231
- 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();
232
639
  registerSearchCommand(program);
233
640
  registerMcpCommand({
234
641
  program,
235
642
  version
236
643
  });
644
+ registerStatsCommand(program);
237
645
  return program;
238
646
  }
239
647
  async function main() {
package/dist/index.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_tools = require("./tools-40nYFbvw.cjs");
2
+ const require_tools = require("./tools-Brr43TiA.cjs");
3
3
  exports.GEMINI_BACKENDS = require_tools.GEMINI_BACKENDS;
4
4
  exports.PROVIDER_NAMES = require_tools.PROVIDER_NAMES;
5
5
  exports.WebseekError = require_tools.WebseekError;
package/dist/index.d.cts CHANGED
@@ -206,6 +206,8 @@ interface CreateWebSearchToolParams {
206
206
  env?: Env;
207
207
  /** Injectable for tests; defaults to the global fetch. */
208
208
  fetchImpl?: typeof fetch;
209
+ /** Called with every successful result before it is returned (e.g. to log usage). */
210
+ onResult?: (result: NormalizedSearchResult) => Promise<void> | void;
209
211
  }
210
212
  interface WebSearchTool {
211
213
  name: string;
package/dist/index.d.mts CHANGED
@@ -206,6 +206,8 @@ interface CreateWebSearchToolParams {
206
206
  env?: Env;
207
207
  /** Injectable for tests; defaults to the global fetch. */
208
208
  fetchImpl?: typeof fetch;
209
+ /** Called with every successful result before it is returned (e.g. to log usage). */
210
+ onResult?: (result: NormalizedSearchResult) => Promise<void> | void;
209
211
  }
210
212
  interface WebSearchTool {
211
213
  name: string;
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { c as runSearch, d as errorExitCode, f as formatError, i as PROVIDER_NAMES, l as estimateCost, n as webSearchInputShape, r as GEMINI_BACKENDS, t as createWebSearchTool, u as WebseekError } from "./tools-B4VbXLvm.mjs";
1
+ import { c as runSearch, d as errorExitCode, f as formatError, i as PROVIDER_NAMES, l as estimateCost, n as webSearchInputShape, r as GEMINI_BACKENDS, t as createWebSearchTool, u as WebseekError } from "./tools-1ZllUYBx.mjs";
2
2
  export { GEMINI_BACKENDS, PROVIDER_NAMES, WebseekError, createWebSearchTool, errorExitCode, estimateCost, formatError, runSearch, webSearchInputShape };
@@ -791,6 +791,9 @@ function createWebSearchTool(params = {}) {
791
791
  env: params.env,
792
792
  fetchImpl: params.fetchImpl
793
793
  });
794
+ try {
795
+ await params.onResult?.(result);
796
+ } catch {}
794
797
  return { content: [{
795
798
  type: "text",
796
799
  text: JSON.stringify(result, null, 2)
@@ -791,6 +791,9 @@ function createWebSearchTool(params = {}) {
791
791
  env: params.env,
792
792
  fetchImpl: params.fetchImpl
793
793
  });
794
+ try {
795
+ await params.onResult?.(result);
796
+ } catch {}
794
797
  return { content: [{
795
798
  type: "text",
796
799
  text: JSON.stringify(result, null, 2)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webseek",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Unified multi-provider web search as a CLI, MCP server, and library (OpenAI, Google Custom Search, Gemini)",
5
5
  "keywords": [
6
6
  "web-search",
@@ -54,18 +54,18 @@
54
54
  "access": "public"
55
55
  },
56
56
  "devDependencies": {
57
- "@openrouter/sdk": "1.3.21",
58
- "@secretlint/secretlint-rule-preset-recommend": "13.0.5",
57
+ "@openrouter/sdk": "1.4.12",
58
+ "@secretlint/secretlint-rule-preset-recommend": "13.0.6",
59
59
  "@tsconfig/node24": "^24.0.4",
60
60
  "@types/node": "^26.0.0",
61
- "cspell": "10.3.3",
62
- "lint-staged": "17.5.1",
63
- "oxfmt": "0.70.0",
64
- "oxlint": "1.85.0",
61
+ "cspell": "10.3.6",
62
+ "lint-staged": "17.6.0",
63
+ "oxfmt": "0.71.0",
64
+ "oxlint": "1.86.0",
65
65
  "repomix": "1.18.1",
66
- "resend": "6.28.1",
67
- "rulesync": "^17.0.0",
68
- "secretlint": "13.0.5",
66
+ "resend": "6.31.0",
67
+ "rulesync": "^24.0.0",
68
+ "secretlint": "13.0.6",
69
69
  "simple-git-hooks": "2.14.0",
70
70
  "smol-toml": "1.9.0",
71
71
  "tsdown": "^0.23.0",