@240xu/dsh-websearch 2.1.0 → 2.2.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.
@@ -5,7 +5,7 @@
5
5
  // text, and .result__snippet text. This is a fallback tier: Exa/Parallel
6
6
  // are richer, DDG is the zero-dependency safety-net.
7
7
 
8
- import { searchAborted, isAbortError } from "../util/abort.js";
8
+ import { searchAborted, isAbortError, throwIfSearchAborted } from "../util/abort.js";
9
9
  import { applyDdgFilters, normalizeFilters } from "../util/filters.js";
10
10
  import { WebError } from "@deepseek-ai/dsh-web";
11
11
 
package/lib/client.js CHANGED
@@ -88,6 +88,7 @@ window.__ModuleLoader__.load({
88
88
  dedupUrl: "按链接",
89
89
  dedupTitle: "链接 + 标题(合并转载)",
90
90
  langPlaceholder: "如 en、zh-CN,留空为默认",
91
+ envProvided: "环境变量提供", readOnly: "只读,无需填写", conflict: "设置已在其他端被修改,请重试", keyFailed: "该 Key 保存失败",
91
92
  };
92
93
  const en = {
93
94
  nav: "Web Search",
@@ -131,6 +132,10 @@ window.__ModuleLoader__.load({
131
132
  dedupUrl: "By URL",
132
133
  dedupTitle: "URL + title (merge syndication)",
133
134
  langPlaceholder: "e.g. en, zh-CN; blank = backend default",
135
+ envProvided: "provided by env",
136
+ readOnly: "read-only, nothing to fill",
137
+ conflict: "Changed elsewhere - please retry",
138
+ keyFailed: "failed to save this key",
134
139
  };
135
140
 
136
141
  //#region styles
@@ -169,6 +174,8 @@ window.__ModuleLoader__.load({
169
174
  this.keyState = new Map(); // envRef -> {configured}
170
175
  this.saving = false;
171
176
  this.failed = false;
177
+ this.conflict = false;
178
+ this.failedKeys = new Set();
172
179
  this.savedAt = 0;
173
180
  this.store = createStore(this.projection());
174
181
  this.scope.subscribe(() => this.republish());
@@ -199,6 +206,8 @@ window.__ModuleLoader__.load({
199
206
  const s = this.staged.get(field);
200
207
  return s ? (s.clear ? "" : s.text) : undefined;
201
208
  },
209
+ conflict: this.conflict,
210
+ failedKeys: [...this.failedKeys],
202
211
  saving: this.saving,
203
212
  failed: this.failed,
204
213
  version: (this.staged.size + ":" + [...this.staged.keys()].join(",")),
@@ -223,7 +232,7 @@ window.__ModuleLoader__.load({
223
232
  const res = await this.connection.api.credentials.describe({ refs: [ref] });
224
233
  if (!res.result.ok) return;
225
234
  const view = res.result.value.credentials[ref];
226
- this.keyState.set(ref, { configured: view?.configured ?? false });
235
+ this.keyState.set(ref, { configured: view?.configured ?? false, writable: view?.writable !== false });
227
236
  } catch { /* ignore */ }
228
237
  this.republish();
229
238
  }
@@ -274,15 +283,26 @@ window.__ModuleLoader__.load({
274
283
  for (const [ref, staged] of this.stagedKeys) {
275
284
  const value = staged.text.trim();
276
285
  if (value !== "") {
277
- await this.connection.api.credentials.set({ ref, value });
278
- this.keyState.set(ref, { configured: true });
279
- doneKeys.push(ref);
286
+ try {
287
+ await this.connection.api.credentials.set({ ref, value });
288
+ this.keyState.set(ref, { configured: true, writable: true });
289
+ doneKeys.push(ref);
290
+ this.failedKeys.delete(ref);
291
+ } catch (keyErr) {
292
+ // env-shadowed reference (fail-loud doctrine): isolate so other keys still land.
293
+ this.failed = true;
294
+ this.failedKeys.add(ref);
295
+ void keyErr;
296
+ }
280
297
  }
281
298
  }
282
299
  for (const f of doneFields) this.staged.delete(f);
283
300
  for (const k of doneKeys) this.stagedKeys.delete(k);
284
301
  if (!this.failed && (doneFields.length > 0 || doneKeys.length > 0)) this.savedAt = Date.now();
285
- } catch { this.failed = true; }
302
+ } catch (err) {
303
+ this.failed = true;
304
+ this.conflict = String(err).includes("SETTINGS_CONFLICT");
305
+ }
286
306
  this.saving = false;
287
307
  this.republish();
288
308
  }
@@ -297,7 +317,7 @@ window.__ModuleLoader__.load({
297
317
  reset: (f) => this.reset(f),
298
318
  editKey: (r, t) => this.editKey(r, t),
299
319
  save: () => this.save(),
300
- discard: () => { this.staged.clear(); this.stagedKeys.clear(); this.failed = false; this.republish(); },
320
+ discard: () => { this.staged.clear(); this.stagedKeys.clear(); this.failed = false; this.conflict = false; this.failedKeys.clear(); this.republish(); },
301
321
  };
302
322
  }
303
323
  }
@@ -458,15 +478,18 @@ window.__ModuleLoader__.load({
458
478
  children: props.t("getKey"),
459
479
  }),
460
480
  react_jsx_runtime.jsx("span", { className: "wsc-badge" + (state.keyState[b.keyEnv]?.configured ? " wsc-badgeOn" : ""), children: state.keyState[b.keyEnv]?.configured ? props.t("keySet") : props.t("keyUnset") }),
481
+ state.keyState[b.keyEnv]?.configured && state.keyState[b.keyEnv]?.writable === false && react_jsx_runtime.jsx("span", { className: "wsc-badge", style: { opacity: .75 }, children: props.t("envProvided") }),
461
482
  ] }),
462
483
  ] }),
463
484
  react_jsx_runtime.jsx("input", {
464
485
  className: "wsc-keyInput", type: "password", autoComplete: "off",
465
- placeholder: b.keyEnv,
486
+ disabled: state.keyState[b.keyEnv]?.configured && state.keyState[b.keyEnv]?.writable === false,
487
+ placeholder: (state.keyState[b.keyEnv]?.configured && state.keyState[b.keyEnv]?.writable === false) ? props.t("readOnly") : b.keyEnv,
466
488
  value: state.stagedKeys[b.keyEnv] ?? "",
467
489
  onChange: (e) => card.editKey(b.keyEnv, e.target.value),
468
490
  }),
469
491
  react_jsx_runtime.jsx("p", { className: "wsc-hint", children: props.t("keyHint") }),
492
+ state.failedKeys.includes(b.keyEnv) && react_jsx_runtime.jsx("p", { className: "wsc-hint", style: { color: "#e5534b" }, children: props.t("keyFailed") }),
470
493
  ] }, b.id)
471
494
  ) }),
472
495
  state.stagedCount > 0 && react_jsx_runtime.jsx("p", { style: { margin: 0, fontSize: 12, opacity: .6 }, children: props.t("unsavedHint") }),
@@ -474,6 +497,7 @@ window.__ModuleLoader__.load({
474
497
  react_jsx_runtime.jsx("button", { className: "wsc-btn", disabled: state.saving || state.stagedCount === 0, onClick: () => card.save(), children: state.saving ? props.t("saving") : props.t("save") }),
475
498
  (state.stagedCount > 0) && react_jsx_runtime.jsx("button", { className: "wsc-btn wsc-btnGhost", onClick: () => card.discard(), children: props.t("discard") }),
476
499
  state.failed && react_jsx_runtime.jsx("span", { className: "wsc-status", style: { color: "#e5534b" }, children: props.t("failed") }),
500
+ state.conflict && react_jsx_runtime.jsx("span", { className: "wsc-status", style: { color: "#e6a700" }, children: props.t("conflict") }),
477
501
  !state.failed && !state.saving && state.savedAt > 0 && react_jsx_runtime.jsx("span", { className: "wsc-status", style: { color: "#2ea043" }, children: props.t("savedOk") }),
478
502
  ] }),
479
503
  ] });
package/lib/provider.js CHANGED
@@ -170,7 +170,10 @@ export function createUnifiedSearchProvider({ ctx, resolveOptions, backends }) {
170
170
  let hardError = null;
171
171
  const failures = [];
172
172
 
173
+ let backendIndex = 0;
173
174
  for (const settled of results) {
175
+ const failedId = eligible[backendIndex] ? eligible[backendIndex].id : "unknown";
176
+ backendIndex++;
174
177
  if (settled.status === "fulfilled") {
175
178
  if (mergedContent === undefined && typeof settled.value?.content === "string" && settled.value.content.length > 0) {
176
179
  mergedContent = settled.value.content;
@@ -182,9 +185,9 @@ export function createUnifiedSearchProvider({ ctx, resolveOptions, backends }) {
182
185
  if (err instanceof WebError && err.code === "WEB_ABORTED") {
183
186
  // Per-backend abort → soft unless caller signal fired.
184
187
  if (signal?.aborted === true) hardAborted = true;
185
- else failures.push({ id: "abort", err });
188
+ else failures.push({ id: failedId, err });
186
189
  } else {
187
- failures.push({ id: "error", err });
190
+ failures.push({ id: failedId, err });
188
191
  if (hardError === null) hardError = err;
189
192
  }
190
193
  }
@@ -215,13 +218,19 @@ export function createUnifiedSearchProvider({ ctx, resolveOptions, backends }) {
215
218
  };
216
219
  }
217
220
 
218
- // All enabled backends failed — synthesize a meaningful WEB_PROVIDER_ERROR.
219
- const firstDetail =
220
- hardError instanceof Error
221
- ? hardError.message
222
- : String(hardError ?? "unknown");
221
+ // All enabled backends failed - synthesize one meaningful error that
222
+ // names each backend's own reason so the caller (model or user) can act.
223
+ const detail = failures.slice(0, 4)
224
+ .map((f) => f.id + ": " + String(f.err instanceof Error ? f.err.message : f.err).slice(0, 120))
225
+ .join("; ");
226
+ const allKeyMissing =
227
+ failures.length > 0 &&
228
+ failures.every((f) => f.err instanceof WebError && f.err.code === "WEB_PROVIDER_UNAVAILABLE");
229
+ const hint = allKeyMissing
230
+ ? " (every enabled backend is missing its API key - add keys in Settings > Web Search, or enable a keyless backend)"
231
+ : "";
223
232
  throw new WebError(
224
- `unified search: all enabled backends failed (${failures.length}). First: ${firstDetail}`,
233
+ `unified search: all enabled backends failed (${failures.length}) - ${detail}` + hint,
225
234
  "WEB_PROVIDER_ERROR",
226
235
  { cause: hardError ?? undefined },
227
236
  );
package/lib/util/log.js CHANGED
@@ -1,51 +1,65 @@
1
1
  /**
2
2
  * @module dsh-websearch/util/log
3
3
  *
4
- * Emit a structured "search/backend-request" event onto the current session,
5
- * so debugging / token-cost attribution is possible from the dsh session log.
6
- * Mirrors the official dsh-web-search-deepseek `recordRequest` pattern but
7
- * is per-backend (each backend records its own endpoint + body so it is
8
- * auditable which backend fired which request).
4
+ * Per-backend request/outcome diagnostics.
9
5
  *
10
- * Resilient to missing agents plane — if the session ledger is unavailable,
11
- * the call silently no-ops (we MUST NOT break search just because we can't
12
- * record an audit row).
6
+ * IMPORTANT: these intentionally go to the HOST logger (ctx.logger), NOT to
7
+ * the session ledger. The session event vocabulary is a closed, generated set
8
+ * (@deepseek-ai/dsh-session known-event-types): the persistence read path
9
+ * refuses to interpret ANY session containing an envelope type outside that
10
+ * set unless the ENVELOPE carries ignorable: true -- and the runtime
11
+ * session.append() API provides no way to set that envelope marker (its opts
12
+ * carry only sourceEventSeqs/surfaceOp). A third-party plugin writing custom
13
+ * types therefore poisons the whole session log: writes succeed, the next
14
+ * continue/load refuses. Mirroring the in-repo web/deepseek-search-llm-request
15
+ * pattern is not available to out-of-repo packages by construction.
16
+ *
17
+ * Both emitters are best-effort and never throw: diagnostics must not break
18
+ * search.
13
19
  */
14
20
 
21
+ /** One-line summary safe for the host log. */
22
+ function summarize(request) {
23
+ try {
24
+ const body = request && request.body ? JSON.stringify(request.body) : "";
25
+ const trimmed = body.length > 200 ? body.slice(0, 200) + "..." : body;
26
+ return String((request && request.endpoint) || "") +
27
+ (request && request.toolName ? " tool=" + request.toolName : "") +
28
+ (trimmed ? " body=" + trimmed : "");
29
+ } catch {
30
+ return String(request && request.endpoint);
31
+ }
32
+ }
33
+
15
34
  /**
16
- * Record one backend request to the current session's event ledger.
35
+ * Record one backend request to the host log.
17
36
  *
18
- * @param {object} ctx - the Cordis plugin context (`ctx.get("agents")`).
19
- * @param {string} backendId - e.g. "exa" / "parallel" / "ddg" / "deepseek" / "anthropic" / "openai".
20
- * @param {object} request - { endpoint, toolName, body, headers? } — headers are
21
- * filtered by the caller (do not pass Authorization/x-api-key here).
37
+ * @param {object} ctx - the Cordis plugin context (ctx.logger).
38
+ * @param {string} backendId - e.g. exa / parallel / brave.
39
+ * @param {object} request - { endpoint, toolName, body } - never includes secrets.
22
40
  */
23
41
  export function recordBackendRequest(ctx, backendId, request) {
24
42
  try {
25
- const agents = ctx.get?.("agents");
26
- const initiator = agents?.currentInitiator?.();
27
- const session = initiator?.session;
28
- if (session?.append) {
29
- session.append("web/unified-search-backend-request", { backendId, ...request, ignorable: true });
30
- }
43
+ const logger = ctx && ctx.logger;
44
+ if (!logger || typeof logger.debug !== "function") return;
45
+ logger.debug("[websearch:" + backendId + "] request " + summarize(request));
31
46
  } catch {
32
- // audit logging is best-effort; never fatal
47
+ // best-effort; never fatal
33
48
  }
34
49
  }
35
50
 
36
51
  /**
37
- * Record a backend's final outcome (number of sources returned / error message).
38
- * Pairs with recordBackendRequest; useful for token-cost attribution.
52
+ * Record a backend's final outcome (sources count / error message) to the host log.
39
53
  */
40
54
  export function recordBackendOutcome(ctx, backendId, outcome) {
41
55
  try {
42
- const agents = ctx.get?.("agents");
43
- const initiator = agents?.currentInitiator?.();
44
- const session = initiator?.session;
45
- if (session?.append) {
46
- session.append("web/unified-search-backend-outcome", { backendId, ...outcome, ignorable: true });
47
- }
56
+ const logger = ctx && ctx.logger;
57
+ if (!logger) return;
58
+ const status = outcome && typeof outcome.status === "string" ? outcome.status : "";
59
+ const line = "[websearch:" + backendId + "] outcome " + JSON.stringify(outcome ?? {});
60
+ if (status === "error" && typeof logger.warn === "function") logger.warn(line);
61
+ else if (typeof logger.debug === "function") logger.debug(line);
48
62
  } catch {
49
63
  // best-effort
50
64
  }
51
- }
65
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@240xu/dsh-websearch",
3
- "version": "2.1.0",
4
- "description": "Unified web search provider for DeepSeek Harness (ctx.web): 11 backends — Exa + Parallel + DDG + SearXNG (keyless), DeepSeek + Anthropic + OpenAI + Brave + Tavily + Serper + Mojeek (key-gated via Settings credentials). Registers provider id \"unified\" for zero-config web_search tool.",
3
+ "version": "2.2.0",
4
+ "description": "Unified web search provider for DeepSeek Harness (ctx.web): 11 backends \u2014 Exa + Parallel + DDG + SearXNG (keyless), DeepSeek + Anthropic + OpenAI + Brave + Tavily + Serper + Mojeek (key-gated via Settings credentials). Registers provider id \"unified\" for zero-config web_search tool.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {