@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/src/env.ts ADDED
@@ -0,0 +1,68 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ /**
6
+ * Loads key files adjacent to the extension (`<extension-root>/.env` and `<extension-root>/parallel.env`)
7
+ * into process.env before any tool runs. Real environment variables always win; nothing here overrides a
8
+ * set variable. The research key (_PARALLEL_API_KEY or PARALLEL_API_KEY) is held aside instead, so the
9
+ * environment and Pi's stored login both win over it; see `packageAdjacentKey`.
10
+ * No Pi imports: callable from a plain script as well.
11
+ */
12
+
13
+ /** Parse dotenv-style text. Supports blank lines, `#` comments, `export KEY=`, and quoted values. */
14
+ export function parseEnvText(text: string): Record<string, string> {
15
+ const out: Record<string, string> = {};
16
+ for (const rawLine of text.split("\n")) {
17
+ const line = rawLine.trim();
18
+ if (!line || line.startsWith("#")) continue;
19
+ const m = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
20
+ if (!m) continue;
21
+ let value = m[2].trim();
22
+ const quote = value[0];
23
+ if ((quote === '"' || quote === "'") && value.length > 1 && value.endsWith(quote)) {
24
+ value = value.slice(1, -1);
25
+ } else {
26
+ value = value.replace(/\s+#.*$/, "").trimEnd();
27
+ }
28
+ out[m[1]] = value;
29
+ }
30
+ return out;
31
+ }
32
+
33
+ /** Apply entries without overriding variables that are already set. An explicit empty value counts as set. */
34
+ export function applyEnv(entries: Record<string, string>, env: Record<string, string | undefined> = process.env): void {
35
+ for (const [key, value] of Object.entries(entries)) {
36
+ if (env[key] === undefined) env[key] = value;
37
+ }
38
+ }
39
+
40
+ /** This module lives in `src/`; key files live in the extension root. */
41
+ const EXTENSION_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
42
+
43
+ /**
44
+ * Research keys found in the package-adjacent files. They are kept out of process.env, so a variable set in
45
+ * the environment always wins over them. The first file that defines a key wins.
46
+ */
47
+ const packageKeys: Record<string, string> = {};
48
+ const KEY_NAMES = new Set(["_PARALLEL_API_KEY", "PARALLEL_API_KEY"]);
49
+
50
+ for (const name of [".env", "parallel.env"]) {
51
+ let entries: Record<string, string>;
52
+ try {
53
+ entries = parseEnvText(readFileSync(join(EXTENSION_ROOT, name), "utf8"));
54
+ } catch {
55
+ continue; /* no readable key file at this path */
56
+ }
57
+ const settings: Record<string, string> = {};
58
+ for (const [key, value] of Object.entries(entries)) {
59
+ if (KEY_NAMES.has(key)) packageKeys[key] ??= value;
60
+ else settings[key] = value;
61
+ }
62
+ applyEnv(settings);
63
+ }
64
+
65
+ /** The research key from the package-adjacent key file, if any. */
66
+ export function packageAdjacentKey(): string | undefined {
67
+ return [...KEY_NAMES].map(name => packageKeys[name]?.trim()).find(Boolean) || undefined;
68
+ }
package/src/index.ts ADDED
@@ -0,0 +1,175 @@
1
+ /**
2
+ * pi-webtasks: session-bound web research for Pi.
3
+ *
4
+ * The extension owns transport, the local evidence store, indexing, recovery,
5
+ * and alert delivery. The model owns the research work: what to search, what to
6
+ * read, and what to ask. Nothing here exposes SQL, provider ids, or file paths.
7
+ *
8
+ * Collection runs inside Pi. There is no webhook, no callback receiver, and no
9
+ * artifact file: received evidence is committed to a local SQLite store owned by
10
+ * the Pi session. If Pi closes, collection stops; the next session start
11
+ * reconciles provider state where retention permits.
12
+ */
13
+
14
+ import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
15
+ import { join } from "node:path";
16
+ import { formatError, researchClient, type StoredKey } from "./ops.ts";
17
+ import { PARALLEL_PROVIDER_ID, parallelProvider } from "./provider.ts";
18
+ import { ResearchStore, type AlertRecord } from "./store.ts";
19
+ import { createResearchService, type ResearchService } from "./research-service.ts";
20
+ import { createRecallService, type RecallService } from "./recall.ts";
21
+ import { createTaskService, type TaskService } from "./task-service.ts";
22
+ import { createAnswerService, type AnswerService } from "./answer-service.ts";
23
+ import { registerResearchTools, type ResearchServices } from "./research-tools.ts";
24
+ import { createAlertDelivery, type AlertDelivery } from "./alert-delivery.ts";
25
+ import { WebtasksStatus } from "./status.ts";
26
+
27
+ /** Optional explicit store directory. */
28
+ const WEBTASKS_DIR_ENV = "WEBTASKS_DIR";
29
+
30
+ /** Explicitly configured store directory. */
31
+ function configuredStoreDir(): string | undefined {
32
+ const dir = process.env[WEBTASKS_DIR_ENV];
33
+ return dir && dir.trim() ? dir : undefined;
34
+ }
35
+
36
+ /** Research evidence lives in WEBTASKS_DIR when set, else `<agent-dir>/webtasks`. */
37
+ function storeDirectory(): string {
38
+ return configuredStoreDir() ?? join(getAgentDir(), "webtasks");
39
+ }
40
+
41
+ /** Everything owned by one started Pi session. A reload or session switch builds a new one. */
42
+ type Runtime = {
43
+ sessionId: string;
44
+ store: ResearchStore;
45
+ research: ResearchService;
46
+ recall: RecallService;
47
+ task: TaskService;
48
+ answer: AnswerService;
49
+ /** Hands research alerts to Pi as follow-ups, at least once. */
50
+ alerts: AlertDelivery;
51
+ /** Footer status (TUI only): web, answer, and research counts with estimated spend. */
52
+ status?: WebtasksStatus;
53
+ released: boolean;
54
+ };
55
+
56
+ export default function piWebtasks(pi: ExtensionAPI) {
57
+ let rt: Runtime | undefined;
58
+
59
+ // Registered like any provider. /login lists it under "Sign in with an API key".
60
+ pi.registerProvider(parallelProvider());
61
+
62
+ async function stopRuntime(): Promise<void> {
63
+ const outgoing = rt;
64
+ if (!outgoing) return;
65
+ // Mark the runtime unusable first: nothing may write to a store that is closing.
66
+ outgoing.released = true;
67
+ if (rt === outgoing) rt = undefined;
68
+ outgoing.status?.close();
69
+ await outgoing.alerts.stop();
70
+ await outgoing.task.stop().catch(() => undefined);
71
+ await outgoing.store.close().catch(() => undefined);
72
+ }
73
+
74
+ function currentServices(): ResearchServices {
75
+ if (!rt || rt.released) throw new Error("Research tools are unavailable: the session is not started");
76
+ return { research: rt.research, recall: rt.recall, task: rt.task, answer: rt.answer, status: rt.status };
77
+ }
78
+
79
+ /**
80
+ * The Pi side of alert delivery: an idle session wakes, a busy one receives
81
+ * the alert after its current work. Throwing leaves the alert to be offered again.
82
+ */
83
+ function alertSink(ctx: ExtensionContext) {
84
+ return (alert: AlertRecord) => {
85
+ const text = [alert.headline, alert.body].filter((line): line is string => Boolean(line)).join("\n");
86
+ if (ctx.hasUI) ctx.ui.notify(text, alert.kind === "completion" ? "info" : "warning");
87
+ pi.sendMessage(
88
+ { customType: "webtasks-research", content: text, display: true, details: { alert_id: alert.alert_id, reference: alert.reference, kind: alert.kind } },
89
+ { deliverAs: "followUp", triggerTurn: true },
90
+ );
91
+ };
92
+ }
93
+
94
+ pi.on("session_start", async (_event, ctx) => {
95
+ await stopRuntime();
96
+ const sessionId = ctx.sessionManager.getSessionId();
97
+ // The stored login for this provider, read on every request. Nothing about the key is cached here.
98
+ const modelRegistry = ctx.modelRegistry;
99
+ const storedKey: StoredKey = async () => (await modelRegistry?.getApiKeyForProvider(PARALLEL_PROVIDER_ID)) ?? undefined;
100
+
101
+ let store: ResearchStore;
102
+ try {
103
+ store = await new ResearchStore(storeDirectory()).init();
104
+ } catch (err) {
105
+ if (ctx.hasUI) ctx.ui.notify(`pi-webtasks: research store unavailable: ${formatError(err)}`, "warning");
106
+ return;
107
+ }
108
+ try {
109
+ await store.registerSession(sessionId);
110
+ // A request interrupted by a crash or a close has no recovery path here: mark it
111
+ // uncertain instead of leaving it silently unfinished. Best effort.
112
+ await store.reconcileInterrupted(sessionId).catch(() => undefined);
113
+ } catch (err) {
114
+ if (ctx.hasUI) ctx.ui.notify(`pi-webtasks: research store unavailable: ${formatError(err)}`, "warning");
115
+ await store.close().catch(() => undefined);
116
+ return;
117
+ }
118
+
119
+ const model = ctx.model as { provider?: string; id?: string } | undefined;
120
+ const client = async () => await researchClient(storedKey);
121
+ const alerts = createAlertDelivery({
122
+ store,
123
+ sessionId,
124
+ deliver: alertSink(ctx),
125
+ onError: err => { if (ctx.hasUI) ctx.ui.notify(`pi-webtasks: alert delivery failed: ${formatError(err)}`, "warning"); },
126
+ });
127
+ const runtime: Runtime = {
128
+ sessionId,
129
+ store,
130
+ research: createResearchService({
131
+ store,
132
+ sessionId,
133
+ client,
134
+ clientModel: model?.provider && model?.id ? `${model.provider}/${model.id}` : undefined,
135
+ }),
136
+ recall: createRecallService({ store, sessionId, alerts }),
137
+ task: createTaskService({
138
+ store,
139
+ sessionId,
140
+ client,
141
+ onAlerts: () => {
142
+ // Progress and completion change what the footer shows: runs in progress, sources, spend.
143
+ void runtime.status?.refresh();
144
+ void alerts.wake();
145
+ },
146
+ log: message => { if (ctx.hasUI) ctx.ui.notify(`pi-webtasks: ${message}`, "warning"); },
147
+ // Testing aid: shorten the progress sweep without changing the default.
148
+ ...(Number(process.env.WEBTASKS_SWEEP_MS) >= 500 ? { sweepMs: Number(process.env.WEBTASKS_SWEEP_MS) } : {}),
149
+ }),
150
+ answer: createAnswerService({ store, sessionId, client }),
151
+ alerts,
152
+ released: false,
153
+ };
154
+ rt = runtime;
155
+ if (ctx.hasUI && ctx.mode === "tui") {
156
+ runtime.status = new WebtasksStatus(text => ctx.ui.setStatus("webtasks", text), () => store.runSummaries(sessionId));
157
+ void runtime.status.refresh();
158
+ }
159
+ runtime.task.start();
160
+ // A new runtime owns delivery: what the previous one left unacknowledged is offered again.
161
+ void alerts.start();
162
+ // Reconcile in the background: a settled run must not delay session start.
163
+ void runtime.task.reconcile().then(() => runtime.status?.refresh()).catch(err => {
164
+ if (ctx.hasUI) ctx.ui.notify(`pi-webtasks: research reconciliation failed: ${formatError(err)}`, "warning");
165
+ });
166
+ });
167
+
168
+ pi.on("session_shutdown", async () => {
169
+ await stopRuntime();
170
+ });
171
+
172
+ // Discovery, reading, recall, background research, and answers resolve the store
173
+ // through the current runtime, not a captured one.
174
+ registerResearchTools(pi, currentServices);
175
+ }
package/src/ops.ts ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Parallel client plumbing shared by every research call.
3
+ *
4
+ * The key precedence rule lives here so the extension, tests, and any future
5
+ * caller resolve credentials the same way. Nothing here retries: the client is
6
+ * built with `maxRetries: 0`, and call sites pass it explicitly as well.
7
+ */
8
+
9
+ import { packageAdjacentKey } from "./env.ts";
10
+ import { Parallel, APIError } from "parallel-web";
11
+
12
+ /** Where a key stored by Pi's /login comes from. Absent when the caller runs without Pi. */
13
+ export type StoredKey = () => Promise<string | undefined>;
14
+
15
+ /** Shown when Pi runs the extension. The sentence names the one step a user can take inside Pi. */
16
+ export const MISSING_KEY_IN_PI = "Run /login, choose Parallel, and paste an API key — or set _PARALLEL_API_KEY.";
17
+ /** Shown when there is no Pi login, so the only sources are the environment and the package key file. */
18
+ export const MISSING_KEY_STANDALONE = "Parallel API key missing. Set _PARALLEL_API_KEY in the environment, or put it in .env or parallel.env beside the package manifest.";
19
+
20
+ export type KeySource = "stored credential" | "environment" | "key file";
21
+
22
+ /**
23
+ * The one precedence rule for the research key: the stored login, then _PARALLEL_API_KEY, then
24
+ * PARALLEL_API_KEY from the environment, then the package-adjacent key file. Pure, so tests can drive it.
25
+ */
26
+ export function resolveResearchKey(input: {
27
+ stored?: string;
28
+ env?: Record<string, string | undefined>;
29
+ packageKey?: string;
30
+ }): { key: string; source: KeySource } | undefined {
31
+ const stored = input.stored?.trim();
32
+ if (stored) return { key: stored, source: "stored credential" };
33
+ const env = input.env ?? process.env;
34
+ for (const name of ["_PARALLEL_API_KEY", "PARALLEL_API_KEY"]) {
35
+ const value = env[name]?.trim();
36
+ if (value) return { key: value, source: "environment" };
37
+ }
38
+ const file = input.packageKey?.trim();
39
+ return file ? { key: file, source: "key file" } : undefined;
40
+ }
41
+
42
+ /** Resolved on every call, never cached, so a login or logout takes effect on the next request. */
43
+ export async function researchKey(stored?: StoredKey): Promise<string> {
44
+ const resolved = resolveResearchKey({ stored: stored ? await stored() : undefined, packageKey: packageAdjacentKey() });
45
+ if (!resolved) throw new Error(stored ? MISSING_KEY_IN_PI : MISSING_KEY_STANDALONE);
46
+ return resolved.key;
47
+ }
48
+
49
+ export async function researchClient(stored?: StoredKey): Promise<Parallel> {
50
+ return new Parallel({ apiKey: await researchKey(stored), timeout: 120_000, maxRetries: 0 });
51
+ }
52
+
53
+ /** A readable message for a provider or local failure, without leaking credentials. */
54
+ export function formatError(err: unknown): string {
55
+ if (err instanceof APIError) {
56
+ try {
57
+ return JSON.stringify(err.error ?? err.message, null, 2);
58
+ } catch {
59
+ return err.message;
60
+ }
61
+ }
62
+ if (err instanceof Error) return err.message;
63
+ return String(err);
64
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * The recorded paid call: one request to Parallel that costs money, recorded
3
+ * before it is sent and settled exactly once.
4
+ *
5
+ * Every paid tool goes through here (web_search, web_fetch, web_answer, and the
6
+ * web_research submission), so these rules hold in one place:
7
+ *
8
+ * - Intent before money: the run is recorded, keyed by the Pi tool invocation,
9
+ * before the request. A replayed invocation returns the stored run and is
10
+ * never sent again.
11
+ * - A missing or unusable credential is a local failure: the provider was never
12
+ * asked, so the run is `failed` and the original, actionable message is kept.
13
+ * - The request is sent once and never retried (`maxRetries: 0`).
14
+ * - A 4xx response is a definite rejection: `failed`. Anything else (5xx, 408,
15
+ * a lost connection, a timeout, an abort, an unexpected error once the
16
+ * request was under way) may have reached the provider, so the run is
17
+ * `uncertain` and is never silently resubmitted.
18
+ * - A usable response is committed with its evidence and citations in one
19
+ * store transaction. A response that arrived but cannot be used settles the
20
+ * run as the caller says (`UnusableResponse`).
21
+ * - A call without `settle` hands the accepted run off (Task research): the run
22
+ * keeps the state that `send` recorded, and collection settles it later.
23
+ *
24
+ * The caller owns what varies: input validation, the request, how a response
25
+ * becomes evidence, and the wording of its receipt.
26
+ */
27
+
28
+ import type { CitationInput, EvidenceInput, ResearchStore, RunKind, RunRecord, SearchHit, StoredEvidence } from "./store.ts";
29
+ import { errorText } from "./receipt.ts";
30
+
31
+ /** Upper bound for one synchronous paid request. */
32
+ export const REQUEST_TIMEOUT_MS = 180_000;
33
+ /** Stored evidence listed for a replayed invocation. */
34
+ const REPLAY_HITS = 20;
35
+
36
+ /** Options every paid request is sent with. */
37
+ export type RequestOptions = { signal?: AbortSignal; maxRetries: 0; timeout: number };
38
+
39
+ /** What a usable response stores, committed in one transaction with the completed state. */
40
+ export type Settlement = {
41
+ summary: string;
42
+ evidence?: EvidenceInput[];
43
+ citations?: CitationInput[];
44
+ /** Provider operation id (search id, extract id, response id). */
45
+ providerRunId?: string;
46
+ /** Provider research context (Search/Extract session id). */
47
+ providerSessionId?: string;
48
+ /** Provider interaction id, for a later continuation. */
49
+ providerInteractionId?: string;
50
+ /** Untouched provider response, stored verbatim. */
51
+ rawResult: unknown;
52
+ };
53
+
54
+ export type PaidCall<C, R, I extends object = Record<string, unknown>> = {
55
+ kind: RunKind;
56
+ /** Names the call in errors: "Search", "Fetch", "Answer", "Research submission". */
57
+ label: string;
58
+ /** The effective request, recorded before it is sent. A replay returns it as stored. */
59
+ intent: I;
60
+ processor?: string;
61
+ /** The Pi tool invocation: its id keys the record, its signal may abort the request. */
62
+ invocation: { invocationId: string; signal?: AbortSignal };
63
+ /** The one paid request. It is never retried; `reference` names the local record. */
64
+ send(client: C, request: { reference: string; options: RequestOptions }): Promise<R>;
65
+ /** What a usable response stores. Omit it to hand the accepted run off to collection. */
66
+ settle?(response: R, reference: string): Settlement;
67
+ /** The advice after an outcome-unknown failure. Default: try again only if the result is still needed. */
68
+ uncertainAdvice?(reference: string): string;
69
+ };
70
+
71
+ export type PaidOutcome<R, I extends object = Record<string, unknown>> =
72
+ | {
73
+ replayed: true;
74
+ reference: string;
75
+ /** The stored run; absent only if it vanished between the lookup and the read. */
76
+ run?: RunRecord;
77
+ /**
78
+ * The intent the first invocation recorded, not the replay's arguments. A row
79
+ * written by an older release may lack fields: read each with a fallback.
80
+ */
81
+ intent?: Partial<I>;
82
+ /** Evidence already stored for it, newest first. */
83
+ hits: SearchHit[];
84
+ }
85
+ | {
86
+ replayed: false;
87
+ reference: string;
88
+ response: R;
89
+ /** What `settle` committed; absent for a hand-off. */
90
+ settlement?: Settlement;
91
+ /** The stored evidence, each with its new handle and its url and pointer, in the order `settle` gave; empty for a hand-off. */
92
+ evidence: StoredEvidence[];
93
+ };
94
+
95
+ /**
96
+ * Thrown by `settle` when a response arrived but cannot be used. The run is
97
+ * settled as `state` with `reason` recorded, and the agent sees `message`.
98
+ */
99
+ export class UnusableResponse extends Error {
100
+ readonly state: "failed" | "uncertain";
101
+ readonly reason: string;
102
+ constructor(state: "failed" | "uncertain", reason: string, message: string) {
103
+ super(message);
104
+ this.name = "UnusableResponse";
105
+ this.state = state;
106
+ this.reason = reason;
107
+ }
108
+ }
109
+
110
+ /** A 4xx response is a definite rejection; anything else may have reached the provider. */
111
+ export function outcomeUnknown(err: unknown): boolean {
112
+ const status = (err as { status?: unknown } | null | undefined)?.status;
113
+ return typeof status === "number" ? status >= 500 || status === 408 : true;
114
+ }
115
+
116
+ /** Longest failure message recorded or shown; provider error bodies can be large. */
117
+ const MESSAGE_LIMIT = 500;
118
+
119
+ /** The readable message of a failure, on one bounded line. */
120
+ export function failureMessage(err: unknown): string {
121
+ return errorText(err, MESSAGE_LIMIT) ?? "unknown error";
122
+ }
123
+
124
+ function defaultAdvice(reference: string): string {
125
+ return `Its outcome is unknown (record ${reference}); try again only if you still need the result.`;
126
+ }
127
+
128
+ /** Paid calls for one Pi session: one store, one session id, one provider client factory. */
129
+ export function createPaidCalls<C>(options: { store: ResearchStore; sessionId: string; client: () => Promise<C> }) {
130
+ const { store, sessionId } = options;
131
+
132
+ /** Best effort: a store that cannot record the outcome must not hide the original error. */
133
+ async function settleAs(reference: string, state: "failed" | "uncertain", error: string): Promise<void> {
134
+ await store.setRunState({ sessionId, reference, state, error }).catch(() => undefined);
135
+ }
136
+
137
+ async function run<R, I extends object = Record<string, unknown>>(call: PaidCall<C, R, I>): Promise<PaidOutcome<R, I>> {
138
+ const begun = await store.beginRun({
139
+ sessionId,
140
+ intentKey: call.invocation.invocationId,
141
+ intent: call.intent,
142
+ kind: call.kind,
143
+ processor: call.processor,
144
+ state: "submitting",
145
+ });
146
+ const reference = begun.reference;
147
+ if (!begun.created) {
148
+ const stored = await store.run(sessionId, reference);
149
+ const page = await store.search(sessionId, { reference, limit: REPLAY_HITS });
150
+ const intent = stored?.intent && typeof stored.intent === "object" ? stored.intent as Partial<I> : undefined;
151
+ return { replayed: true, reference, run: stored, intent, hits: page.hits };
152
+ }
153
+
154
+ let client: C;
155
+ try {
156
+ client = await options.client();
157
+ } catch (err) {
158
+ await settleAs(reference, "failed", failureMessage(err));
159
+ throw err;
160
+ }
161
+
162
+ let response: R;
163
+ try {
164
+ response = await call.send(client, {
165
+ reference,
166
+ options: { signal: call.invocation.signal, maxRetries: 0, timeout: REQUEST_TIMEOUT_MS },
167
+ });
168
+ } catch (err) {
169
+ const uncertain = outcomeUnknown(err);
170
+ const message = failureMessage(err);
171
+ await settleAs(reference, uncertain ? "uncertain" : "failed", message);
172
+ throw new Error(
173
+ `${call.label} did not complete: ${message}. ${uncertain ? (call.uncertainAdvice ?? defaultAdvice)(reference) : `Record ${reference}.`}`,
174
+ );
175
+ }
176
+
177
+ if (!call.settle) return { replayed: false, reference, response, evidence: [] };
178
+ try {
179
+ const settlement = call.settle(response, reference);
180
+ const completion = await store.completeRun({ sessionId, reference, state: "completed", ...settlement });
181
+ return { replayed: false, reference, response, settlement, evidence: completion.evidence };
182
+ } catch (err) {
183
+ // The provider answered, so nothing here may claim the request failed outright,
184
+ // unless the caller judged the response itself.
185
+ if (err instanceof UnusableResponse) {
186
+ await settleAs(reference, err.state, err.reason);
187
+ throw err;
188
+ }
189
+ const message = failureMessage(err);
190
+ await settleAs(reference, "uncertain", `the response could not be stored: ${message}`);
191
+ // Say that it ran: an agent that reads a bare storage error would pay for it again.
192
+ throw new Error(
193
+ `${call.label} ran, but its result could not be stored: ${message}. Record ${reference} is uncertain; repeat the request only if you still need the result.`,
194
+ { cause: err },
195
+ );
196
+ }
197
+ }
198
+
199
+ return { run };
200
+ }
201
+
202
+ export type PaidCalls<C> = ReturnType<typeof createPaidCalls<C>>;