@spendgraph/tools 0.2.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/README.md +283 -0
- package/dist/builtin/calculate/calculate.d.ts +13 -0
- package/dist/builtin/calculate/calculate.js +27 -0
- package/dist/builtin/calculate/index.d.ts +2 -0
- package/dist/builtin/calculate/index.js +2 -0
- package/dist/builtin/calculate/parse.d.ts +8 -0
- package/dist/builtin/calculate/parse.js +144 -0
- package/dist/builtin/clock/clock.d.ts +27 -0
- package/dist/builtin/clock/clock.js +76 -0
- package/dist/builtin/clock/index.d.ts +1 -0
- package/dist/builtin/clock/index.js +1 -0
- package/dist/builtin/date/date.d.ts +32 -0
- package/dist/builtin/date/date.js +47 -0
- package/dist/builtin/date/index.d.ts +2 -0
- package/dist/builtin/date/index.js +2 -0
- package/dist/builtin/date/shift.d.ts +12 -0
- package/dist/builtin/date/shift.js +62 -0
- package/dist/builtin/deep-recall/deep-recall.d.ts +27 -0
- package/dist/builtin/deep-recall/deep-recall.js +51 -0
- package/dist/builtin/deep-recall/index.d.ts +1 -0
- package/dist/builtin/deep-recall/index.js +1 -0
- package/dist/builtin/escalate/escalate.d.ts +46 -0
- package/dist/builtin/escalate/escalate.js +29 -0
- package/dist/builtin/escalate/index.d.ts +1 -0
- package/dist/builtin/escalate/index.js +1 -0
- package/dist/builtin/finish/finish.d.ts +27 -0
- package/dist/builtin/finish/finish.js +39 -0
- package/dist/builtin/finish/index.d.ts +1 -0
- package/dist/builtin/finish/index.js +1 -0
- package/dist/builtin/http/allow.d.ts +8 -0
- package/dist/builtin/http/allow.js +45 -0
- package/dist/builtin/http/http.d.ts +43 -0
- package/dist/builtin/http/http.js +51 -0
- package/dist/builtin/http/index.d.ts +2 -0
- package/dist/builtin/http/index.js +2 -0
- package/dist/builtin/index.d.ts +11 -0
- package/dist/builtin/index.js +11 -0
- package/dist/builtin/json/index.d.ts +2 -0
- package/dist/builtin/json/index.js +2 -0
- package/dist/builtin/json/json.d.ts +22 -0
- package/dist/builtin/json/json.js +44 -0
- package/dist/builtin/json/path.d.ts +8 -0
- package/dist/builtin/json/path.js +63 -0
- package/dist/builtin/think/index.d.ts +1 -0
- package/dist/builtin/think/index.js +1 -0
- package/dist/builtin/think/think.d.ts +13 -0
- package/dist/builtin/think/think.js +19 -0
- package/dist/builtin/web-search/index.d.ts +1 -0
- package/dist/builtin/web-search/index.js +1 -0
- package/dist/builtin/web-search/reply.d.ts +23 -0
- package/dist/builtin/web-search/reply.js +35 -0
- package/dist/builtin/web-search/web-search.d.ts +50 -0
- package/dist/builtin/web-search/web-search.js +93 -0
- package/dist/builtin/write-document/commission.d.ts +56 -0
- package/dist/builtin/write-document/commission.js +163 -0
- package/dist/builtin/write-document/design.d.ts +49 -0
- package/dist/builtin/write-document/design.js +99 -0
- package/dist/builtin/write-document/http.d.ts +34 -0
- package/dist/builtin/write-document/http.js +74 -0
- package/dist/builtin/write-document/index.d.ts +6 -0
- package/dist/builtin/write-document/index.js +4 -0
- package/dist/builtin/write-document/types.d.ts +33 -0
- package/dist/builtin/write-document/types.js +1 -0
- package/dist/builtin/write-document/write-document.d.ts +69 -0
- package/dist/builtin/write-document/write-document.js +150 -0
- package/dist/bus/bus.d.ts +76 -0
- package/dist/bus/bus.js +103 -0
- package/dist/bus/index.d.ts +3 -0
- package/dist/bus/index.js +2 -0
- package/dist/bus/invoke.d.ts +9 -0
- package/dist/bus/invoke.js +50 -0
- package/dist/bus/options.d.ts +22 -0
- package/dist/bus/options.js +1 -0
- package/dist/bus/overlap.d.ts +12 -0
- package/dist/bus/overlap.js +43 -0
- package/dist/bus/score.d.ts +9 -0
- package/dist/bus/score.js +19 -0
- package/dist/bus/select.d.ts +18 -0
- package/dist/bus/select.js +71 -0
- package/dist/bus/step.d.ts +4 -0
- package/dist/bus/step.js +12 -0
- package/dist/bus/trace.d.ts +42 -0
- package/dist/bus/trace.js +34 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +3 -0
- package/dist/internals.d.ts +16 -0
- package/dist/internals.js +16 -0
- package/dist/tool/bind.d.ts +42 -0
- package/dist/tool/bind.js +89 -0
- package/dist/tool/index.d.ts +4 -0
- package/dist/tool/index.js +2 -0
- package/dist/tool/tool.d.ts +20 -0
- package/dist/tool/tool.js +62 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.js +1 -0
- package/dist/types/result.d.ts +10 -0
- package/dist/types/result.js +1 -0
- package/dist/types/tool.d.ts +105 -0
- package/dist/types/tool.js +1 -0
- package/dist/wire/index.d.ts +3 -0
- package/dist/wire/index.js +3 -0
- package/dist/wire/json-schema.d.ts +26 -0
- package/dist/wire/json-schema.js +81 -0
- package/dist/wire/markdown.d.ts +9 -0
- package/dist/wire/markdown.js +42 -0
- package/dist/wire/providers.d.ts +26 -0
- package/dist/wire/providers.js +20 -0
- package/package.json +64 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { think } from "./think.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { think } from "./think.js";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Somewhere to reason without it becoming the answer.
|
|
3
|
+
*
|
|
4
|
+
* It does nothing, which is the point: the thought lands in the steps rather
|
|
5
|
+
* than in the reply, and a model given room to plan before a multi-step tool
|
|
6
|
+
* sequence uses the tools better.
|
|
7
|
+
*/
|
|
8
|
+
export declare function think(): import("../../index.js").Tool<import("../../index.js").ArgsOf<readonly [{
|
|
9
|
+
readonly name: "thought";
|
|
10
|
+
readonly type: "text";
|
|
11
|
+
readonly required: true;
|
|
12
|
+
readonly maxLength: 4000;
|
|
13
|
+
}]>>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { tool } from "../../tool/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Somewhere to reason without it becoming the answer.
|
|
4
|
+
*
|
|
5
|
+
* It does nothing, which is the point: the thought lands in the steps rather
|
|
6
|
+
* than in the reply, and a model given room to plan before a multi-step tool
|
|
7
|
+
* sequence uses the tools better.
|
|
8
|
+
*/
|
|
9
|
+
export function think() {
|
|
10
|
+
return tool({
|
|
11
|
+
name: "think",
|
|
12
|
+
effect: "readonly",
|
|
13
|
+
description: "Think a step through before acting. Use it to check a plan against the rules, " +
|
|
14
|
+
"to work out what is still missing, or to decide between two tools. It changes " +
|
|
15
|
+
"nothing and returns nothing — it is scratch space, not an answer.",
|
|
16
|
+
args: [{ name: "thought", type: "text", required: true, maxLength: 4000 }],
|
|
17
|
+
run: () => "noted",
|
|
18
|
+
});
|
|
19
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { type SearchDepth, type WebSearchOptions, webSearch } from "./web-search.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { webSearch } from "./web-search.js";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The provider's reply as the tool hands it back.
|
|
3
|
+
*
|
|
4
|
+
* Sources come from `search_results` where the account returns them and from
|
|
5
|
+
* `citations` otherwise, so one tool works across both reply shapes. Citation
|
|
6
|
+
* and reasoning tokens are their own line items on a grounded model, and
|
|
7
|
+
* `computeCostMicros` takes both — dropping them prices a paid call at zero.
|
|
8
|
+
*/
|
|
9
|
+
export declare function readReply(body: unknown, fallbackModel: string, maxChars: number, maxResults: number): {
|
|
10
|
+
answer: string;
|
|
11
|
+
results: {
|
|
12
|
+
url: string;
|
|
13
|
+
}[];
|
|
14
|
+
model: string;
|
|
15
|
+
truncated: boolean;
|
|
16
|
+
usage: {
|
|
17
|
+
inputTokens: number;
|
|
18
|
+
outputTokens: number;
|
|
19
|
+
citationTokens: number;
|
|
20
|
+
reasoningTokens: number;
|
|
21
|
+
searchQueries: number;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
function sources(raw) {
|
|
2
|
+
const listed = raw.search_results ?? [];
|
|
3
|
+
if (listed.length) {
|
|
4
|
+
return listed.flatMap((r) => (r.url ? [{ url: r.url, title: r.title, date: r.date }] : []));
|
|
5
|
+
}
|
|
6
|
+
return (raw.citations ?? []).map((url) => ({ url }));
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The provider's reply as the tool hands it back.
|
|
10
|
+
*
|
|
11
|
+
* Sources come from `search_results` where the account returns them and from
|
|
12
|
+
* `citations` otherwise, so one tool works across both reply shapes. Citation
|
|
13
|
+
* and reasoning tokens are their own line items on a grounded model, and
|
|
14
|
+
* `computeCostMicros` takes both — dropping them prices a paid call at zero.
|
|
15
|
+
*/
|
|
16
|
+
export function readReply(body, fallbackModel, maxChars, maxResults) {
|
|
17
|
+
const raw = body;
|
|
18
|
+
const answer = raw.choices?.[0]?.message?.content ?? "";
|
|
19
|
+
const found = sources(raw);
|
|
20
|
+
const results = found.slice(0, maxResults);
|
|
21
|
+
const usage = raw.usage ?? {};
|
|
22
|
+
return {
|
|
23
|
+
answer: answer.slice(0, maxChars),
|
|
24
|
+
results,
|
|
25
|
+
model: raw.model ?? fallbackModel,
|
|
26
|
+
truncated: answer.length > maxChars || found.length > results.length,
|
|
27
|
+
usage: {
|
|
28
|
+
inputTokens: usage.prompt_tokens ?? 0,
|
|
29
|
+
outputTokens: usage.completion_tokens ?? 0,
|
|
30
|
+
citationTokens: usage.citation_tokens ?? 0,
|
|
31
|
+
reasoningTokens: usage.reasoning_tokens ?? 0,
|
|
32
|
+
searchQueries: usage.num_search_queries ?? 0,
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** How much searching one call is worth. */
|
|
2
|
+
export type SearchDepth = "quick" | "pro" | "deep";
|
|
3
|
+
export interface WebSearchOptions {
|
|
4
|
+
/** Perplexity key. Required, and read from here rather than the environment. */
|
|
5
|
+
apiKey: string;
|
|
6
|
+
/**
|
|
7
|
+
* Deepest tier the model may reach. Default `"pro"`.
|
|
8
|
+
*
|
|
9
|
+
* `"deep"` is opt-in because `sonar-deep-research` runs for minutes and bills
|
|
10
|
+
* reasoning and citation tokens on top of the usual two.
|
|
11
|
+
*/
|
|
12
|
+
maxDepth?: SearchDepth;
|
|
13
|
+
/** Hosts results are restricted to. Set here, so the model cannot widen it. */
|
|
14
|
+
domains?: string[];
|
|
15
|
+
/** Give up after this. Defaults to 60000, or 300000 on a deep search. */
|
|
16
|
+
timeoutMs?: number;
|
|
17
|
+
/** Longest answer handed back. Default 20000 characters. */
|
|
18
|
+
maxChars?: number;
|
|
19
|
+
/** Longest source list handed back. Default 20. */
|
|
20
|
+
maxResults?: number;
|
|
21
|
+
/** Injected for tests. */
|
|
22
|
+
fetch?: typeof fetch;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The live web, through Perplexity's Sonar models.
|
|
26
|
+
*
|
|
27
|
+
* The tier is the model's to choose and yours to cap: `sonar-deep-research` is
|
|
28
|
+
* minutes and orders of magnitude, and a model reaching for it unprompted is
|
|
29
|
+
* the failure `maxDepth` exists to prevent. Tokens come back rather than a
|
|
30
|
+
* price, because what a search costs is the pricing table's business.
|
|
31
|
+
*/
|
|
32
|
+
export declare function webSearch(opts: WebSearchOptions): import("../../index.js").Tool<import("../../index.js").ArgsOf<readonly [{
|
|
33
|
+
readonly name: "query";
|
|
34
|
+
readonly type: "string";
|
|
35
|
+
readonly required: true;
|
|
36
|
+
readonly maxLength: 1000;
|
|
37
|
+
readonly description: "What to find out, as a question. One thing, not several.";
|
|
38
|
+
}, {
|
|
39
|
+
readonly name: "depth";
|
|
40
|
+
readonly type: "enum";
|
|
41
|
+
readonly required: false;
|
|
42
|
+
readonly options: readonly ["quick", "pro", "deep"];
|
|
43
|
+
readonly description: "Default quick. Raise it only when a first pass would not settle it.";
|
|
44
|
+
}, {
|
|
45
|
+
readonly name: "recency";
|
|
46
|
+
readonly type: "enum";
|
|
47
|
+
readonly required: false;
|
|
48
|
+
readonly options: readonly ["day", "week", "month", "year"];
|
|
49
|
+
readonly description: "Only consider pages published within this. Leave unset for anything.";
|
|
50
|
+
}]>>;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { tool } from "../../tool/index.js";
|
|
2
|
+
import { readReply } from "./reply.js";
|
|
3
|
+
const DEPTHS = ["quick", "pro", "deep"];
|
|
4
|
+
const MODELS = {
|
|
5
|
+
quick: "sonar",
|
|
6
|
+
pro: "sonar-pro",
|
|
7
|
+
deep: "sonar-deep-research",
|
|
8
|
+
};
|
|
9
|
+
const ENDPOINT = "https://api.perplexity.ai/chat/completions";
|
|
10
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
11
|
+
const DEEP_TIMEOUT_MS = 300_000;
|
|
12
|
+
const DEFAULT_MAX_CHARS = 20_000;
|
|
13
|
+
const DEFAULT_MAX_RESULTS = 20;
|
|
14
|
+
const ERROR_DETAIL_CHARS = 500;
|
|
15
|
+
function describe(maxDepth, domains) {
|
|
16
|
+
return ("Searches the live web and returns an answer with the sources it rests on. Use it for " +
|
|
17
|
+
"anything that turns on current facts — prices, releases, who holds a post, what " +
|
|
18
|
+
"changed. quick is one pass and is right for most lookups; pro searches wider and cites " +
|
|
19
|
+
"more." +
|
|
20
|
+
(maxDepth === "deep"
|
|
21
|
+
? " deep is an exhaustive multi-step review: minutes, and many times the cost. Ask for it only when a wrong answer is expensive."
|
|
22
|
+
: " deep is not enabled here.") +
|
|
23
|
+
(domains?.length ? ` Results come only from ${domains.join(", ")}.` : ""));
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The live web, through Perplexity's Sonar models.
|
|
27
|
+
*
|
|
28
|
+
* The tier is the model's to choose and yours to cap: `sonar-deep-research` is
|
|
29
|
+
* minutes and orders of magnitude, and a model reaching for it unprompted is
|
|
30
|
+
* the failure `maxDepth` exists to prevent. Tokens come back rather than a
|
|
31
|
+
* price, because what a search costs is the pricing table's business.
|
|
32
|
+
*/
|
|
33
|
+
export function webSearch(opts) {
|
|
34
|
+
if (!opts.apiKey?.trim()) {
|
|
35
|
+
throw new Error("webSearch needs a Perplexity apiKey; it will not read one from the process.");
|
|
36
|
+
}
|
|
37
|
+
const doFetch = opts.fetch ?? globalThis.fetch;
|
|
38
|
+
const maxDepth = opts.maxDepth ?? "pro";
|
|
39
|
+
return tool({
|
|
40
|
+
name: "web_search",
|
|
41
|
+
description: describe(maxDepth, opts.domains),
|
|
42
|
+
effect: "readonly",
|
|
43
|
+
args: [
|
|
44
|
+
{
|
|
45
|
+
name: "query",
|
|
46
|
+
type: "string",
|
|
47
|
+
required: true,
|
|
48
|
+
maxLength: 1000,
|
|
49
|
+
description: "What to find out, as a question. One thing, not several.",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: "depth",
|
|
53
|
+
type: "enum",
|
|
54
|
+
required: false,
|
|
55
|
+
options: ["quick", "pro", "deep"],
|
|
56
|
+
description: "Default quick. Raise it only when a first pass would not settle it.",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "recency",
|
|
60
|
+
type: "enum",
|
|
61
|
+
required: false,
|
|
62
|
+
options: ["day", "week", "month", "year"],
|
|
63
|
+
description: "Only consider pages published within this. Leave unset for anything.",
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
run: async ({ query, depth = "quick", recency }) => {
|
|
67
|
+
if (DEPTHS.indexOf(depth) > DEPTHS.indexOf(maxDepth)) {
|
|
68
|
+
throw new Error(`web_search was asked for "${depth}", above the "${maxDepth}" this deployment allows.`);
|
|
69
|
+
}
|
|
70
|
+
const model = MODELS[depth];
|
|
71
|
+
const fallback = depth === "deep" ? DEEP_TIMEOUT_MS : DEFAULT_TIMEOUT_MS;
|
|
72
|
+
const res = await doFetch(ENDPOINT, {
|
|
73
|
+
method: "POST",
|
|
74
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? fallback),
|
|
75
|
+
headers: {
|
|
76
|
+
authorization: `Bearer ${opts.apiKey}`,
|
|
77
|
+
"content-type": "application/json",
|
|
78
|
+
},
|
|
79
|
+
body: JSON.stringify({
|
|
80
|
+
model,
|
|
81
|
+
messages: [{ role: "user", content: query }],
|
|
82
|
+
...(recency ? { search_recency_filter: recency } : {}),
|
|
83
|
+
...(opts.domains?.length ? { search_domain_filter: opts.domains } : {}),
|
|
84
|
+
}),
|
|
85
|
+
});
|
|
86
|
+
if (!res.ok) {
|
|
87
|
+
const detail = (await res.text()).slice(0, ERROR_DETAIL_CHARS);
|
|
88
|
+
throw new Error(`Perplexity returned ${res.status} for ${model}: ${detail}`);
|
|
89
|
+
}
|
|
90
|
+
return readReply(await res.json(), model, opts.maxChars ?? DEFAULT_MAX_CHARS, opts.maxResults ?? DEFAULT_MAX_RESULTS);
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type AbortSignalLike, type MoaClientConfig } from "./http.js";
|
|
2
|
+
import type { CommissionInput, CommissionResult } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Moa's task surface — fire, then await.
|
|
5
|
+
*
|
|
6
|
+
* `create()` hands back an id immediately and `get()` reads it; pass `waitMs`
|
|
7
|
+
* (or use {@link pollUntilDone}) to long-poll, so one call is awaited rather
|
|
8
|
+
* than a loop tight-polling. `cancel()` is optional so a test stub stays small.
|
|
9
|
+
*/
|
|
10
|
+
export interface CommissionClient {
|
|
11
|
+
create(input: CommissionInput): Promise<string>;
|
|
12
|
+
get(id: string, opts?: {
|
|
13
|
+
waitMs?: number;
|
|
14
|
+
signal?: AbortSignalLike;
|
|
15
|
+
}): Promise<CommissionResult>;
|
|
16
|
+
cancel?(id: string): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* HTTP {@link CommissionClient} over Moa's task API (`POST /tasks` → `GET /tasks/:id`).
|
|
20
|
+
*
|
|
21
|
+
* The request body carries the ask as prose — Moa's artifact pipeline is
|
|
22
|
+
* prompt-driven and `POST /tasks` strips unknown fields, so context, design and
|
|
23
|
+
* deliverables are blocks on the task rather than parameters beside it.
|
|
24
|
+
*/
|
|
25
|
+
export declare function createCommissionClient(config?: MoaClientConfig): CommissionClient;
|
|
26
|
+
/** The sane ceiling for a chat-bound call. A rendered document wants minutes, not this. */
|
|
27
|
+
export declare const INTERACTIVE_TIMEOUT_MS = 30000;
|
|
28
|
+
/** Thrown by {@link pollUntilDone} when the caller's abort signal fires. */
|
|
29
|
+
export declare class CommissionAbortedError extends Error {
|
|
30
|
+
readonly commissionId: string;
|
|
31
|
+
constructor(commissionId: string);
|
|
32
|
+
}
|
|
33
|
+
/** Thrown when the wait ran out. The task itself is still running server-side. */
|
|
34
|
+
export declare class CommissionTimeoutError extends Error {
|
|
35
|
+
readonly commissionId: string;
|
|
36
|
+
readonly timeoutMs: number;
|
|
37
|
+
constructor(commissionId: string, timeoutMs: number);
|
|
38
|
+
}
|
|
39
|
+
export interface PollOptions {
|
|
40
|
+
timeoutMs?: number;
|
|
41
|
+
pollIntervalMs?: number;
|
|
42
|
+
/**
|
|
43
|
+
* Stops the wait and throws {@link CommissionAbortedError}, cutting the held
|
|
44
|
+
* long-poll with it. The task keeps running — cancelling it is the caller's call.
|
|
45
|
+
*/
|
|
46
|
+
signal?: AbortSignalLike;
|
|
47
|
+
/**
|
|
48
|
+
* How long to wait before re-reading a done task whose result has not landed
|
|
49
|
+
* yet. Moa flips the status slightly before it writes. Default 0.
|
|
50
|
+
*/
|
|
51
|
+
settleMs?: number;
|
|
52
|
+
/** Keep settling while a done task carries no artifacts. Set it when files were asked for. */
|
|
53
|
+
expectArtifacts?: boolean;
|
|
54
|
+
}
|
|
55
|
+
/** Wait for a commission to settle, long-polling rather than tight-polling. */
|
|
56
|
+
export declare function pollUntilDone(client: CommissionClient, id: string, opts?: PollOptions): Promise<CommissionResult>;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { createRequest, MoaApiError } from "./http.js";
|
|
2
|
+
const TASK_STATUS = {
|
|
3
|
+
queued: "pending",
|
|
4
|
+
running: "running",
|
|
5
|
+
succeeded: "done",
|
|
6
|
+
failed: "failed",
|
|
7
|
+
canceled: "failed",
|
|
8
|
+
};
|
|
9
|
+
const ARTIFACT_FORMATS = ["md", "pdf", "docx", "pptx"];
|
|
10
|
+
const formatOf = (name) => {
|
|
11
|
+
const ext = name?.split(".").pop()?.toLowerCase();
|
|
12
|
+
return ext && ARTIFACT_FORMATS.includes(ext) ? ext : undefined;
|
|
13
|
+
};
|
|
14
|
+
function toArtifacts(raw) {
|
|
15
|
+
if (!raw?.length)
|
|
16
|
+
return undefined;
|
|
17
|
+
const artifacts = raw.flatMap((a) => {
|
|
18
|
+
const format = formatOf(a.name);
|
|
19
|
+
if (!format || !(a.url || a.data))
|
|
20
|
+
return [];
|
|
21
|
+
return [
|
|
22
|
+
{
|
|
23
|
+
name: a.name ?? `document.${format}`,
|
|
24
|
+
format,
|
|
25
|
+
url: a.url,
|
|
26
|
+
data: a.data,
|
|
27
|
+
mime: a.contentType,
|
|
28
|
+
sizeBytes: a.sizeBytes,
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
});
|
|
32
|
+
return artifacts.length ? artifacts : undefined;
|
|
33
|
+
}
|
|
34
|
+
function deliverablesBlock(formats) {
|
|
35
|
+
return (`\n\n<deliverables>\nBesides the text answer, save the finished deliverable ` +
|
|
36
|
+
`into the artifacts/ directory of your working dir as: ` +
|
|
37
|
+
`${formats.map((f) => `one .${f} file`).join(", ")}.\n</deliverables>`);
|
|
38
|
+
}
|
|
39
|
+
function taskText(input) {
|
|
40
|
+
let task = input.task;
|
|
41
|
+
if (input.context)
|
|
42
|
+
task += `\n\n<context>\n${input.context}\n</context>`;
|
|
43
|
+
if (input.design)
|
|
44
|
+
task += `\n\n<design>\n${input.design}\n</design>`;
|
|
45
|
+
if (input.artifacts?.length)
|
|
46
|
+
task += deliverablesBlock(input.artifacts);
|
|
47
|
+
return task;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* HTTP {@link CommissionClient} over Moa's task API (`POST /tasks` → `GET /tasks/:id`).
|
|
51
|
+
*
|
|
52
|
+
* The request body carries the ask as prose — Moa's artifact pipeline is
|
|
53
|
+
* prompt-driven and `POST /tasks` strips unknown fields, so context, design and
|
|
54
|
+
* deliverables are blocks on the task rather than parameters beside it.
|
|
55
|
+
*/
|
|
56
|
+
export function createCommissionClient(config = {}) {
|
|
57
|
+
const request = createRequest(config);
|
|
58
|
+
return {
|
|
59
|
+
async create(input) {
|
|
60
|
+
const created = await request("/tasks", {
|
|
61
|
+
method: "POST",
|
|
62
|
+
body: {
|
|
63
|
+
task: taskText(input),
|
|
64
|
+
mode: "striker",
|
|
65
|
+
...(input.sessionId ? { sessionId: input.sessionId } : {}),
|
|
66
|
+
...(input.newSessionId ? { newSessionId: input.newSessionId } : {}),
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
if (!created.id)
|
|
70
|
+
throw new MoaApiError("Moa did not return a task id", 502);
|
|
71
|
+
return created.id;
|
|
72
|
+
},
|
|
73
|
+
async get(id, opts) {
|
|
74
|
+
const qs = opts?.waitMs && opts.waitMs > 0 ? `?waitMs=${Math.floor(opts.waitMs)}` : "";
|
|
75
|
+
const t = await request(`/tasks/${encodeURIComponent(id)}${qs}`, {
|
|
76
|
+
signal: opts?.signal,
|
|
77
|
+
});
|
|
78
|
+
const status = t.status ? TASK_STATUS[t.status] : undefined;
|
|
79
|
+
return {
|
|
80
|
+
id: t.id ?? id,
|
|
81
|
+
status: status ?? "running",
|
|
82
|
+
output: t.result,
|
|
83
|
+
error: t.error,
|
|
84
|
+
costUsd: t.costUsd,
|
|
85
|
+
numTurns: t.numTurns,
|
|
86
|
+
sessionId: t.sessionId,
|
|
87
|
+
artifacts: toArtifacts(t.artifacts),
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
async cancel(id) {
|
|
91
|
+
await request(`/tasks/${encodeURIComponent(id)}`, { method: "DELETE" });
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/** The sane ceiling for a chat-bound call. A rendered document wants minutes, not this. */
|
|
96
|
+
export const INTERACTIVE_TIMEOUT_MS = 30_000;
|
|
97
|
+
const DEFAULT_POLL_INTERVAL_MS = 2_000;
|
|
98
|
+
const SETTLE_ATTEMPTS = 3;
|
|
99
|
+
const LONGPOLL_WINDOW_MS = 25_000;
|
|
100
|
+
/** Thrown by {@link pollUntilDone} when the caller's abort signal fires. */
|
|
101
|
+
export class CommissionAbortedError extends Error {
|
|
102
|
+
commissionId;
|
|
103
|
+
constructor(commissionId) {
|
|
104
|
+
super(`Commission ${commissionId} was cancelled by the caller.`);
|
|
105
|
+
this.name = "CommissionAbortedError";
|
|
106
|
+
this.commissionId = commissionId;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** Thrown when the wait ran out. The task itself is still running server-side. */
|
|
110
|
+
export class CommissionTimeoutError extends Error {
|
|
111
|
+
commissionId;
|
|
112
|
+
timeoutMs;
|
|
113
|
+
constructor(commissionId, timeoutMs) {
|
|
114
|
+
super(`Commission ${commissionId} did not complete within ${timeoutMs / 1000}s. ` +
|
|
115
|
+
`The task may still be running in the background.`);
|
|
116
|
+
this.name = "CommissionTimeoutError";
|
|
117
|
+
this.commissionId = commissionId;
|
|
118
|
+
this.timeoutMs = timeoutMs;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const sleep = (ms, signal) => new Promise((resolve) => {
|
|
122
|
+
const onAbort = () => resolve();
|
|
123
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
124
|
+
setTimeout(() => {
|
|
125
|
+
signal?.removeEventListener("abort", onAbort);
|
|
126
|
+
resolve();
|
|
127
|
+
}, ms);
|
|
128
|
+
});
|
|
129
|
+
/** Wait for a commission to settle, long-polling rather than tight-polling. */
|
|
130
|
+
export async function pollUntilDone(client, id, opts = {}) {
|
|
131
|
+
const timeoutMs = opts.timeoutMs ?? INTERACTIVE_TIMEOUT_MS;
|
|
132
|
+
const pollIntervalMs = opts.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
133
|
+
const settleMs = opts.settleMs ?? 0;
|
|
134
|
+
const signal = opts.signal;
|
|
135
|
+
const deadline = Date.now() + timeoutMs;
|
|
136
|
+
while (true) {
|
|
137
|
+
if (signal?.aborted)
|
|
138
|
+
throw new CommissionAbortedError(id);
|
|
139
|
+
const remaining = deadline - Date.now();
|
|
140
|
+
if (remaining <= 0)
|
|
141
|
+
throw new CommissionTimeoutError(id, timeoutMs);
|
|
142
|
+
let result;
|
|
143
|
+
try {
|
|
144
|
+
result = await client.get(id, { waitMs: Math.min(remaining, LONGPOLL_WINDOW_MS), signal });
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
if (signal?.aborted)
|
|
148
|
+
throw new CommissionAbortedError(id);
|
|
149
|
+
throw err;
|
|
150
|
+
}
|
|
151
|
+
if (result.status === "failed")
|
|
152
|
+
return result;
|
|
153
|
+
if (result.status === "done") {
|
|
154
|
+
const unsettled = (r) => !r.output?.trim() || (opts.expectArtifacts === true && !r.artifacts?.length);
|
|
155
|
+
for (let i = 0; settleMs > 0 && i < SETTLE_ATTEMPTS && unsettled(result); i++) {
|
|
156
|
+
await sleep(settleMs);
|
|
157
|
+
result = await client.get(id);
|
|
158
|
+
}
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
161
|
+
await sleep(Math.min(pollIntervalMs, Math.max(0, deadline - Date.now())), signal);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ArtifactFormat } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The house style a commissioned document is built to.
|
|
4
|
+
*
|
|
5
|
+
* Every field is optional because a caller overrides only what its brand
|
|
6
|
+
* actually fixes; the rest falls back to {@link DEFAULT_DESIGN}.
|
|
7
|
+
*/
|
|
8
|
+
export interface DesignGuide {
|
|
9
|
+
/** What the style is called, so the document can say which one it followed. */
|
|
10
|
+
name?: string;
|
|
11
|
+
fonts?: {
|
|
12
|
+
heading?: string;
|
|
13
|
+
body?: string;
|
|
14
|
+
mono?: string;
|
|
15
|
+
};
|
|
16
|
+
palette?: {
|
|
17
|
+
primary?: string;
|
|
18
|
+
accent?: string;
|
|
19
|
+
text?: string;
|
|
20
|
+
muted?: string;
|
|
21
|
+
surface?: string;
|
|
22
|
+
};
|
|
23
|
+
/** Page size, margins, grid, density. */
|
|
24
|
+
layout?: string;
|
|
25
|
+
/** How a figure earns its place and what it may not do. */
|
|
26
|
+
charts?: string;
|
|
27
|
+
/** How the writing sounds. */
|
|
28
|
+
tone?: string;
|
|
29
|
+
/** Fetchable logo, dropped on the cover and nowhere else. */
|
|
30
|
+
logoUrl?: string;
|
|
31
|
+
/** Hard rules, appended to the defaults rather than replacing them. */
|
|
32
|
+
rules?: readonly string[];
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* What a document looks like when nobody has said.
|
|
36
|
+
*
|
|
37
|
+
* Chosen to be defensible rather than distinctive: one typeface, one accent,
|
|
38
|
+
* real whitespace, and figures that carry a single message.
|
|
39
|
+
*/
|
|
40
|
+
export declare const DEFAULT_DESIGN: DesignGuide;
|
|
41
|
+
/**
|
|
42
|
+
* A caller's guide over the defaults: named fields win, `rules` accumulate.
|
|
43
|
+
*
|
|
44
|
+
* Rules append because they are constraints — a brand adding "never use red"
|
|
45
|
+
* means it as well as the defaults, not instead of them.
|
|
46
|
+
*/
|
|
47
|
+
export declare function mergeDesign(base: DesignGuide, over?: DesignGuide): DesignGuide;
|
|
48
|
+
/** The guide as the block Moa reads, since its artifact pipeline is prompt-driven. */
|
|
49
|
+
export declare function renderDesign(guide: DesignGuide, format: ArtifactFormat, notes?: string): string;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a document looks like when nobody has said.
|
|
3
|
+
*
|
|
4
|
+
* Chosen to be defensible rather than distinctive: one typeface, one accent,
|
|
5
|
+
* real whitespace, and figures that carry a single message.
|
|
6
|
+
*/
|
|
7
|
+
export const DEFAULT_DESIGN = {
|
|
8
|
+
name: "Spendgraph house style",
|
|
9
|
+
fonts: { heading: "Inter", body: "Inter", mono: "JetBrains Mono" },
|
|
10
|
+
palette: {
|
|
11
|
+
primary: "#0F172A",
|
|
12
|
+
accent: "#2563EB",
|
|
13
|
+
text: "#111827",
|
|
14
|
+
muted: "#64748B",
|
|
15
|
+
surface: "#FFFFFF",
|
|
16
|
+
},
|
|
17
|
+
layout: "A4 portrait, 20mm margins, a single text column at 60-75 characters, a 4pt spacing scale, " +
|
|
18
|
+
"and one heading scale used consistently (28/20/16/12). Whitespace is the design.",
|
|
19
|
+
charts: "One message per figure, stated in its caption. Label series directly where there are three " +
|
|
20
|
+
"or fewer. No 3D, no gradients, no gridline heavier than the data, no pie chart past three " +
|
|
21
|
+
"slices, and axes that start at zero unless the caption says why not.",
|
|
22
|
+
tone: "Plain and specific. Lead with the finding, then the evidence. No filler adjectives, no " +
|
|
23
|
+
"throat-clearing, no sentence that survives being deleted.",
|
|
24
|
+
rules: [
|
|
25
|
+
"A cover carrying the title, date and author, and no other ornament.",
|
|
26
|
+
"Every number traceable to something in the source material; invent nothing.",
|
|
27
|
+
"Tables for anything being compared; prose for anything being argued.",
|
|
28
|
+
"No stock imagery, no clip art, no placeholder text in the finished file.",
|
|
29
|
+
"Page numbers on every page after the cover.",
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
const FORMAT_CRAFT = {
|
|
33
|
+
pdf: "Cover, then contents if it runs past four pages, then the body. Figures captioned and " +
|
|
34
|
+
"referenced from the text. Nothing straddles a page break: no orphan heading, no split table row.",
|
|
35
|
+
pptx: "16:9. One idea per slide, and the title is that idea as a sentence — a claim, not a label. " +
|
|
36
|
+
"At most six lines of body per slide, one figure per slide at most, and everything you cut " +
|
|
37
|
+
"goes into the speaker notes. Close on decisions and next steps, not on 'thank you'.",
|
|
38
|
+
docx: "Real Word styles for headings rather than manual bold, a table-of-contents field rather " +
|
|
39
|
+
"than a typed list, a header row on every table, and captions on figures.",
|
|
40
|
+
md: "A title heading, ATX headings throughout, tables for comparisons, fenced code with a language tag, and no HTML.",
|
|
41
|
+
};
|
|
42
|
+
function mergeGroup(base, over) {
|
|
43
|
+
if (!base)
|
|
44
|
+
return over;
|
|
45
|
+
if (!over)
|
|
46
|
+
return base;
|
|
47
|
+
return { ...base, ...over };
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* A caller's guide over the defaults: named fields win, `rules` accumulate.
|
|
51
|
+
*
|
|
52
|
+
* Rules append because they are constraints — a brand adding "never use red"
|
|
53
|
+
* means it as well as the defaults, not instead of them.
|
|
54
|
+
*/
|
|
55
|
+
export function mergeDesign(base, over) {
|
|
56
|
+
if (!over)
|
|
57
|
+
return base;
|
|
58
|
+
return {
|
|
59
|
+
...base,
|
|
60
|
+
...over,
|
|
61
|
+
fonts: mergeGroup(base.fonts, over.fonts),
|
|
62
|
+
palette: mergeGroup(base.palette, over.palette),
|
|
63
|
+
rules: [...(base.rules ?? []), ...(over.rules ?? [])],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const line = (label, value) => (value ? [`${label}: ${value}`] : []);
|
|
67
|
+
/** The guide as the block Moa reads, since its artifact pipeline is prompt-driven. */
|
|
68
|
+
export function renderDesign(guide, format, notes) {
|
|
69
|
+
const { fonts, palette } = guide;
|
|
70
|
+
const parts = [
|
|
71
|
+
...line("Style", guide.name),
|
|
72
|
+
...line("Typefaces", [
|
|
73
|
+
fonts?.heading && `headings ${fonts.heading}`,
|
|
74
|
+
fonts?.body && `body ${fonts.body}`,
|
|
75
|
+
fonts?.mono && `code ${fonts.mono}`,
|
|
76
|
+
]
|
|
77
|
+
.filter(Boolean)
|
|
78
|
+
.join(", ") || undefined),
|
|
79
|
+
...line("Palette", [
|
|
80
|
+
palette?.primary && `primary ${palette.primary}`,
|
|
81
|
+
palette?.accent && `accent ${palette.accent}`,
|
|
82
|
+
palette?.text && `text ${palette.text}`,
|
|
83
|
+
palette?.muted && `muted ${palette.muted}`,
|
|
84
|
+
palette?.surface && `surface ${palette.surface}`,
|
|
85
|
+
]
|
|
86
|
+
.filter(Boolean)
|
|
87
|
+
.join(", ") || undefined),
|
|
88
|
+
...line("Layout", guide.layout),
|
|
89
|
+
...line("Figures", guide.charts),
|
|
90
|
+
...line("Voice", guide.tone),
|
|
91
|
+
...line("Logo", guide.logoUrl),
|
|
92
|
+
`Format craft (.${format}): ${FORMAT_CRAFT[format]}`,
|
|
93
|
+
...(guide.rules?.length ? [`Rules:\n${guide.rules.map((r) => `- ${r}`).join("\n")}`] : []),
|
|
94
|
+
...(notes?.trim()
|
|
95
|
+
? [`Overrides for this document, which win over everything above:\n${notes.trim()}`]
|
|
96
|
+
: []),
|
|
97
|
+
];
|
|
98
|
+
return parts.join("\n");
|
|
99
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Anything shaped like an `AbortSignal`, so a Worker's own signal fits too. */
|
|
2
|
+
export interface AbortSignalLike {
|
|
3
|
+
readonly aborted: boolean;
|
|
4
|
+
addEventListener(type: "abort", listener: () => void, opts?: {
|
|
5
|
+
once?: boolean;
|
|
6
|
+
}): void;
|
|
7
|
+
removeEventListener(type: "abort", listener: () => void): void;
|
|
8
|
+
}
|
|
9
|
+
/** Where Moa lives, unless a config says otherwise. */
|
|
10
|
+
export declare const MOA_BASE_URL = "https://api.fnmoa.com";
|
|
11
|
+
export interface MoaClientConfig {
|
|
12
|
+
/** Read from here rather than the environment, as every other builtin does. */
|
|
13
|
+
apiKey?: string;
|
|
14
|
+
baseUrl?: string;
|
|
15
|
+
/** Ceiling on one HTTP call, not on the commission. Default 60000. */
|
|
16
|
+
timeoutMs?: number;
|
|
17
|
+
/** Injected for tests. */
|
|
18
|
+
fetch?: typeof fetch;
|
|
19
|
+
}
|
|
20
|
+
/** A non-2xx from Moa, carrying the status so a caller can tell 4xx from 5xx. */
|
|
21
|
+
export declare class MoaApiError extends Error {
|
|
22
|
+
readonly status: number;
|
|
23
|
+
readonly detail?: string;
|
|
24
|
+
constructor(message: string, status: number, detail?: string);
|
|
25
|
+
}
|
|
26
|
+
interface RequestOptions {
|
|
27
|
+
method?: string;
|
|
28
|
+
body?: unknown;
|
|
29
|
+
signal?: AbortSignalLike;
|
|
30
|
+
timeoutMs?: number;
|
|
31
|
+
}
|
|
32
|
+
/** One `request<T>(path, opts)` against Moa, with auth, timeout and error shape settled. */
|
|
33
|
+
export declare function createRequest(config?: MoaClientConfig): <T>(path: string, opts?: RequestOptions) => Promise<T>;
|
|
34
|
+
export {};
|