@keenable/dsh-keenable 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +92 -0
- package/cordis.patch.yml +18 -0
- package/package.json +66 -0
- package/src/fetch.js +120 -0
- package/src/http.js +148 -0
- package/src/index.js +61 -0
- package/src/search.js +119 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Keenable
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Keenable for DeepSeek Harness
|
|
2
|
+
|
|
3
|
+
Web search and page reading for DeepSeek Harness through [Keenable](https://keenable.ai), with **no API key and no account**. It works with whatever model you run in Harness, local models included.
|
|
4
|
+
|
|
5
|
+
The plugin points Harness's built-in `web_search` and `web_fetch` tools at Keenable. It adds no new tools: the model asks for a query or a URL exactly as before, and Keenable answers.
|
|
6
|
+
|
|
7
|
+
## Why
|
|
8
|
+
|
|
9
|
+
The search provider Harness ships with needs a DeepSeek account or a DeepSeek API key. With a local or third-party model and neither of those, `web_search` fails. Keenable's public endpoints need no key, so search works on a fresh install:
|
|
10
|
+
|
|
11
|
+
- `web_search` returns ranked web results with an excerpt of each page and its publication date, when known.
|
|
12
|
+
- `web_fetch` returns a page as markdown. The page is retrieved on Keenable's servers, so a URL the model chose is never requested from the machine running Harness, and private or internal hosts are refused.
|
|
13
|
+
|
|
14
|
+
An API key is optional. It only lifts the rate limit described [below](#limits-and-the-optional-api-key).
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
Requires DeepSeek Harness 0.1.7-rc.2 or later and Node.js 22.12 or later.
|
|
19
|
+
|
|
20
|
+
**From Harness:** open **Plugins → Add plugin**, enter `@keenable/dsh-keenable`, then **Install → Enable now**.
|
|
21
|
+
|
|
22
|
+
**From the terminal:** install into the profile you use, then restart Harness:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
npx @deepseek-ai/dsh plugin --profile web add @keenable/dsh-keenable
|
|
26
|
+
npx @deepseek-ai/dsh web
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Profiles are separate: `web`, `tui` and `headless` each need their own install. Replace `web` with the profile you run.
|
|
30
|
+
|
|
31
|
+
Then ask your model to search the web. No further setup is needed.
|
|
32
|
+
|
|
33
|
+
## Limits and the optional API key
|
|
34
|
+
|
|
35
|
+
Without a key, requests go to Keenable's public endpoints, limited to 10 requests per second and 1,000 per hour per IP address. Search and fetch are counted separately. Everyone behind the same network address shares the limit, so a busy office or CI runner can reach it; the tool error then says so.
|
|
36
|
+
|
|
37
|
+
A key lifts the limit. Create one in the [Keenable console](https://app.keenable.ai/console), then either:
|
|
38
|
+
|
|
39
|
+
- set `KEENABLE_API_KEY` in the environment Harness starts in, or
|
|
40
|
+
- enter it as **apiKey** in this plugin's settings on the **Plugins** page.
|
|
41
|
+
|
|
42
|
+
A key in the settings wins over the environment variable.
|
|
43
|
+
|
|
44
|
+
## Settings
|
|
45
|
+
|
|
46
|
+
| Setting | Default | Meaning |
|
|
47
|
+
| --- | --- | --- |
|
|
48
|
+
| `apiKey` | `$KEENABLE_API_KEY` | Optional. Switches both tools to the authenticated endpoints. |
|
|
49
|
+
| `maxSnippetChars` | `500` | Excerpt length per search result, 180 to 10,000 characters. |
|
|
50
|
+
| `fetchLive` | `true` | Fetch pages live from the source. When off, `web_fetch` returns Keenable's indexed copy and fails for pages it has not indexed. |
|
|
51
|
+
| `maxBodyChars` | `100000` | Maximum characters of page text `web_fetch` returns. Longer pages are cut and marked as truncated. |
|
|
52
|
+
| `baseURL` | `https://api.keenable.ai` | API origin. HTTPS only. |
|
|
53
|
+
|
|
54
|
+
## Keeping the local fetch provider
|
|
55
|
+
|
|
56
|
+
The plugin pins both capabilities to Keenable. To keep Harness's local HTTP fetch provider and use Keenable for search only, override the web row in your profile's `cordis.patch.yml`:
|
|
57
|
+
|
|
58
|
+
```yaml
|
|
59
|
+
- id: web
|
|
60
|
+
config:
|
|
61
|
+
searchProvider: keenable
|
|
62
|
+
fetchProvider: http
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Both fields are needed: a config patch replaces the row's whole config object.
|
|
66
|
+
|
|
67
|
+
## Privacy
|
|
68
|
+
|
|
69
|
+
Search queries and fetched URLs are sent to Keenable, even when your model runs locally. Requests identify the plugin with an `X-Keenable-Title: dsh-keenable` header, which the public endpoints require. Results are external content, and Harness marks them as untrusted data for the model.
|
|
70
|
+
|
|
71
|
+
## Troubleshooting
|
|
72
|
+
|
|
73
|
+
| Symptom | What to check |
|
|
74
|
+
| --- | --- |
|
|
75
|
+
| `WEB_PROVIDER_CONFIGURED_MISSING` | The plugin is not loaded in this profile. Install it into the profile you run, and restart Harness. |
|
|
76
|
+
| "requests share a per-IP limit" | The keyless limit was reached. Wait, or set an API key. |
|
|
77
|
+
| "Fetching from private/internal hosts is not allowed" | Keenable does not fetch `localhost` or private addresses. Use the local HTTP fetch provider for those (see above). |
|
|
78
|
+
| Search still asks for DeepSeek credentials | Another patch layer pins `searchProvider` back. Check the composed profile with `npx @deepseek-ai/dsh --profile web --dump-config`. |
|
|
79
|
+
|
|
80
|
+
## Development
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
npm ci
|
|
84
|
+
npm test # offline: providers, plugin registration, model-facing tools, manifest
|
|
85
|
+
npm run test:live # real Keenable API, no key
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The tests load the plugin into a real Cordis context with Harness's own `dsh-web` seam and `web_search`/`web_fetch` tools. The code is plain JavaScript and needs no build step.
|
|
89
|
+
|
|
90
|
+
## License
|
|
91
|
+
|
|
92
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# The layer this bundle contributes when a profile lists it.
|
|
2
|
+
#
|
|
3
|
+
# Both providers register under the id `keenable`, one per capability registry.
|
|
4
|
+
# The built-in DeepSeek search provider and the local HTTP fetch provider stay
|
|
5
|
+
# mounted, so both ids are pinned: with two usable providers and no pin, the
|
|
6
|
+
# seam fails with WEB_PROVIDER_AMBIGUOUS instead of picking one. A config patch
|
|
7
|
+
# replaces the row's config object, so both fields are stated.
|
|
8
|
+
#
|
|
9
|
+
# To keep the local HTTP fetch provider, set fetchProvider back to `http`.
|
|
10
|
+
- id: web
|
|
11
|
+
name: '@deepseek-ai/dsh-web'
|
|
12
|
+
config:
|
|
13
|
+
searchProvider: keenable
|
|
14
|
+
fetchProvider: keenable
|
|
15
|
+
|
|
16
|
+
- insert:
|
|
17
|
+
- id: keenable
|
|
18
|
+
name: '@keenable/dsh-keenable'
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@keenable/dsh-keenable",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Keenable web search and page fetch for DeepSeek Harness: the built-in web_search and web_fetch tools, with no API key needed",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Keenable (https://keenable.ai)",
|
|
7
|
+
"homepage": "https://github.com/keenableai/dsh-keenable#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/keenableai/dsh-keenable.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/keenableai/dsh-keenable/issues"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"sideEffects": false,
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=22.12.0"
|
|
19
|
+
},
|
|
20
|
+
"main": "./src/index.js",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": "./src/index.js",
|
|
23
|
+
"./package.json": "./package.json"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"src",
|
|
27
|
+
"cordis.patch.yml",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"deepseek-harness",
|
|
36
|
+
"dsh-plugin",
|
|
37
|
+
"keenable",
|
|
38
|
+
"web-search",
|
|
39
|
+
"web-fetch",
|
|
40
|
+
"no-api-key"
|
|
41
|
+
],
|
|
42
|
+
"dsh": {
|
|
43
|
+
"bundle": {
|
|
44
|
+
"patch": "./cordis.patch.yml"
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"test": "node --test test/*.test.js",
|
|
49
|
+
"test:live": "KEENABLE_LIVE=1 node --test test/live.test.js"
|
|
50
|
+
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"@deepseek-ai/dsh-launch-environment": ">=0.1.7-rc.2",
|
|
53
|
+
"@deepseek-ai/dsh-web": ">=0.1.7-rc.2",
|
|
54
|
+
"@deepseek-ai/schemastery": "^3.18.4"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@deepseek-ai/cordis": "4.0.4",
|
|
58
|
+
"@deepseek-ai/dsh-launch-environment": "0.2.0-rc.2",
|
|
59
|
+
"@deepseek-ai/dsh-system-prompt": "0.2.0-rc.2",
|
|
60
|
+
"@deepseek-ai/dsh-tool-web": "0.2.0-rc.2",
|
|
61
|
+
"@deepseek-ai/dsh-tools": "0.2.0-rc.2",
|
|
62
|
+
"@deepseek-ai/dsh-web": "0.2.0-rc.2",
|
|
63
|
+
"@deepseek-ai/schemastery": "3.18.4",
|
|
64
|
+
"js-yaml": "4.1.0"
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/fetch.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `KeenableFetchProvider`: a `WebFetchProvider` for the DeepSeek Harness web
|
|
3
|
+
* seam (`ctx.web`), backed by `GET /v1/fetch`. The page is retrieved on
|
|
4
|
+
* Keenable's servers and returned as markdown, so the model-chosen URL is never
|
|
5
|
+
* requested from the machine running Harness. Keyless by default.
|
|
6
|
+
* @module @keenable/dsh-keenable/fetch
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { WebError } from '@deepseek-ai/dsh-web'
|
|
10
|
+
import {
|
|
11
|
+
PROVIDER_ID,
|
|
12
|
+
callKeenable,
|
|
13
|
+
endpoint,
|
|
14
|
+
isPositiveInteger,
|
|
15
|
+
isValidBaseUrl,
|
|
16
|
+
nonblank,
|
|
17
|
+
providerError,
|
|
18
|
+
} from './http.js'
|
|
19
|
+
|
|
20
|
+
const LABEL = 'Keenable fetch'
|
|
21
|
+
|
|
22
|
+
/** Largest `max_chars` the API accepts, and so the largest body this provider asks for. */
|
|
23
|
+
export const MAX_BODY_CHARS_LIMIT = 200_000
|
|
24
|
+
|
|
25
|
+
export class KeenableFetchProvider {
|
|
26
|
+
/**
|
|
27
|
+
* @param {object} options
|
|
28
|
+
* @param {string} options.apiKey - API key, or `''` for the keyless endpoint.
|
|
29
|
+
* @param {string} options.baseURL - API origin.
|
|
30
|
+
* @param {boolean} options.live - fetch from the source instead of the index.
|
|
31
|
+
* @param {number} options.maxBodyChars - cap on returned characters.
|
|
32
|
+
* @param {number} options.maxUrlLength - longest accepted request URL.
|
|
33
|
+
*/
|
|
34
|
+
constructor(options) {
|
|
35
|
+
this.id = PROVIDER_ID
|
|
36
|
+
this.options = options
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A local check only: the seam forbids network calls here. No key is needed. */
|
|
40
|
+
available() {
|
|
41
|
+
const { baseURL, maxBodyChars, maxUrlLength } = this.options
|
|
42
|
+
return isValidBaseUrl(baseURL)
|
|
43
|
+
&& isPositiveInteger(maxBodyChars) && maxBodyChars < MAX_BODY_CHARS_LIMIT
|
|
44
|
+
&& isPositiveInteger(maxUrlLength)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {{ url: string }} request
|
|
49
|
+
* @param {AbortSignal} [signal]
|
|
50
|
+
*/
|
|
51
|
+
async fetch(request, signal) {
|
|
52
|
+
// Reject locally, before anything is sent: a file:// or credential-carrying
|
|
53
|
+
// URL must never reach the API.
|
|
54
|
+
assertFetchableUrl(request.url, this.options.maxUrlLength)
|
|
55
|
+
const payload = await callKeenable({
|
|
56
|
+
url: `${endpoint(this.options.baseURL, '/v1/fetch', this.options.apiKey)}?${buildFetchQuery(request, this.options)}`,
|
|
57
|
+
method: 'GET',
|
|
58
|
+
apiKey: this.options.apiKey,
|
|
59
|
+
label: LABEL,
|
|
60
|
+
signal,
|
|
61
|
+
})
|
|
62
|
+
try {
|
|
63
|
+
return mapFetchResponse(payload, request.url, this.options)
|
|
64
|
+
} catch (error) {
|
|
65
|
+
throw providerError(`${LABEL} returned an unprocessable response body: ${error.message}`, error)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Validate a fetch target, throwing `WEB_INVALID_URL` (the local HTTP
|
|
72
|
+
* provider's code) when it must not be requested.
|
|
73
|
+
*/
|
|
74
|
+
export function assertFetchableUrl(url, maxUrlLength) {
|
|
75
|
+
if (typeof url !== 'string' || url.length > maxUrlLength) {
|
|
76
|
+
throw new WebError(`URL exceeds the ${maxUrlLength}-character limit`, 'WEB_INVALID_URL')
|
|
77
|
+
}
|
|
78
|
+
if (!URL.canParse(url)) throw new WebError(`invalid URL: ${url}`, 'WEB_INVALID_URL')
|
|
79
|
+
const parsed = new URL(url)
|
|
80
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
81
|
+
throw new WebError(`unsupported URL scheme "${parsed.protocol}"; only http and https are fetchable`, 'WEB_INVALID_URL')
|
|
82
|
+
}
|
|
83
|
+
if (parsed.username || parsed.password) {
|
|
84
|
+
throw new WebError('URL must not carry embedded credentials', 'WEB_INVALID_URL')
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The `GET /v1/fetch` query. One character more than the budget is requested,
|
|
90
|
+
* so a page longer than the budget is detected and flagged as truncated.
|
|
91
|
+
*/
|
|
92
|
+
export function buildFetchQuery(request, { live, maxBodyChars }) {
|
|
93
|
+
const query = new URLSearchParams({ url: request.url, max_chars: String(maxBodyChars + 1) })
|
|
94
|
+
if (live) query.set('live', 'true')
|
|
95
|
+
return query.toString()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Map a fetch response to the seam's result. The body is markdown, the seam's
|
|
100
|
+
* `text` kind. Keenable returns the page title as a separate field, often
|
|
101
|
+
* absent from `content`, so it is prepended as a heading when the text does
|
|
102
|
+
* not already open with it. Keenable reports failures as HTTP errors and
|
|
103
|
+
* returns no status code of the page on success, so success is reported as 200.
|
|
104
|
+
*/
|
|
105
|
+
export function mapFetchResponse(payload, requestedUrl, { maxBodyChars }) {
|
|
106
|
+
if (typeof payload !== 'object' || payload === null || typeof payload.content !== 'string') {
|
|
107
|
+
throw new TypeError('response.content is not a string')
|
|
108
|
+
}
|
|
109
|
+
const title = nonblank(payload.title)
|
|
110
|
+
const content = title !== undefined && !payload.content.slice(0, 300).includes(title)
|
|
111
|
+
? `# ${title}\n\n${payload.content}`
|
|
112
|
+
: payload.content
|
|
113
|
+
const truncated = content.length > maxBodyChars
|
|
114
|
+
return {
|
|
115
|
+
url: nonblank(payload.url) ?? requestedUrl,
|
|
116
|
+
statusCode: 200,
|
|
117
|
+
body: { kind: 'text', content: truncated ? content.slice(0, maxBodyChars) : content },
|
|
118
|
+
truncated,
|
|
119
|
+
}
|
|
120
|
+
}
|
package/src/http.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared transport for the Keenable search and fetch providers: one request
|
|
3
|
+
* helper and one error vocabulary, so the redirect policy, the attribution
|
|
4
|
+
* headers and the abort/error classification cannot drift between the two.
|
|
5
|
+
* @module @keenable/dsh-keenable/http
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { WebError } from '@deepseek-ai/dsh-web'
|
|
9
|
+
|
|
10
|
+
/** The id both providers register under, one per capability registry. */
|
|
11
|
+
export const PROVIDER_ID = 'keenable'
|
|
12
|
+
|
|
13
|
+
/** Default API origin; the versioned path is appended per operation. */
|
|
14
|
+
export const DEFAULT_BASE_URL = 'https://api.keenable.ai'
|
|
15
|
+
|
|
16
|
+
/** Package version, sent in the user agent. Bump with package.json. */
|
|
17
|
+
export const VERSION = '0.1.0'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Application name sent as `X-Keenable-Title`. The keyless endpoints reject a
|
|
21
|
+
* request without it (400 "Missing app identifier"), and it attributes traffic.
|
|
22
|
+
*/
|
|
23
|
+
export const APP_TITLE = 'dsh-keenable'
|
|
24
|
+
|
|
25
|
+
const USER_AGENT = `@keenable/dsh-keenable/${VERSION} (+https://github.com/keenableai/dsh-keenable)`
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The endpoint for one operation. Without a key the request goes to the
|
|
29
|
+
* keyless `/public` variant, which takes the same parameters and returns the
|
|
30
|
+
* same shape but is rate limited per IP.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} baseURL - the configured API origin.
|
|
33
|
+
* @param {string} path - the operation path, such as `/v1/search`.
|
|
34
|
+
* @param {string} apiKey - the API key, or `''` for keyless.
|
|
35
|
+
* @returns {string} the absolute endpoint URL.
|
|
36
|
+
*/
|
|
37
|
+
export function endpoint(baseURL, path, apiKey) {
|
|
38
|
+
const base = baseURL.endsWith('/') ? baseURL.slice(0, -1) : baseURL
|
|
39
|
+
return `${base}${path}${apiKey ? '' : '/public'}`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Send one request to the Keenable API and return the parsed JSON body.
|
|
44
|
+
*
|
|
45
|
+
* Redirects fail before the `Location` target is contacted, because the
|
|
46
|
+
* request may carry the API key. Every non-2xx response is a provider error;
|
|
47
|
+
* its message keeps Keenable's own explanation (for example "Fetching from
|
|
48
|
+
* private/internal hosts is not allowed") and, for a keyless rate limit, says
|
|
49
|
+
* how to lift it.
|
|
50
|
+
*
|
|
51
|
+
* @param {object} request
|
|
52
|
+
* @param {string} request.url - the absolute endpoint URL, with any query.
|
|
53
|
+
* @param {'GET' | 'POST'} request.method - the HTTP method.
|
|
54
|
+
* @param {unknown} [request.body] - JSON body for a POST.
|
|
55
|
+
* @param {string} request.apiKey - the API key, or `''` for keyless.
|
|
56
|
+
* @param {string} request.label - operation name used in messages ("Keenable search").
|
|
57
|
+
* @param {AbortSignal} [request.signal] - cancellation signal.
|
|
58
|
+
* @returns {Promise<any>} the parsed response body.
|
|
59
|
+
*/
|
|
60
|
+
export async function callKeenable({ url, method, body, apiKey, label, signal }) {
|
|
61
|
+
const headers = {
|
|
62
|
+
'accept': 'application/json',
|
|
63
|
+
'user-agent': USER_AGENT,
|
|
64
|
+
'x-keenable-title': APP_TITLE,
|
|
65
|
+
}
|
|
66
|
+
if (body !== undefined) headers['content-type'] = 'application/json'
|
|
67
|
+
if (apiKey) headers['x-api-key'] = apiKey
|
|
68
|
+
|
|
69
|
+
let response
|
|
70
|
+
try {
|
|
71
|
+
response = await fetch(url, {
|
|
72
|
+
method,
|
|
73
|
+
redirect: 'error',
|
|
74
|
+
headers,
|
|
75
|
+
...body === undefined ? {} : { body: JSON.stringify(body) },
|
|
76
|
+
...signal === undefined ? {} : { signal },
|
|
77
|
+
})
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (signal?.aborted || isAbortError(error)) throw aborted(label, error)
|
|
80
|
+
throw providerError(`${label} request failed: ${describe(error)}`, error)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let payload
|
|
84
|
+
try {
|
|
85
|
+
payload = await response.json()
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (signal?.aborted || isAbortError(error)) throw aborted(label, error)
|
|
88
|
+
// Gateway errors often carry no JSON; the status is the real signal then.
|
|
89
|
+
if (!response.ok) throw providerError(`${label} failed (HTTP ${response.status})`, error)
|
|
90
|
+
throw providerError(`${label} returned an unprocessable response body: ${describe(error)}`, error)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!response.ok) throw providerError(failureMessage(label, response.status, payload, apiKey))
|
|
94
|
+
return payload
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Build the message for a Keenable-reported failure from its `error` and
|
|
99
|
+
* `message` fields.
|
|
100
|
+
*/
|
|
101
|
+
function failureMessage(label, status, payload, apiKey) {
|
|
102
|
+
const parts = []
|
|
103
|
+
for (const field of ['error', 'message']) {
|
|
104
|
+
const value = typeof payload?.[field] === 'string' ? payload[field].trim() : ''
|
|
105
|
+
if (value && !parts.includes(value)) parts.push(value)
|
|
106
|
+
}
|
|
107
|
+
let message = `${label} failed (HTTP ${status})${parts.length ? `: ${parts.join(': ')}` : ''}`
|
|
108
|
+
if (status === 429 && !apiKey) {
|
|
109
|
+
message += ' Without an API key, requests share a per-IP limit; set the plugin\'s apiKey or KEENABLE_API_KEY to lift it.'
|
|
110
|
+
}
|
|
111
|
+
return message
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function describe(error) {
|
|
115
|
+
return error instanceof Error ? error.message : String(error)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** True for a `fetch`/`AbortSignal` abort, which is cancellation, not failure. */
|
|
119
|
+
export function isAbortError(error) {
|
|
120
|
+
return typeof error === 'object' && error !== null && error.name === 'AbortError'
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** `WEB_ABORTED`: cancellation is not a provider error. */
|
|
124
|
+
export function aborted(label, cause) {
|
|
125
|
+
return new WebError(`${label} aborted`, 'WEB_ABORTED', cause === undefined ? {} : { cause })
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** `WEB_PROVIDER_ERROR`: the seam's catch-all for a provider's own failure. */
|
|
129
|
+
export function providerError(message, cause) {
|
|
130
|
+
return new WebError(message, 'WEB_PROVIDER_ERROR', cause === undefined ? {} : { cause })
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** True when `baseURL` is an HTTPS URL with no credentials, query or fragment. */
|
|
134
|
+
export function isValidBaseUrl(baseURL) {
|
|
135
|
+
if (typeof baseURL !== 'string' || !URL.canParse(baseURL)) return false
|
|
136
|
+
const url = new URL(baseURL)
|
|
137
|
+
return url.protocol === 'https:' && !url.username && !url.password && !url.search && !url.hash
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** True for a positive integer. */
|
|
141
|
+
export function isPositiveInteger(value) {
|
|
142
|
+
return Number.isInteger(value) && value > 0
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The trimmed string when it is non-blank, else `undefined`. */
|
|
146
|
+
export function nonblank(value) {
|
|
147
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined
|
|
148
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@keenable/dsh-keenable`: registers Keenable search and fetch providers with
|
|
3
|
+
* the DeepSeek Harness web seam (`ctx.web`), so the built-in `web_search` and
|
|
4
|
+
* `web_fetch` tools answer through Keenable. Both work without an API key; a
|
|
5
|
+
* key only lifts the per-IP rate limit of the keyless endpoints.
|
|
6
|
+
*
|
|
7
|
+
* A function plugin, not a service: it registers into the registries that
|
|
8
|
+
* `@deepseek-ai/dsh-web` owns. The bundle patch pins both provider ids.
|
|
9
|
+
* @module @keenable/dsh-keenable
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'
|
|
13
|
+
import z from '@deepseek-ai/schemastery'
|
|
14
|
+
import { DEFAULT_BASE_URL, nonblank } from './http.js'
|
|
15
|
+
import { KeenableFetchProvider } from './fetch.js'
|
|
16
|
+
import { KeenableSearchProvider, SNIPPET_CHARS_MAX, SNIPPET_CHARS_MIN } from './search.js'
|
|
17
|
+
|
|
18
|
+
export { APP_TITLE, DEFAULT_BASE_URL, PROVIDER_ID, VERSION } from './http.js'
|
|
19
|
+
export { KeenableSearchProvider, buildSearchBody, mapSearchResponse } from './search.js'
|
|
20
|
+
export { KeenableFetchProvider, assertFetchableUrl, buildFetchQuery, mapFetchResponse } from './fetch.js'
|
|
21
|
+
|
|
22
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
23
|
+
export const name = 'keenable'
|
|
24
|
+
|
|
25
|
+
/** The web seam both providers register into. */
|
|
26
|
+
export const inject = ['web']
|
|
27
|
+
|
|
28
|
+
/** Same cap as the local HTTP fetch provider, below the tool's own output cap. */
|
|
29
|
+
export const DEFAULT_MAX_BODY_CHARS = 100_000
|
|
30
|
+
|
|
31
|
+
export const Config = z.object({
|
|
32
|
+
apiKey: z.string().role('secret')
|
|
33
|
+
.description('Optional Keenable API key. Without one, search and fetch use the keyless endpoints, limited to 10 requests per second and 1,000 per hour per IP address. Falls back to $KEENABLE_API_KEY.'),
|
|
34
|
+
baseURL: z.string().default(DEFAULT_BASE_URL)
|
|
35
|
+
.description('Keenable API origin. HTTPS only.'),
|
|
36
|
+
maxSnippetChars: z.number().step(1).min(SNIPPET_CHARS_MIN).max(SNIPPET_CHARS_MAX).default(500)
|
|
37
|
+
.description('Excerpt length per search result, in characters.'),
|
|
38
|
+
fetchLive: z.boolean().default(true)
|
|
39
|
+
.description('Fetch pages live from the source. When off, web_fetch returns Keenable\'s indexed copy and fails for pages it has not indexed.'),
|
|
40
|
+
maxBodyChars: z.number().step(1).min(1).max(DEFAULT_MAX_BODY_CHARS * 2 - 1).default(DEFAULT_MAX_BODY_CHARS)
|
|
41
|
+
.description('Maximum characters of page text web_fetch returns.'),
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
/** Register both providers. Cordis disposes the registrations with the plugin. */
|
|
45
|
+
export function apply(ctx, config) {
|
|
46
|
+
const apiKey = nonblank(config.apiKey)
|
|
47
|
+
?? nonblank(launchEnvironmentOf(ctx).get('KEENABLE_API_KEY')?.value)
|
|
48
|
+
?? ''
|
|
49
|
+
ctx.web.registerSearchProvider(new KeenableSearchProvider({
|
|
50
|
+
apiKey,
|
|
51
|
+
baseURL: config.baseURL,
|
|
52
|
+
maxSnippetChars: config.maxSnippetChars,
|
|
53
|
+
}))
|
|
54
|
+
ctx.web.registerFetchProvider(new KeenableFetchProvider({
|
|
55
|
+
apiKey,
|
|
56
|
+
baseURL: config.baseURL,
|
|
57
|
+
live: config.fetchLive,
|
|
58
|
+
maxBodyChars: config.maxBodyChars,
|
|
59
|
+
maxUrlLength: 2048,
|
|
60
|
+
}))
|
|
61
|
+
}
|
package/src/search.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `KeenableSearchProvider`: a `WebSearchProvider` for the DeepSeek Harness web
|
|
3
|
+
* seam (`ctx.web`), backed by `POST /v1/search`. Keyless by default.
|
|
4
|
+
* @module @keenable/dsh-keenable/search
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
PROVIDER_ID,
|
|
9
|
+
callKeenable,
|
|
10
|
+
endpoint,
|
|
11
|
+
isPositiveInteger,
|
|
12
|
+
isValidBaseUrl,
|
|
13
|
+
nonblank,
|
|
14
|
+
providerError,
|
|
15
|
+
} from './http.js'
|
|
16
|
+
|
|
17
|
+
const LABEL = 'Keenable search'
|
|
18
|
+
|
|
19
|
+
/** The API's own bounds for `max_results` and `snippet_max_length`. */
|
|
20
|
+
export const MAX_RESULTS_LIMIT = 50
|
|
21
|
+
export const SNIPPET_CHARS_MIN = 180
|
|
22
|
+
export const SNIPPET_CHARS_MAX = 10_000
|
|
23
|
+
|
|
24
|
+
export class KeenableSearchProvider {
|
|
25
|
+
/**
|
|
26
|
+
* @param {object} options
|
|
27
|
+
* @param {string} options.apiKey - API key, or `''` for the keyless endpoint.
|
|
28
|
+
* @param {string} options.baseURL - API origin.
|
|
29
|
+
* @param {number} options.maxSnippetChars - excerpt budget per result.
|
|
30
|
+
*/
|
|
31
|
+
constructor(options) {
|
|
32
|
+
this.id = PROVIDER_ID
|
|
33
|
+
this.options = options
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A local check only: the seam forbids network calls here. No key is needed. */
|
|
37
|
+
available() {
|
|
38
|
+
const { baseURL, maxSnippetChars } = this.options
|
|
39
|
+
return isValidBaseUrl(baseURL)
|
|
40
|
+
&& Number.isInteger(maxSnippetChars)
|
|
41
|
+
&& maxSnippetChars >= SNIPPET_CHARS_MIN && maxSnippetChars <= SNIPPET_CHARS_MAX
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {{ query: string, maxResults?: number }} request
|
|
46
|
+
* @param {AbortSignal} [signal]
|
|
47
|
+
*/
|
|
48
|
+
async search(request, signal) {
|
|
49
|
+
const payload = await callKeenable({
|
|
50
|
+
url: endpoint(this.options.baseURL, '/v1/search', this.options.apiKey),
|
|
51
|
+
method: 'POST',
|
|
52
|
+
body: buildSearchBody(request, this.options),
|
|
53
|
+
apiKey: this.options.apiKey,
|
|
54
|
+
label: LABEL,
|
|
55
|
+
signal,
|
|
56
|
+
})
|
|
57
|
+
try {
|
|
58
|
+
return mapSearchResponse(payload, this.options)
|
|
59
|
+
} catch (error) {
|
|
60
|
+
throw providerError(`${LABEL} returned an unprocessable response body: ${error.message}`, error)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The `POST /v1/search` body. `maxResults` is sent as a cost and latency
|
|
67
|
+
* optimization; the seam enforces the final bound regardless.
|
|
68
|
+
*/
|
|
69
|
+
export function buildSearchBody(request, { maxSnippetChars }) {
|
|
70
|
+
const body = { query: request.query, snippet_max_length: maxSnippetChars }
|
|
71
|
+
if (isPositiveInteger(request.maxResults)) body.max_results = Math.min(request.maxResults, MAX_RESULTS_LIMIT)
|
|
72
|
+
return body
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Map a search response to the seam's result. Keenable puts the page text in
|
|
77
|
+
* `snippet`, with line breaks, and usually sends `description` empty, so the
|
|
78
|
+
* excerpt reads `snippet` first and collapses whitespace to one line.
|
|
79
|
+
* `snippet_max_length` is a hint the API may exceed slightly, so the budget is
|
|
80
|
+
* enforced here as well.
|
|
81
|
+
*/
|
|
82
|
+
export function mapSearchResponse(payload, { maxSnippetChars }) {
|
|
83
|
+
if (typeof payload !== 'object' || payload === null || !Array.isArray(payload.results)) {
|
|
84
|
+
throw new TypeError('response.results is not an array')
|
|
85
|
+
}
|
|
86
|
+
const seen = new Set()
|
|
87
|
+
const sources = []
|
|
88
|
+
for (const item of payload.results) {
|
|
89
|
+
if (typeof item !== 'object' || item === null) continue
|
|
90
|
+
const url = webUrl(item.url)
|
|
91
|
+
if (url === undefined || seen.has(url)) continue
|
|
92
|
+
seen.add(url)
|
|
93
|
+
const title = nonblank(item.title)
|
|
94
|
+
const snippet = excerpt(item, maxSnippetChars)
|
|
95
|
+
const publishedAt = nonblank(item.published_at)
|
|
96
|
+
sources.push({
|
|
97
|
+
url,
|
|
98
|
+
...title === undefined ? {} : { title },
|
|
99
|
+
...snippet === undefined ? {} : { snippet },
|
|
100
|
+
...publishedAt === undefined ? {} : { publishedAt },
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
return { sources, truncated: false }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function excerpt(item, maxChars) {
|
|
107
|
+
const text = [item.snippet, item.description].map(nonblank).find(value => value !== undefined)
|
|
108
|
+
if (text === undefined) return undefined
|
|
109
|
+
const line = text.replace(/\s+/gu, ' ')
|
|
110
|
+
return line.length > maxChars ? `${line.slice(0, maxChars - 1).trimEnd()}…` : line
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** An http(s) URL without embedded credentials, or `undefined`. */
|
|
114
|
+
function webUrl(value) {
|
|
115
|
+
if (typeof value !== 'string' || !URL.canParse(value)) return undefined
|
|
116
|
+
const url = new URL(value)
|
|
117
|
+
if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.username || url.password) return undefined
|
|
118
|
+
return url.href
|
|
119
|
+
}
|