@normful/parallel-web-pi-extension 0.0.0-stage → 1.0.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 +23 -0
- package/README.md +231 -2
- package/package.json +63 -4
- package/src/index.ts +362 -0
- package/src/oauth/index.ts +256 -0
- package/src/parallel-auth.ts +116 -0
- package/src/parallel-client.ts +84 -0
- package/src/parallel-responses.ts +282 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Parallel Web
|
|
4
|
+
Copyright (c) 2026 Norman Sue
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
|
23
|
+
|
package/README.md
CHANGED
|
@@ -1,3 +1,232 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @normful/parallel-web-pi-extension
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Pi extension that adds `web_search`, `web_fetch`, and `web_research` backed by
|
|
4
|
+
Parallel. Keep your usual coding agent and give it web tools with one install.
|
|
5
|
+
|
|
6
|
+
This is a fork: a manually maintained copy of `@parallel-web/pi-extension` 1.3.0
|
|
7
|
+
from `parallel-web/parallel-npm-packages`, renamed and released here. The
|
|
8
|
+
upstream `@parallel-web/oauth` helper (private on npm) is vendored into
|
|
9
|
+
`src/oauth/` so the package builds and tests standalone.
|
|
10
|
+
|
|
11
|
+
Install it with:
|
|
12
|
+
```
|
|
13
|
+
pi install npm:@normful/parallel-web-pi-extension
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## What It Does
|
|
17
|
+
|
|
18
|
+
- Registers `web_search`
|
|
19
|
+
- Registers `web_fetch`
|
|
20
|
+
- Registers `web_research` for synthesized answers with sources through the
|
|
21
|
+
Responses API
|
|
22
|
+
- Registers a `parallel` auth provider, so Pi's own `/login parallel` runs the
|
|
23
|
+
Parallel browser OAuth flow and stores the API key in Pi's auth store
|
|
24
|
+
(`auth.json`) alongside every other provider credential
|
|
25
|
+
- Adds a `parallel-login` command that reports current auth status
|
|
26
|
+
|
|
27
|
+
Auth resolution order (owned by Pi, not the extension):
|
|
28
|
+
|
|
29
|
+
1. The credential Pi stored for provider `parallel`
|
|
30
|
+
2. `PARALLEL_API_KEY`
|
|
31
|
+
|
|
32
|
+
Run `/parallel-login` inside Pi to check whether Parallel is configured. To
|
|
33
|
+
remove a stored credential, run `/logout` and select Parallel. Environment
|
|
34
|
+
variables are not affected by Pi's logout flow.
|
|
35
|
+
|
|
36
|
+
Requires `@earendil-works/pi-coding-agent` 0.83.0 or newer.
|
|
37
|
+
|
|
38
|
+
## Web Research
|
|
39
|
+
|
|
40
|
+
After installing this extension, run `/login parallel` and ask your usual Pi
|
|
41
|
+
agent a research question. No additional package or model selection is needed:
|
|
42
|
+
|
|
43
|
+
```text
|
|
44
|
+
Compare the current Node.js compatibility of Node and Bun for a production API server. Research the tradeoffs and cite primary sources.
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The agent can call the research tool directly:
|
|
48
|
+
|
|
49
|
+
```javascript
|
|
50
|
+
web_research({
|
|
51
|
+
query: "Compare the current Node.js compatibility of Node and Bun for a production API server. Cite primary sources.",
|
|
52
|
+
effort: "medium"
|
|
53
|
+
});
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
| Tool | Use it for |
|
|
57
|
+
| --- | --- |
|
|
58
|
+
| `web_research` | A complete answer that needs web research and synthesis |
|
|
59
|
+
| `web_search` | Discovering sources and raw excerpts to investigate yourself |
|
|
60
|
+
| `web_fetch` | Reading known URLs or checking original sources |
|
|
61
|
+
|
|
62
|
+
Start with a complete, self-contained `query`. Research does not see the Pi
|
|
63
|
+
conversation or local files, so include relevant constraints and only context
|
|
64
|
+
that is safe to send.
|
|
65
|
+
|
|
66
|
+
When available, the result starts with a `Response ID`. For a focused follow-up
|
|
67
|
+
on the same investigation, pass that ID as `previous_response_id`:
|
|
68
|
+
|
|
69
|
+
```javascript
|
|
70
|
+
web_research({
|
|
71
|
+
query: "Which of those compatibility gaps would matter for an API server that uses native Node.js addons?",
|
|
72
|
+
previous_response_id: "resp_..." // Copy the actual ID from the previous result.
|
|
73
|
+
});
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Use the new ID returned by each follow-up to continue from its answer. Omit the
|
|
77
|
+
ID for a new or unrelated question. Calls never chain automatically, and the
|
|
78
|
+
extension keeps no local research-session state. Continuation reuses saved
|
|
79
|
+
research context, including earlier answer summaries, not every internal step
|
|
80
|
+
of the research process. IDs are opaque; the tool accepts non-empty IDs up to
|
|
81
|
+
512 characters without whitespace or control characters.
|
|
82
|
+
|
|
83
|
+
Saved context is not guaranteed to remain available, and continuation is not
|
|
84
|
+
supported for zero data retention (ZDR) accounts. An unavailable ID or a ZDR
|
|
85
|
+
restriction returns an error without retrying as fresh research. A valid
|
|
86
|
+
answer without a usable new ID is still returned, but cannot be continued
|
|
87
|
+
through that result. If Pi loses an ID during conversation compaction, do not
|
|
88
|
+
invent a replacement.
|
|
89
|
+
|
|
90
|
+
`effort` is optional and defaults to `medium`, matching the Responses API
|
|
91
|
+
default. Use `low` for focused lookups, `medium` for general research, and
|
|
92
|
+
`high` for extensive research. Responses is billed per successful call; see
|
|
93
|
+
the [current pricing](https://docs.parallel.ai/getting-started/pricing).
|
|
94
|
+
|
|
95
|
+
Each invocation makes one non-streaming `POST /v1/responses` request, with no
|
|
96
|
+
automatic retries or background jobs. The local deadline
|
|
97
|
+
is 120 seconds, including reading the response; slower valid research can
|
|
98
|
+
exceed this client limit. Cancelling the tool
|
|
99
|
+
aborts the local request on a best-effort basis; it does not confirm that work
|
|
100
|
+
stopped on the server. A manual retry is a new request and may incur a new charge.
|
|
101
|
+
|
|
102
|
+
The request contains only the fixed research instructions and explicit query,
|
|
103
|
+
with the selected effort and `previous_response_id` only when explicitly
|
|
104
|
+
supplied. It does not automatically forward parent history,
|
|
105
|
+
local files, cwd, environment variables, Pi tools, or session metadata.
|
|
106
|
+
Anything the calling agent includes in `query` is sent to Parallel.
|
|
107
|
+
|
|
108
|
+
The tool rejects requests over 20,000 combined instruction and input
|
|
109
|
+
characters, including the separator the API counts, before sending them.
|
|
110
|
+
It preserves the answer and renders returned HTTP(S) citations as a
|
|
111
|
+
deduplicated Markdown source list. When a citation identifies a passage,
|
|
112
|
+
the source includes that exact quoted answer text and its location in the
|
|
113
|
+
original text part. These are passages from the answer, not excerpts from
|
|
114
|
+
the source page. Unresolved citation ranges keep the source link without
|
|
115
|
+
inventing a passage. Results that exceed Pi's output limits are shown as a
|
|
116
|
+
marked preview with a path to the complete answer and sources in a private
|
|
117
|
+
temporary file. The response ID stays visible in the preview and is included
|
|
118
|
+
in the complete report.
|
|
119
|
+
|
|
120
|
+
## Dogfooding Locally
|
|
121
|
+
|
|
122
|
+
Pi transpiles TypeScript extensions with jiti, so it loads `src/index.ts`
|
|
123
|
+
directly. There is no build step: the package ships source.
|
|
124
|
+
|
|
125
|
+
### Option 1: Load It Directly With `pi -e`
|
|
126
|
+
|
|
127
|
+
This is the fastest way to test a local checkout.
|
|
128
|
+
|
|
129
|
+
From the repo root:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
pi --no-extensions --no-skills -e ./src/index.ts
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
If the extension loads successfully, Pi will have:
|
|
136
|
+
|
|
137
|
+
- the `web_search` tool
|
|
138
|
+
- the `web_fetch` tool
|
|
139
|
+
- `parallel` listed under `/login`
|
|
140
|
+
- the `parallel-login` status command
|
|
141
|
+
- the `web_research` tool
|
|
142
|
+
- per-session Parallel `session_id` reuse inside that Pi session
|
|
143
|
+
|
|
144
|
+
### Option 2: Symlink It Into Pi Extensions
|
|
145
|
+
|
|
146
|
+
This is better if you want Pi to auto-discover it and support `/reload`.
|
|
147
|
+
|
|
148
|
+
Then symlink the package directory into Pi's global extensions folder:
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
mkdir -p ~/.pi/agent/extensions
|
|
152
|
+
ln -s \
|
|
153
|
+
"$PWD" \
|
|
154
|
+
~/.pi/agent/extensions/normful-parallel-pi-extension
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Then start Pi normally:
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
pi
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
After code changes, run `/reload` inside Pi.
|
|
164
|
+
|
|
165
|
+
## Local Auth Testing
|
|
166
|
+
|
|
167
|
+
### Use Stored Pi Auth
|
|
168
|
+
|
|
169
|
+
Inside Pi, run:
|
|
170
|
+
|
|
171
|
+
```text
|
|
172
|
+
/login parallel
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
That opens the browser for Parallel OAuth. On success, Pi stores the API key in
|
|
176
|
+
its auth store under `parallel`. Run `/parallel-login` to see the current status,
|
|
177
|
+
and run `/logout` and select Parallel to remove the stored credential.
|
|
178
|
+
|
|
179
|
+
### Use Environment Variable Instead
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
export PARALLEL_API_KEY=your_key_here
|
|
183
|
+
pi --no-extensions --no-skills -e ./src/index.ts
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Note: stored Pi auth is preferred over `PARALLEL_API_KEY` if both exist.
|
|
187
|
+
|
|
188
|
+
### Override The OAuth Platform URL
|
|
189
|
+
|
|
190
|
+
For local or staging OAuth testing, set `PARALLEL_PLATFORM_URL` before starting Pi:
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
export PARALLEL_PLATFORM_URL=https://your-platform-host
|
|
194
|
+
pi --no-extensions --no-skills -e ./src/index.ts
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
This changes the browser login endpoints used by `/login parallel`.
|
|
198
|
+
|
|
199
|
+
## Quick Smoke Test
|
|
200
|
+
|
|
201
|
+
Once Pi is running with the extension loaded, ask something that should force web usage, for example:
|
|
202
|
+
|
|
203
|
+
```text
|
|
204
|
+
Search the web for the latest Parallel docs on OAuth and summarize them.
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Or ask Pi to call the tool explicitly.
|
|
208
|
+
|
|
209
|
+
`web_search` requires `search_queries`.
|
|
210
|
+
|
|
211
|
+
`web_fetch` accepts multiple URLs in a single call, so the agent can batch extraction work instead of parallelizing many single-URL requests.
|
|
212
|
+
|
|
213
|
+
## Dev Loop
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
npm test
|
|
217
|
+
npm run typecheck
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## Notes
|
|
221
|
+
|
|
222
|
+
- Search and Fetch use the `parallel-web` TypeScript SDK; Research calls the
|
|
223
|
+
Responses endpoint directly.
|
|
224
|
+
- Search requests use Parallel SDK `fast` mode.
|
|
225
|
+
- Search requests include `client_model` when Pi has an active model selected.
|
|
226
|
+
- Search and extract requests reuse a generated `session_id` for the life of the current Pi session.
|
|
227
|
+
- The login flow tries to open your browser automatically.
|
|
228
|
+
- If automatic callback capture does not complete, the login dialog asks you to paste the callback URL.
|
|
229
|
+
- Credential storage is entirely Pi's; the extension only reads the resolved key
|
|
230
|
+
through `ctx.modelRegistry.getApiKeyForProvider("parallel")`.
|
|
231
|
+
- Research requests do not reuse the Search/Extract `session_id`.
|
|
232
|
+
- Skill suppression inside the extension is prompt-level only. If you want a clean dogfooding session without your usual skills list, start Pi with `--no-skills`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,65 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@normful/parallel-web-pi-extension",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Add Parallel web search, fetch, and research tools to your pi agent",
|
|
5
|
+
"author": "Norman Sue",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"pi": {
|
|
9
|
+
"image": "https://assets.parallel.ai/white-parallel-avatar-540.png",
|
|
10
|
+
"extensions": [
|
|
11
|
+
"./src/index.ts"
|
|
12
|
+
]
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"src",
|
|
16
|
+
"!src/**/*.test.ts",
|
|
17
|
+
"package.json",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"parallel-web": "1.3.0",
|
|
22
|
+
"typebox": "^1.1.37"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"@earendil-works/pi-coding-agent": ">=0.83.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@earendil-works/pi-ai": ">=0.83.0",
|
|
29
|
+
"@types/node": "^24.0.0",
|
|
30
|
+
"@typescript-eslint/eslint-plugin": "^6.13.0",
|
|
31
|
+
"@typescript-eslint/parser": "^6.13.0",
|
|
32
|
+
"@vitest/coverage-v8": "^5.0.4",
|
|
33
|
+
"eslint": "^8.54.0",
|
|
34
|
+
"prettier": "^3.1.0",
|
|
35
|
+
"typescript": "^5.3.2",
|
|
36
|
+
"vitest": "^5.0.4"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"test": "vitest run",
|
|
40
|
+
"typecheck": "tsc --noEmit",
|
|
41
|
+
"lint": "eslint .",
|
|
42
|
+
"lint:fix": "eslint . --fix",
|
|
43
|
+
"format": "prettier --write .",
|
|
44
|
+
"format:check": "prettier --check ."
|
|
45
|
+
},
|
|
46
|
+
"keywords": [
|
|
47
|
+
"pi-package",
|
|
48
|
+
"pi.dev",
|
|
49
|
+
"pi agent",
|
|
50
|
+
"extension",
|
|
51
|
+
"parallel",
|
|
52
|
+
"research",
|
|
53
|
+
"web",
|
|
54
|
+
"search",
|
|
55
|
+
"fetch",
|
|
56
|
+
"ai"
|
|
57
|
+
],
|
|
58
|
+
"repository": {
|
|
59
|
+
"type": "git",
|
|
60
|
+
"url": "git+https://github.com/normful/parallel-web-pi-extension-fork.git"
|
|
61
|
+
},
|
|
62
|
+
"publishConfig": {
|
|
63
|
+
"access": "public"
|
|
64
|
+
}
|
|
65
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdtemp, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import type {
|
|
6
|
+
ExtensionAPI,
|
|
7
|
+
ExtensionContext,
|
|
8
|
+
} from '@earendil-works/pi-coding-agent';
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_MAX_BYTES,
|
|
11
|
+
DEFAULT_MAX_LINES,
|
|
12
|
+
formatSize,
|
|
13
|
+
truncateHead,
|
|
14
|
+
} from '@earendil-works/pi-coding-agent';
|
|
15
|
+
import { Type } from 'typebox';
|
|
16
|
+
import {
|
|
17
|
+
MAX_RESPONSE_ID_LENGTH,
|
|
18
|
+
parseResearchInput,
|
|
19
|
+
runParallelResearch,
|
|
20
|
+
} from './parallel-responses';
|
|
21
|
+
import {
|
|
22
|
+
getParallelApiKey,
|
|
23
|
+
getParallelAuthStatus,
|
|
24
|
+
registerParallelAuthProvider,
|
|
25
|
+
} from './parallel-auth';
|
|
26
|
+
import {
|
|
27
|
+
isParallelAuthenticationError,
|
|
28
|
+
runParallelExtract,
|
|
29
|
+
runParallelSearch,
|
|
30
|
+
} from './parallel-client';
|
|
31
|
+
|
|
32
|
+
function truncateJson(value: unknown) {
|
|
33
|
+
const pretty = JSON.stringify(value, null, 2);
|
|
34
|
+
const truncation = truncateHead(pretty, {
|
|
35
|
+
maxLines: DEFAULT_MAX_LINES,
|
|
36
|
+
maxBytes: DEFAULT_MAX_BYTES,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
let text = truncation.content;
|
|
40
|
+
if (truncation.truncated) {
|
|
41
|
+
text += `\n\n[Output truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)})]`;
|
|
42
|
+
}
|
|
43
|
+
return text;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const SUPPRESSED_SKILL_NAMES = new Set([
|
|
47
|
+
'parallel-cli-setup',
|
|
48
|
+
'parallel-web-extract',
|
|
49
|
+
'parallel-web-search',
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
function escapeRegex(value: string) {
|
|
53
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function suppressSkillsFromPrompt(systemPrompt: string) {
|
|
57
|
+
let nextPrompt = systemPrompt;
|
|
58
|
+
|
|
59
|
+
for (const skillName of SUPPRESSED_SKILL_NAMES) {
|
|
60
|
+
const pattern = new RegExp(
|
|
61
|
+
String.raw`\n?\s*<skill>\s*<name>${escapeRegex(skillName)}</name>[\s\S]*?<\/skill>`,
|
|
62
|
+
'g'
|
|
63
|
+
);
|
|
64
|
+
nextPrompt = nextPrompt.replace(pattern, '');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return nextPrompt.replace(
|
|
68
|
+
/\n?<available_skills>\s*<\/available_skills>/g,
|
|
69
|
+
''
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const WEB_TOOL_GUIDANCE = {
|
|
74
|
+
web_research:
|
|
75
|
+
'Use web_research for a complete answer that requires current web research and synthesis. Start with the full self-contained question, including constraints. For a focused follow-up, pass the latest returned Response ID as previous_response_id; omit it for unrelated research.',
|
|
76
|
+
web_search:
|
|
77
|
+
'Use web_search for source discovery and raw excerpts when you need to investigate sources yourself.',
|
|
78
|
+
web_fetch:
|
|
79
|
+
'Use web_fetch to read a known URL or inspect the original source behind a claim.',
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export default function (pi: ExtensionAPI) {
|
|
83
|
+
const parallelSessionId = randomUUID();
|
|
84
|
+
|
|
85
|
+
registerParallelAuthProvider(pi);
|
|
86
|
+
|
|
87
|
+
async function resolveApiKey(ctx: ExtensionContext) {
|
|
88
|
+
const apiKey = await getParallelApiKey(ctx);
|
|
89
|
+
if (apiKey) {
|
|
90
|
+
return apiKey;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
throw new Error(
|
|
94
|
+
'Parallel authentication required. Run `/login parallel` in Pi, or set PARALLEL_API_KEY.'
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function runWithAuth<T>(
|
|
99
|
+
ctx: ExtensionContext,
|
|
100
|
+
request: (apiKey: string) => Promise<T>,
|
|
101
|
+
isAuthenticationError = isParallelAuthenticationError
|
|
102
|
+
) {
|
|
103
|
+
const apiKey = await resolveApiKey(ctx);
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
return await request(apiKey);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (!isAuthenticationError(error)) {
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (process.env.PARALLEL_API_KEY) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
'Parallel rejected PARALLEL_API_KEY. Update or unset it, then try again.'
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
throw new Error(
|
|
119
|
+
'Parallel rejected the stored credential. Run `/login parallel` in Pi to sign in again.'
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
pi.registerCommand('parallel-login', {
|
|
125
|
+
description: 'Show Parallel authentication status and how to sign in',
|
|
126
|
+
handler: async (_args, ctx) => {
|
|
127
|
+
// getProviderAuthStatus is a synchronous snapshot that only knows about
|
|
128
|
+
// stored credentials, so ask for the resolved key to catch the env var too.
|
|
129
|
+
const apiKey = await getParallelApiKey(ctx);
|
|
130
|
+
if (!apiKey) {
|
|
131
|
+
ctx.ui.notify(
|
|
132
|
+
'Parallel is not authenticated. Run `/login parallel` to sign in, or set PARALLEL_API_KEY.',
|
|
133
|
+
'info'
|
|
134
|
+
);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const status = getParallelAuthStatus(ctx);
|
|
139
|
+
const source = status.label ?? status.source ?? 'PARALLEL_API_KEY';
|
|
140
|
+
const guidance = status.configured
|
|
141
|
+
? 'Run `/login parallel` to replace the stored credential, or `/logout` and select Parallel to remove it.'
|
|
142
|
+
: 'Run `/login parallel` to store a credential, or unset PARALLEL_API_KEY to remove the current one.';
|
|
143
|
+
ctx.ui.notify(
|
|
144
|
+
`Parallel is authenticated (${source}). ${guidance}`,
|
|
145
|
+
'info'
|
|
146
|
+
);
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
pi.on('before_agent_start', async (event) => {
|
|
151
|
+
const filteredPrompt = suppressSkillsFromPrompt(event.systemPrompt);
|
|
152
|
+
const selectedTools = event.systemPromptOptions.selectedTools ?? [];
|
|
153
|
+
const guidance = Object.entries(WEB_TOOL_GUIDANCE)
|
|
154
|
+
.filter(([tool]) => selectedTools.includes(tool))
|
|
155
|
+
.map(([, text]) => `- ${text}`);
|
|
156
|
+
|
|
157
|
+
if (guidance.length === 0) {
|
|
158
|
+
return filteredPrompt === event.systemPrompt
|
|
159
|
+
? undefined
|
|
160
|
+
: { systemPrompt: filteredPrompt };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
systemPrompt: `${filteredPrompt}\n\n## Grounding and web usage\n\nUse available web tools when current information or source evidence would improve the answer.\n\n${guidance.join('\n')}\n\nWhen citing web evidence, include the returned source URLs as clickable Markdown links. Do not replace source links with source names alone.`,
|
|
165
|
+
};
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
pi.registerTool({
|
|
169
|
+
name: 'web_research',
|
|
170
|
+
label: 'Web Research',
|
|
171
|
+
description:
|
|
172
|
+
"Answer a complete question using Parallel's Responses API. Delegates multi-step web research and returns a synthesized answer with sources, not raw search results. One call can handle the full research question.",
|
|
173
|
+
promptSnippet:
|
|
174
|
+
'Get a cited answer to a complete web research question using Parallel',
|
|
175
|
+
promptGuidelines: [
|
|
176
|
+
'Use web_research for questions that need web research and synthesis; pass the complete question in one call before making focused follow-ups.',
|
|
177
|
+
'Include dates, constraints, and relevant context in query. Research cannot see this Pi conversation or local files; include only context that is safe to send.',
|
|
178
|
+
'For a follow-up on the same investigation, pass its latest returned Response ID as previous_response_id to reuse prior research context. Omit it for unrelated questions. If no ID was returned, continuation is unavailable.',
|
|
179
|
+
'Use low effort for focused lookups, medium for general research, and high only for extensive research. The default is medium.',
|
|
180
|
+
],
|
|
181
|
+
parameters: Type.Object({
|
|
182
|
+
query: Type.String({
|
|
183
|
+
minLength: 1,
|
|
184
|
+
description:
|
|
185
|
+
'The research question and constraints that are safe to send. Make a new question self-contained; a follow-up with previous_response_id may refer to prior research. Research cannot see the Pi conversation or local files.',
|
|
186
|
+
}),
|
|
187
|
+
effort: Type.Optional(
|
|
188
|
+
Type.Union(
|
|
189
|
+
[Type.Literal('low'), Type.Literal('medium'), Type.Literal('high')],
|
|
190
|
+
{
|
|
191
|
+
description:
|
|
192
|
+
'Research depth: low for focused lookups, medium for general research (default), high for extensive research.',
|
|
193
|
+
}
|
|
194
|
+
)
|
|
195
|
+
),
|
|
196
|
+
previous_response_id: Type.Optional(
|
|
197
|
+
Type.String({
|
|
198
|
+
minLength: 1,
|
|
199
|
+
maxLength: MAX_RESPONSE_ID_LENGTH,
|
|
200
|
+
description:
|
|
201
|
+
'The latest Response ID returned by web_research for this investigation. Reuses prior research context for a follow-up. Omit for a new or unrelated question; never invent an ID.',
|
|
202
|
+
})
|
|
203
|
+
),
|
|
204
|
+
}),
|
|
205
|
+
// Reject invalid raw values before Pi coerces them to schema types.
|
|
206
|
+
prepareArguments: parseResearchInput,
|
|
207
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
208
|
+
onUpdate?.({
|
|
209
|
+
content: [{ type: 'text', text: 'Researching the web...' }],
|
|
210
|
+
details: { provider: 'parallel', product: 'responses' },
|
|
211
|
+
});
|
|
212
|
+
const result = await runWithAuth(
|
|
213
|
+
ctx,
|
|
214
|
+
(apiKey) => runParallelResearch(apiKey, params, signal),
|
|
215
|
+
// Responses errors carry HTTP status. Their text can echo opaque IDs,
|
|
216
|
+
// so words such as "unauthorized" do not identify rejected credentials.
|
|
217
|
+
(error) =>
|
|
218
|
+
error instanceof Error && 'status' in error && error.status === 401
|
|
219
|
+
);
|
|
220
|
+
// Pi sends content to the parent, not details. Keep the ID ahead of the
|
|
221
|
+
// answer so a truncated preview still supports an explicit follow-up.
|
|
222
|
+
const report = result.responseId
|
|
223
|
+
? `Response ID: ${result.responseId}\n\n${result.text}`
|
|
224
|
+
: result.text;
|
|
225
|
+
const preview = truncateHead(report, {
|
|
226
|
+
maxLines: DEFAULT_MAX_LINES,
|
|
227
|
+
maxBytes: DEFAULT_MAX_BYTES,
|
|
228
|
+
});
|
|
229
|
+
let text = preview.content;
|
|
230
|
+
let outputFile: string | undefined;
|
|
231
|
+
if (preview.truncated) {
|
|
232
|
+
// Keep the complete answer and citations available when Pi's context
|
|
233
|
+
// limit requires a preview. Normal research results create no file.
|
|
234
|
+
const directory = await mkdtemp(join(tmpdir(), 'parallel-research-'));
|
|
235
|
+
outputFile = join(directory, 'research.md');
|
|
236
|
+
await writeFile(outputFile, report, { mode: 0o600 });
|
|
237
|
+
const notice = `\n\n[Research output truncated. Full answer and sources: ${outputFile}]`;
|
|
238
|
+
text =
|
|
239
|
+
truncateHead(report, {
|
|
240
|
+
maxLines: DEFAULT_MAX_LINES - 2,
|
|
241
|
+
maxBytes: DEFAULT_MAX_BYTES - Buffer.byteLength(notice),
|
|
242
|
+
}).content + notice;
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
content: [{ type: 'text', text }],
|
|
246
|
+
details: {
|
|
247
|
+
provider: 'parallel',
|
|
248
|
+
product: 'responses',
|
|
249
|
+
effort: result.effort,
|
|
250
|
+
...(result.responseId ? { responseId: result.responseId } : {}),
|
|
251
|
+
...(outputFile ? { outputFile } : {}),
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
pi.registerTool({
|
|
258
|
+
name: 'web_search',
|
|
259
|
+
label: 'Web Search',
|
|
260
|
+
description:
|
|
261
|
+
"Search the web using Parallel's Search API. Prefer this over generic browser-like search tools for current web results.",
|
|
262
|
+
promptSnippet:
|
|
263
|
+
"Search the web using Parallel's Search API for current information",
|
|
264
|
+
promptGuidelines: [
|
|
265
|
+
'Use web_search for source discovery and raw excerpts when you need to investigate sources yourself.',
|
|
266
|
+
'Provide 2-3 concise keyword search queries when possible; search_queries is required.',
|
|
267
|
+
],
|
|
268
|
+
parameters: Type.Object({
|
|
269
|
+
objective: Type.String({
|
|
270
|
+
description:
|
|
271
|
+
'Natural-language description of the underlying question or goal driving the search. Used together with search_queries to focus results on the most relevant content. Should be self-contained with enough context to understand the intent of the search.',
|
|
272
|
+
}),
|
|
273
|
+
search_queries: Type.Array(
|
|
274
|
+
Type.String({
|
|
275
|
+
description:
|
|
276
|
+
'Concise keyword search queries, 3-6 words each. At least one query is required, provide 2-3 for best results. Used together with objective to focus results on the most relevant content.',
|
|
277
|
+
})
|
|
278
|
+
),
|
|
279
|
+
}),
|
|
280
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
281
|
+
const result = await runWithAuth(ctx, (apiKey) =>
|
|
282
|
+
runParallelSearch(
|
|
283
|
+
apiKey,
|
|
284
|
+
{
|
|
285
|
+
objective: params.objective,
|
|
286
|
+
search_queries: params.search_queries,
|
|
287
|
+
client_model: ctx.model?.id,
|
|
288
|
+
session_id: parallelSessionId,
|
|
289
|
+
},
|
|
290
|
+
signal
|
|
291
|
+
)
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
content: [{ type: 'text', text: truncateJson(result) }],
|
|
296
|
+
details: {
|
|
297
|
+
provider: 'parallel',
|
|
298
|
+
product: 'search',
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
pi.registerTool({
|
|
305
|
+
name: 'web_fetch',
|
|
306
|
+
label: 'Web Fetch',
|
|
307
|
+
description:
|
|
308
|
+
"Fetch and extract content from a URL using Parallel's Extract API. Prefer this over raw HTML fetch tools for readable content extraction.",
|
|
309
|
+
promptSnippet:
|
|
310
|
+
"Fetch and extract readable webpage content from one or more URLs using Parallel's Extract API",
|
|
311
|
+
promptGuidelines: [
|
|
312
|
+
'Use web_fetch when the user provides one or more URLs and wants the page content or a clean extraction.',
|
|
313
|
+
'Batch multiple URLs into one web_fetch call instead of parallelizing many single-URL calls.',
|
|
314
|
+
],
|
|
315
|
+
parameters: Type.Object({
|
|
316
|
+
urls: Type.Array(
|
|
317
|
+
Type.String({
|
|
318
|
+
description:
|
|
319
|
+
'List of URLs to extract content from. Must be valid HTTP/HTTPS URLs. Up to 20 URLs.',
|
|
320
|
+
})
|
|
321
|
+
),
|
|
322
|
+
objective: Type.Optional(
|
|
323
|
+
Type.String({
|
|
324
|
+
description:
|
|
325
|
+
'As in SearchRequest, a natural-language description of the underlying question or goal driving the request. Used together with search_queries to focus excerpts on the most relevant content.',
|
|
326
|
+
})
|
|
327
|
+
),
|
|
328
|
+
search_queries: Type.Optional(
|
|
329
|
+
Type.Array(
|
|
330
|
+
Type.String({
|
|
331
|
+
description:
|
|
332
|
+
'Optional keyword search queries, as in SearchRequest. Used together with objective to focus excerpts on the most relevant content.',
|
|
333
|
+
})
|
|
334
|
+
)
|
|
335
|
+
),
|
|
336
|
+
}),
|
|
337
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
338
|
+
const result = await runWithAuth(ctx, (apiKey) =>
|
|
339
|
+
runParallelExtract(
|
|
340
|
+
apiKey,
|
|
341
|
+
{
|
|
342
|
+
urls: params.urls,
|
|
343
|
+
objective: params.objective,
|
|
344
|
+
search_queries: params.search_queries,
|
|
345
|
+
client_model: ctx.model?.id,
|
|
346
|
+
session_id: parallelSessionId,
|
|
347
|
+
},
|
|
348
|
+
signal
|
|
349
|
+
)
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
content: [{ type: 'text', text: truncateJson(result) }],
|
|
354
|
+
details: {
|
|
355
|
+
provider: 'parallel',
|
|
356
|
+
product: 'extract',
|
|
357
|
+
urls: params.urls,
|
|
358
|
+
},
|
|
359
|
+
};
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
}
|