@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 +21 -0
- package/README.md +193 -0
- package/package.json +64 -0
- package/src/alert-delivery.ts +128 -0
- package/src/answer-service.ts +237 -0
- package/src/env.ts +68 -0
- package/src/index.ts +175 -0
- package/src/ops.ts +64 -0
- package/src/paid-call.ts +202 -0
- package/src/parallel-oauth.ts +249 -0
- package/src/provider.ts +63 -0
- package/src/recall.ts +151 -0
- package/src/receipt.ts +95 -0
- package/src/research-service.ts +407 -0
- package/src/research-tools.ts +244 -0
- package/src/source-policy.ts +60 -0
- package/src/status.ts +154 -0
- package/src/store.ts +1445 -0
- package/src/task-run-lifecycle.ts +393 -0
- package/src/task-service.ts +489 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one source-policy rule for every tool that takes one: web_search,
|
|
3
|
+
* web_answer, and web_research.
|
|
4
|
+
*
|
|
5
|
+
* - Domains are trimmed, blanks dropped, and each must be a bare domain
|
|
6
|
+
* (`example.com`, `*.example.com`), never a URL.
|
|
7
|
+
* - include_domains and exclude_domains together take at most 200 entries.
|
|
8
|
+
* - after_date, where a tool takes one, is a real calendar date, YYYY-MM-DD.
|
|
9
|
+
*
|
|
10
|
+
* Every violation is thrown before a run is recorded or a request is paid for.
|
|
11
|
+
* Each tool maps the normalized policy onto its own request body.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type SourcePolicyInput = {
|
|
15
|
+
include_domains?: string[];
|
|
16
|
+
exclude_domains?: string[];
|
|
17
|
+
after_date?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type SourcePolicy = {
|
|
21
|
+
include: string[];
|
|
22
|
+
exclude: string[];
|
|
23
|
+
afterDate?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const MAX_DOMAINS = 200;
|
|
27
|
+
const BARE_DOMAIN = /^[a-z0-9.*-]+$/i;
|
|
28
|
+
const CALENDAR_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
29
|
+
|
|
30
|
+
function domains(list: string[] | undefined): string[] {
|
|
31
|
+
const cleaned = (list ?? []).map(domain => domain.trim()).filter(Boolean);
|
|
32
|
+
for (const domain of cleaned) {
|
|
33
|
+
if (!BARE_DOMAIN.test(domain)) throw new Error(`"${domain}" is not a bare domain (write example.com, not a URL)`);
|
|
34
|
+
}
|
|
35
|
+
return cleaned;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Validate and normalize a source policy. */
|
|
39
|
+
export function sourcePolicy(input: SourcePolicyInput): SourcePolicy {
|
|
40
|
+
const include = domains(input.include_domains);
|
|
41
|
+
const exclude = domains(input.exclude_domains);
|
|
42
|
+
if (include.length + exclude.length > MAX_DOMAINS) {
|
|
43
|
+
throw new Error(`include_domains and exclude_domains together take at most ${MAX_DOMAINS} entries`);
|
|
44
|
+
}
|
|
45
|
+
const afterDate = input.after_date?.trim() || undefined;
|
|
46
|
+
if (afterDate !== undefined && (!CALENDAR_DATE.test(afterDate) || Number.isNaN(Date.parse(afterDate)))) {
|
|
47
|
+
throw new Error("after_date must be a calendar date in YYYY-MM-DD form");
|
|
48
|
+
}
|
|
49
|
+
return { include, exclude, ...(afterDate ? { afterDate } : {}) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The policy in Parallel's Search and Task `source_policy` shape, or undefined when it restricts nothing. */
|
|
53
|
+
export function providerSourcePolicy(policy: SourcePolicy): { include_domains?: string[]; exclude_domains?: string[]; after_date?: string } | undefined {
|
|
54
|
+
if (!policy.include.length && !policy.exclude.length && !policy.afterDate) return undefined;
|
|
55
|
+
return {
|
|
56
|
+
...(policy.afterDate ? { after_date: policy.afterDate } : {}),
|
|
57
|
+
...(policy.include.length ? { include_domains: policy.include } : {}),
|
|
58
|
+
...(policy.exclude.length ? { exclude_domains: policy.exclude } : {}),
|
|
59
|
+
};
|
|
60
|
+
}
|
package/src/status.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Footer status for the web tools, shown through `ctx.ui.setStatus("webtasks", ...)`.
|
|
3
|
+
* Footer extensions (zentui, starship, powerline) render extension statuses, so this is
|
|
4
|
+
* the whole integration. It is for the user, never the agent.
|
|
5
|
+
*
|
|
6
|
+
* Four lanes, each a Nerd Font icon and a count; a spinner frame follows a lane while it
|
|
7
|
+
* works:
|
|
8
|
+
* web sources (web_search, web_fetch) · answers (web_answer) ·
|
|
9
|
+
* research sources (web_research, spinning while a run is in progress) ·
|
|
10
|
+
* estimated spend at published list prices.
|
|
11
|
+
*
|
|
12
|
+
* Counts come from the session's stored runs, so they survive a reload. The spend is an
|
|
13
|
+
* estimate, not a bill: credits and negotiated rates are not deducted, and a run whose
|
|
14
|
+
* outcome is unknown is shown as "+?".
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { OPEN_RUN_STATES, type RunKind, type RunState } from "./store.ts";
|
|
18
|
+
|
|
19
|
+
export type StatusLane = "web" | "answer" | "research";
|
|
20
|
+
|
|
21
|
+
export type RunSummary = { kind: RunKind; state: RunState; processor?: string; intent: unknown; sources: number };
|
|
22
|
+
|
|
23
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
24
|
+
const ICON = { web: "\u{F002}", answer: "\u{F27A}", research: "\u{F02D}", spend: "\u{F0D6}" };
|
|
25
|
+
const COLOR = { web: 39, answer: 141, research: 214, spend: 82 };
|
|
26
|
+
const color = (code: number, text: string) => `\x1b[38;5;${code}m${text}\x1b[0m`;
|
|
27
|
+
|
|
28
|
+
// Published list prices in USD millionths (https://docs.parallel.ai/getting-started/pricing).
|
|
29
|
+
const SEARCH_PRICE: Record<string, number> = { turbo: 1_000, fast: 1_000, basic: 5_000, advanced: 5_000 };
|
|
30
|
+
const EXTRA_RESULT_PRICE = 1_000;
|
|
31
|
+
const EXTRACT_PRICE_PER_URL = 1_000;
|
|
32
|
+
const ANSWER_PRICE: Record<string, number> = { low: 10_000, medium: 50_000, high: 250_000 };
|
|
33
|
+
const TASK_PRICE: Record<string, number> = { core: 25_000, core2x: 50_000, pro: 100_000, ultra: 300_000, ultra2x: 600_000 };
|
|
34
|
+
|
|
35
|
+
export type StatusTotals = {
|
|
36
|
+
counts: Record<StatusLane, number>;
|
|
37
|
+
microdollars: number;
|
|
38
|
+
/** Some spend cannot be priced yet: an outcome is unknown, or a price is not published. */
|
|
39
|
+
unpriced: boolean;
|
|
40
|
+
researchInProgress: number;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Fold the session's runs into the footer's counts and estimated spend. Pure, for tests. */
|
|
44
|
+
export function statusTotals(runs: RunSummary[]): StatusTotals {
|
|
45
|
+
const totals: StatusTotals = { counts: { web: 0, answer: 0, research: 0 }, microdollars: 0, unpriced: false, researchInProgress: 0 };
|
|
46
|
+
for (const run of runs) {
|
|
47
|
+
const intent = (run.intent ?? {}) as { mode?: string; urls?: unknown[]; effort?: string };
|
|
48
|
+
// A Task run is only billed when it completes; an open run may still complete.
|
|
49
|
+
if (run.kind === "task" && OPEN_RUN_STATES.has(run.state)) totals.researchInProgress++;
|
|
50
|
+
if (run.state === "uncertain") totals.unpriced = true;
|
|
51
|
+
if (run.state !== "completed") continue;
|
|
52
|
+
if (run.kind === "search") {
|
|
53
|
+
totals.counts.web += run.sources;
|
|
54
|
+
const price = SEARCH_PRICE[intent.mode ?? "basic"];
|
|
55
|
+
if (price === undefined) totals.unpriced = true;
|
|
56
|
+
else totals.microdollars += price + Math.max(0, run.sources - 10) * EXTRA_RESULT_PRICE;
|
|
57
|
+
} else if (run.kind === "extract") {
|
|
58
|
+
totals.counts.web += run.sources;
|
|
59
|
+
totals.microdollars += (Array.isArray(intent.urls) ? intent.urls.length : 1) * EXTRACT_PRICE_PER_URL;
|
|
60
|
+
} else if (run.kind === "answer") {
|
|
61
|
+
totals.counts.answer += 1;
|
|
62
|
+
const price = ANSWER_PRICE[intent.effort ?? "medium"];
|
|
63
|
+
if (price === undefined) totals.unpriced = true;
|
|
64
|
+
else totals.microdollars += price;
|
|
65
|
+
} else if (run.kind === "task") {
|
|
66
|
+
totals.counts.research += run.sources;
|
|
67
|
+
const price = TASK_PRICE[run.processor ?? "core"];
|
|
68
|
+
if (price === undefined) totals.unpriced = true;
|
|
69
|
+
else totals.microdollars += price;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return totals;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Dollars in at most four characters: 0, 0.42, 7.10, 38, 412, 1.2K. */
|
|
76
|
+
export function formatSpend(microdollars: number): string {
|
|
77
|
+
const dollars = microdollars / 1_000_000;
|
|
78
|
+
const cents = Math.round(microdollars / 10_000);
|
|
79
|
+
if (cents === 0) return "0";
|
|
80
|
+
if (dollars < 10) return (Math.min(cents, 999) / 100).toFixed(2);
|
|
81
|
+
if (Math.round(dollars) < 1_000) return String(Math.round(dollars));
|
|
82
|
+
return new Intl.NumberFormat("en-US", { notation: "compact", maximumSignificantDigits: 3 }).format(dollars);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export class WebtasksStatus {
|
|
86
|
+
private readonly setStatus: (text: string | undefined) => void;
|
|
87
|
+
private readonly load: () => Promise<RunSummary[]>;
|
|
88
|
+
private readonly active: Record<StatusLane, number> = { web: 0, answer: 0, research: 0 };
|
|
89
|
+
private totals: StatusTotals = statusTotals([]);
|
|
90
|
+
private frame = 0;
|
|
91
|
+
private timer: ReturnType<typeof setInterval> | undefined;
|
|
92
|
+
private closed = false;
|
|
93
|
+
|
|
94
|
+
constructor(setStatus: (text: string | undefined) => void, load: () => Promise<RunSummary[]>) {
|
|
95
|
+
this.setStatus = setStatus;
|
|
96
|
+
this.load = load;
|
|
97
|
+
this.render();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Mark a lane busy while a tool call runs; the returned function ends it once. */
|
|
101
|
+
begin(lane: StatusLane): () => void {
|
|
102
|
+
if (this.closed) return () => undefined;
|
|
103
|
+
this.active[lane]++;
|
|
104
|
+
this.render();
|
|
105
|
+
let ended = false;
|
|
106
|
+
return () => {
|
|
107
|
+
if (ended || this.closed) return;
|
|
108
|
+
ended = true;
|
|
109
|
+
this.active[lane]--;
|
|
110
|
+
void this.refresh();
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Re-read the session's runs. Never throws: a footer must not break a tool call. */
|
|
115
|
+
async refresh(): Promise<void> {
|
|
116
|
+
if (this.closed) return;
|
|
117
|
+
try {
|
|
118
|
+
this.totals = statusTotals(await this.load());
|
|
119
|
+
} catch {
|
|
120
|
+
/* keep the last totals */
|
|
121
|
+
}
|
|
122
|
+
this.render();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
close(): void {
|
|
126
|
+
if (this.closed) return;
|
|
127
|
+
this.closed = true;
|
|
128
|
+
if (this.timer) clearInterval(this.timer);
|
|
129
|
+
this.timer = undefined;
|
|
130
|
+
this.setStatus(undefined);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private render(): void {
|
|
134
|
+
if (this.closed) return;
|
|
135
|
+
const researching = this.active.research + this.totals.researchInProgress > 0;
|
|
136
|
+
const busy = this.active.web + this.active.answer > 0 || researching;
|
|
137
|
+
if (busy && !this.timer) {
|
|
138
|
+
this.timer = setInterval(() => { this.frame = (this.frame + 1) % FRAMES.length; this.render(); }, 120);
|
|
139
|
+
this.timer.unref?.();
|
|
140
|
+
} else if (!busy && this.timer) {
|
|
141
|
+
clearInterval(this.timer);
|
|
142
|
+
this.timer = undefined;
|
|
143
|
+
this.frame = 0;
|
|
144
|
+
}
|
|
145
|
+
const marker = FRAMES[this.frame];
|
|
146
|
+
const { counts } = this.totals;
|
|
147
|
+
this.setStatus([
|
|
148
|
+
color(COLOR.web, `${ICON.web} ${counts.web}${this.active.web ? marker : ""}`),
|
|
149
|
+
color(COLOR.answer, `${ICON.answer} ${counts.answer}${this.active.answer ? marker : ""}`),
|
|
150
|
+
color(COLOR.research, `${ICON.research} ${counts.research}${researching ? marker : ""}`),
|
|
151
|
+
color(COLOR.spend, `${ICON.spend} ${formatSpend(this.totals.microdollars)}${this.totals.unpriced ? "+?" : ""}${busy ? marker : ""}`),
|
|
152
|
+
].join(" "));
|
|
153
|
+
}
|
|
154
|
+
}
|