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 +58 -2
- package/dist/cli/index.cjs +411 -3
- package/dist/cli/index.mjs +411 -3
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +2 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.mjs +1 -1
- package/dist/{tools-B4VbXLvm.mjs → tools-1ZllUYBx.mjs} +3 -0
- package/dist/{tools-40nYFbvw.cjs → tools-Brr43TiA.cjs} +3 -0
- package/package.json +10 -10
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 `
|
|
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
|
package/dist/cli/index.cjs
CHANGED
|
@@ -1,10 +1,122 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
const require_tools = require("../tools-
|
|
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() {
|
package/dist/cli/index.mjs
CHANGED
|
@@ -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-
|
|
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-
|
|
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-
|
|
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.
|
|
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.
|
|
58
|
-
"@secretlint/secretlint-rule-preset-recommend": "13.0.
|
|
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.
|
|
62
|
-
"lint-staged": "17.
|
|
63
|
-
"oxfmt": "0.
|
|
64
|
-
"oxlint": "1.
|
|
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.
|
|
67
|
-
"rulesync": "^
|
|
68
|
-
"secretlint": "13.0.
|
|
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",
|