@dumbmice/pi-deepseek-websearch 0.1.4 → 0.1.6

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
@@ -10,6 +10,7 @@ Instructions for anyone — human or coding agent — changing
10
10
  src/index.ts the extension: two tool registrations, transport, formatting, guards
11
11
  test/index.test.ts node:test suite; global fetch is stubbed, nothing touches the network
12
12
  docs/docs-alignment.md every client-side deviation from DeepSeek's published API contract
13
+ docs/pi-api-alignment.md which pi release this package is verified against
13
14
  README.md user-facing documentation
14
15
  AGENTS.md this file
15
16
  CONTRIBUTING.md four-line pointer to this file (GitHub convention)
@@ -21,8 +22,8 @@ tsconfig.json the strict / noEmit settings used by typecheck
21
22
 
22
23
  ## Requirements and commands
23
24
 
24
- Node >= 22.6 (`engines`; the entry point is TypeScript and the test runner uses
25
- `--experimental-strip-types`). devDependencies stay limited to `typescript` and `@types/node`; the
25
+ Node >= 22.19 (`engines`, the floor pi 1.5.1 itself requires; the entry point is TypeScript and
26
+ the test runner uses `--experimental-strip-types`). devDependencies stay limited to `typescript` and `@types/node`; the
26
27
  publish workflow installs with `npm ci`, the test matrix with `npm install`.
27
28
 
28
29
  ```bash
@@ -45,10 +46,12 @@ README text, the matching `docs/docs-alignment.md` entry (or marking that item s
45
46
  no-runtime-dependencies rule. Do not replace them with `Type.Object` imports.
46
47
  - **A fitting query is never rewritten.** `fitQuery` returns the caller's text untouched when it fits,
47
48
  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).
49
+ - **Filters are constraints; words are expendable.** When the assembled query exceeds 128 characters,
50
+ keep every filter and remove trailing words; cut one indivisible token by code point only if it
51
+ alone exceeds the remaining budget. If the filters leave no room for search text, fail before the
52
+ request. Drop a dangling operator or unmatched quote, and record every removal in `dropped`.
53
+ Whitespace stripped from `query`, `ori_question` and `localization` before use is silent
54
+ (register item 3).
52
55
  - **Cost follows the raw response.** `estimatedCostUsd` is keyed to `rawResults.length`, not to what
53
56
  survives client-side filtering, because DeepSeek bills a non-empty result set.
54
57
  - **A malformed 200 is an error.** A body without a `results` array is rejected; an empty array is a
@@ -116,6 +119,9 @@ is enough to check rendering. `npm test` needs neither key nor network.
116
119
  and constant and at the top of the register — keep them in step.
117
120
  - `CHANGELOG.md` follows Keep a Changelog. Put unreleased work under `[Unreleased]` with
118
121
  Added/Changed/Fixed headings; add the version heading when the release is tagged.
122
+ - `docs/pi-api-alignment.md` records the pi release and Node version this package is verified
123
+ against. `PI_API_SNAPSHOT` in `src/index.ts`, that file and the README *Install* line move together;
124
+ `test/docs.test.ts` fails when they drift.
119
125
  - `AGENTS.md` (this file) holds contributor, architecture and release material.
120
126
 
121
127
  ## Release process
package/CHANGELOG.md CHANGED
@@ -6,6 +6,27 @@ All notable changes to this extension are documented in this file. The format fo
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.1.6] - 2026-11-06
10
+
11
+ ### Added
12
+
13
+ - `docs/pi-api-alignment.md`: a tested-version table recording the pi release (`1.5.1`) and Node
14
+ version this package is verified against, pinned by `PI_API_SNAPSHOT` and a docs test so the claim
15
+ cannot drift from the code.
16
+
17
+ ### Changed
18
+
19
+ - `engines.node` raised to `>=22.19`, matching pi 1.5.1's own floor. The README now states the pi
20
+ version it was tested with.
21
+
22
+ ## [0.1.5] - 2026-11-06
23
+
24
+ ### Fixed
25
+
26
+ - Over-length queries no longer lose their filters. Trimming keeps `allowed_domains`, `blocked_domains`,
27
+ `lang` and `recency` and removes trailing words instead; a filter set that leaves no room for search
28
+ text fails before the request rather than sending the query with those filters stripped.
29
+
9
30
  ## [0.1.4] - 2026-10-30
10
31
 
11
32
  ### Added
@@ -72,7 +93,9 @@ results are not billed. Validated against the DeepSeek API documentation as of 2
72
93
  - `deepseek_fetch` rejects local and private targets and never unescapes inside code.
73
94
 
74
95
 
75
- [Unreleased]: https://github.com/DumbMice/pi-deepseek-websearch/compare/v0.1.4...HEAD
96
+ [Unreleased]: https://github.com/DumbMice/pi-deepseek-websearch/compare/v0.1.6...HEAD
97
+ [0.1.6]: https://github.com/DumbMice/pi-deepseek-websearch/compare/v0.1.5...v0.1.6
98
+ [0.1.5]: https://github.com/DumbMice/pi-deepseek-websearch/compare/v0.1.4...v0.1.5
76
99
  [0.1.4]: https://github.com/DumbMice/pi-deepseek-websearch/compare/v0.1.3...v0.1.4
77
100
  [0.1.3]: https://github.com/DumbMice/pi-deepseek-websearch/compare/v0.1.2...v0.1.3
78
101
  [0.1.2]: https://github.com/DumbMice/pi-deepseek-websearch/compare/v0.1.1...v0.1.2
package/README.md CHANGED
@@ -46,13 +46,17 @@ pi install git:github.com/DumbMice/pi-deepseek-websearch
46
46
 
47
47
  Requirements:
48
48
 
49
- - Node >= 22.6.
49
+ - **pi 1.5.1** (tested).
50
+ - Node >= 22.19, the floor pi itself requires.
50
51
  - A DeepSeek key, from pi's `deepseek` provider (`/login`) or the `DEEPSEEK_API_KEY` variable. Create
51
52
  one at <https://platform.deepseek.com/api_keys>.
52
53
  - Optional, for a compatible gateway: set `PI_DEEPSEEK_BASE_URL` or `DEEPSEEK_SEARCH_BASE_URL`. A
53
54
  trailing slash or `/v1` suffix is stripped. The bare `DEEPSEEK_BASE_URL` is ignored on purpose,
54
55
  because other tools point it at an OpenAI-compatible `/v1` root.
55
56
 
57
+ Last verified 2026-11-06; the table and what was checked is in
58
+ [docs/pi-api-alignment.md](https://github.com/DumbMice/pi-deepseek-websearch/blob/main/docs/pi-api-alignment.md).
59
+
56
60
  Confirm registration by starting `pi` and asking *"use deepseek_search to look up the DeepSeek API
57
61
  pricing"*; the reply begins with a `DeepSeek search · N result(s)` header.
58
62
 
@@ -104,9 +108,11 @@ an error, not as an empty success.
104
108
 
105
109
  ## Behaviour to expect
106
110
 
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.
111
+ - **Long queries are trimmed, not rejected.** The 128-character cap includes operators. When a query
112
+ is shortened, trailing words are removed and the filters are kept (a single word or quoted
113
+ phrase longer than the remaining room is cut); the reply names what went and prints the final query. If the filters alone
114
+ leave no room for search text, the call fails before any request — shorten the domain list or
115
+ remove a filter to send it.
110
116
  - **Transient failures are retried.** `408`, `429`, any `5xx`, network failures and timeouts get up
111
117
  to three attempts, honouring `retry-after` (seconds or an HTTP date) capped at 5 seconds. Every
112
118
  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
@@ -0,0 +1,42 @@
1
+ # pi extension API alignment
2
+
3
+ How this extension's use of pi's extension API relates to the documented contract, and which exact
4
+ versions that was established against.
5
+
6
+ | Component | Tested version | Recorded in |
7
+ |---|---|---|
8
+ | `@earendil-works/pi-coding-agent` | **1.5.1** | `PI_API_SNAPSHOT` in `src/index.ts` |
9
+ | `@earendil-works/pi-schema` (tool validation) | ships with pi 1.5.1 | this file (notes below) |
10
+ | Node | 22.23.2 (tested); floor 22.19, the floor pi itself requires | `package.json` `engines`, README *Install* |
11
+ | DeepSeek Search & Fetch API | docs snapshot 2026-10-30 | `DOCS_SNAPSHOT`; see `docs/docs-alignment.md` |
12
+
13
+ Last verified **2026-11-06**. Bump a row here, its constant where one exists, and the README
14
+ *Install* line in the same commit as the change that depends on it.
15
+
16
+ ## What this extension actually uses
17
+
18
+ The contract is followed where it is documented, and nothing here calls pi internals:
19
+
20
+ - `pi.registerTool()` with a hand-written JSON Schema `parameters` object. pi validates any JSON
21
+ Schema; the `typebox` import in the docs is a convenience, and avoiding it keeps the package
22
+ dependency-free.
23
+ - `execute(toolCallId, params, signal, onUpdate, ctx)` — the five-argument form.
24
+ - `ctx.modelRegistry.getApiKeyForProvider("deepseek")` for credentials, falling back to
25
+ `DEEPSEEK_API_KEY`.
26
+ - The `pi.extensions` manifest entry in `package.json`.
27
+
28
+ It registers no commands, hooks, renderers or UI, so a release that only changes those surfaces
29
+ cannot affect this package.
30
+
31
+ ## Notes on the 1.5.x line
32
+
33
+ 1. **Tool validation moved to `@earendil-works/pi-schema` in 1.5.0.** TypeBox features outside JSON
34
+ Schema no longer apply; plain JSON Schema tools now get the same coercion as TypeBox tools
35
+ (numeric strings to numbers, `"true"` to `true`, a single value to a one-item array, `null` in an
36
+ optional property dropped). This package uses only JSON Schema keywords, so nothing was lost, and
37
+ the coercion is a net improvement: `allowed_domains: "example.com"` now arrives as
38
+ `["example.com"]` instead of being rejected.
39
+ 2. **Package changelogs are read from the package root by 1.5.x.** `CHANGELOG.md` uses the required
40
+ `## [x.y.z]` headings, so extension updates surface our notes without a `pi.changelogPath` entry.
41
+ 3. **Node floor.** pi 1.5.1 declares `>=22.19.0`; this package declares the same floor, because it
42
+ only runs inside pi.
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.6",
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",
@@ -41,12 +41,12 @@
41
41
  ]
42
42
  },
43
43
  "scripts": {
44
- "test": "node --experimental-strip-types --no-warnings --test test/index.test.ts",
44
+ "test": "node --experimental-strip-types --no-warnings --test test/index.test.ts test/docs.test.ts",
45
45
  "typecheck": "tsc -p tsconfig.json",
46
46
  "pack:check": "npm pack --dry-run"
47
47
  },
48
48
  "engines": {
49
- "node": ">=22.6"
49
+ "node": ">=22.19"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@types/node": "^22.20.5",
package/src/index.ts CHANGED
@@ -21,6 +21,7 @@
21
21
  import { isIP } from "node:net";
22
22
 
23
23
  export const DOCS_SNAPSHOT = "2026-10-30";
24
+ export const PI_API_SNAPSHOT = "2026-11-06";
24
25
  export const PRICE_PER_SUCCESSFUL_REQUEST_USD = 0.00075;
25
26
  export const MAX_QUERY_CHARS = 128;
26
27
 
@@ -242,10 +243,9 @@ async function postDeepSeek<T>(
242
243
  /* ------------------------------------------------------------------- query */
243
244
 
244
245
  /**
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.
246
+ * Fit the assembled query into the API's hard limit (docs: 1-128 Unicode characters,
247
+ * operators included). Filters are explicit constraints, so they are kept and the
248
+ * free-text part is shortened instead. Every removal is reported.
249
249
  */
250
250
  export function fitQuery(options: SearchOptions): { query: string; dropped: string[] } {
251
251
  const dropped: string[] = [];
@@ -272,33 +272,36 @@ export function fitQuery(options: SearchOptions): { query: string; dropped: stri
272
272
  const untouched = [raw, ...filters].filter(Boolean).join(" ");
273
273
  if (charLength(untouched) <= MAX_QUERY_CHARS) return { query: untouched, dropped };
274
274
 
275
- // Only a query that cannot fit is rewritten, token by token, and every change is reported.
275
+ // The filters occupy the tail of the string, so they set the budget for the words.
276
+ const filterText = filters.join(" ");
277
+ const filterCost = filterText ? charLength(filterText) + 1 : 0;
278
+ const available = MAX_QUERY_CHARS - filterCost;
279
+ if (available < 1) {
280
+ const names = [
281
+ options.allowedDomains.length > 0 ? "allowed_domains" : "",
282
+ options.blockedDomains.length > 0 ? "blocked_domains" : "",
283
+ options.lang ? "lang" : "",
284
+ options.recency ? "recency" : "",
285
+ ].filter(Boolean);
286
+ const named =
287
+ names.length > 1 ? `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}` : names[0]!;
288
+ throw new Error(
289
+ `The ${named} ${names.length > 1 ? "filters need" : "filter needs"} ${filterCost} characters ` +
290
+ "of the 128-character query limit, leaving no room for the search text. " +
291
+ "Shorten the filter values or remove a filter.",
292
+ );
293
+ }
294
+
276
295
  // An unescaped quoted phrase is one token: slicing mid-phrase would change its meaning.
277
296
  // Backslash-escaped quotes are not special-cased (only reachable on over-length queries).
278
297
  const matchedTokens = raw.match(/"[^"]*"|\S+/g);
279
- const tokens: string[] = matchedTokens ? [...matchedTokens] : [raw];
298
+ let words: string[] = matchedTokens ? [...matchedTokens] : [raw];
280
299
  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
- }
300
+ if (raw !== words.join(" ")) dropped.push("query whitespace normalised");
299
301
 
300
302
  const wordsBefore = words.join(" ");
301
- while (words.length > 1 && overBudget()) words = words.slice(0, -1);
303
+ while (words.length > 1 && charLength(words.join(" ")) > available) words = words.slice(0, -1);
304
+
302
305
  // Never leave a dangling operator (`site:`, `-site:`, `after:`, a bare `-`) or an
303
306
  // unbalanced quote behind: DeepSeek would answer 400 and no retry would help.
304
307
  const danglingOperator = /^(?:-|-[A-Za-z]+:|[A-Za-z]+:)$/;
@@ -313,20 +316,37 @@ export function fitQuery(options: SearchOptions): { query: string; dropped: stri
313
316
  dropped.push("trailing query words");
314
317
  }
315
318
 
316
- let query = join(words, active);
319
+ let query = join(words, filters);
317
320
  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();
321
+ // One indivisible token longer than the remaining budget: cutting it is unavoidable,
322
+ // so do that by code point rather than dropping a filter.
323
+ let cut = Array.from(words.join(" ")).slice(0, available).join("").trim();
324
+ // Repeat until stable: splicing an unmatched quote can expose a trailing `-`/`:`.
325
+ for (let changed = true; changed; ) {
326
+ changed = false;
327
+ const stripped = cut.replace(/[-:]+$/, "").trimEnd();
328
+ if (stripped !== cut) {
329
+ cut = stripped;
330
+ changed = true;
331
+ }
332
+ if (quotesWereBalanced && (cut.match(/"/g) ?? []).length % 2 === 1) {
333
+ const balanced = Array.from(cut);
334
+ const lastQuote = balanced.lastIndexOf('"');
335
+ if (lastQuote >= 0) balanced.splice(lastQuote, 1);
336
+ const next = balanced.join("").trimEnd();
337
+ if (next !== cut) {
338
+ cut = next;
339
+ changed = true;
340
+ }
341
+ }
342
+ }
343
+ if (cut === "" && filters.length === 0) {
344
+ throw new Error(
345
+ "The query has no searchable text within the 128-character limit — it consists only of " +
346
+ "operators or punctuation. Add search terms.",
347
+ );
329
348
  }
349
+ query = join([cut], filters);
330
350
  dropped.push("query tail");
331
351
  }
332
352
  return { query, dropped };
@@ -502,7 +522,7 @@ const SEARCH_SCHEMA = {
502
522
  type: "string",
503
523
  minLength: 1,
504
524
  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.',
525
+ '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
526
  },
507
527
  count: {
508
528
  type: "integer",
@@ -524,13 +544,13 @@ const SEARCH_SCHEMA = {
524
544
  type: "array",
525
545
  items: { type: "string" },
526
546
  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.",
547
+ "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
548
  },
529
549
  blocked_domains: {
530
550
  type: "array",
531
551
  items: { type: "string" },
532
552
  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.",
553
+ "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
554
  },
535
555
  lang: { type: "string", description: 'Language codes, e.g. "zh,en" (becomes lang:).' },
536
556
  recency: {