dsh-web-search-plugin 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -22,6 +22,7 @@ import { MAX_RESULTS_CAP } from "./shared.js";
22
22
  import { TAVILY_DEFAULT_API_KEY_ENV, TAVILY_DEFAULT_BASE_URL, TavilySearchProvider, resolveTavilyOptions } from "./tavily.js";
23
23
  import { BRAVE_DEFAULT_API_KEY_ENV, BRAVE_DEFAULT_BASE_URL, BraveSearchProvider, resolveBraveOptions } from "./brave.js";
24
24
  import { DEEPSEEK_DEFAULT_API_KEY_ENV, DEEPSEEK_DEFAULT_BASE_URL, DeepSeekSearchProvider, resolveDeepseekOptions } from "./deepseek.js";
25
+ import { TAVILY_USAGE_POLL_MS, UsageTracker } from "./usage.js";
25
26
 
26
27
  /** Stable id this plugin registers under on `ctx.web`. */
27
28
  const SEARCH_PROVIDER_ID = "dsh-web-search";
@@ -94,11 +95,17 @@ const WEB_SEARCH_TAVILY_SETTINGS_NAMESPACE = WEB_SEARCH_SETTINGS_NAMESPACE;
94
95
  class PluginSearchProvider {
95
96
  id = SEARCH_PROVIDER_ID;
96
97
 
97
- constructor(resolveOptions) {
98
+ constructor(resolveOptions, usage) {
98
99
  this.resolveOptions = resolveOptions;
99
100
  this.deepseek = new DeepSeekSearchProvider(() => this.resolveOptions().deepseek);
100
- this.tavily = new TavilySearchProvider(() => this.resolveOptions().tavily);
101
- this.brave = new BraveSearchProvider(() => this.resolveOptions().brave);
101
+ this.tavily = new TavilySearchProvider(() => ({
102
+ ...this.resolveOptions().tavily,
103
+ onUsage: (credits) => usage?.recordTavilyCredits(credits)
104
+ }));
105
+ this.brave = new BraveSearchProvider(() => ({
106
+ ...this.resolveOptions().brave,
107
+ onRateLimit: (headers) => usage?.recordBraveHeaders(headers)
108
+ }));
102
109
  }
103
110
 
104
111
  available() {
@@ -128,6 +135,38 @@ function resolveOptions(ctx, config) {
128
135
  };
129
136
  }
130
137
 
138
+ /** JSON body helper for the usage refresh POST. */
139
+ function readJsonBody(req) {
140
+ return new Promise((resolve, reject) => {
141
+ let body = "";
142
+ req.on("data", (chunk) => {
143
+ body += chunk;
144
+ if (body.length > 1e6) {
145
+ req.destroy();
146
+ reject(new Error("Payload too large"));
147
+ }
148
+ });
149
+ req.on("end", () => {
150
+ try {
151
+ resolve(body ? JSON.parse(body) : {});
152
+ } catch {
153
+ reject(new Error("Invalid JSON"));
154
+ }
155
+ });
156
+ req.on("error", reject);
157
+ });
158
+ }
159
+
160
+ function sendJson(res, statusCode, data) {
161
+ const body = JSON.stringify(data);
162
+ res.writeHead(statusCode, {
163
+ "Content-Type": "application/json; charset=utf-8",
164
+ "Cache-Control": "no-store",
165
+ "Content-Length": Buffer.byteLength(body)
166
+ });
167
+ res.end(body);
168
+ }
169
+
131
170
  /** Register the dispatched search provider with `ctx.web`. */
132
171
  function apply(ctx, config) {
133
172
  let current = () => config;
@@ -137,7 +176,61 @@ function apply(ctx, config) {
137
176
  },
138
177
  onChange: () => {}
139
178
  });
140
- ctx.web.registerSearchProvider(new PluginSearchProvider(() => resolveOptions(ctx, current())));
179
+ const live = () => resolveOptions(ctx, current());
180
+ const usage = new UsageTracker({
181
+ getTavilyOptions: () => live().tavily
182
+ });
183
+ ctx.effect(() => {
184
+ let cancelled = false;
185
+ void usage.hydrate().then(() => {
186
+ if (cancelled) return;
187
+ if (live().provider === "tavily" && live().tavily.mode === "keyed") return usage.refreshTavily(undefined, true);
188
+ }).catch(() => {});
189
+ return () => {
190
+ cancelled = true;
191
+ usage.dispose();
192
+ };
193
+ }, "dsh-web-search-plugin: usage hydrate");
194
+ ctx.effect(() => {
195
+ const tick = () => {
196
+ if (live().provider === "tavily" && live().tavily.mode === "keyed") void usage.refreshTavily().catch(() => {});
197
+ };
198
+ const timer = setInterval(tick, TAVILY_USAGE_POLL_MS);
199
+ return () => clearInterval(timer);
200
+ }, "dsh-web-search-plugin: tavily usage poll");
201
+ ctx.web.registerSearchProvider(new PluginSearchProvider(live, usage));
202
+ ctx.inject(["webServer"], (webCtx) => {
203
+ webCtx.effect(() => webCtx.webServer.register({
204
+ kind: "exact",
205
+ path: "/dsh-web-search/usage",
206
+ async handler(req, res) {
207
+ if (req.method !== "GET" && req.method !== "HEAD" && req.method !== "POST") {
208
+ res.writeHead(405, { Allow: "GET, HEAD, POST" });
209
+ res.end();
210
+ return;
211
+ }
212
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
213
+ const force = req.method === "POST" || url.searchParams.get("force") === "1" || url.searchParams.get("force") === "true";
214
+ const options = live();
215
+ const tavilyState = usage.snapshot().tavily;
216
+ const missingIdentity = tavilyState.plan == null && tavilyState.limit == null;
217
+ if ((force || missingIdentity) && options.provider === "tavily" && options.tavily.mode === "keyed") {
218
+ try {
219
+ if (req.method === "POST") await readJsonBody(req).catch(() => ({}));
220
+ await usage.refreshTavily(undefined, force);
221
+ } catch {
222
+ /* serialize still returns the last cache */
223
+ }
224
+ }
225
+ if (req.method === "HEAD") {
226
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" });
227
+ res.end();
228
+ return;
229
+ }
230
+ sendJson(res, 200, usage.serialize(options.provider, options.tavily.mode));
231
+ }
232
+ }), "dsh-web-search-plugin: usage route");
233
+ });
141
234
  }
142
235
 
143
236
  export {
@@ -151,7 +244,9 @@ export {
151
244
  SEARCH_PROVIDER_ID,
152
245
  TAVILY_DEFAULT_API_KEY_ENV,
153
246
  TAVILY_DEFAULT_BASE_URL,
247
+ TAVILY_USAGE_POLL_MS,
154
248
  TavilySearchProvider,
249
+ UsageTracker,
155
250
  WEB_SEARCH_SETTINGS_NAMESPACE,
156
251
  WEB_SEARCH_TAVILY_SETTINGS_NAMESPACE,
157
252
  apply,
package/lib/shared.js CHANGED
@@ -7,7 +7,7 @@ import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
7
7
  import { WebError } from "@deepseek-ai/dsh-web";
8
8
 
9
9
  /** Attribution header sent on every request. */
10
- export const USER_AGENT = "dsh-web-search-plugin/0.1.2";
10
+ export const USER_AGENT = "dsh-web-search-plugin/0.2.1";
11
11
  /** Shared cap: Tavily `max_results` and Brave `count` both accept 1..20. */
12
12
  export const MAX_RESULTS_CAP = 20;
13
13
 
package/lib/tavily.js CHANGED
@@ -5,6 +5,7 @@
5
5
  import { WebError } from "@deepseek-ai/dsh-web";
6
6
  import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
7
7
  import { MAX_RESULTS_CAP, USER_AGENT, isAbortError, resolveApiKey, resolveSecret, searchAborted, throwIfSearchAborted } from "./shared.js";
8
+ import { parseTavilySearchCredits } from "./usage.js";
8
9
 
9
10
  /** Default Tavily REST API base; `/search` is appended. */
10
11
  export const TAVILY_DEFAULT_BASE_URL = "https://api.tavily.com";
@@ -67,13 +68,15 @@ export class TavilySearchProvider {
67
68
  throwIfSearchAborted(signal);
68
69
  }
69
70
  const endpoint = `${options.baseURL.replace(/\/+$/u, "")}/search`;
71
+ const keyed = options.mode === "keyed";
70
72
  const body = {
71
73
  query: request.query,
72
74
  max_results: Math.min(request.maxResults ?? options.maxResults, MAX_RESULTS_CAP),
73
75
  search_depth: options.searchDepth,
74
76
  topic: options.topic,
75
77
  include_answer: options.includeAnswer === true,
76
- include_images: false
78
+ include_images: false,
79
+ ...keyed ? { include_usage: true } : {}
77
80
  };
78
81
  const headers = {
79
82
  "content-type": "application/json",
@@ -106,7 +109,12 @@ export class TavilySearchProvider {
106
109
  throw new WebError(message, "WEB_PROVIDER_ERROR");
107
110
  }
108
111
  try {
109
- return mapTavilyResponse(await response.json(), options.includeAnswer === true);
112
+ const json = await response.json();
113
+ if (keyed) {
114
+ const credits = parseTavilySearchCredits(json);
115
+ if (credits !== null) options.onUsage?.(credits);
116
+ }
117
+ return mapTavilyResponse(json, options.includeAnswer === true);
110
118
  } catch (error) {
111
119
  if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
112
120
  if (error instanceof WebError) throw error;
package/lib/usage.js ADDED
@@ -0,0 +1,352 @@
1
+ /**
2
+ * Local usage cache for Tavily keyed searches and Brave rate-limit headers.
3
+ *
4
+ * Tavily: each keyed search sends `include_usage` and adds those credits to a
5
+ * high-water local counter. A slower `GET /usage` poll supplies plan name /
6
+ * plan_limit and the vendor's own used count. Displayed used is
7
+ * `max(local, remote)`. A drop in remote used, or a plan/limit change, is
8
+ * treated as a new billing cycle (local resets to remote).
9
+ *
10
+ * Brave: there is no usage endpoint. Limit / remaining / reset come from
11
+ * `X-RateLimit-*` on each search response and are cached as-is.
12
+ *
13
+ * @module dsh-web-search-plugin/usage
14
+ */
15
+ import { homedir } from "node:os";
16
+ import { dirname, join } from "node:path";
17
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
18
+ import { USER_AGENT, resolveApiKey } from "./shared.js";
19
+
20
+ /** Persist path under `$DSH_HOME/storages`. */
21
+ export const USAGE_FILE = join(process.env.DSH_HOME || join(homedir(), ".dsh"), "storages", "dsh-web-search-usage.json");
22
+ /** Tavily `/usage` is itself rate-limited (~10 / 10 min); poll slower than that. */
23
+ export const TAVILY_USAGE_POLL_MS = 10 * 60 * 1000;
24
+ /** Coalesce disk writes. */
25
+ const SAVE_DEBOUNCE_MS = 800;
26
+
27
+ /** Finite non-negative number, otherwise `fallback`. */
28
+ function num(value, fallback = 0) {
29
+ const n = Number(value);
30
+ return Number.isFinite(n) && n >= 0 ? n : fallback;
31
+ }
32
+
33
+ /** Finite number or `null` (Tavily `key.limit` is null when unlimited). */
34
+ function numOrNull(value) {
35
+ if (value == null) return null;
36
+ const n = Number(value);
37
+ return Number.isFinite(n) && n >= 0 ? n : null;
38
+ }
39
+
40
+ /** Split `1, 15000` style Brave headers into numbers. */
41
+ function csvNumbers(header) {
42
+ if (header == null || header === "") return [];
43
+ return String(header).split(",").map((part) => Number(part.trim())).filter((n) => Number.isFinite(n));
44
+ }
45
+
46
+ /**
47
+ * Parse Brave `X-RateLimit-Policy` (`1;w=1, 15000;w=2592000`) into
48
+ * `{ limit, windowSec }[]` aligned with Limit / Remaining / Reset by index.
49
+ */
50
+ export function parseBravePolicy(header) {
51
+ if (header == null || header === "") return [];
52
+ return String(header).split(",").flatMap((part) => {
53
+ const match = part.trim().match(/^(\d+)\s*;\s*w\s*=\s*(\d+)$/u);
54
+ if (match === null) return [];
55
+ return [{ limit: Number(match[1]), windowSec: Number(match[2]) }];
56
+ });
57
+ }
58
+
59
+ /**
60
+ * Project Brave rate-limit headers into burst + monthly windows.
61
+ * Monthly is the largest window (≥ 1 day); burst is the smallest (≤ 60s).
62
+ * Brave documents monthly `limit: 0` as unlimited — not “zero quota left”.
63
+ */
64
+ export function parseBraveRateLimitHeaders(headers) {
65
+ const get = typeof headers.get === "function"
66
+ ? (name) => headers.get(name)
67
+ : (name) => headers[name] ?? headers[name.toLowerCase()];
68
+ const policy = parseBravePolicy(get("x-ratelimit-policy"));
69
+ const limits = csvNumbers(get("x-ratelimit-limit"));
70
+ const remaining = csvNumbers(get("x-ratelimit-remaining"));
71
+ const reset = csvNumbers(get("x-ratelimit-reset"));
72
+ const count = Math.max(policy.length, limits.length, remaining.length, reset.length);
73
+ if (count === 0) return null;
74
+ const now = Date.now();
75
+ const windows = [];
76
+ for (let i = 0; i < count; i++) {
77
+ const windowSec = policy[i]?.windowSec ?? (i === 0 ? 1 : 2_592_000);
78
+ const limit = policy[i]?.limit ?? limits[i];
79
+ const left = remaining[i];
80
+ const resetSec = reset[i];
81
+ if (!Number.isFinite(limit) || !Number.isFinite(left)) continue;
82
+ const unlimited = limit === 0;
83
+ windows.push({
84
+ limit,
85
+ remaining: unlimited ? null : Math.max(0, left),
86
+ used: unlimited ? null : Math.max(0, limit - Math.max(0, left)),
87
+ unlimited,
88
+ windowSec,
89
+ resetAt: Number.isFinite(resetSec) ? now + Math.max(0, resetSec) * 1000 : null
90
+ });
91
+ }
92
+ if (windows.length === 0) return null;
93
+ const monthly = windows.reduce((best, item) => item.windowSec > best.windowSec ? item : best);
94
+ const burst = windows.find((item) => item.windowSec <= 60 && item !== monthly) ?? (monthly.windowSec <= 60 ? monthly : null);
95
+ return {
96
+ monthly: monthly.windowSec > 60 ? monthly : windows[windows.length - 1],
97
+ burst: burst !== null && burst.windowSec <= 60 ? burst : null,
98
+ fetchedAt: now
99
+ };
100
+ }
101
+
102
+ /** Normalize Tavily `GET /usage` into plan-level used/limit. */
103
+ export function parseTavilyUsageBody(body) {
104
+ if (body === null || typeof body !== "object") return null;
105
+ const account = body.account !== null && typeof body.account === "object" ? body.account : null;
106
+ const key = body.key !== null && typeof body.key === "object" ? body.key : null;
107
+ if (account === null && key === null) return null;
108
+ const acc = account ?? {};
109
+ const ky = key ?? {};
110
+ const plan = typeof acc.current_plan === "string" && acc.current_plan.length > 0 ? acc.current_plan : null;
111
+ const limit = numOrNull(acc.plan_limit) ?? numOrNull(ky.limit);
112
+ const hasPlanUsage = acc.plan_usage != null;
113
+ const hasKeyUsage = ky.usage != null;
114
+ if (plan == null && limit == null && !hasPlanUsage && !hasKeyUsage) return null;
115
+ const remoteUsed = Math.max(hasPlanUsage ? num(acc.plan_usage) : 0, hasKeyUsage ? num(ky.usage) : 0);
116
+ return {
117
+ plan,
118
+ limit,
119
+ remoteUsed,
120
+ keyUsed: numOrNull(ky.usage),
121
+ keyLimit: numOrNull(ky.limit),
122
+ paygoUsed: numOrNull(acc.paygo_usage),
123
+ paygoLimit: numOrNull(acc.paygo_limit)
124
+ };
125
+ }
126
+
127
+ /** Credits charged for one Tavily search (`include_usage` body). */
128
+ export function parseTavilySearchCredits(body) {
129
+ const credits = Number(body?.usage?.credits);
130
+ return Number.isFinite(credits) && credits >= 0 ? credits : null;
131
+ }
132
+
133
+ /** Empty persisted shape. */
134
+ function emptyState() {
135
+ return {
136
+ version: 1,
137
+ tavily: {
138
+ plan: null,
139
+ limit: null,
140
+ localUsed: 0,
141
+ remoteUsed: 0,
142
+ used: 0,
143
+ fetchedAt: 0,
144
+ error: null
145
+ },
146
+ brave: {
147
+ monthly: null,
148
+ burst: null,
149
+ fetchedAt: 0,
150
+ error: null
151
+ }
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Merge a Tavily `/usage` snapshot into local high-water used.
157
+ * Plan/limit change or a drop in remote used starts a new cycle.
158
+ */
159
+ export function mergeTavilyRemote(current, remote) {
160
+ const prev = current ?? emptyState().tavily;
161
+ if (remote === null) return { ...prev, error: prev.error };
162
+ const hadIdentity = prev.plan != null || prev.limit != null;
163
+ const planChanged = hadIdentity && (remote.plan !== prev.plan || remote.limit !== prev.limit);
164
+ const cycleReset = prev.remoteUsed > 0 && remote.remoteUsed < prev.remoteUsed;
165
+ const localUsed = planChanged || cycleReset ? remote.remoteUsed : Math.max(prev.localUsed, remote.remoteUsed);
166
+ return {
167
+ plan: remote.plan ?? prev.plan,
168
+ limit: remote.limit ?? prev.limit,
169
+ localUsed,
170
+ remoteUsed: remote.remoteUsed,
171
+ used: Math.max(localUsed, remote.remoteUsed),
172
+ fetchedAt: Date.now(),
173
+ error: null,
174
+ keyUsed: remote.keyUsed,
175
+ keyLimit: remote.keyLimit,
176
+ paygoUsed: remote.paygoUsed,
177
+ paygoLimit: remote.paygoLimit
178
+ };
179
+ }
180
+
181
+ /** Add one search's credits onto the Tavily high-water counter. */
182
+ export function mergeTavilyCredits(current, credits) {
183
+ const prev = current ?? emptyState().tavily;
184
+ if (!Number.isFinite(credits) || credits < 0) return prev;
185
+ const localUsed = Math.max(prev.localUsed, prev.used) + credits;
186
+ return {
187
+ ...prev,
188
+ localUsed,
189
+ used: Math.max(localUsed, prev.remoteUsed),
190
+ error: null
191
+ };
192
+ }
193
+
194
+ /** Host-side usage cache with disk persist and Tavily `/usage` polling. */
195
+ export class UsageTracker {
196
+ constructor(options) {
197
+ this.getTavilyOptions = options.getTavilyOptions;
198
+ this.state = emptyState();
199
+ this.saveTimer = null;
200
+ this.hydrated = false;
201
+ }
202
+
203
+ /** Load the previous process's cache; missing file is a first run. */
204
+ async hydrate() {
205
+ try {
206
+ const raw = JSON.parse(await readFile(USAGE_FILE, "utf8"));
207
+ if (raw && typeof raw === "object") {
208
+ this.state = {
209
+ version: 1,
210
+ tavily: { ...emptyState().tavily, ...(raw.tavily && typeof raw.tavily === "object" ? raw.tavily : {}) },
211
+ brave: { ...emptyState().brave, ...(raw.brave && typeof raw.brave === "object" ? raw.brave : {}) }
212
+ };
213
+ }
214
+ } catch {
215
+ /* first run */
216
+ }
217
+ this.hydrated = true;
218
+ return this.snapshot();
219
+ }
220
+
221
+ snapshot() {
222
+ return this.state;
223
+ }
224
+
225
+ /** Browser-safe view used by `GET /dsh-web-search/usage`. */
226
+ serialize(provider, tavilyMode) {
227
+ const tavily = this.state.tavily;
228
+ const brave = this.state.brave;
229
+ const tavilyLimit = tavily.limit;
230
+ const tavilyUsed = tavily.used;
231
+ const tavilyRemaining = tavilyLimit == null ? null : Math.max(0, tavilyLimit - tavilyUsed);
232
+ return {
233
+ ok: true,
234
+ provider,
235
+ tavilyMode: tavilyMode ?? "keyless",
236
+ tavily: {
237
+ available: tavilyMode === "keyed",
238
+ plan: tavily.plan,
239
+ used: tavilyUsed,
240
+ limit: tavilyLimit,
241
+ remaining: tavilyRemaining,
242
+ localUsed: tavily.localUsed,
243
+ remoteUsed: tavily.remoteUsed,
244
+ fetchedAt: tavily.fetchedAt,
245
+ error: tavily.error,
246
+ paygoUsed: tavily.paygoUsed ?? null,
247
+ paygoLimit: tavily.paygoLimit ?? null
248
+ },
249
+ brave: {
250
+ available: provider === "brave",
251
+ monthly: brave.monthly,
252
+ burst: brave.burst,
253
+ fetchedAt: brave.fetchedAt,
254
+ error: brave.error
255
+ },
256
+ fetchedAt: Math.max(tavily.fetchedAt ?? 0, brave.fetchedAt ?? 0)
257
+ };
258
+ }
259
+
260
+ recordTavilyCredits(credits) {
261
+ this.state.tavily = mergeTavilyCredits(this.state.tavily, credits);
262
+ this.scheduleSave();
263
+ }
264
+
265
+ recordBraveHeaders(headers) {
266
+ const parsed = parseBraveRateLimitHeaders(headers);
267
+ if (parsed === null) return;
268
+ this.state.brave = { ...parsed, error: null };
269
+ this.scheduleSave();
270
+ }
271
+
272
+ recordBraveError(message) {
273
+ this.state.brave = { ...this.state.brave, error: String(message) };
274
+ this.scheduleSave();
275
+ }
276
+
277
+ /** Pull Tavily `/usage` and reconcile with the local high-water counter. */
278
+ async refreshTavily(signal, force = false) {
279
+ const options = this.getTavilyOptions?.();
280
+ if (options == null || options.mode !== "keyed") return this.snapshot();
281
+ if (!URL.canParse(options.baseURL)) return this.snapshot();
282
+ const last = this.state.tavily.fetchedAt || 0;
283
+ const hasIdentity = this.state.tavily.plan != null || this.state.tavily.limit != null;
284
+ const interval = hasIdentity ? TAVILY_USAGE_POLL_MS : 60_000;
285
+ if (!force && last > 0 && (Date.now() - last) < interval) return this.snapshot();
286
+ let apiKey;
287
+ try {
288
+ apiKey = await resolveApiKey(options, signal, "Tavily usage has no API key");
289
+ } catch (error) {
290
+ this.state.tavily = { ...this.state.tavily, error: String(error?.message ?? error), fetchedAt: Date.now() };
291
+ this.scheduleSave();
292
+ return this.snapshot();
293
+ }
294
+ const endpoint = `${options.baseURL.replace(/\/+$/u, "")}/usage`;
295
+ let response;
296
+ try {
297
+ response = await fetch(endpoint, {
298
+ method: "GET",
299
+ redirect: "error",
300
+ headers: {
301
+ authorization: `Bearer ${apiKey}`,
302
+ accept: "application/json",
303
+ "user-agent": USER_AGENT
304
+ },
305
+ ...signal !== void 0 ? { signal } : {}
306
+ });
307
+ } catch (error) {
308
+ this.state.tavily = { ...this.state.tavily, error: String(error?.message ?? error), fetchedAt: Date.now() };
309
+ this.scheduleSave();
310
+ return this.snapshot();
311
+ }
312
+ if (!response.ok) {
313
+ this.state.tavily = { ...this.state.tavily, error: `Tavily /usage HTTP ${response.status}`, fetchedAt: Date.now() };
314
+ this.scheduleSave();
315
+ return this.snapshot();
316
+ }
317
+ let body;
318
+ try {
319
+ body = await response.json();
320
+ } catch (error) {
321
+ this.state.tavily = { ...this.state.tavily, error: String(error?.message ?? error), fetchedAt: Date.now() };
322
+ this.scheduleSave();
323
+ return this.snapshot();
324
+ }
325
+ const remote = parseTavilyUsageBody(body);
326
+ if (remote === null) {
327
+ this.state.tavily = { ...this.state.tavily, error: "Tavily /usage returned an unreadable body", fetchedAt: Date.now() };
328
+ } else {
329
+ this.state.tavily = mergeTavilyRemote(this.state.tavily, remote);
330
+ }
331
+ this.scheduleSave();
332
+ return this.snapshot();
333
+ }
334
+
335
+ scheduleSave() {
336
+ if (this.saveTimer !== null) return;
337
+ this.saveTimer = setTimeout(() => {
338
+ this.saveTimer = null;
339
+ const payload = JSON.stringify(this.state);
340
+ mkdir(dirname(USAGE_FILE), { recursive: true })
341
+ .then(() => writeFile(USAGE_FILE, payload))
342
+ .catch(() => { /* in-memory cache still serves the UI */ });
343
+ }, SAVE_DEBOUNCE_MS);
344
+ }
345
+
346
+ dispose() {
347
+ if (this.saveTimer !== null) {
348
+ clearTimeout(this.saveTimer);
349
+ this.saveTimer = null;
350
+ }
351
+ }
352
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-web-search-plugin",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Unified web search for the DeepSeek Harness seam (ctx.web): DeepSeek (official), Tavily, and Brave backends with an in-browser selector",
5
5
  "keywords": [
6
6
  "dsh-plugin",
@@ -53,7 +53,7 @@
53
53
  }
54
54
  },
55
55
  "scripts": {
56
- "check": "node --check lib/index.js && node --check lib/client.js && node --check lib/shared.js && node --check lib/tavily.js && node --check lib/brave.js && node --check lib/deepseek.js"
56
+ "check": "node --check lib/index.js && node --check lib/client.js && node --check lib/shared.js && node --check lib/tavily.js && node --check lib/brave.js && node --check lib/deepseek.js && node --check lib/usage.js"
57
57
  },
58
58
  "dependencies": {
59
59
  "undici": "^6.21.0"