@dumbmice/pi-deepseek-websearch 0.1.4 → 0.1.5

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/AGENTS.md CHANGED
@@ -45,10 +45,12 @@ README text, the matching `docs/docs-alignment.md` entry (or marking that item s
45
45
  no-runtime-dependencies rule. Do not replace them with `Type.Object` imports.
46
46
  - **A fitting query is never rewritten.** `fitQuery` returns the caller's text untouched when it fits,
47
47
  apart from stripped leading/trailing whitespace. Only an over-long query is tokenised and trimmed.
48
- - **Trimming is bounded and reported.** Shed filters (recency, language, domains) first, then trailing
49
- words, then a code-point tail cut; drop a dangling operator or unmatched quote; record every removal
50
- in `dropped`. Whitespace stripped from `query`, `ori_question` and `localization` before use is
51
- silent (register item 3).
48
+ - **Filters are constraints; words are expendable.** When the assembled query exceeds 128 characters,
49
+ keep every filter and remove trailing words; cut one indivisible token by code point only if it
50
+ alone exceeds the remaining budget. If the filters leave no room for search text, fail before the
51
+ request. Drop a dangling operator or unmatched quote, and record every removal in `dropped`.
52
+ Whitespace stripped from `query`, `ori_question` and `localization` before use is silent
53
+ (register item 3).
52
54
  - **Cost follows the raw response.** `estimatedCostUsd` is keyed to `rawResults.length`, not to what
53
55
  survives client-side filtering, because DeepSeek bills a non-empty result set.
54
56
  - **A malformed 200 is an error.** A body without a `results` array is rejected; an empty array is a
package/CHANGELOG.md CHANGED
@@ -6,6 +6,14 @@ All notable changes to this extension are documented in this file. The format fo
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.1.5] - 2026-11-06
10
+
11
+ ### Fixed
12
+
13
+ - Over-length queries no longer lose their filters. Trimming keeps `allowed_domains`, `blocked_domains`,
14
+ `lang` and `recency` and removes trailing words instead; a filter set that leaves no room for search
15
+ text fails before the request rather than sending the query with those filters stripped.
16
+
9
17
  ## [0.1.4] - 2026-10-30
10
18
 
11
19
  ### Added
@@ -72,7 +80,8 @@ results are not billed. Validated against the DeepSeek API documentation as of 2
72
80
  - `deepseek_fetch` rejects local and private targets and never unescapes inside code.
73
81
 
74
82
 
75
- [Unreleased]: https://github.com/DumbMice/pi-deepseek-websearch/compare/v0.1.4...HEAD
83
+ [Unreleased]: https://github.com/DumbMice/pi-deepseek-websearch/compare/v0.1.5...HEAD
84
+ [0.1.5]: https://github.com/DumbMice/pi-deepseek-websearch/compare/v0.1.4...v0.1.5
76
85
  [0.1.4]: https://github.com/DumbMice/pi-deepseek-websearch/compare/v0.1.3...v0.1.4
77
86
  [0.1.3]: https://github.com/DumbMice/pi-deepseek-websearch/compare/v0.1.2...v0.1.3
78
87
  [0.1.2]: https://github.com/DumbMice/pi-deepseek-websearch/compare/v0.1.1...v0.1.2
package/README.md CHANGED
@@ -104,9 +104,11 @@ an error, not as an empty success.
104
104
 
105
105
  ## Behaviour to expect
106
106
 
107
- - **Long queries are trimmed, not rejected.** The 128-character cap includes operators; when a query
108
- is shortened, the reply names what went — filters first, then trailing words, then a truncated tail
109
- — and prints the final query.
107
+ - **Long queries are trimmed, not rejected.** The 128-character cap includes operators. When a query
108
+ is shortened, trailing words are removed and the filters are kept (a single word or quoted
109
+ phrase longer than the remaining room is cut); the reply names what went and prints the final query. If the filters alone
110
+ leave no room for search text, the call fails before any request — shorten the domain list or
111
+ remove a filter to send it.
110
112
  - **Transient failures are retried.** `408`, `429`, any `5xx`, network failures and timeouts get up
111
113
  to three attempts, honouring `retry-after` (seconds or an HTTP date) capped at 5 seconds. Every
112
114
  other `4xx` fails immediately with DeepSeek's own message. Each attempt has its own deadline: 30
@@ -11,8 +11,9 @@ extension can emit is always a valid request.
11
11
  Client-side deviations and transport details, listed in full. Items that produce no signal in the
12
12
  output are marked _(silent)_; everything else is observable in the reply or in `details`.
13
13
 
14
- 1. over-length queries are trimmed rather than rejected, and every removal is reported; a single
15
- token longer than the whole budget is cut mid-token;
14
+ 1. over-length queries are trimmed rather than rejected, and every removal is reported. Filters are
15
+ kept and trailing words are removed; a single token longer than the remaining budget is cut
16
+ mid-token. If the filters alone leave no room for search text, the call fails before the request;
16
17
  2. query whitespace is normalised only when the query must be rewritten, and that is reported in the
17
18
  same line;
18
19
  3. leading and trailing whitespace is stripped from `query`, `ori_question` and `localization` before
@@ -63,7 +64,9 @@ output are marked _(silent)_; everything else is observable in the reply or in `
63
64
  23. result fields of an unexpected type are ignored rather than passed through, so one malformed
64
65
  result degrades instead of failing the call _(silent)_;
65
66
  24. `recency` windows are fixed client-side at 1 / 7 / 30 / 365 days for `day` / `week` / `month` /
66
- `year`.
67
+ `year`;
68
+ 25. domain list entries that are empty or contain whitespace or a quote are dropped before the query is
69
+ assembled _(silent)_.
67
70
 
68
71
  Transport details: requests send `Accept: application/json` alongside the two documented headers;
69
72
  retry backoff is 500 ms then 1 s when no `retry-after` is present (two sleeps for three attempts); and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dumbmice/pi-deepseek-websearch",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "DeepSeek web search and page fetch tools for the pi coding agent: publication dates, authority, rerank scores, ori_question, count 1-50.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -242,10 +242,9 @@ async function postDeepSeek<T>(
242
242
  /* ------------------------------------------------------------------- query */
243
243
 
244
244
  /**
245
- * Trim to the API's hard character budget (docs: 1-128 Unicode characters including
246
- * operators). Filters are shed first — recency, then language, then domains — and only
247
- * then trailing free-text words. Every removal is reported so the caller can see that
248
- * the request differs from what was asked for.
245
+ * Fit the assembled query into the API's hard limit (docs: 1-128 Unicode characters,
246
+ * operators included). Filters are explicit constraints, so they are kept and the
247
+ * free-text part is shortened instead. Every removal is reported.
249
248
  */
250
249
  export function fitQuery(options: SearchOptions): { query: string; dropped: string[] } {
251
250
  const dropped: string[] = [];
@@ -272,33 +271,36 @@ export function fitQuery(options: SearchOptions): { query: string; dropped: stri
272
271
  const untouched = [raw, ...filters].filter(Boolean).join(" ");
273
272
  if (charLength(untouched) <= MAX_QUERY_CHARS) return { query: untouched, dropped };
274
273
 
275
- // Only a query that cannot fit is rewritten, token by token, and every change is reported.
274
+ // The filters occupy the tail of the string, so they set the budget for the words.
275
+ const filterText = filters.join(" ");
276
+ const filterCost = filterText ? charLength(filterText) + 1 : 0;
277
+ const available = MAX_QUERY_CHARS - filterCost;
278
+ if (available < 1) {
279
+ const names = [
280
+ options.allowedDomains.length > 0 ? "allowed_domains" : "",
281
+ options.blockedDomains.length > 0 ? "blocked_domains" : "",
282
+ options.lang ? "lang" : "",
283
+ options.recency ? "recency" : "",
284
+ ].filter(Boolean);
285
+ const named =
286
+ names.length > 1 ? `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}` : names[0]!;
287
+ throw new Error(
288
+ `The ${named} ${names.length > 1 ? "filters need" : "filter needs"} ${filterCost} characters ` +
289
+ "of the 128-character query limit, leaving no room for the search text. " +
290
+ "Shorten the filter values or remove a filter.",
291
+ );
292
+ }
293
+
276
294
  // An unescaped quoted phrase is one token: slicing mid-phrase would change its meaning.
277
295
  // Backslash-escaped quotes are not special-cased (only reachable on over-length queries).
278
296
  const matchedTokens = raw.match(/"[^"]*"|\S+/g);
279
- const tokens: string[] = matchedTokens ? [...matchedTokens] : [raw];
297
+ let words: string[] = matchedTokens ? [...matchedTokens] : [raw];
280
298
  const quotesWereBalanced = ((raw.match(/"/g) ?? []).length % 2) === 0;
281
- if (raw !== tokens.join(" ")) dropped.push("query whitespace normalised");
282
- let words = tokens;
283
- let active = [...filters];
284
-
285
- const overBudget = () => charLength(join(words, active)) > MAX_QUERY_CHARS;
286
- const dropFilter = (predicate: (filter: string) => boolean, label: (filter: string) => string): void => {
287
- const index = active.findIndex(predicate);
288
- if (index < 0 || !overBudget()) return;
289
- const [removed] = active.splice(index, 1);
290
- dropped.push(label(removed!));
291
- };
292
-
293
- dropFilter((f) => f.startsWith("after:"), (f) => `recency (${f})`);
294
- dropFilter((f) => f.startsWith("lang:"), (f) => `language (${f})`);
295
- while (overBudget() && active.length > 0) {
296
- const removed = active.pop()!;
297
- dropped.push(`domain filter (${removed.slice(0, 40)})`);
298
- }
299
+ if (raw !== words.join(" ")) dropped.push("query whitespace normalised");
299
300
 
300
301
  const wordsBefore = words.join(" ");
301
- while (words.length > 1 && overBudget()) words = words.slice(0, -1);
302
+ while (words.length > 1 && charLength(words.join(" ")) > available) words = words.slice(0, -1);
303
+
302
304
  // Never leave a dangling operator (`site:`, `-site:`, `after:`, a bare `-`) or an
303
305
  // unbalanced quote behind: DeepSeek would answer 400 and no retry would help.
304
306
  const danglingOperator = /^(?:-|-[A-Za-z]+:|[A-Za-z]+:)$/;
@@ -313,20 +315,37 @@ export function fitQuery(options: SearchOptions): { query: string; dropped: stri
313
315
  dropped.push("trailing query words");
314
316
  }
315
317
 
316
- let query = join(words, active);
318
+ let query = join(words, filters);
317
319
  if (charLength(query) > MAX_QUERY_CHARS) {
318
- // A single token longer than the whole budget: cutting it is unavoidable, so do it
319
- // by code point and report it rather than sending an invalid request.
320
- query = Array.from(query).slice(0, MAX_QUERY_CHARS).join("").trim();
321
- // A cut can land on a bare operator (`-`, `:`), which the API would reject.
322
- while (query.length > 0 && /[-:]$/.test(query)) query = Array.from(query).slice(0, -1).join("").trimEnd();
323
- if (quotesWereBalanced && (query.match(/"/g) ?? []).length % 2 === 1) {
324
- // The cut left one unmatched quote: remove that quote rather than invent a pair.
325
- const balanced = Array.from(query);
326
- const lastQuote = balanced.lastIndexOf('"');
327
- if (lastQuote >= 0) balanced.splice(lastQuote, 1);
328
- query = balanced.join("").trimEnd();
320
+ // One indivisible token longer than the remaining budget: cutting it is unavoidable,
321
+ // so do that by code point rather than dropping a filter.
322
+ let cut = Array.from(words.join(" ")).slice(0, available).join("").trim();
323
+ // Repeat until stable: splicing an unmatched quote can expose a trailing `-`/`:`.
324
+ for (let changed = true; changed; ) {
325
+ changed = false;
326
+ const stripped = cut.replace(/[-:]+$/, "").trimEnd();
327
+ if (stripped !== cut) {
328
+ cut = stripped;
329
+ changed = true;
330
+ }
331
+ if (quotesWereBalanced && (cut.match(/"/g) ?? []).length % 2 === 1) {
332
+ const balanced = Array.from(cut);
333
+ const lastQuote = balanced.lastIndexOf('"');
334
+ if (lastQuote >= 0) balanced.splice(lastQuote, 1);
335
+ const next = balanced.join("").trimEnd();
336
+ if (next !== cut) {
337
+ cut = next;
338
+ changed = true;
339
+ }
340
+ }
341
+ }
342
+ if (cut === "" && filters.length === 0) {
343
+ throw new Error(
344
+ "The query has no searchable text within the 128-character limit — it consists only of " +
345
+ "operators or punctuation. Add search terms.",
346
+ );
329
347
  }
348
+ query = join([cut], filters);
330
349
  dropped.push("query tail");
331
350
  }
332
351
  return { query, dropped };
@@ -502,7 +521,7 @@ const SEARCH_SCHEMA = {
502
521
  type: "string",
503
522
  minLength: 1,
504
523
  description:
505
- 'Search query. Supports operators: "exact phrase", -word, -"phrase", site:a.com OR site:b.com, -site:c.com, lang:en,zh, after:2025-01-01, before:2026-06-01. Queries are capped at 128 chars including operators; anything longer is trimmed automatically (filters shed first, then trailing words) and the removals are reported in the output — do not pre-shorten.',
524
+ 'Search query. Supports operators: "exact phrase", -word, -"phrase", site:a.com OR site:b.com, -site:c.com, lang:en,zh, after:2025-01-01, before:2026-06-01. Queries are capped at 128 chars including operators; anything longer is trimmed automatically (trailing words are removed, filters are kept) and the removals are reported in the output — do not pre-shorten.',
506
525
  },
507
526
  count: {
508
527
  type: "integer",
@@ -524,13 +543,13 @@ const SEARCH_SCHEMA = {
524
543
  type: "array",
525
544
  items: { type: "string" },
526
545
  description:
527
- "Restrict results to these domains (compiled into site: OR site:). Counts toward the 128-char query cap, so a long domain list can be dropped for very long queries — any drop is reported in the output.",
546
+ "Restrict results to these domains (compiled into site: OR site:). Counts toward the 128-char query cap and is never dropped by trimming; if the filters leave no room for the query text, the call fails before any request.",
528
547
  },
529
548
  blocked_domains: {
530
549
  type: "array",
531
550
  items: { type: "string" },
532
551
  description:
533
- "Exclude these domains (compiled into -site:). Counts toward the 128-char query cap and may be dropped for very long queries; any drop is reported.",
552
+ "Exclude these domains (compiled into -site:). Counts toward the 128-char query cap and is never dropped by trimming; if the filters leave no room for the query text, the call fails before any request.",
534
553
  },
535
554
  lang: { type: "string", description: 'Language codes, e.g. "zh,en" (becomes lang:).' },
536
555
  recency: {