@dumbmice/pi-deepseek-websearch 0.1.3 → 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 ADDED
@@ -0,0 +1,158 @@
1
+ # Working on this repository
2
+
3
+ Instructions for anyone — human or coding agent — changing
4
+ `@dumbmice/pi-deepseek-websearch`. For what the extension does and how to install it, read
5
+ [README.md](README.md).
6
+
7
+ ## Layout
8
+
9
+ ```
10
+ src/index.ts the extension: two tool registrations, transport, formatting, guards
11
+ test/index.test.ts node:test suite; global fetch is stubbed, nothing touches the network
12
+ docs/docs-alignment.md every client-side deviation from DeepSeek's published API contract
13
+ README.md user-facing documentation
14
+ AGENTS.md this file
15
+ CONTRIBUTING.md four-line pointer to this file (GitHub convention)
16
+ CHANGELOG.md Keep a Changelog format, released and unreleased entries
17
+ package.json scripts, engines, the files allow-list, pi.extensions entry point
18
+ tsconfig.json the strict / noEmit settings used by typecheck
19
+ .github/workflows/ test.yml (Node 22 and 24) and publish.yml (trusted publishing)
20
+ ```
21
+
22
+ ## Requirements and commands
23
+
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
26
+ publish workflow installs with `npm ci`, the test matrix with `npm install`.
27
+
28
+ ```bash
29
+ npm install # devDependencies only: typescript, @types/node
30
+ npm test # node:test, mocked HTTP, no key required
31
+ npm run typecheck # tsc --noEmit, strict
32
+ npm run pack:check # tarball contents: package.json, src, docs, README, AGENTS, CONTRIBUTING, LICENSE, CHANGELOG
33
+ ```
34
+
35
+ ## Design decisions worth preserving
36
+
37
+ These are the invariants the suite protects. Changing one means changing its test, any affected
38
+ README text, the matching `docs/docs-alignment.md` entry (or marking that item silent), and adding a
39
+ `CHANGELOG.md` line — all in the same commit.
40
+
41
+ - **No runtime dependencies.** The only import is `node:net`. Everything else is the platform. Do not
42
+ add a dependency for argument validation, HTTP, or markdown cleanup.
43
+ - **Plain JSON Schema, not TypeBox.** The `parameters` are literal JSON Schema objects rather than
44
+ TypeBox builders. That keeps the schema out of the dependency graph, which is the point of the
45
+ no-runtime-dependencies rule. Do not replace them with `Type.Object` imports.
46
+ - **A fitting query is never rewritten.** `fitQuery` returns the caller's text untouched when it fits,
47
+ apart from stripped leading/trailing whitespace. Only an over-long query is tokenised and trimmed.
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).
54
+ - **Cost follows the raw response.** `estimatedCostUsd` is keyed to `rawResults.length`, not to what
55
+ survives client-side filtering, because DeepSeek bills a non-empty result set.
56
+ - **A malformed 200 is an error.** A body without a `results` array is rejected; an empty array is a
57
+ legitimate "no results".
58
+ - **Abort is not failure.** The caller's abort reason is preserved on every path, including retry
59
+ backoff, and an already-aborted signal issues no request.
60
+ - **Never trust response field types.** `formatResult` degrades per field instead of failing the call.
61
+ - **Code points, not UTF-16 units.** Every documented length cap (`128`, `8192`, `max_chars`, the
62
+ 4,000-character result body) uses `Array.from`.
63
+ - **Endpoint and credential precedence is deliberate.** Key: pi's `deepseek` provider, then
64
+ `DEEPSEEK_API_KEY`. Base URL: `PI_DEEPSEEK_BASE_URL`, then `DEEPSEEK_SEARCH_BASE_URL`, then
65
+ `https://api.deepseek.com`, with a trailing slash or `/v1` suffix stripped. The bare
66
+ `DEEPSEEK_BASE_URL` is ignored on purpose — other tools point it at an OpenAI-compatible `/v1`
67
+ root. Pinned by the `baseUrl` test.
68
+ - **The private-address guard is a policy, not a protocol rule.** It normalises trailing dots and
69
+ decodes IPv4 embedded in IPv6 (`::ffff:127.0.0.1`, `::7f00:1`, NAT64 `64:ff9b::…`). It performs no
70
+ DNS lookup, so a public hostname resolving to a private address is intentionally not caught.
71
+ - **No secrets in output.** The API key is redacted from upstream error text and network failures, and
72
+ error bodies are sliced before display. The redactor only substitutes keys longer than 8 characters,
73
+ which every real DeepSeek key is.
74
+
75
+ ## Adding or changing a tool
76
+
77
+ Register the tool with `pi.registerTool({ … })` inside the default export; pi finds the entry point
78
+ through `package.json` → `pi.extensions`.
79
+
80
+ - `name`, `label`, `description`, `promptSnippet` are model-facing. State when to use the tool and what
81
+ comes back, and keep the parameter descriptions truthful about limits.
82
+ - `parameters` is literal JSON Schema (`type: "object"`, `required`, `additionalProperties: false`).
83
+ - `execute(toolCallId, params, signal, onUpdate, ctx)` reads credentials through
84
+ `ctx.modelRegistry`, passes the abort signal into `postDeepSeek`, and returns
85
+ `{ content: [{ type: "text", text }], details }`.
86
+ - `details` carries machine-readable metadata only — counts, what was trimmed or discarded, cost. Do
87
+ not repeat the rendered text, and keep the object JSON-serialisable (no `undefined`, `NaN` or
88
+ `Infinity`).
89
+ - Then update the README (parameters and behaviour), `docs/docs-alignment.md` if behaviour deviates,
90
+ and `CHANGELOG.md` under `[Unreleased]`. Keep `package.json` `files` and `pi.extensions` in step.
91
+
92
+ ## Tests
93
+
94
+ `test/index.test.ts` stubs `globalThis.fetch` (`harness()` and `stubFetch()`) and asserts behaviour,
95
+ not implementation trivia. Restore the stub in a `finally` block. When fixing a bug, add the failing
96
+ case first; several rounds of review found real defects this way (operator-tail trimming, quote
97
+ parity, abort reasons, malformed bodies). Run `npm test` before every commit; CI repeats it on Node 22
98
+ and 24 together with `npm run typecheck` and `npm run pack:check`.
99
+
100
+ ### Try a change locally
101
+
102
+ There is no build step: pi loads `src/index.ts` through `package.json` → `pi.extensions`.
103
+
104
+ ```bash
105
+ pi -e /absolute/path/to/pi-deepseek-websearch # one session, straight from the working copy
106
+ ```
107
+
108
+ A live call needs a real key and is billed $0.00075 per successful request; one search and one fetch
109
+ is enough to check rendering. `npm test` needs neither key nor network.
110
+
111
+ ## Documentation conventions
112
+
113
+ - `README.md` is for users of the extension: what the tools do, when to use them, parameters, cost,
114
+ observable behaviour, troubleshooting. No implementation inventories, no CI or release steps.
115
+ - `docs/docs-alignment.md` is the register of deliberate differences from DeepSeek's API. Add an item
116
+ when behaviour deviates and pin it with a test wherever the behaviour is observable; items that
117
+ produce no output signal are marked _(silent)_. `DOCS_SNAPSHOT` appears in the `src/index.ts` header
118
+ and constant and at the top of the register — keep them in step.
119
+ - `CHANGELOG.md` follows Keep a Changelog. Put unreleased work under `[Unreleased]` with
120
+ Added/Changed/Fixed headings; add the version heading when the release is tagged.
121
+ - `AGENTS.md` (this file) holds contributor, architecture and release material.
122
+
123
+ ## Release process
124
+
125
+ Publishing uses npm [trusted publishing](https://docs.npmjs.com/trusted-publishers): the workflow
126
+ exchanges a GitHub OIDC token for a short-lived credential, so the repository holds no `NPM_TOKEN`
127
+ and no long-lived write token is needed. Because the repository is public, npm attaches a provenance
128
+ attestation automatically — no `--provenance` flag.
129
+
130
+ ```bash
131
+ npm version patch --no-git-tag-version # or minor/major; the working tree must be clean
132
+ VERSION="$(node -p "require('./package.json').version")"
133
+ # add the CHANGELOG.md entry for this version, then:
134
+ git add -A # include any new docs; -am skips untracked files
135
+ git commit -m "chore: release v$VERSION"
136
+ git tag -a "v$VERSION" -m "v$VERSION"
137
+ git push origin main --follow-tags
138
+ ```
139
+
140
+ `--follow-tags` pushes only **annotated** tags, which is why the recipe uses `git tag -a`; a
141
+ lightweight tag would stay local and publish nothing. `.github/workflows/publish.yml` runs the suite
142
+ and the typecheck, refuses to publish when the tag and `package.json` version disagree, then publishes.
143
+ A manual `workflow_dispatch` run skips the tag guard and publishes the selected ref as-is — use it only
144
+ to retry a failed tag build.
145
+
146
+ Optionally, after a successful trusted publish, set the package's *Publishing access* to "Require
147
+ two-factor authentication and disallow tokens": trusted publishing keeps working, token publishing
148
+ stops.
149
+
150
+ ## Commits and security
151
+
152
+ - Commits follow Conventional Commits (`feat:`, `fix:`, `docs:`, `ci:`, `chore:`); release commits are
153
+ `chore: release vX.Y.Z`.
154
+ - Style: tabs and double quotes, no formatter or linter in the repository. Do not reformat untouched
155
+ lines.
156
+ - Report vulnerabilities through GitHub's private advisory form (Security → Report a vulnerability),
157
+ not a public issue.
158
+ - Contributions are accepted under the MIT license (see [LICENSE](LICENSE)).
package/CHANGELOG.md CHANGED
@@ -1,75 +1,89 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.3 — 2026-10-30
3
+ All notable changes to this extension are documented in this file. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4
6
 
5
- - README is user-facing only: the development and release instructions moved to
6
- CONTRIBUTING.md, with a single pointer left behind.
7
+ ## [Unreleased]
7
8
 
8
- ## 0.1.2 — 2026-10-30
9
+ ## [0.1.5] - 2026-11-06
9
10
 
10
- - README: document the release path in full — trusted publishing via GitHub OIDC, no
11
- `NPM_TOKEN`, automatic provenance, the tag/version guard, and the option to disallow
12
- token publishing once trusted publishing is proven.
11
+ ### Fixed
13
12
 
14
- ## 0.1.1 — 2026-10-30
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.
15
16
 
16
- Documentation-only release: npm snapshots the README at publish time, so this
17
- republishes the corrected text.
17
+ ## [0.1.4] - 2026-10-30
18
18
 
19
- - Install command moved directly under the intro and no longer says "once the
20
- package is published".
21
- - The sample-output block no longer overflows horizontally; the per-result
22
- fields it used to cram into a comment are described in the prose below it.
23
- - README badges: npm version, license, Node engine, CI status, and a
24
- "coexists with pi-web-access" badge (a downloads badge is deferred until the
25
- package has statistics).
19
+ ### Added
26
20
 
27
- ## 0.1.0 — 2026-10-30
21
+ - Test coverage for previously unpinned behaviour: the API request is sent with `redirect: "error"` so
22
+ a redirect is not followed (register item 15), upstream error text is sliced to 400 characters
23
+ (register item 22), Fetch output truncation and the `max_chars` upper clamp, the `retry-after` delay
24
+ and its 5 second cap, and rejection of domain filters that would rewrite the query structure.
28
25
 
29
- First release. Package name: `@dumbmice/pi-deepseek-websearch` (web search and page fetch over
30
- DeepSeek's Search & Fetch APIs); published scoped because npm treats the unscoped name as too
31
- similar to the existing `pi-deepseek-web-search`.
26
+ ### Changed
32
27
 
33
- **Tools**
28
+ - Documentation overhaul. The README is now strictly for users of the extension: what the tools do,
29
+ when to use them, parameters, cost, observable behaviour and troubleshooting. The list of deliberate
30
+ differences from DeepSeek's API moved to `docs/docs-alignment.md`. Contributor, architecture and
31
+ release material moved to `AGENTS.md` (`CONTRIBUTING.md` now points there), including the corrected
32
+ release recipe with annotated tags. This changelog moved to the Keep a Changelog format.
33
+ - Package metadata: the npm description and keywords were rewritten, `files` now also ships `docs/`,
34
+ `AGENTS.md` and `CONTRIBUTING.md` so the deviation register and contributor guide travel with the
35
+ package, `author.name` is `DumbMice`, and the `LICENSE` copyright holder changed to `DumbMice` to
36
+ match.
34
37
 
35
- - `deepseek_search` and `deepseek_fetch` over `POST /v1/tools/search` and `POST /v1/tools/fetch`.
36
- - Blank/whitespace-only `query` is rejected locally with a clear message.
37
- - Search parameters: `count` 1–50, `ori_question`, `need_content`, `localization`, `allowed_domains`, `blocked_domains`, `lang`, `recency`.
38
- - Fetch parameters: `url`, `max_chars` (500–200,000, default 20,000).
39
- - Result metadata: `date`, `authority` (`-1` → *unknown*), `rerank_score` (≥1.5 annotated), `site_name`; `site_icon` available in `details`.
40
- - `details` reports the final query, anything trimmed, discarded non-http(s) results, and `estimatedCostUsd`.
38
+ ### Fixed
41
39
 
42
- **Query handling**
40
+ - The contributor release recipe paired `git tag` with `git push --follow-tags`, which pushes only
41
+ annotated tags, so the documented steps would not have published anything. The recipe now creates an
42
+ annotated tag after the version bump.
43
43
 
44
- - A fitting query is sent exactly as written, spacing included.
45
- - An over-length query is trimmed instead of rejected: recency → language → domain filters → trailing words, with a final code-point cut only if a single token exceeds the budget. Every removal is reported.
46
- - Trimming is token-aware: quoted phrases stay whole and a trailing operator is never left dangling.
47
- - `ori_question` longer than 128 code points is cut, and the cut is reported in the output and `details`; the tool-parameter schema has no `maxLength`, so the code-point cut is authoritative.
48
- - `details.requestedCount` reports the pre-clamp `count` ask.
49
- - Unknown `recency` values and non-IP `localization` values are rejected with an actionable message.
44
+ ## [0.1.3] - 2026-10-30
50
45
 
51
- **Transport**
46
+ ### Changed
52
47
 
53
- - Retries for `408`, `429`, any `5xx`, network failures and timeouts; 3 attempts; `retry-after` honoured in seconds or HTTP-date form, capped at 5 s.
54
- - Documented remedies for `401`/`402`/`403`/`404`/`413`/`429`; all other `4xx` fail immediately.
55
- - 30 s search / 60 s fetch timeouts; the caller's abort reason is preserved on every path, including during retry backoff.
56
- - A 200 search response without a `results` array is rejected as an invalid response rather than reported as "No results".
57
- - Endpoint override via `PI_DEEPSEEK_BASE_URL` or `DEEPSEEK_SEARCH_BASE_URL`, with `/v1` and trailing slashes stripped.
58
- - API key from pi's `deepseek` provider, then `DEEPSEEK_API_KEY`; redacted from all error output, and upstream error text is sliced to 400 chars.
59
- - Fetch URLs are sent normalised, and the 8192-char limit is enforced on both the raw and normalised form.
48
+ - README kept user-facing: development and release instructions moved to `CONTRIBUTING.md`, with a
49
+ single pointer left behind.
60
50
 
61
- **Safety**
51
+ ## [0.1.2] - 2026-10-30
62
52
 
63
- - `deepseek_fetch` refuses localhost (including trailing-dot forms), IPv4 private/loopback/CGNAT/link-local/benchmarking ranges, IPv6 `::`, `::1`, `fc00::/7`, `fe80::/10`, and IPv4-mapped IPv6 forms.
64
- - Markdown unescaping never applies inside fenced code blocks (including indented fences) or inline code spans.
65
- - Malformed result fields degrade instead of failing the whole call.
53
+ ### Changed
66
54
 
67
- **Cost and compatibility**
55
+ - README documents the release path: trusted publishing through GitHub OIDC, no `NPM_TOKEN`,
56
+ automatic provenance, the tag/version guard, and the option to disallow token publishing.
68
57
 
69
- - `estimatedCostUsd` = $0.00075 per billable request, following the raw result array so client-side filtering cannot under-report; 0 for an empty result set.
70
- - Coexists with `pi-web-access`: distinct tool names, no config keys.
71
- - Validated against DeepSeek API docs as of 2026-10-30.
58
+ ## [0.1.1] - 2026-10-30
72
59
 
73
- **Tests**
60
+ ### Changed
74
61
 
75
- - 68 `node:test` cases with mocked HTTP — no key or network required.
62
+ - Documentation only: install command moved up, sample output reflowed, README badges added (a
63
+ downloads badge is deferred until the package has statistics).
64
+
65
+ ## [0.1.0] - 2026-10-30
66
+
67
+ Published at $0.00075 per billable request for both Search and Fetch; failures, timeouts and empty
68
+ results are not billed. Validated against the DeepSeek API documentation as of 2026-10-30.
69
+
70
+ ### Added
71
+
72
+ - `deepseek_search` and `deepseek_fetch`, over `POST /v1/tools/search` and `POST /v1/tools/fetch`.
73
+ - Search parameters: `count` (1–50), `ori_question`, `need_content`, `localization`,
74
+ `allowed_domains`, `blocked_domains`, `lang`, `recency`. Fetch parameters: `url`, `max_chars`.
75
+ - Result metadata: publication date, authority tier, rerank score and site name, with `site_icon`,
76
+ mapped results and run metadata in `details`.
77
+ - Client-side trimming of over-length queries, reporting every removal.
78
+ - Retries for `408`, `429`, any `5xx`, network failures and timeouts, three attempts, with
79
+ `retry-after` honoured up to 5 seconds.
80
+ - `deepseek_fetch` rejects local and private targets and never unescapes inside code.
81
+
82
+
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
85
+ [0.1.4]: https://github.com/DumbMice/pi-deepseek-websearch/compare/v0.1.3...v0.1.4
86
+ [0.1.3]: https://github.com/DumbMice/pi-deepseek-websearch/compare/v0.1.2...v0.1.3
87
+ [0.1.2]: https://github.com/DumbMice/pi-deepseek-websearch/compare/v0.1.1...v0.1.2
88
+ [0.1.1]: https://www.npmjs.com/package/@dumbmice/pi-deepseek-websearch/v/0.1.1
89
+ [0.1.0]: https://www.npmjs.com/package/@dumbmice/pi-deepseek-websearch/v/0.1.0
@@ -0,0 +1,4 @@
1
+ # Contributing
2
+
3
+ See [AGENTS.md](AGENTS.md) for the repository layout, tests, design invariants, documentation
4
+ conventions and release process.
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 pi-deepseek-websearch contributors
3
+ Copyright (c) 2026 DumbMice
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,17 +1,21 @@
1
1
  # @dumbmice/pi-deepseek-websearch
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/@dumbmice/pi-deepseek-websearch)](https://www.npmjs.com/package/@dumbmice/pi-deepseek-websearch)
4
- [![license](https://img.shields.io/npm/l/@dumbmice/pi-deepseek-websearch)](LICENSE)
5
- [![node](https://img.shields.io/node/v/@dumbmice/pi-deepseek-websearch)](package.json)
4
+ [![license](https://img.shields.io/npm/l/@dumbmice/pi-deepseek-websearch)](https://github.com/DumbMice/pi-deepseek-websearch/blob/main/LICENSE)
5
+ [![node](https://img.shields.io/node/v/@dumbmice/pi-deepseek-websearch)](https://github.com/DumbMice/pi-deepseek-websearch/blob/main/package.json)
6
6
  [![tests](https://github.com/DumbMice/pi-deepseek-websearch/actions/workflows/test.yml/badge.svg)](https://github.com/DumbMice/pi-deepseek-websearch/actions/workflows/test.yml)
7
- [![coexists with pi-web-access](https://img.shields.io/badge/coexists%20with-pi--web--access-2ea44f)](#why)
8
7
 
9
- DeepSeek **Search** and **Fetch** API tools for the [pi coding agent](https://pi.dev) — direct access to the fields and parameters that generic web-search wrappers throw away.
8
+ Two tools for the [pi coding agent](https://pi.dev), wrapping DeepSeek's Search and Fetch APIs.
9
+ `deepseek_search` returns ranked web results with a publication date, authority tier and rerank score
10
+ per hit, plus `ori_question` and `localization` for steering the ranking. `deepseek_fetch` returns a
11
+ page as Markdown.
10
12
 
11
- Two tools, no dependencies, and it **coexists with `pi-web-access`**: different tool names, no web-search.json keys of its own, no settings to migrate.
13
+ ## Example output
14
+
15
+ `deepseek_search` with `need_content: true` and `count: 2`, second result omitted:
12
16
 
13
17
  ```text
14
- DeepSeek search · 2 results ← abridged
18
+ DeepSeek search · 2 results
15
19
  query: DeepSeek V4 Pro release
16
20
 
17
21
  1. DeepSeek-V4-Pro GA Release | DeepSeek API Docs
@@ -21,23 +25,14 @@ query: DeepSeek V4 Pro release
21
25
  We're launching DeepSeek-V4-Pro today! …
22
26
  ```
23
27
 
24
- Every result also carries site, publication date, authority tier and rerank score; `details` holds the full set.
25
-
26
- ## Why
28
+ ## When to use it
27
29
 
28
- `pi-web-access` maps DeepSeek's Search API onto its generic provider interface (compared against `pi-web-access@0.44.0`, verified 2026-10-30). That is the right shape for a 40-provider router, but it loses what makes DeepSeek's API useful:
29
-
30
- | | `pi-web-access` DeepSeek provider | this extension |
31
- |---|---|---|
32
- | `date`, `authority`, `rerank_score` | dropped | rendered per result |
33
- | `ori_question` (ranking hint) | never sent | `ori_question` param |
34
- | `localization` | never sent | `localization` param |
35
- | `count` | hidden, clamped to 20 | `count` param, 1–50 |
36
- | query > 128 chars | hard error, no fallback | trimmed, with the removals reported |
37
- | 429 / 5xx | no retry | 3 attempts honouring `retry-after` |
38
- | `need_content` | only via a generic flag | `need_content` param |
39
-
40
- Use it when you need DeepSeek's ranking, publication dates, or a specific `count`. Keep `pi-web-access` for routing across providers, `fetch_content`'s extraction chain, and `source_check`.
30
+ - The answer depends on **when** something was published, or on an authoritative source — the results
31
+ carry `date` and `authority` where a generic search returns bare snippets.
32
+ - The agent needs a **specific count**, from 1 to 50.
33
+ - The user's original question can ride along as `ori_question`, which improves ranking.
34
+ - The agent should read the page rather than the snippet: `need_content` returns extracted text, and
35
+ `deepseek_fetch` reads a known URL.
41
36
 
42
37
  ## Install
43
38
 
@@ -45,32 +40,21 @@ Use it when you need DeepSeek's ranking, publication dates, or a specific `count
45
40
  # from npm
46
41
  pi install npm:@dumbmice/pi-deepseek-websearch
47
42
 
48
- # straight from GitHub
43
+ # straight from the repository
49
44
  pi install git:github.com/DumbMice/pi-deepseek-websearch
50
-
51
- # from a working copy (no publish required)
52
- pi install /absolute/path/to/pi-deepseek-websearch
53
-
54
- # try it for one session
55
- pi -e /absolute/path/to/pi-deepseek-websearch
56
45
  ```
57
46
 
58
- Requires Node >= 22.6 (`engines`; the shipped entry point is TypeScript).
47
+ Requirements:
59
48
 
60
- If you previously installed a single-file copy of this extension in `~/.pi/agent/extensions/`, remove it first — two copies would register `deepseek_search` twice. `pi-web-access` itself needs no change.
49
+ - Node >= 22.6.
50
+ - A DeepSeek key, from pi's `deepseek` provider (`/login`) or the `DEEPSEEK_API_KEY` variable. Create
51
+ one at <https://platform.deepseek.com/api_keys>.
52
+ - Optional, for a compatible gateway: set `PI_DEEPSEEK_BASE_URL` or `DEEPSEEK_SEARCH_BASE_URL`. A
53
+ trailing slash or `/v1` suffix is stripped. The bare `DEEPSEEK_BASE_URL` is ignored on purpose,
54
+ because other tools point it at an OpenAI-compatible `/v1` root.
61
55
 
62
- Confirm it registered: start `pi`, ask *"use deepseek_search to look up the DeepSeek API pricing"*, and you should see a `DeepSeek search · N results` header. Or check the tool list in your session.
63
-
64
- ## Credentials
65
-
66
- The extension uses, in order:
67
-
68
- 1. pi's `deepseek` provider credentials (`ctx.modelRegistry.getApiKeyForProvider("deepseek")`) — i.e. `/login` or `auth.json`;
69
- 2. `DEEPSEEK_API_KEY`.
70
-
71
- Endpoint override: `PI_DEEPSEEK_BASE_URL`, then `DEEPSEEK_SEARCH_BASE_URL` (default `https://api.deepseek.com`; a trailing slash or `/v1` suffix is stripped). The bare `DEEPSEEK_BASE_URL` is deliberately ignored: other tools commonly set it to an OpenAI-compatible `/v1` root.
72
-
73
- Get a key at <https://platform.deepseek.com/api_keys>.
56
+ Confirm registration by starting `pi` and asking *"use deepseek_search to look up the DeepSeek API
57
+ pricing"*; the reply begins with a `DeepSeek search · N result(s)` header.
74
58
 
75
59
  ## Tools
76
60
 
@@ -78,85 +62,82 @@ Get a key at <https://platform.deepseek.com/api_keys>.
78
62
 
79
63
  | Parameter | Type | Notes |
80
64
  |---|---|---|
81
- | `query` | string, required | Operators pass through: `"exact phrase"`, `-word`, `-"phrase"`, `site:a OR site:b`, `-site:c`, `lang:en,zh`, `after:2025-01-01`, `before:2026-06-01`. Capped at 128 chars; longer queries are trimmed (see below) rather than rejected |
82
- | `count` | integer 1–50 | default 10; the per-request price does not depend on it |
83
- | `ori_question` | string (cut to 128) | your original question, used for ranking; a longer value is cut to 128 code points and the cut is reported in the output |
84
- | `need_content` | boolean | return extracted page text when the API provides it (otherwise the `summary`, or the `snippet` if there is no summary), capped at 4,000 chars per result with truncation reported; same price, more context |
85
- | `allowed_domains` | string[] | compiled to `site:` clauses joined with `OR`; counts toward the 128-char cap |
65
+ | `query` | string, required | capped at 128 characters, operators included |
66
+ | `count` | integer 1–50 | default 10 |
67
+ | `ori_question` | string | the user's question, used for ranking; values over 128 characters are cut and the cut is reported |
68
+ | `need_content` | boolean | return extracted page text when available, otherwise the summary or snippet; 4,000 characters per result, truncation reported |
69
+ | `allowed_domains` | string[] | compiled to `site:` clauses; counts toward the 128-character cap |
86
70
  | `blocked_domains` | string[] | compiled to `-site:` clauses; counts toward the cap |
87
- | `lang` | string | e.g. `"zh,en"` → `lang:zh,en` |
88
- | `recency` | `day`\|`week`\|`month`\|`year` | compiled to `after:<date>` |
89
- | `localization` | string | IPv4/IPv6 literal used to localize results; anything else is rejected |
71
+ | `lang` | string | e.g. `"zh,en"` becomes `lang:zh,en` |
72
+ | `recency` | `day` \| `week` \| `month` \| `year` | compiled to `after:<date>` |
73
+ | `localization` | string | optional IPv4 or IPv6 literal; results are localized to that address's region; country codes are rejected |
74
+
75
+ `query` accepts DeepSeek's operators unchanged: `"exact phrase"`, `-word`, `-"phrase"`,
76
+ `site:a.example OR site:b.example`, `-site:c.example`, `lang:en,zh`, `after:2025-01-01`,
77
+ `before:2026-06-01`.
90
78
 
91
- Each result carries title, URL, site, publication date, authority tier (`0–4`; the API's `-1` renders as *authority unknown*), and rerank score (`≥1.5` annotated as high quality), plus the model-oriented `summary` or the extracted page text. `site_icon` is not rendered but is included in `details`.
79
+ Each result shows title, URL, site, date, authority tier (`0–4`; the API's `-1` appears as *authority
80
+ unknown*), rerank score (`≥1.5` marked high quality), and the summary or extracted text. Results
81
+ without an `http(s)` URL are discarded, and the reply reports how many.
92
82
 
93
- `details` also reports: the final `query`, `dropped` (anything trimmed), `count` (results returned), `requestedCount`, `discarded` (unusable results, e.g. no `http(s)` URL), `oriQuestionTruncated`, `needContent`, the mapped `results[]`, and `estimatedCostUsd`.
83
+ `details` carries the same call in structured form: the final query, the requested and returned
84
+ counts, anything trimmed or discarded, one record per result, and the estimated cost.
94
85
 
95
86
  ### `deepseek_fetch`
96
87
 
97
88
  | Parameter | Type | Notes |
98
89
  |---|---|---|
99
- | `url` | string, required | absolute public HTTP(S), ≤8192 chars, no credentials; local/private targets are refused |
100
- | `max_chars` | integer 500–200000 | default 20000; truncation and clamping are both reported |
101
-
102
- Returns the page as Markdown, rendered and extracted server-side. A page with no extractable content is an error rather than an empty success. `details` reports `url`, `title`, `chars`, `maxChars`, `truncated` and `estimatedCostUsd`.
103
-
104
- ## Behaviour worth knowing
105
-
106
- - **The 128-character limit is real** and includes `site:`, `lang:` and `after:` operators. When the query fits, it is sent exactly as written, spacing included. Only an over-long query is rewritten, and then filters are shed first (recency → language → domains), then trailing words, then — only if a single token exceeds the whole budget — the tail is cut. Every trimming removal is printed as `trimmed to fit 128 chars: …`, so the query itself is never silently shortened. Trimming is token-aware: quoted phrases stay whole, and a trailing operator (`site:`, `after:`, a bare `-`) is dropped rather than left dangling.
107
- - **Retries** cover `408`, `429` and **any** `5xx` — the documented "wait and retry" set — plus network failures and timeouts, up to 3 attempts. `retry-after` is honoured in seconds or HTTP-date form, capped at 5 s. Every other `4xx` fails immediately.
108
- - **Hints** accompany `401` (key), `402` (top up), `403`/`404` on Fetch (security policy, no usable content), `413` (body too large) and `429` (rate limits); `400` surfaces the API's own message.
109
- - **Billing**: $0.75 per 1,000 successful requests for both Search and Fetch (<https://api-docs.deepseek.com/guides/search#billing>). A request is billable when it returns HTTP 200 **and** a non-empty result, so `estimatedCostUsd` follows the raw result array even if every URL is later discarded. Failed calls, empty search results and timeouts are reported as free, because no HTTP 200 is observed for them. `count` does not affect price.
110
- - The docs' note that server-side tool calls are free until 2026-11-01 00:00 Beijing time is scoped to DeepSeek Harness / Claude Code / Codex usage; this extension calls the API directly, so the published per-request price applies.
111
- - **Limits**: 100 QPS search, 25 QPS fetch per account, plus a per-domain fetch limit (<https://api-docs.deepseek.com/guides/search#rate-limits>).
112
- - **Empty is not an error**: a search with no matches returns a normal "No results" answer and costs nothing.
113
-
114
- ## Safety
115
-
116
- - The API key is redacted from every error message before it reaches the model or the transcript.
117
- - `deepseek_fetch` refuses `localhost` and `*.localhost` (including trailing-dot forms), IPv4 loopback/private/CGNAT/link-local/benchmarking ranges, `::`, `::1`, `fc00::/7`, `fe80::/10`, and IPv4-mapped IPv6 forms such as `::ffff:127.0.0.1`.
118
- - This is a **policy** choice, not a docs requirement: DeepSeek fetches pages server-side, so the guard exists to avoid handing intranet URLs to a third party. It matches literal addresses and localhost names only — a public hostname that resolves to a private address is not caught, because no DNS lookup is performed.
119
- - Markdown unescaping of Fetch output (DeepSeek escapes punctuation that was already plain text) never touches fenced blocks — including fences indented inside list items — or inline code spans.
120
-
121
- ## Docs alignment
122
-
123
- Validated against <https://api-docs.deepseek.com/guides/search>, `/api/search` and `/api/fetch` on **2026-10-30** (`DOCS_SNAPSHOT` in `src/index.ts`). The wire contract is followed exactly where the docs define it: endpoints, auth headers, request fields and defaults, response field semantics, price, QPS and the documented error taxonomy.
124
-
125
- Client-side deviations and transport details, listed in full. Items producing no signal in the output are marked _(silent)_.
126
-
127
- 1. over-length queries are trimmed rather than rejected, and every removal is reported; a single token longer than the whole budget is cut mid-token;
128
- 2. query whitespace is normalised only when the query must be rewritten, and that is reported in the same line;
129
- 3. leading/trailing whitespace is stripped from `query`, `ori_question` and `localization` before use _(silent — the API rejects all-whitespace queries anyway)_;
130
- 4. result body text (extracted content, else `summary`, else `snippet`) is capped at 4,000 chars per result; the true length is printed;
131
- 5. `count` is clamped to 1–50 instead of the API returning 400 — the schema declares the range too, and clamping is the fallback if a host passes values through unvalidated; `details.requestedCount` still shows the original ask;
132
- 6. results without an `http(s)` URL are discarded defensively — the count is reported and the price still reflects the raw response;
133
- 7. `site_icon` is surfaced in `details` but not rendered;
134
- 8. `ori_question` longer than 128 code points is cut, and the cut is reported in the output and `details`;
135
- 9. `deepseek_fetch` blocks local/private addresses (docs impose no such rule);
136
- 10. Fetch Markdown is unescaped heuristically (docs only promise Markdown) _(silent; never inside code)_;
137
- 11. `max_chars` truncates Fetch output and clamps out-of-range values (again, the schema declares the range), both reported;
138
- 12. a 200 response with empty Fetch content is treated as an error (docs route empty extraction to 404);
139
- 13. `date` is displayed as `YYYY-MM-DD` while the API returns RFC 3339 — the full timestamp stays in `details`;
140
- 14. the Fetch `url` is sent in its normalised form (`URL.toString()`: IDN punycoding, lowercased host, added `/`), and the 8192-char limit is checked against both the raw and the normalised form;
141
- 15. redirects of the API request itself are not followed (`redirect: "error"` on the call to `api.deepseek.com`), so a 3xx from the API surfaces as a fetch error and is retried like a network failure (item 16). Target-page redirects are DeepSeek's concern, not this client's _(silent)_;
142
- 16. network failures and timeouts are retried beyond the documented `408/429/5xx` list (they are unbilled) _(silent)_;
143
- 17. `retry-after` support is an inference — the docs never mention the header — and the delay is capped at 5 s _(silent)_;
144
- 18. the 128- and 8192-character limits are counted in code points. The docs say "1 to 128 Unicode characters" for `query` and "characters" elsewhere, without fixing code points vs UTF-16 units _(silent)_;
145
- 19. client request timeouts are 30 s (search) and 60 s (fetch) **per attempt**, so a call that keeps timing out can run ~90 s (search) or ~180 s (fetch) plus backoff before the third attempt fails; the docs describe no response deadline _(silent)_;
146
- 20. a 200 search body without a `results` array is rejected as an invalid response instead of being reported as "No results";
147
- 21. `localization` must be an IP literal — the docs only say "an optional IP address", so a country code is rejected client-side;
148
- 22. upstream error messages are sliced to 400 characters before display _(silent)_;
149
- 23. result fields of an unexpected type are ignored rather than passed through, so one malformed result degrades instead of failing the call _(silent)_;
150
- 24. `recency` windows are fixed client-side at 1 / 7 / 30 / 365 days for `day` / `week` / `month` / `year`.
151
-
152
- Transport details, for completeness: requests send `Accept: application/json` alongside the two documented headers; retry backoff is 500 ms then 1 s when no `retry-after` is present (two sleeps for three attempts); and a 200 response with a non-JSON body fails immediately without a retry.
153
-
154
- If DeepSeek changes the API, bump `DOCS_SNAPSHOT` and update the affected tests.
155
-
156
- ## Contributing
157
-
158
- Development setup, tests and the release process live in [CONTRIBUTING.md](https://github.com/DumbMice/pi-deepseek-websearch/blob/main/CONTRIBUTING.md).
90
+ | `url` | string, required | absolute public HTTP(S), at most 8192 characters, no credentials |
91
+ | `max_chars` | integer 500–200000 | default 20000; truncation and clamping are reported |
92
+
93
+ Returns the page as Markdown, extracted server-side. A page with no extractable content is reported as
94
+ an error, not as an empty success.
95
+
96
+ ## Cost and limits
97
+
98
+ - $0.75 per 1,000 successful requests for both tools
99
+ ([billing](https://api-docs.deepseek.com/guides/search#billing)). A search is billable only when the
100
+ API returns HTTP 200 with a non-empty `results` array, and a fetch only when it returns page
101
+ content, so failures, timeouts and empty searches cost nothing. `count` does not change the price.
102
+ - 100 QPS for Search, 25 QPS for Fetch per account, plus a per-domain Fetch limit
103
+ ([rate limits](https://api-docs.deepseek.com/guides/search#rate-limits)).
104
+
105
+ ## Behaviour to expect
106
+
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.
112
+ - **Transient failures are retried.** `408`, `429`, any `5xx`, network failures and timeouts get up
113
+ to three attempts, honouring `retry-after` (seconds or an HTTP date) capped at 5 seconds. Every
114
+ other `4xx` fails immediately with DeepSeek's own message. Each attempt has its own deadline: 30
115
+ seconds for search, 60 for fetch.
116
+ - **A search with no matches is a normal answer**, not an error.
117
+ - **Private targets are refused.** `deepseek_fetch` will not fetch `localhost` or private address
118
+ ranges. This is deliberate: DeepSeek fetches pages server-side, and the guard avoids handing it
119
+ intranet URLs. It matches literal addresses only — no DNS lookup is performed.
120
+ - **The API key is redacted** from upstream error text and network-failure messages before they reach
121
+ the model or the transcript.
122
+
123
+ ## Troubleshooting
124
+
125
+ - **`deepseek_search` appears twice in the tool list.** Remove the older copy — for example a copy you
126
+ placed directly in `~/.pi/agent/extensions/` — then `/reload`.
127
+ - **`DeepSeek API key not found`.** Create a key at <https://platform.deepseek.com/api_keys>,
128
+ configure pi's `deepseek` provider with `/login`, or set `DEEPSEEK_API_KEY`.
129
+ - **The output says `trimmed to fit 128 chars`.** The request was shortened; shorten the query or drop
130
+ filters to avoid it.
131
+
132
+ ## Further reading
133
+
134
+ - **Deviations from DeepSeek's API** — every deliberate difference:
135
+ [docs/docs-alignment.md](https://github.com/DumbMice/pi-deepseek-websearch/blob/main/docs/docs-alignment.md)
136
+ - **Working on this repository** — layout, tests, invariants, release process:
137
+ [AGENTS.md](https://github.com/DumbMice/pi-deepseek-websearch/blob/main/AGENTS.md)
138
+ - **Changes by version**:
139
+ [CHANGELOG.md](https://github.com/DumbMice/pi-deepseek-websearch/blob/main/CHANGELOG.md)
159
140
 
160
141
  ## License
161
142
 
162
- MIT. Issues and source: <https://github.com/DumbMice/pi-deepseek-websearch>.
143
+ MIT. Source and issues: <https://github.com/DumbMice/pi-deepseek-websearch>.
@@ -0,0 +1,84 @@
1
+ # DeepSeek API alignment
2
+
3
+ How this extension's behaviour relates to DeepSeek's published Search & Fetch API contract,
4
+ validated against <https://api-docs.deepseek.com/guides/search>, `/api/search` and `/api/fetch`
5
+ on **2026-10-30** (`DOCS_SNAPSHOT` in `src/index.ts`).
6
+
7
+ The wire contract is followed where the docs define it: endpoints, auth headers, request fields and
8
+ defaults, response field semantics, price, QPS, and the documented error taxonomy. A request this
9
+ extension can emit is always a valid request.
10
+
11
+ Client-side deviations and transport details, listed in full. Items that produce no signal in the
12
+ output are marked _(silent)_; everything else is observable in the reply or in `details`.
13
+
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;
17
+ 2. query whitespace is normalised only when the query must be rewritten, and that is reported in the
18
+ same line;
19
+ 3. leading and trailing whitespace is stripped from `query`, `ori_question` and `localization` before
20
+ use _(silent — for `query` the API rejects an all-whitespace value; the other two are trimmed for
21
+ hygiene)_;
22
+ 4. result body text is the extracted `content` when `need_content` is set, otherwise the `summary`,
23
+ else the `snippet`; the rendered text is capped at 4,000 chars per result and the true length is
24
+ printed;
25
+ 5. `count` is clamped to 1–50 instead of the API returning 400 — the schema declares the range too,
26
+ and clamping is the fallback if a host passes values through unvalidated; `details.requestedCount`
27
+ still shows the caller's numeric ask (floored; a non-numeric or non-finite ask is reported as 10);
28
+ 6. results without an `http(s)` URL are discarded defensively — the count is reported and the price
29
+ still reflects the raw response;
30
+ 7. `site_icon` is surfaced as `siteIcon` in `details` but not rendered;
31
+ 8. `ori_question` longer than 128 code points is cut, and the cut is reported in the output and
32
+ `details`;
33
+ 9. `deepseek_fetch` blocks local and private addresses (the docs impose no such rule). IPv6 forms
34
+ beyond `::ffff:`, `::/96` and NAT64 `64:ff9b::/96` — 6to4 and RFC 8215 — are out of scope;
35
+ 10. Fetch Markdown is unescaped heuristically (the docs only promise Markdown) _(silent; never inside
36
+ code)_;
37
+ 11. `max_chars` truncates Fetch output and clamps out-of-range values (again, the schema declares the
38
+ range), both reported;
39
+ 12. a 200 response with empty Fetch content is treated as an error (the docs route empty extraction to
40
+ 404);
41
+ 13. `date` is displayed as `YYYY-MM-DD` while the API returns RFC 3339 — the full timestamp stays in
42
+ `details`;
43
+ 14. the Fetch `url` is sent in its normalised form (`URL.toString()`: IDN punycoding, lowercased host,
44
+ added `/`), and the 8192-character limit is checked against both the raw and the normalised form;
45
+ 15. redirects of the API request itself are not followed (`redirect: "error"` on the call to
46
+ `api.deepseek.com`), so a 3xx from the API surfaces as a fetch error and is retried like a network
47
+ failure (item 16); target-page redirects are DeepSeek's concern, not this client's. The failure
48
+ message names the redirect rather than the redirect target _(silent)_;
49
+ 16. network failures and timeouts are retried beyond the documented `408`/`429`/`5xx` list (they are
50
+ unbilled) _(silent)_;
51
+ 17. `retry-after` support is an inference — the docs never mention the header — and the delay is
52
+ capped at 5 s _(silent)_;
53
+ 18. the 128- and 8192-character limits are counted in code points. The docs say "1 to 128 Unicode
54
+ characters" for `query` and "characters" elsewhere, without fixing code points versus UTF-16
55
+ units _(silent)_;
56
+ 19. client request timeouts are 30 s (search) and 60 s (fetch) **per attempt**, so a call that keeps
57
+ timing out can run ~90 s (search) or ~180 s (fetch) plus backoff before the third attempt fails;
58
+ the docs describe no response deadline. The duration appears in the failure message;
59
+ 20. a 200 search body without a `results` array is rejected as an invalid response instead of being
60
+ reported as "No results";
61
+ 21. `localization` must be an IP literal — the docs only say "an optional IP address", so a country
62
+ code is rejected client-side;
63
+ 22. upstream error messages are sliced to 400 characters before display _(silent)_;
64
+ 23. result fields of an unexpected type are ignored rather than passed through, so one malformed
65
+ result degrades instead of failing the call _(silent)_;
66
+ 24. `recency` windows are fixed client-side at 1 / 7 / 30 / 365 days for `day` / `week` / `month` /
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)_.
70
+
71
+ Transport details: requests send `Accept: application/json` alongside the two documented headers;
72
+ retry backoff is 500 ms then 1 s when no `retry-after` is present (two sleeps for three attempts); and
73
+ a 200 response with a non-JSON body fails immediately without a retry.
74
+
75
+ ## Maintaining this list
76
+
77
+ Most items are pinned by a test in `test/index.test.ts`. The exceptions are transport-only details
78
+ asserted by inspection — the per-attempt timeout values (item 19) and the `recency` day counts
79
+ (item 24, pinned only as an `after:<date>` clause).
80
+
81
+ Changing a documented behaviour means changing its item here, the matching README text and the test in
82
+ the same commit. `DOCS_SNAPSHOT` lives in `src/index.ts` (header comment and constant) and at the
83
+ top of this file — keep them in step. When DeepSeek revises the API, bump
84
+ that date and re-audit every item.
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@dumbmice/pi-deepseek-websearch",
3
- "version": "0.1.3",
4
- "description": "Web search and page fetch for pi via DeepSeek's Search & Fetch APIs: publication dates, authority, rerank score, ori_question, count up to 50, and graceful 128-char handling. Coexists with pi-web-access.",
3
+ "version": "0.1.5",
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",
7
7
  "author": {
8
- "name": "yangbangcheng",
8
+ "name": "DumbMice",
9
9
  "email": "bangchengyang@hotmail.com"
10
10
  },
11
11
  "homepage": "https://github.com/DumbMice/pi-deepseek-websearch#readme",
@@ -18,18 +18,20 @@
18
18
  "pi-package",
19
19
  "pi-extension",
20
20
  "pi-coding-agent",
21
- "pi",
22
21
  "deepseek",
22
+ "deepseek-api",
23
23
  "web-search",
24
24
  "web-fetch",
25
25
  "search-api",
26
26
  "llm-tools",
27
- "websearch",
28
- "deepseek-search"
27
+ "ai-coding-agent"
29
28
  ],
30
29
  "files": [
31
30
  "src",
31
+ "docs",
32
32
  "README.md",
33
+ "AGENTS.md",
34
+ "CONTRIBUTING.md",
33
35
  "LICENSE",
34
36
  "CHANGELOG.md"
35
37
  ],
package/src/index.ts CHANGED
@@ -30,7 +30,7 @@ const FETCH_TIMEOUT_MS = 60_000;
30
30
  const MAX_ATTEMPTS = 3;
31
31
  const MAX_ORI_QUESTION_CHARS = 128;
32
32
  const MAX_RESULT_CONTENT_CHARS = 4_000;
33
- const MAX_FETCH_CONTENT_CHARS = 20_000;
33
+ const DEFAULT_FETCH_MAX_CHARS = 20_000;
34
34
 
35
35
  export interface SearchOptions {
36
36
  query: string;
@@ -134,7 +134,7 @@ function isRetryableStatus(status: number): boolean {
134
134
  return status === 408 || status === 429 || status >= 500;
135
135
  }
136
136
 
137
- function retryDelayMs(response: Response, attempt: number): number {
137
+ export function retryDelayMs(response: Response, attempt: number): number {
138
138
  const RETRY_CAP_MS = 5_000;
139
139
  const header = response.headers.get("retry-after");
140
140
  if (header) {
@@ -208,17 +208,23 @@ async function postDeepSeek<T>(
208
208
  try {
209
209
  return (await response.json()) as T;
210
210
  } catch {
211
+ if (signal?.aborted) throw abortError(signal);
211
212
  throw new Error(`DeepSeek ${path} returned invalid JSON`);
212
213
  }
213
214
  }
214
215
 
215
- const raw = await response.text().catch(() => "");
216
+ let raw = "";
217
+ try {
218
+ raw = await response.text();
219
+ } catch {
220
+ if (signal?.aborted) throw abortError(signal);
221
+ }
216
222
  let message = raw;
217
223
  try {
218
224
  const parsed = JSON.parse(raw) as { error?: { message?: string } };
219
225
  message = parsed.error?.message?.trim() || raw;
220
226
  } catch {}
221
- message = REDACT(message.slice(0, 400), apiKey);
227
+ message = REDACT(message, apiKey).slice(0, 400);
222
228
 
223
229
  const error = new DeepSeekHttpError(
224
230
  response.status,
@@ -236,10 +242,9 @@ async function postDeepSeek<T>(
236
242
  /* ------------------------------------------------------------------- query */
237
243
 
238
244
  /**
239
- * Trim to the API's hard character budget (docs: 1-128 Unicode characters including
240
- * operators). Filters are shed first — recency, then language, then domains — and only
241
- * then trailing free-text words. Every removal is reported so the caller can see that
242
- * 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.
243
248
  */
244
249
  export function fitQuery(options: SearchOptions): { query: string; dropped: string[] } {
245
250
  const dropped: string[] = [];
@@ -266,33 +271,36 @@ export function fitQuery(options: SearchOptions): { query: string; dropped: stri
266
271
  const untouched = [raw, ...filters].filter(Boolean).join(" ");
267
272
  if (charLength(untouched) <= MAX_QUERY_CHARS) return { query: untouched, dropped };
268
273
 
269
- // 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
+
270
294
  // An unescaped quoted phrase is one token: slicing mid-phrase would change its meaning.
271
295
  // Backslash-escaped quotes are not special-cased (only reachable on over-length queries).
272
296
  const matchedTokens = raw.match(/"[^"]*"|\S+/g);
273
- const tokens: string[] = matchedTokens ? [...matchedTokens] : [raw];
297
+ let words: string[] = matchedTokens ? [...matchedTokens] : [raw];
274
298
  const quotesWereBalanced = ((raw.match(/"/g) ?? []).length % 2) === 0;
275
- if (raw !== tokens.join(" ")) dropped.push("query whitespace normalised");
276
- let words = tokens;
277
- let active = [...filters];
278
-
279
- const overBudget = () => charLength(join(words, active)) > MAX_QUERY_CHARS;
280
- const dropFilter = (predicate: (filter: string) => boolean, label: (filter: string) => string): void => {
281
- const index = active.findIndex(predicate);
282
- if (index < 0 || !overBudget()) return;
283
- const [removed] = active.splice(index, 1);
284
- dropped.push(label(removed!));
285
- };
286
-
287
- dropFilter((f) => f.startsWith("after:"), (f) => `recency (${f})`);
288
- dropFilter((f) => f.startsWith("lang:"), (f) => `language (${f})`);
289
- while (overBudget() && active.length > 0) {
290
- const removed = active.pop()!;
291
- dropped.push(`domain filter (${removed.slice(0, 40)})`);
292
- }
299
+ if (raw !== words.join(" ")) dropped.push("query whitespace normalised");
293
300
 
294
301
  const wordsBefore = words.join(" ");
295
- 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
+
296
304
  // Never leave a dangling operator (`site:`, `-site:`, `after:`, a bare `-`) or an
297
305
  // unbalanced quote behind: DeepSeek would answer 400 and no retry would help.
298
306
  const danglingOperator = /^(?:-|-[A-Za-z]+:|[A-Za-z]+:)$/;
@@ -307,20 +315,37 @@ export function fitQuery(options: SearchOptions): { query: string; dropped: stri
307
315
  dropped.push("trailing query words");
308
316
  }
309
317
 
310
- let query = join(words, active);
318
+ let query = join(words, filters);
311
319
  if (charLength(query) > MAX_QUERY_CHARS) {
312
- // A single token longer than the whole budget: cutting it is unavoidable, so do it
313
- // by code point and report it rather than sending an invalid request.
314
- query = Array.from(query).slice(0, MAX_QUERY_CHARS).join("").trim();
315
- // A cut can land on a bare operator (`-`, `:`), which the API would reject.
316
- while (query.length > 0 && /[-:]$/.test(query)) query = Array.from(query).slice(0, -1).join("").trimEnd();
317
- if (quotesWereBalanced && (query.match(/"/g) ?? []).length % 2 === 1) {
318
- // The cut left one unmatched quote: remove that quote rather than invent a pair.
319
- const balanced = Array.from(query);
320
- const lastQuote = balanced.lastIndexOf('"');
321
- if (lastQuote >= 0) balanced.splice(lastQuote, 1);
322
- 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
+ }
323
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
+ );
347
+ }
348
+ query = join([cut], filters);
324
349
  dropped.push("query tail");
325
350
  }
326
351
  return { query, dropped };
@@ -496,7 +521,7 @@ const SEARCH_SCHEMA = {
496
521
  type: "string",
497
522
  minLength: 1,
498
523
  description:
499
- '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.',
500
525
  },
501
526
  count: {
502
527
  type: "integer",
@@ -518,13 +543,13 @@ const SEARCH_SCHEMA = {
518
543
  type: "array",
519
544
  items: { type: "string" },
520
545
  description:
521
- "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.",
522
547
  },
523
548
  blocked_domains: {
524
549
  type: "array",
525
550
  items: { type: "string" },
526
551
  description:
527
- "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.",
528
553
  },
529
554
  lang: { type: "string", description: 'Language codes, e.g. "zh,en" (becomes lang:).' },
530
555
  recency: {
@@ -553,7 +578,7 @@ const FETCH_SCHEMA = {
553
578
  type: "integer",
554
579
  minimum: 500,
555
580
  maximum: 200_000,
556
- description: `Return at most this many characters (default ${MAX_FETCH_CONTENT_CHARS}). Values outside 500-200000 are clamped, and the clamp is reported in the output.`,
581
+ description: `Return at most this many characters (default ${DEFAULT_FETCH_MAX_CHARS}). Values outside 500-200000 are clamped, and the clamp is reported in the output.`,
557
582
  },
558
583
  },
559
584
  required: ["url"],
@@ -587,7 +612,7 @@ export default function (pi: any): void {
587
612
  return value
588
613
  .filter((entry): entry is string => typeof entry === "string")
589
614
  .map((entry) => entry.trim())
590
- .filter(Boolean);
615
+ .filter((entry) => entry.length > 0 && !/[\s"]/.test(entry));
591
616
  };
592
617
  const oriChars = Array.from(
593
618
  typeof params.ori_question === "string" ? params.ori_question.trim() : "",
@@ -706,8 +731,8 @@ export default function (pi: any): void {
706
731
  ctx: any,
707
732
  ) {
708
733
  const url = assertFetchableUrl(String(params.url ?? ""));
709
- const requestedChars = Number(params.max_chars ?? MAX_FETCH_CONTENT_CHARS);
710
- const requested = Number.isFinite(requestedChars) ? Math.floor(requestedChars) : MAX_FETCH_CONTENT_CHARS;
734
+ const requestedChars = Number(params.max_chars ?? DEFAULT_FETCH_MAX_CHARS);
735
+ const requested = Number.isFinite(requestedChars) ? Math.floor(requestedChars) : DEFAULT_FETCH_MAX_CHARS;
711
736
  const maxChars = Math.min(200_000, Math.max(500, requested));
712
737
  const clamped = maxChars !== requested;
713
738
  const apiKey = await resolveApiKey(ctx);
@@ -728,15 +753,14 @@ export default function (pi: any): void {
728
753
 
729
754
  const total = charLength(content);
730
755
  const body = total > maxChars ? Array.from(content).slice(0, maxChars).join("") : content;
731
- const text = [
756
+ const head = [
732
757
  `DeepSeek fetch · ${total} chars${total > maxChars ? ` (truncated to ${maxChars})` : ""}${clamped ? ` (max_chars clamped to ${maxChars})` : ""}`,
733
758
  title ? `title: ${title}` : "",
734
759
  url.toString(),
735
- "",
736
- body,
737
760
  ]
738
- .filter((line) => line !== "")
761
+ .filter(Boolean)
739
762
  .join("\n");
763
+ const text = `${head}\n\n${body}`;
740
764
 
741
765
  return {
742
766
  content: [{ type: "text", text }],