@jangmanj/pi-webtasks 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 JangMan-J
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,193 @@
1
+ # pi-webtasks
2
+
3
+ Session-bound web research for Pi, with a local evidence store.
4
+
5
+ The extension owns transport, storage, indexing, recovery, and delivery. The model
6
+ owns the research work: what to search, what to read, and what to ask. Nothing
7
+ exposes SQL, provider ids, file paths, or raw HTTP.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ pi install npm:@jangmanj/pi-webtasks
13
+ ```
14
+
15
+ Then run `/login` in Pi and choose **Parallel** (see [Credentials](#credentials)).
16
+
17
+ This package was earlier published unscoped as `pi-webtasks`; that name is no
18
+ longer published. If you installed it, run `pi remove npm:pi-webtasks`, then
19
+ install the scoped name. The research store directory is the same, so stored
20
+ evidence carries over.
21
+
22
+ ## Status
23
+
24
+ Five tools, split by who writes the answer. The agent writes it from pages it
25
+ reads (`web_search`, `web_fetch`), or a Parallel research agent writes it
26
+ (`web_answer`, `web_research`):
27
+
28
+ | Tool | Work |
29
+ | --- | --- |
30
+ | `web_search` | Find pages for an objective and 1-3 keyword queries; store the ranked sources and excerpts and return handles. |
31
+ | `web_fetch` | Read known http(s) URLs (pages, PDFs) into the same store, as focused excerpts or full content. |
32
+ | `web_answer` | Ask Parallel's research agent (Responses API) for a cited answer in seconds, and follow up in the same conversation. |
33
+ | `web_research` | Hand a research job to Parallel's deep-research agent (Task API) in the background: a cited report or structured fields with per-field citations. `core` is the processor floor and `ultra2x` the most an agent may choose; progress and completion arrive as alerts. |
34
+ | `web_recall` | Search stored evidence, or read one item by handle in bounded slices. |
35
+
36
+ The agent-facing wording (tool descriptions, guidelines, receipts, and errors)
37
+ describes Parallel's capabilities and what to do next. It does not expose the
38
+ extension's policies or plumbing: processor limits, event streams, and the
39
+ store stay internal. `tests/tool-wording.test.ts` checks that every guideline
40
+ names its own tool, because Pi merges all guidelines into one list.
41
+
42
+ There are no webhooks, no background receiver, no artifact files, and no awkward
43
+ effort tiers. Collection happens inside Pi; if Pi is closed, collection stops
44
+ and the next session reconciles provider state where retention permits.
45
+
46
+ ## Background research
47
+
48
+ `web_research` requires an explicit output contract: either `output_schema` (a
49
+ JSON Schema object) for fields, or `output_description` for the cited text report
50
+ you want. The extension never invents one, and never relies on the provider's
51
+ `auto` default. It submits once and returns a receipt. The run's event stream is
52
+ collected while Pi is open: every event is recorded, and interim findings become
53
+ stored evidence as they arrive, so `web_recall` can read partial results
54
+ before the run finishes. Progress messages and source statistics feed the
55
+ progress alerts. If the stream drops, it reconnects with backoff (3 s up to
56
+ 5 min).
57
+
58
+ Alerts wake an idle session and queue behind a busy one; they never steer or
59
+ interrupt active work. Progress updates follow one third of the lower published
60
+ upper-latency estimate per processor (for example `core` every 1m40s). They are
61
+ reminders to look, not deadlines or completion percentages. Completion, failure,
62
+ and action-required alerts are immediate. A run that Parallel halts as
63
+ `action_required` is alerted once and stays watched: progress alerts pause, and
64
+ when it resumes or ends, that arrives as another alert. A closed stream is never
65
+ treated as completion: the provider's run status decides.
66
+
67
+ Alerts are persisted before delivery and delivered at-least-once: an alert stays
68
+ unacknowledged until the agent reads that run, and a new runtime offers
69
+ unacknowledged alerts again rather than losing them. Do not expect exactly-once.
70
+
71
+ Task runs carry the local reference in their metadata inside the session's Task
72
+ Group, so a crash between submission and recording the provider id is recovered
73
+ from the group instead of resubmitting. `core` is the processor floor
74
+ (`lite`/`base` are not offered for research) and `ultra2x` is what an agent may
75
+ choose; the configured default is `core` (`WEBTASKS_TASK_PROCESSOR` overrides).
76
+
77
+ ## How evidence behaves
78
+
79
+ - Every Search and Extract result is stored in `research.sqlite` under the Pi
80
+ session that requested it (`WEBTASKS_DIR`, else `<agent-dir>/webtasks`).
81
+ - The model sees a bounded receipt with stable handles, never the whole corpus.
82
+ `web_recall { handle }` returns a bounded slice plus the next offset;
83
+ `web_recall { query }` searches stored text and returns handles.
84
+ - Evidence belongs to one Pi session. A handle from another session behaves
85
+ exactly like a missing one, so nothing leaks across sessions.
86
+ - Receipts return a local **context handle**. Passing it as `context` to a later
87
+ `web_search`/`web_fetch` keeps related calls in one provider research session;
88
+ the provider id itself never leaves the store.
89
+ - A paid call is recorded before it is sent and is never retried automatically.
90
+ Replaying the same Pi invocation returns what is already stored instead of
91
+ paying twice; an interrupted request is marked `uncertain`, not silently
92
+ resubmitted. Every paid tool settles a failure the same way: a missing key or
93
+ a 4xx rejection is `failed`; anything else that may have reached Parallel
94
+ (5xx, 408, a lost connection, a timeout) is `uncertain`.
95
+ - Retrieved text is untrusted material, not instructions.
96
+
97
+ ## Development
98
+
99
+ Requirements: Node.js 22.19 or later, npm, and Pi 1.1 or later.
100
+ The only runtime dependency is the pinned `parallel-web` SDK. SQLite uses Node's
101
+ built-in implementation.
102
+
103
+ ```sh
104
+ npm ci
105
+ npm run check
106
+ ```
107
+
108
+ `npm run check` runs the type checker and the offline suite. The offline suite
109
+ uses real SQLite in temporary directories and a stubbed HTTP layer; it makes no
110
+ paid request. `PI_PACKAGE_DIR` selects another installed Pi package directory for
111
+ runtime tests.
112
+
113
+ `node scripts/extract-tool-schemas.ts <dir>` dumps the registered tools exactly
114
+ as the model sees them.
115
+
116
+ `npm run smoke:pack` packs the package, installs the tarball into a scratch
117
+ project, and loads it through Pi's real extension loader.
118
+
119
+ CI (`.github/workflows/ci.yml`) runs the type checker, the suite, and the pack
120
+ smoke test on Node 22.19 (the engines floor), 24, and 26, plus Pi 1.1.0 (the
121
+ peer floor).
122
+
123
+ ## Releasing
124
+
125
+ Releases publish from GitHub Actions through npm trusted publishing (OIDC), so
126
+ no npm token is stored anywhere and every version carries a provenance
127
+ attestation.
128
+
129
+ ```sh
130
+ npm version patch # or minor / major: bumps package.json, commits, tags vX.Y.Z
131
+ git push --follow-tags # the tag runs .github/workflows/publish.yml
132
+ ```
133
+
134
+ The publish workflow reruns CI, refuses a tag that does not match
135
+ `package.json`, publishes, and creates the GitHub release.
136
+
137
+ One-time setup for a package name that is not on npm yet: `npm login`, then
138
+ `npm run bootstrap:npm` in an interactive terminal (one 2FA approval). The
139
+ script stages a throwaway `0.0.0-bootstrap` to create the package, links
140
+ `publish.yml` (environment `npm`) as its trusted publisher, and pushes the
141
+ `v<version>` tag, so CI publishes the first real version with provenance. Run
142
+ `npm run bootstrap:npm -- --dry-run` first to check the plan. Later,
143
+ `npm run bootstrap:npm -- --cleanup` removes the staged throwaway and the
144
+ `0.0.0-stage` placeholder (two more approvals).
145
+
146
+ Retired experiments, benchmarks, and the pre-rewrite implementation live in
147
+ `.archive/` (git-ignored). `npm run docs:snapshot` refreshes the Parallel docs
148
+ mirror into `docs/parallel/` (git-ignored); the design record and API notes live
149
+ there too, outside version control.
150
+
151
+ ## Credentials
152
+
153
+ Run `/login` in Pi and choose **Parallel**. Pi offers two sign-in methods:
154
+
155
+ - **Browser sign-in** (listed under *Sign in with an account*). Pi opens
156
+ platform.parallel.ai in your browser. There you approve the request for
157
+ `127.0.0.1` and select an existing API key or create one. Pi receives the
158
+ approval on a temporary listener on `127.0.0.1`, exchanges it for the key, and
159
+ stores it. The listener accepts one callback and closes on success, error, or
160
+ cancel. After 120 seconds, Pi offers a prompt for the callback address from the
161
+ browser's address bar, for example over SSH without port forwarding. The
162
+ listener stays open while that prompt waits, so a browser callback can still
163
+ complete sign-in.
164
+ - **API key** (listed under *Sign in with an API key*). Paste a key from the API
165
+ keys page on platform.parallel.ai.
166
+
167
+ An empty entry stores nothing. Parallel adds no models, so the model picker is
168
+ unchanged. The extension registers no commands.
169
+
170
+ The stored login is used first. Without one, the extension uses
171
+ `_PARALLEL_API_KEY` (`PARALLEL_API_KEY` is also accepted), then a key in `.env` or
172
+ `parallel.env` beside the package manifest. Exported settings take precedence
173
+ over the key files. The key is resolved on every call, so a login or logout takes
174
+ effect on the next request. Credential files are not packaged or committed.
175
+
176
+ Do not install `@parallel-web/pi-extension` alongside this package: it registers a
177
+ `web_search` tool with the same name, so the two would collide.
178
+
179
+ Balance is not shown by the extension; check the provider account directly. Do
180
+ not paste credential values into tool arguments, diagnostics, or source files.
181
+
182
+ ## Limits
183
+
184
+ - Search, Extract, and Answers are synchronous calls; only research tasks run in
185
+ the background.
186
+ - `web_recall` returns bounded pages and slices, never a whole corpus in one
187
+ call. A page cursor carries its own search, so `web_recall { cursor }`
188
+ alone continues it.
189
+ - Collection stops when Pi closes. Intermediate progress from while Pi was closed
190
+ is not replayed by the provider; completed results are recovered where the
191
+ account's retention policy allows.
192
+ - Stored evidence is private data under the agent directory; retention and
193
+ cross-session visibility are still open decisions.
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@jangmanj/pi-webtasks",
3
+ "version": "0.1.0",
4
+ "description": "Session-bound web research tools with a local evidence store for Pi.",
5
+ "license": "MIT",
6
+ "author": "JangMan-J",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/JangMan-J/pi-webtasks.git"
10
+ },
11
+ "homepage": "https://github.com/JangMan-J/pi-webtasks#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/JangMan-J/pi-webtasks/issues"
14
+ },
15
+ "type": "module",
16
+ "keywords": [
17
+ "pi-package",
18
+ "pi",
19
+ "parallel",
20
+ "web-search",
21
+ "research"
22
+ ],
23
+ "engines": {
24
+ "node": ">=22.19.0"
25
+ },
26
+ "files": [
27
+ "src",
28
+ "README.md"
29
+ ],
30
+ "pi": {
31
+ "extensions": [
32
+ "./src/index.ts"
33
+ ]
34
+ },
35
+ "scripts": {
36
+ "test": "node --test tests/*.test.ts",
37
+ "typecheck": "tsc --noEmit",
38
+ "check": "npm run typecheck && npm test",
39
+ "smoke:pack": "node scripts/smoke-pack.mjs",
40
+ "docs:snapshot": "node scripts/snapshot-docs.mjs docs/parallel",
41
+ "bootstrap:npm": "bash scripts/npm-bootstrap.sh"
42
+ },
43
+ "dependencies": {
44
+ "parallel-web": "1.3.3"
45
+ },
46
+ "peerDependencies": {
47
+ "@earendil-works/pi-ai": "*",
48
+ "@earendil-works/pi-coding-agent": ">=1.1.0",
49
+ "typebox": "*"
50
+ },
51
+ "devDependencies": {
52
+ "@earendil-works/pi-ai": "1.4.0",
53
+ "@earendil-works/pi-coding-agent": "1.4.0",
54
+ "@types/node": "26.6.2",
55
+ "typebox": "1.3.27",
56
+ "typescript": "6.0.3"
57
+ },
58
+ "overrides": {
59
+ "undici": "8.12.3"
60
+ },
61
+ "publishConfig": {
62
+ "access": "public"
63
+ }
64
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Alert delivery: hands the session's stored alerts to the agent, at least once.
3
+ *
4
+ * Alerts are written by the store: a completion commits its alert in the same
5
+ * transaction as its evidence, and progress alerts are queued on their
6
+ * schedule. This module owns everything after that:
7
+ *
8
+ * - Draining: claim waiting alerts (failures and attention first, then
9
+ * completions, then progress), hand each to the sink, mark it delivered.
10
+ * Wake-ups coalesce: one drain runs at a time, and a wake-up during a drain
11
+ * makes it look again before it goes idle.
12
+ * - A failed hand-off is not delivery: the alert stays claimed and the next
13
+ * wake-up offers it again.
14
+ * - Delivered is not received: an alert stays open until the agent reads its
15
+ * run (`received`), and a new runtime offers every unacknowledged alert
16
+ * again (`start`). Duplicates are possible; silence is not.
17
+ * - Nothing is handed over before `start` or after `stop`, and `stop` waits
18
+ * for a running drain, so the store is never closed under it.
19
+ */
20
+
21
+ import type { AlertRecord, ResearchStore } from "./store.ts";
22
+
23
+ /** Hands one alert to the agent. Throwing means it was not handed over. */
24
+ export type AlertSink = (alert: AlertRecord) => void | Promise<void>;
25
+
26
+ type Options = {
27
+ store: ResearchStore;
28
+ sessionId: string;
29
+ deliver: AlertSink;
30
+ /** Reports a failed hand-off or an unavailable store. Delivery itself never throws. */
31
+ onError?: (err: unknown) => void;
32
+ };
33
+
34
+ /** Alerts claimed per pass. */
35
+ const BATCH = 5;
36
+ /** Passes per drain; anything left waits for the next wake-up. */
37
+ const MAX_PASSES = 20;
38
+
39
+ export function createAlertDelivery(options: Options) {
40
+ const { store, sessionId, deliver } = options;
41
+ const report = options.onError ?? (() => undefined);
42
+ /** The takeover and first drain; set once by `start`. */
43
+ let startup: Promise<void> | undefined;
44
+ let started = false;
45
+ let stopped = false;
46
+ let draining = false;
47
+ /** A wake-up arrived during the running drain: look once more before going idle. */
48
+ let again = false;
49
+ let current: Promise<void> = Promise.resolve();
50
+
51
+ async function drain(): Promise<void> {
52
+ try {
53
+ for (let pass = 0; pass < MAX_PASSES; pass++) {
54
+ if (stopped) return;
55
+ again = false;
56
+ const batch = await store.claimAlerts(sessionId, { limit: BATCH });
57
+ if (!batch.length) {
58
+ if (again) continue;
59
+ return;
60
+ }
61
+ for (const alert of batch) {
62
+ if (stopped) return;
63
+ try {
64
+ await deliver(alert);
65
+ } catch (err) {
66
+ // Not marked delivered: the alert stays claimed and the next wake-up offers it again.
67
+ report(err);
68
+ return;
69
+ }
70
+ // Unmarked, it is offered again: a duplicate, never a loss.
71
+ await store.markAlertDelivered(sessionId, alert.alert_id).catch(() => undefined);
72
+ }
73
+ }
74
+ } catch (err) {
75
+ report(err);
76
+ } finally {
77
+ // Cleared in the same step as the last check of `again`, so no wake-up falls between them.
78
+ draining = false;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Something may be waiting: drain it, or make the running drain look again.
84
+ * Resolves when that drain is idle. Never rejects.
85
+ */
86
+ function wake(): Promise<void> {
87
+ if (!started || stopped) return current;
88
+ if (draining) {
89
+ again = true;
90
+ return current;
91
+ }
92
+ draining = true;
93
+ current = drain();
94
+ return current;
95
+ }
96
+
97
+ /**
98
+ * Take over delivery for this session: what an earlier runtime handed over
99
+ * but never saw read is offered again, then everything waiting is drained.
100
+ * Wake-ups before this are covered by its drain.
101
+ */
102
+ function start(): Promise<void> {
103
+ if (stopped) return Promise.resolve();
104
+ startup ??= (async () => {
105
+ await store.requeueUnacknowledgedAlerts(sessionId).catch(report);
106
+ if (stopped) return;
107
+ started = true;
108
+ await wake();
109
+ })();
110
+ return startup;
111
+ }
112
+
113
+ /** The agent read this run's evidence: its alerts were received and are not offered again. */
114
+ async function received(reference: string): Promise<void> {
115
+ await store.acknowledgeRunAlerts(sessionId, reference).catch(() => undefined);
116
+ }
117
+
118
+ /** Hand nothing more over, and wait for a running takeover or drain to finish. */
119
+ async function stop(): Promise<void> {
120
+ stopped = true;
121
+ await startup;
122
+ await current;
123
+ }
124
+
125
+ return { start, wake, received, stop };
126
+ }
127
+
128
+ export type AlertDelivery = ReturnType<typeof createAlertDelivery>;
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Cited answers through the Responses API (`POST /v1/responses`), with local
3
+ * continuation and stored evidence.
4
+ *
5
+ * The pinned SDK has no Responses resource, so this module issues the documented
6
+ * raw request as a recorded paid call (`paid-call.ts`): never retried, because
7
+ * the API has no native background mode or retrieve-by-id, so an ambiguous
8
+ * outcome is recorded as `uncertain` rather than repeated.
9
+ */
10
+
11
+ import type { ResearchStore, EvidenceInput, CitationInput } from "./store.ts";
12
+ import { createPaidCalls, UnusableResponse } from "./paid-call.ts";
13
+ import { sourcePolicy } from "./source-policy.ts";
14
+ import { clean, oneLine, RECEIPT_LIMIT, replayReceipt, UNTRUSTED } from "./receipt.ts";
15
+
16
+ /** The part of the Parallel client the answer path needs. */
17
+ export type AnswerClient = {
18
+ apiKey?: string;
19
+ post(path: string, options?: {
20
+ body?: Record<string, unknown>;
21
+ headers?: Record<string, string>;
22
+ maxRetries?: number;
23
+ timeout?: number;
24
+ signal?: AbortSignal;
25
+ }): Promise<unknown>;
26
+ };
27
+
28
+ export type AnswerInput = {
29
+ question: string;
30
+ effort?: "low" | "medium" | "high";
31
+ instructions?: string;
32
+ include_domains?: string[];
33
+ exclude_domains?: string[];
34
+ /** Local reference of an earlier answer to continue. */
35
+ continue_from?: string;
36
+ };
37
+
38
+ export const ANSWER_EFFORTS = ["low", "medium", "high"] as const;
39
+ /** Parallel's own default; low suits only a fact one good source settles. */
40
+ export const DEFAULT_ANSWER_EFFORT: (typeof ANSWER_EFFORTS)[number] = "medium";
41
+
42
+ export type AnswerReceipt = { text: string; details: Record<string, unknown> };
43
+
44
+ type Options = {
45
+ store: ResearchStore;
46
+ sessionId: string;
47
+ client: () => Promise<AnswerClient>;
48
+ };
49
+
50
+ type Annotation = { type?: string; url?: string; title?: string; start_index?: number; end_index?: number };
51
+
52
+ /** Read the answer text: `output_text` when present, otherwise the message items. */
53
+ function answerText(response: Record<string, unknown>): string {
54
+ if (typeof response.output_text === "string" && response.output_text.trim()) return response.output_text;
55
+ const output = response.output;
56
+ if (!Array.isArray(output)) return "";
57
+ const parts: string[] = [];
58
+ for (const item of output) {
59
+ if (!item || typeof item !== "object") continue;
60
+ const content = (item as { content?: unknown }).content;
61
+ if (!Array.isArray(content)) continue;
62
+ for (const part of content) {
63
+ if (part && typeof part === "object" && typeof (part as { text?: unknown }).text === "string") {
64
+ parts.push((part as { text: string }).text);
65
+ }
66
+ }
67
+ }
68
+ return parts.join("\n");
69
+ }
70
+
71
+ function annotations(response: Record<string, unknown>): Annotation[] {
72
+ const output = response.output;
73
+ if (!Array.isArray(output)) return [];
74
+ const found: Annotation[] = [];
75
+ for (const item of output) {
76
+ if (!item || typeof item !== "object") continue;
77
+ const content = (item as { content?: unknown }).content;
78
+ if (!Array.isArray(content)) continue;
79
+ for (const part of content) {
80
+ if (!part || typeof part !== "object") continue;
81
+ const list = (part as { annotations?: unknown }).annotations;
82
+ if (!Array.isArray(list)) continue;
83
+ for (const annotation of list) {
84
+ if (annotation && typeof annotation === "object" && (annotation as Annotation).type === "url_citation") {
85
+ found.push(annotation as Annotation);
86
+ }
87
+ }
88
+ }
89
+ }
90
+ return found;
91
+ }
92
+
93
+ function evidenceFromAnswer(text: string, list: Annotation[]): { evidence: EvidenceInput[]; citations: CitationInput[] } {
94
+ const evidence: EvidenceInput[] = [{
95
+ kind: "report",
96
+ origin: "provider_report",
97
+ title: "Cited answer",
98
+ body: text,
99
+ pointer: "output_text",
100
+ ref: "answer",
101
+ }];
102
+ const citations: CitationInput[] = [];
103
+ const seen = new Map<string, string>();
104
+ for (const annotation of list) {
105
+ const url = clean(annotation.url);
106
+ if (!url) continue;
107
+ let ref = seen.get(url);
108
+ if (!ref) {
109
+ ref = `src:${seen.size}`;
110
+ seen.set(url, ref);
111
+ evidence.push({
112
+ kind: "source",
113
+ origin: "provider_source",
114
+ title: clean(annotation.title),
115
+ url,
116
+ pointer: "annotations",
117
+ body: url,
118
+ payload: annotation,
119
+ ref,
120
+ });
121
+ }
122
+ const start = typeof annotation.start_index === "number" ? annotation.start_index : undefined;
123
+ const end = typeof annotation.end_index === "number" ? annotation.end_index : undefined;
124
+ const claim = start !== undefined && end !== undefined && end > start ? text.slice(start, end) : undefined;
125
+ citations.push({ evidence: "answer", source: ref, url, title: clean(annotation.title), claim: claim ? oneLine(claim, 300) : undefined });
126
+ }
127
+ return { evidence, citations };
128
+ }
129
+
130
+ /** The answer text for the receipt, cut at the budget with a pointer to the rest. */
131
+ function answerPreview(text: string, limit = RECEIPT_LIMIT): { text: string; truncated: boolean } {
132
+ if (text.length <= limit) return { text, truncated: false };
133
+ return { text: `${text.slice(0, limit)}\n\n[Answer continues; read the rest with web_recall { handle }]`, truncated: true };
134
+ }
135
+
136
+ export function createAnswerService({ store, sessionId, client }: Options) {
137
+ const calls = createPaidCalls({ store, sessionId, client });
138
+
139
+ async function continuationId(reference: string): Promise<string> {
140
+ const run = await store.run(sessionId, reference);
141
+ if (!run) throw new Error(`${reference} is not a research record in this session`);
142
+ if (run.kind !== "answer") throw new Error(`${reference} is not an earlier web_answer; continue a web_research run with web_research { continue_from }`);
143
+ if (!run.provider_run_id) throw new Error(`${reference} has no completed answer to continue from (state: ${run.state})`);
144
+ return run.provider_run_id;
145
+ }
146
+
147
+ async function answer(input: AnswerInput, call: { invocationId: string; signal?: AbortSignal }): Promise<AnswerReceipt> {
148
+ const question = clean(input.question);
149
+ if (!question) throw new Error("question must be a non-empty string");
150
+ const effort = input.effort ?? DEFAULT_ANSWER_EFFORT;
151
+ if (!(ANSWER_EFFORTS as readonly string[]).includes(effort)) throw new Error("effort must be low, medium, or high");
152
+ const previous = input.continue_from ? await continuationId(clean(input.continue_from)!) : undefined;
153
+ const { include, exclude } = sourcePolicy(input);
154
+
155
+ const intent = {
156
+ kind: "answer" as const,
157
+ question,
158
+ effort,
159
+ instructions: clean(input.instructions),
160
+ include_domains: include,
161
+ exclude_domains: exclude,
162
+ continue_from: clean(input.continue_from),
163
+ };
164
+ const body: Record<string, unknown> = {
165
+ model: "parallel",
166
+ input: question,
167
+ reasoning: { effort },
168
+ ...(previous ? { previous_response_id: previous } : {}),
169
+ ...(clean(input.instructions) ? { instructions: clean(input.instructions) } : {}),
170
+ ...(include.length || exclude.length
171
+ ? { tools: [{ type: "web_search", filters: { allowed_domains: include, blocked_domains: exclude } }] }
172
+ : {}),
173
+ };
174
+
175
+ const outcome = await calls.run({
176
+ kind: "answer",
177
+ label: "Answer",
178
+ intent,
179
+ invocation: call,
180
+ uncertainAdvice: reference => `Its outcome is unknown (record ${reference}); ask again only if you still need the answer.`,
181
+ send: async (parallel, { options }) => await parallel.post("/v1/responses", {
182
+ body,
183
+ ...(parallel.apiKey ? { headers: { Authorization: `Bearer ${parallel.apiKey}` } } : {}),
184
+ ...options,
185
+ }) as Record<string, unknown>,
186
+ settle: (response, reference) => {
187
+ const id = typeof response.id === "string" ? response.id : undefined;
188
+ if (!id) {
189
+ throw new UnusableResponse("uncertain", "response carried no id", `The answer came back incomplete (record ${reference}); ask again if you still need it.`);
190
+ }
191
+ const text = answerText(response);
192
+ if (!text.trim()) {
193
+ throw new UnusableResponse("failed", "response carried no answer text", `The answer came back empty (record ${reference}).`);
194
+ }
195
+ const { evidence, citations } = evidenceFromAnswer(text, annotations(response));
196
+ return { summary: oneLine(text, 120), providerRunId: id, rawResult: response, evidence, citations };
197
+ },
198
+ });
199
+
200
+ if (outcome.replayed) {
201
+ const { reference, run, hits } = outcome;
202
+ const first = hits.find(hit => hit.kind === "report") ?? hits[0];
203
+ // The stored answer is the body; leave room for the header and footer within the receipt budget.
204
+ const stored = first ? (await store.readEvidence(sessionId, first.handle, { limit: RECEIPT_LIMIT - 800 })).text : "";
205
+ const { text, details } = replayReceipt({ noun: "question", reference, state: run?.state ?? "unknown", hits, body: stored ? [stored] : [], listHits: !stored });
206
+ return { text, details: { ...details, handle: first?.handle } };
207
+ }
208
+
209
+ const { reference, settlement } = outcome;
210
+ const text = settlement?.evidence?.find(item => item.kind === "report")?.body ?? "";
211
+ const citations = settlement?.citations ?? [];
212
+ const report = outcome.evidence.find(item => item.kind === "report")?.handle;
213
+ const preview = answerPreview(text);
214
+ return {
215
+ text: [
216
+ preview.text,
217
+ "",
218
+ `Citations: ${citations.length} source link(s). Full answer: ${reference}.`,
219
+ `Ask a follow-up with web_answer { question, continue_from: "${reference}" }.`,
220
+ report ? `Read the stored answer with web_recall { handle: "${report}" }.` : undefined,
221
+ UNTRUSTED,
222
+ ].filter((line): line is string => line !== undefined).join("\n"),
223
+ details: {
224
+ reference,
225
+ handle: report,
226
+ sources: citations.length,
227
+ truncated: preview.truncated,
228
+ effort,
229
+ continued: previous !== undefined,
230
+ },
231
+ };
232
+ }
233
+
234
+ return { answer };
235
+ }
236
+
237
+ export type AnswerService = ReturnType<typeof createAnswerService>;