@foldrun/core 0.4.0 → 0.6.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 +7 -4
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/src/account.d.ts +45 -0
- package/dist/src/account.js +130 -0
- package/dist/src/api-tools.d.ts +8 -0
- package/dist/src/api-tools.js +22 -4
- package/dist/src/browser-pod.d.ts +148 -0
- package/dist/src/browser-pod.js +333 -0
- package/dist/src/catalog.js +2 -1
- package/dist/src/clock.d.ts +49 -0
- package/dist/src/clock.js +121 -0
- package/dist/src/completions.js +17 -8
- package/dist/src/confine.d.ts +22 -0
- package/dist/src/confine.js +218 -3
- package/dist/src/container.d.ts +9 -0
- package/dist/src/container.js +115 -5
- package/dist/src/context-tools.d.ts +1 -1
- package/dist/src/deploy.d.ts +33 -0
- package/dist/src/deploy.js +145 -6
- package/dist/src/egress.d.ts +7 -0
- package/dist/src/egress.js +6 -0
- package/dist/src/eol.d.ts +2 -0
- package/dist/src/eol.js +7 -0
- package/dist/src/evals.d.ts +5 -0
- package/dist/src/evals.js +9 -2
- package/dist/src/flow-lint.d.ts +8 -0
- package/dist/src/flow-lint.js +223 -1
- package/dist/src/flow-patterns.d.ts +141 -0
- package/dist/src/flow-patterns.js +568 -0
- package/dist/src/gitrepo.d.ts +18 -0
- package/dist/src/gitrepo.js +49 -0
- package/dist/src/host-env.js +5 -0
- package/dist/src/kinds.js +1 -1
- package/dist/src/language.d.ts +17 -0
- package/dist/src/language.js +55 -0
- package/dist/src/layout.d.ts +69 -0
- package/dist/src/layout.js +200 -0
- package/dist/src/library.d.ts +9 -0
- package/dist/src/library.js +30 -3
- package/dist/src/limits.d.ts +84 -0
- package/dist/src/limits.js +287 -0
- package/dist/src/locale.d.ts +47 -0
- package/dist/src/locale.js +177 -0
- package/dist/src/mail.d.ts +88 -0
- package/dist/src/mail.js +247 -0
- package/dist/src/model-credential.d.ts +14 -0
- package/dist/src/model-credential.js +58 -0
- package/dist/src/model-probe.d.ts +3 -0
- package/dist/src/model-probe.js +1 -0
- package/dist/src/notify.d.ts +53 -7
- package/dist/src/notify.js +194 -126
- package/dist/src/openapi.js +5 -3
- package/dist/src/operator.d.ts +201 -0
- package/dist/src/operator.js +251 -0
- package/dist/src/paths.d.ts +28 -0
- package/dist/src/paths.js +55 -0
- package/dist/src/platform.d.ts +43 -3
- package/dist/src/platform.js +9 -1
- package/dist/src/provider-health.js +3 -2
- package/dist/src/providers.d.ts +481 -0
- package/dist/src/providers.js +1105 -6
- package/dist/src/refusal.d.ts +83 -0
- package/dist/src/refusal.js +281 -0
- package/dist/src/run-container.d.ts +149 -13
- package/dist/src/run-container.js +424 -43
- package/dist/src/runner.d.ts +177 -9
- package/dist/src/runner.js +1146 -154
- package/dist/src/runtime.d.ts +52 -0
- package/dist/src/runtime.js +493 -58
- package/dist/src/script-tools.d.ts +17 -2
- package/dist/src/script-tools.js +82 -10
- package/dist/src/secret-files.d.ts +6 -5
- package/dist/src/secret-files.js +24 -11
- package/dist/src/secret-health.d.ts +30 -6
- package/dist/src/secret-health.js +42 -20
- package/dist/src/secrets.d.ts +19 -0
- package/dist/src/secrets.js +155 -86
- package/dist/src/starter.d.ts +9 -0
- package/dist/src/starter.js +92 -11
- package/dist/src/step-exec.d.ts +72 -2
- package/dist/src/step-exec.js +431 -37
- package/dist/src/step-removal.d.ts +15 -0
- package/dist/src/step-removal.js +49 -0
- package/dist/src/storage.d.ts +23 -4
- package/dist/src/storage.js +125 -23
- package/dist/src/store.d.ts +222 -37
- package/dist/src/store.js +349 -62
- package/dist/src/subagents.d.ts +75 -0
- package/dist/src/subagents.js +149 -0
- package/dist/src/test-mode.d.ts +17 -2
- package/dist/src/test-mode.js +53 -13
- package/dist/src/tool-names.d.ts +13 -1
- package/dist/src/tool-names.js +38 -11
- package/dist/src/tool-programs.d.ts +26 -0
- package/dist/src/tool-programs.js +172 -0
- package/dist/src/tool-test.js +104 -12
- package/dist/src/translator.d.ts +8 -2
- package/dist/src/translator.js +69 -23
- package/dist/src/trigger-log.d.ts +1 -1
- package/dist/src/triggers.d.ts +3 -0
- package/dist/src/triggers.js +10 -2
- package/dist/src/untrusted-fetch.d.ts +11 -0
- package/dist/src/untrusted-fetch.js +77 -0
- package/dist/src/web-actions.d.ts +91 -0
- package/dist/src/web-actions.js +199 -0
- package/dist/src/webhook.d.ts +4 -0
- package/dist/src/webhook.js +39 -0
- package/index.ts +4 -0
- package/package.json +3 -3
- package/src/account.ts +149 -0
- package/src/api-tools.ts +32 -4
- package/src/browser-pod.ts +350 -0
- package/src/catalog.ts +2 -1
- package/src/clock.ts +149 -0
- package/src/completions.ts +17 -8
- package/src/confine.ts +218 -3
- package/src/container.ts +112 -5
- package/src/context-tools.ts +1 -1
- package/src/deploy.ts +137 -6
- package/src/egress.ts +13 -1
- package/src/eol.ts +7 -0
- package/src/evals.ts +14 -2
- package/src/flow-lint.ts +253 -1
- package/src/flow-patterns.ts +597 -0
- package/src/gitrepo.ts +48 -0
- package/src/host-env.ts +5 -0
- package/src/kinds.ts +1 -1
- package/src/language.ts +62 -0
- package/src/layout.ts +228 -0
- package/src/library.ts +37 -3
- package/src/limits.ts +290 -0
- package/src/locale.ts +217 -0
- package/src/mail.ts +318 -0
- package/src/model-credential.ts +71 -0
- package/src/model-probe.ts +4 -0
- package/src/notify.ts +189 -103
- package/src/openapi.ts +5 -3
- package/src/operator.ts +300 -0
- package/src/paths.ts +54 -0
- package/src/platform.ts +52 -3
- package/src/provider-health.ts +3 -2
- package/src/providers.ts +1368 -6
- package/src/refusal.ts +320 -0
- package/src/run-container.ts +460 -44
- package/src/runner.ts +1168 -155
- package/src/runtime.ts +487 -55
- package/src/script-tools.ts +81 -10
- package/src/secret-files.ts +22 -10
- package/src/secret-health.ts +64 -19
- package/src/secrets.ts +149 -87
- package/src/starter.ts +93 -11
- package/src/step-exec.ts +447 -38
- package/src/step-removal.ts +59 -0
- package/src/storage.ts +141 -22
- package/src/store.ts +506 -70
- package/src/subagents.ts +189 -0
- package/src/test-mode.ts +51 -11
- package/src/tool-names.ts +40 -11
- package/src/tool-programs.ts +179 -0
- package/src/tool-test.ts +110 -12
- package/src/translator.ts +72 -22
- package/src/trigger-log.ts +1 -0
- package/src/triggers.ts +10 -2
- package/src/untrusted-fetch.ts +88 -0
- package/src/web-actions.ts +237 -0
- package/src/webhook.ts +41 -0
- package/templates/blog-desk/agents/writer/agent.md +1 -1
- package/templates/hello/agents/curator/agent.md +4 -4
- package/templates/hello/agents/notetaker/agent.md +3 -3
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// Per-step call limits: how many times a step may call a tool before the
|
|
2
|
+
// next call is refused.
|
|
3
|
+
//
|
|
4
|
+
// limits:
|
|
5
|
+
// web.search: 40 # one action of the web tool
|
|
6
|
+
// web: 150 # every web action together
|
|
7
|
+
// crm: 20 # an API, script or MCP tool, by the name it is granted as
|
|
8
|
+
// calls: 300 # every tool call in the step, whatever the tool
|
|
9
|
+
//
|
|
10
|
+
// Counted per step and enforced in step-exec.ts's PreToolUse hook — the one
|
|
11
|
+
// place every call passes, the step's own and its sub-agents', on the
|
|
12
|
+
// in-process path and inside a run container alike. A call past a limit is
|
|
13
|
+
// refused before it runs: the tool never sees it, so a paid search API is
|
|
14
|
+
// never asked and the cost is capped exactly. The refusal counts toward
|
|
15
|
+
// nothing. A retry of the step starts from zero; so does each fan-out
|
|
16
|
+
// instance, which is its own step.
|
|
17
|
+
//
|
|
18
|
+
// Nearest wins per key: the account's AGENTS.md, then the workspace's, then
|
|
19
|
+
// the agent's own `limits:`, then a flow step's `limits:` option.
|
|
20
|
+
import { WEB_ACTIONS } from "./providers.js";
|
|
21
|
+
import { DELEGATE_TOOLS } from "./subagents.js";
|
|
22
|
+
/** The key that caps every call in the step. */
|
|
23
|
+
export const TOTAL_KEY = "calls";
|
|
24
|
+
/** SDK built-ins, counted under the foldrun group that grants them — and
|
|
25
|
+
* under their own SDK name, so `Bash: 5` works for a Claude Code file. */
|
|
26
|
+
const BUILTIN_GROUP = {
|
|
27
|
+
Read: "read", Glob: "read", Grep: "read",
|
|
28
|
+
Write: "write", Edit: "write", MultiEdit: "write", NotebookEdit: "write",
|
|
29
|
+
Bash: "code",
|
|
30
|
+
};
|
|
31
|
+
/** Retired spellings an author may still write as a key. */
|
|
32
|
+
const KEY_ALIASES = { files: "write", bash: "code" };
|
|
33
|
+
/** The platform's own in-process servers and the name each is granted as. */
|
|
34
|
+
const PLATFORM_SERVERS = {
|
|
35
|
+
foldrun_search: "search",
|
|
36
|
+
foldrun_history: "history",
|
|
37
|
+
foldrun_desks: "desks",
|
|
38
|
+
foldrun_ask: "ask",
|
|
39
|
+
};
|
|
40
|
+
/** One spelling for comparing: `desk-email` and `desk_email` are the same
|
|
41
|
+
* tool (a script tool's SDK name has its dashes made underscores). */
|
|
42
|
+
export function normKey(key) {
|
|
43
|
+
const k = key.trim();
|
|
44
|
+
return (KEY_ALIASES[k] ?? k).replace(/-/g, "_");
|
|
45
|
+
}
|
|
46
|
+
const KEY_RE = /^[A-Za-z0-9_-]+(\.[a-z]+)?$/;
|
|
47
|
+
/**
|
|
48
|
+
* A `limits:` block as written: the values that can be read, and a sentence
|
|
49
|
+
* for each that cannot. Shape only — whether a key names a tool this agent
|
|
50
|
+
* has is `limitKeyProblems`, which needs the grants.
|
|
51
|
+
*/
|
|
52
|
+
export function readLimits(raw, where = "limits") {
|
|
53
|
+
const limits = {};
|
|
54
|
+
const problems = [];
|
|
55
|
+
if (raw === undefined || raw === null)
|
|
56
|
+
return { limits, problems };
|
|
57
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
58
|
+
problems.push(`${where}: is a block of tool: count, e.g. \`limits: {web.search: 40, calls: 300}\`.`);
|
|
59
|
+
return { limits, problems };
|
|
60
|
+
}
|
|
61
|
+
// A YAML block written `web: {search: 40}` arrives nested; read it as the
|
|
62
|
+
// dotted keys it means rather than refusing a natural spelling.
|
|
63
|
+
const flat = [];
|
|
64
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
65
|
+
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
66
|
+
for (const [sub, n] of Object.entries(v))
|
|
67
|
+
flat.push([`${k}.${sub}`, n]);
|
|
68
|
+
}
|
|
69
|
+
else
|
|
70
|
+
flat.push([k, v]);
|
|
71
|
+
}
|
|
72
|
+
for (const [key, value] of flat) {
|
|
73
|
+
const k = key.trim();
|
|
74
|
+
if (!KEY_RE.test(k)) {
|
|
75
|
+
problems.push(`${where}: "${k}" is not a key — a tool's name, web.<action>, or calls.`);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (k.includes(".")) {
|
|
79
|
+
const [head, action] = k.split(".");
|
|
80
|
+
if (head !== "web" || !WEB_ACTIONS.includes(action)) {
|
|
81
|
+
problems.push(`${where}: "${k}" is not a key — the dotted keys are web.<action>: ${WEB_ACTIONS.map((a) => `web.${a}`).join(", ")}.`);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const n = typeof value === "number" ? value : typeof value === "string" && /^\s*\d+\s*$/.test(value) ? Number(value) : NaN;
|
|
86
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
87
|
+
problems.push(`${where}: ${k}: ${String(value)} — a whole number of calls, 1 or more. To withhold a tool, leave it out of tools:.`);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
limits[k] = n;
|
|
91
|
+
}
|
|
92
|
+
return { limits, problems };
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The limits a step runs under, nearest last: account, workspace, agent,
|
|
96
|
+
* step. A key set nearer replaces the same key further out; the others
|
|
97
|
+
* stand. Unreadable values are dropped here — `check` and the deploy gate
|
|
98
|
+
* say so where they were written.
|
|
99
|
+
*/
|
|
100
|
+
export function cascadeLimits(levels) {
|
|
101
|
+
const out = {};
|
|
102
|
+
const at = new Map(); // normalised → the key as last written
|
|
103
|
+
for (const level of levels) {
|
|
104
|
+
for (const [k, n] of Object.entries(readLimits(level).limits)) {
|
|
105
|
+
const nk = normKey(k);
|
|
106
|
+
const prev = at.get(nk);
|
|
107
|
+
if (prev !== undefined)
|
|
108
|
+
delete out[prev];
|
|
109
|
+
out[k] = n;
|
|
110
|
+
at.set(nk, k);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
/** SDK tool name → the foldrun name it is granted as, for the tools whose
|
|
116
|
+
* SDK name does not say (an openapi tool's operations, a script whose name
|
|
117
|
+
* was made safe). `apiOwners` is api-tools' own record of which API each
|
|
118
|
+
* tool was built for — not guessed from the name, which cannot tell `crm`'s
|
|
119
|
+
* operation `admin_list` from `crm_admin`'s `list`. Built host-side; plain
|
|
120
|
+
* JSON so it crosses into a pod. */
|
|
121
|
+
export function toolOwners(apiOwners, scripts) {
|
|
122
|
+
const out = { ...apiOwners };
|
|
123
|
+
for (const s of scripts)
|
|
124
|
+
out[`mcp__foldrun_scripts__${s.name}`] = s.name;
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Every key one call counts under, `calls` included. Pure: the SDK's tool
|
|
129
|
+
* name, its input, and the owners map.
|
|
130
|
+
*/
|
|
131
|
+
export function limitKeysFor(toolName, input, owners = {}) {
|
|
132
|
+
const keys = [TOTAL_KEY];
|
|
133
|
+
const webAction = (action) => {
|
|
134
|
+
keys.push("web");
|
|
135
|
+
const a = typeof action === "string" ? action.trim().toLowerCase() : "";
|
|
136
|
+
if (a)
|
|
137
|
+
keys.push(`web.${a}`);
|
|
138
|
+
};
|
|
139
|
+
// A model provider's own search or fetch, standing in for the web tool's.
|
|
140
|
+
if (toolName === "WebSearch") {
|
|
141
|
+
webAction("search");
|
|
142
|
+
return keys;
|
|
143
|
+
}
|
|
144
|
+
if (toolName === "WebFetch") {
|
|
145
|
+
webAction("fetch");
|
|
146
|
+
return keys;
|
|
147
|
+
}
|
|
148
|
+
if (DELEGATE_TOOLS.includes(toolName))
|
|
149
|
+
return keys;
|
|
150
|
+
const owner = owners[toolName];
|
|
151
|
+
const mcp = /^mcp__(.+?)__(.+)$/.exec(toolName);
|
|
152
|
+
const name = owner
|
|
153
|
+
?? (mcp
|
|
154
|
+
? mcp[1] === "foldrun_scripts" ? mcp[2]
|
|
155
|
+
: mcp[1] === "foldrun_apis" ? mcp[2].replace(/^call_/, "")
|
|
156
|
+
: mcp[1] === "foldrun_agents" ? mcp[2].replace(/^consult_/, "")
|
|
157
|
+
: PLATFORM_SERVERS[mcp[1]] ?? mcp[1]
|
|
158
|
+
: null);
|
|
159
|
+
if (name !== null) {
|
|
160
|
+
if (normKey(name) === "web")
|
|
161
|
+
webAction(input?.action);
|
|
162
|
+
else
|
|
163
|
+
keys.push(name);
|
|
164
|
+
return keys;
|
|
165
|
+
}
|
|
166
|
+
const group = BUILTIN_GROUP[toolName];
|
|
167
|
+
if (group)
|
|
168
|
+
keys.push(group);
|
|
169
|
+
keys.push(toolName);
|
|
170
|
+
return keys;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* One step's counts. `take` checks every key the call counts under and
|
|
174
|
+
* either refuses — counting nothing — or counts it under all of them.
|
|
175
|
+
*/
|
|
176
|
+
export class CallCounter {
|
|
177
|
+
limits = new Map();
|
|
178
|
+
used = new Map();
|
|
179
|
+
refused = 0;
|
|
180
|
+
constructor(limits) {
|
|
181
|
+
for (const [k, n] of Object.entries(limits ?? {}))
|
|
182
|
+
this.limits.set(normKey(k), { key: k, limit: n });
|
|
183
|
+
}
|
|
184
|
+
get active() {
|
|
185
|
+
return this.limits.size > 0;
|
|
186
|
+
}
|
|
187
|
+
take(keys) {
|
|
188
|
+
const norm = [...new Set(keys.map(normKey))];
|
|
189
|
+
for (const k of norm) {
|
|
190
|
+
const l = this.limits.get(k);
|
|
191
|
+
if (!l)
|
|
192
|
+
continue;
|
|
193
|
+
const used = this.used.get(k) ?? 0;
|
|
194
|
+
if (used >= l.limit) {
|
|
195
|
+
this.refused++;
|
|
196
|
+
return {
|
|
197
|
+
key: l.key, used, limit: l.limit,
|
|
198
|
+
message: `limit reached: ${l.key} ${used} of ${l.limit} in this step — work with what you have, or say what more you would need`,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
for (const k of norm)
|
|
203
|
+
this.used.set(k, (this.used.get(k) ?? 0) + 1);
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
/** Give back a call that was counted and then refused by a later check. */
|
|
207
|
+
refund(keys) {
|
|
208
|
+
for (const k of new Set(keys.map(normKey))) {
|
|
209
|
+
const n = this.used.get(k) ?? 0;
|
|
210
|
+
if (n > 0)
|
|
211
|
+
this.used.set(k, n - 1);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
count(key) {
|
|
215
|
+
return this.used.get(normKey(key)) ?? 0;
|
|
216
|
+
}
|
|
217
|
+
/** `limits: web.search 40/40, crm 3/20` — null when no limits are set. */
|
|
218
|
+
summary() {
|
|
219
|
+
if (!this.active)
|
|
220
|
+
return null;
|
|
221
|
+
const parts = [...this.limits.entries()].map(([k, l]) => `${l.key} ${this.used.get(k) ?? 0}/${l.limit}`);
|
|
222
|
+
return `limits: ${parts.join(", ")}${this.refused ? ` — ${this.refused} call${this.refused === 1 ? "" : "s"} refused` : ""}`;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Keys an agent's `limits:` names that it cannot use. `known` is every tool
|
|
227
|
+
* name in reach (built-ins, the workspace's and library's tools, this
|
|
228
|
+
* agent's APIs, scripts and MCP servers); `granted` is what this agent's
|
|
229
|
+
* `tools:` (and `apis:`, `scripts:`, `mcpServers:`) gives it. A key that
|
|
230
|
+
* names nothing is an error; a real tool this agent does not hold is a
|
|
231
|
+
* warning — the limit is harmless, and probably a leftover.
|
|
232
|
+
*/
|
|
233
|
+
export function limitKeyProblems(limits, ctx) {
|
|
234
|
+
const out = [];
|
|
235
|
+
const builtinKeys = ["read", "write", "code", ...Object.keys(BUILTIN_GROUP), "TodoWrite", "search", "history", "desks", "ask", "web"];
|
|
236
|
+
const known = new Set([...builtinKeys, ...ctx.known].map(normKey));
|
|
237
|
+
const granted = new Set([...ctx.granted].flatMap((g) => {
|
|
238
|
+
const n = normKey(g);
|
|
239
|
+
// A group grants its members' keys, and `write` holds Read too.
|
|
240
|
+
if (n === "write")
|
|
241
|
+
return [n, "read", "Read", "Write", "Edit", "MultiEdit", "NotebookEdit", "Glob", "Grep"];
|
|
242
|
+
if (n === "read")
|
|
243
|
+
return [n, "Read", "Glob", "Grep"];
|
|
244
|
+
if (n === "code")
|
|
245
|
+
return [n, "Bash"];
|
|
246
|
+
if (BUILTIN_GROUP[g])
|
|
247
|
+
return [n, BUILTIN_GROUP[g]];
|
|
248
|
+
return [n];
|
|
249
|
+
}).map(normKey));
|
|
250
|
+
for (const key of Object.keys(limits)) {
|
|
251
|
+
if (key === TOTAL_KEY)
|
|
252
|
+
continue;
|
|
253
|
+
const base = key.startsWith("web.") ? "web" : key;
|
|
254
|
+
const n = normKey(base);
|
|
255
|
+
if (!known.has(n)) {
|
|
256
|
+
const valid = [TOTAL_KEY, "web", ...WEB_ACTIONS.map((a) => `web.${a}`), ...[...new Set([...ctx.granted].map(String))].filter((g) => g !== "web")];
|
|
257
|
+
out.push({ level: "error", message: `limits: ${key} — not a tool this agent could call. The keys: ${[...new Set(valid)].join(", ")}.` });
|
|
258
|
+
}
|
|
259
|
+
else if (!granted.has(n)) {
|
|
260
|
+
out.push({ level: "warn", message: `limits: ${key} — this agent is not granted ${base}, so the limit never applies` });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* A flow step's `limits:` option, written on one line:
|
|
267
|
+
* `limits: {web.search: 10, calls: 50}` (the braces are optional). Read by
|
|
268
|
+
* hand rather than as YAML, because `web.search` is a plain key here and one
|
|
269
|
+
* line is all a step option has.
|
|
270
|
+
*/
|
|
271
|
+
export function parseStepLimits(text) {
|
|
272
|
+
const body = text.trim().replace(/^\{/, "").replace(/\}$/, "").trim();
|
|
273
|
+
if (!body)
|
|
274
|
+
return { limits: {}, problems: ["a block of tool: count on one line, e.g. {web.search: 10, calls: 50}"] };
|
|
275
|
+
const raw = {};
|
|
276
|
+
const problems = [];
|
|
277
|
+
for (const part of body.split(",")) {
|
|
278
|
+
const m = /^\s*([^:\s]+)\s*:\s*(\S+)\s*$/.exec(part);
|
|
279
|
+
if (!m) {
|
|
280
|
+
problems.push(`"${part.trim()}" is not tool: count`);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
raw[m[1]] = m[2];
|
|
284
|
+
}
|
|
285
|
+
const read = readLimits(raw, "limits");
|
|
286
|
+
return { limits: read.limits, problems: [...problems, ...read.problems.map((p) => p.replace(/^limits: /, ""))] };
|
|
287
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export interface RegionChoice {
|
|
2
|
+
/** ISO 3166-1 alpha-2, upper-case: AU, IR, DE. Null when nobody said and
|
|
3
|
+
* the language tag carries none. */
|
|
4
|
+
region: string | null;
|
|
5
|
+
from: "agent" | "flow" | "workspace" | "account" | "env" | "language" | "none";
|
|
6
|
+
lines: string[];
|
|
7
|
+
}
|
|
8
|
+
export declare function normalizeRegion(raw: unknown): string | null;
|
|
9
|
+
export declare function regionProblem(raw: unknown): string | null;
|
|
10
|
+
export declare function resolveRegion(levels: {
|
|
11
|
+
level: RegionChoice["from"];
|
|
12
|
+
value: unknown;
|
|
13
|
+
}[], languageTag: string, env?: Record<string, string | undefined>): RegionChoice;
|
|
14
|
+
export type Units = "metric" | "imperial";
|
|
15
|
+
export interface LocaleFacts {
|
|
16
|
+
/** The BCP-47 tag Intl was asked with, e.g. fa-IR. */
|
|
17
|
+
tag: string;
|
|
18
|
+
language: string;
|
|
19
|
+
region: string | null;
|
|
20
|
+
currency: string | null;
|
|
21
|
+
units: Units;
|
|
22
|
+
/** The calendar in use — Intl's first for the locale, or the override. */
|
|
23
|
+
calendar: string;
|
|
24
|
+
/** Other calendars the locale commonly uses, e.g. gregory beside persian. */
|
|
25
|
+
calendars: string[];
|
|
26
|
+
/** Today in that calendar, formatted for the locale. Null when it is
|
|
27
|
+
* gregory and would only repeat FOLDRUN_DATE. */
|
|
28
|
+
dateLocal: string | null;
|
|
29
|
+
/** 1 = Monday … 7 = Sunday, ISO. */
|
|
30
|
+
weekStart: number | null;
|
|
31
|
+
weekend: number[];
|
|
32
|
+
direction: "ltr" | "rtl";
|
|
33
|
+
hourCycle: "h12" | "h23" | null;
|
|
34
|
+
/** A sample date, so the model sees the order without being told. */
|
|
35
|
+
dateSample: string;
|
|
36
|
+
}
|
|
37
|
+
export declare const dayName: (n: number) => string;
|
|
38
|
+
export declare function localeProblems(front: Record<string, unknown>): string[];
|
|
39
|
+
export declare function deriveLocale(language: string, region: string | null, overrides?: {
|
|
40
|
+
currency?: unknown;
|
|
41
|
+
calendar?: unknown;
|
|
42
|
+
units?: unknown;
|
|
43
|
+
}, timeZone?: string, now?: Date): LocaleFacts;
|
|
44
|
+
/** The sentence block the prompt carries. Only what is true for this
|
|
45
|
+
* locale; nothing hedged, nothing the model has to guess at. */
|
|
46
|
+
export declare function localeProse(f: LocaleFacts, languageName: string, regionName: string | null): string;
|
|
47
|
+
export declare function regionName(code: string | null): string | null;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Everything a country changes that a language does not — derived, not
|
|
2
|
+
// declared. `region:` is the one key; from language + region the runtime
|
|
3
|
+
// works out the currency, the units, the calendar (and today's date in it),
|
|
4
|
+
// which days are the weekend, the text direction, and how dates and hours
|
|
5
|
+
// read. Overrides exist only where derivation can be wrong for a business:
|
|
6
|
+
// `currency:`, `calendar:`, `units:`. Nothing else is a key, so there is no
|
|
7
|
+
// eighth setting to forget.
|
|
8
|
+
//
|
|
9
|
+
// Intl does the deriving. Every accessor here has a fallback to the older
|
|
10
|
+
// property form (Node 20) and to "unknown", because a runtime that cannot
|
|
11
|
+
// say the weekend must not say Saturday.
|
|
12
|
+
import { normalizeLanguage } from "./language.js";
|
|
13
|
+
export function normalizeRegion(raw) {
|
|
14
|
+
if (typeof raw !== "string")
|
|
15
|
+
return null;
|
|
16
|
+
const m = /^[a-z]{2}$/i.exec(raw.trim());
|
|
17
|
+
return m ? raw.trim().toUpperCase() : null;
|
|
18
|
+
}
|
|
19
|
+
export function regionProblem(raw) {
|
|
20
|
+
if (raw === undefined || raw === null || raw === "")
|
|
21
|
+
return null;
|
|
22
|
+
if (normalizeRegion(raw))
|
|
23
|
+
return null;
|
|
24
|
+
return `region: ${JSON.stringify(raw)} is not a country code — write the two letters, like \`au\`, \`ir\` or \`de\`.`;
|
|
25
|
+
}
|
|
26
|
+
export function resolveRegion(levels, languageTag, env = process.env) {
|
|
27
|
+
const lines = [];
|
|
28
|
+
for (const { level, value } of levels) {
|
|
29
|
+
if (value === undefined || value === null || value === "")
|
|
30
|
+
continue;
|
|
31
|
+
const r = normalizeRegion(value);
|
|
32
|
+
if (r)
|
|
33
|
+
return { region: r, from: level, lines };
|
|
34
|
+
lines.push(`region: ${level} says ${JSON.stringify(value)}, which is not a country code — using the next level`);
|
|
35
|
+
}
|
|
36
|
+
const fromEnv = normalizeRegion(env.FOLDRUN_REGION);
|
|
37
|
+
if (fromEnv)
|
|
38
|
+
return { region: fromEnv, from: "env", lines };
|
|
39
|
+
const tag = normalizeLanguage(languageTag);
|
|
40
|
+
const fromTag = tag && tag.includes("-") ? tag.split("-")[1] : null;
|
|
41
|
+
if (fromTag)
|
|
42
|
+
return { region: fromTag, from: "language", lines };
|
|
43
|
+
return { region: null, from: "none", lines };
|
|
44
|
+
}
|
|
45
|
+
// ISO 4217 by region, for the regions a business here is likely to name.
|
|
46
|
+
// Intl has no region→currency table; this is the smallest one that is not
|
|
47
|
+
// wrong. Absent means "no currency assumed", which is better than a guess.
|
|
48
|
+
const CURRENCY = {
|
|
49
|
+
AU: "AUD", NZ: "NZD", US: "USD", CA: "CAD", GB: "GBP", IE: "EUR", DE: "EUR", FR: "EUR", IT: "EUR", ES: "EUR",
|
|
50
|
+
NL: "EUR", BE: "EUR", AT: "EUR", PT: "EUR", FI: "EUR", GR: "EUR", CH: "CHF", SE: "SEK", NO: "NOK", DK: "DKK",
|
|
51
|
+
PL: "PLN", CZ: "CZK", TR: "TRY", RU: "RUB", UA: "UAH", IR: "IRR", IQ: "IQD", AF: "AFN", PK: "PKR", IN: "INR",
|
|
52
|
+
BD: "BDT", LK: "LKR", NP: "NPR", AE: "AED", SA: "SAR", QA: "QAR", KW: "KWD", BH: "BHD", OM: "OMR", JO: "JOD",
|
|
53
|
+
IL: "ILS", EG: "EGP", MA: "MAD", ZA: "ZAR", NG: "NGN", KE: "KES", CN: "CNY", HK: "HKD", TW: "TWD", JP: "JPY",
|
|
54
|
+
KR: "KRW", SG: "SGD", MY: "MYR", ID: "IDR", TH: "THB", VN: "VND", PH: "PHP", BR: "BRL", MX: "MXN", AR: "ARS",
|
|
55
|
+
CL: "CLP", CO: "COP",
|
|
56
|
+
};
|
|
57
|
+
const IMPERIAL = new Set(["US", "LR", "MM"]);
|
|
58
|
+
const DAY = ["", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
|
59
|
+
export const dayName = (n) => DAY[n] ?? String(n);
|
|
60
|
+
export function localeProblems(front) {
|
|
61
|
+
const out = [];
|
|
62
|
+
const r = regionProblem(front.region);
|
|
63
|
+
if (r)
|
|
64
|
+
out.push(r);
|
|
65
|
+
if (front.currency !== undefined && front.currency !== null && front.currency !== "") {
|
|
66
|
+
if (!/^[A-Za-z]{3}$/.test(String(front.currency)))
|
|
67
|
+
out.push(`currency: ${JSON.stringify(front.currency)} is not a currency code — three letters, like \`AUD\`, \`IRR\` or \`EUR\`.`);
|
|
68
|
+
}
|
|
69
|
+
if (front.units !== undefined && front.units !== null && front.units !== "") {
|
|
70
|
+
if (!["metric", "imperial"].includes(String(front.units).toLowerCase()))
|
|
71
|
+
out.push(`units: ${JSON.stringify(front.units)} — write \`metric\` or \`imperial\`.`);
|
|
72
|
+
}
|
|
73
|
+
if (front.calendar !== undefined && front.calendar !== null && front.calendar !== "") {
|
|
74
|
+
const known = supportedCalendars();
|
|
75
|
+
if (known.length && !known.includes(String(front.calendar).toLowerCase())) {
|
|
76
|
+
out.push(`calendar: ${JSON.stringify(front.calendar)} is not a calendar this runtime knows — one of ${known.join(", ")}.`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
function supportedCalendars() {
|
|
82
|
+
try {
|
|
83
|
+
const f = Intl.supportedValuesOf;
|
|
84
|
+
return f ? f("calendar") : [];
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function localeInfo(tag) {
|
|
91
|
+
try {
|
|
92
|
+
const L = new Intl.Locale(tag);
|
|
93
|
+
const call = (m, p) => {
|
|
94
|
+
const fn = L[m];
|
|
95
|
+
if (typeof fn === "function")
|
|
96
|
+
return fn.call(L);
|
|
97
|
+
return L[p];
|
|
98
|
+
};
|
|
99
|
+
const wk = call("getWeekInfo", "weekInfo");
|
|
100
|
+
const ti = call("getTextInfo", "textInfo");
|
|
101
|
+
const cals = call("getCalendars", "calendars") ?? [];
|
|
102
|
+
const hc = call("getHourCycles", "hourCycles")?.[0];
|
|
103
|
+
return {
|
|
104
|
+
weekStart: wk?.firstDay ?? null,
|
|
105
|
+
weekend: wk?.weekend ?? [],
|
|
106
|
+
direction: ti?.direction === "rtl" ? "rtl" : "ltr",
|
|
107
|
+
calendars: cals,
|
|
108
|
+
hourCycle: hc === "h12" || hc === "h23" ? hc : null,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return { weekStart: null, weekend: [], direction: "ltr", calendars: [], hourCycle: null };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
export function deriveLocale(language, region, overrides = {}, timeZone = "UTC", now = new Date()) {
|
|
116
|
+
const lang = normalizeLanguage(language) ?? "en";
|
|
117
|
+
const base = lang.split("-")[0];
|
|
118
|
+
const tag = region ? `${base}-${region}` : lang;
|
|
119
|
+
const info = localeInfo(tag);
|
|
120
|
+
const calendar = typeof overrides.calendar === "string" && overrides.calendar ? overrides.calendar.toLowerCase() : info.calendars[0] ?? "gregory";
|
|
121
|
+
const currency = typeof overrides.currency === "string" && overrides.currency ? overrides.currency.toUpperCase() : region ? CURRENCY[region] ?? null : null;
|
|
122
|
+
const units = typeof overrides.units === "string" && ["metric", "imperial"].includes(overrides.units.toLowerCase())
|
|
123
|
+
? overrides.units.toLowerCase()
|
|
124
|
+
: region && IMPERIAL.has(region) ? "imperial" : "metric";
|
|
125
|
+
const fmt = (opts, cal = calendar) => {
|
|
126
|
+
try {
|
|
127
|
+
return new Intl.DateTimeFormat(`${tag}-u-ca-${cal}`, { ...opts, timeZone }).format(now);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return new Intl.DateTimeFormat("en", { ...opts, timeZone }).format(now);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
return {
|
|
134
|
+
tag,
|
|
135
|
+
language: lang,
|
|
136
|
+
region,
|
|
137
|
+
currency,
|
|
138
|
+
units,
|
|
139
|
+
calendar,
|
|
140
|
+
calendars: info.calendars.filter((c) => c !== calendar),
|
|
141
|
+
dateLocal: calendar === "gregory" ? null : fmt({ dateStyle: "long" }),
|
|
142
|
+
weekStart: info.weekStart,
|
|
143
|
+
weekend: info.weekend,
|
|
144
|
+
direction: info.direction,
|
|
145
|
+
hourCycle: info.hourCycle,
|
|
146
|
+
dateSample: fmt({ dateStyle: "medium" }, "gregory"),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/** The sentence block the prompt carries. Only what is true for this
|
|
150
|
+
* locale; nothing hedged, nothing the model has to guess at. */
|
|
151
|
+
export function localeProse(f, languageName, regionName) {
|
|
152
|
+
const parts = [];
|
|
153
|
+
parts.push(`Language: ${languageName} (\`${f.language}\`)${regionName ? `. Region: ${regionName} (\`${f.region}\`)` : ""}.`);
|
|
154
|
+
if (f.currency)
|
|
155
|
+
parts.push(`Currency: ${f.currency}.`);
|
|
156
|
+
parts.push(`Units: ${f.units}.`);
|
|
157
|
+
if (f.calendar !== "gregory") {
|
|
158
|
+
parts.push(`Calendar: ${f.calendar}${f.dateLocal ? ` — today is ${f.dateLocal}` : ""}${f.calendars.length ? ` (also ${f.calendars.join(", ")})` : ""}.`);
|
|
159
|
+
}
|
|
160
|
+
if (f.weekStart) {
|
|
161
|
+
parts.push(`Week starts ${dayName(f.weekStart)}; weekend ${f.weekend.map(dayName).join(" and ") || "unknown"}.`);
|
|
162
|
+
}
|
|
163
|
+
parts.push(`Dates read like ${f.dateSample}${f.hourCycle ? `; ${f.hourCycle === "h23" ? "24-hour" : "12-hour"} time` : ""}.`);
|
|
164
|
+
if (f.direction === "rtl")
|
|
165
|
+
parts.push(`Text runs right to left: anything you render — HTML, email, PDF — needs dir="rtl".`);
|
|
166
|
+
return parts.join(" ");
|
|
167
|
+
}
|
|
168
|
+
export function regionName(code) {
|
|
169
|
+
if (!code)
|
|
170
|
+
return null;
|
|
171
|
+
try {
|
|
172
|
+
return new Intl.DisplayNames(["en"], { type: "region" }).of(code) ?? code;
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return code;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
export type MailCategory = "security" | "invites" | "billing" | "account" | "run-alerts" | "approvals" | "low-balance" | "product-updates";
|
|
2
|
+
export interface MailCategoryInfo {
|
|
3
|
+
id: MailCategory;
|
|
4
|
+
label: string;
|
|
5
|
+
/** Required mail cannot be turned off — see `why`. */
|
|
6
|
+
required: boolean;
|
|
7
|
+
/** Can be turned off for one workspace while the rest still send. */
|
|
8
|
+
perWorkspace: boolean;
|
|
9
|
+
/** What it is, for the settings page and `foldrun notifications`. */
|
|
10
|
+
description: string;
|
|
11
|
+
/** For a required category: why it cannot be refused. */
|
|
12
|
+
why?: string;
|
|
13
|
+
}
|
|
14
|
+
export declare const MAIL_CATEGORIES: readonly MailCategoryInfo[];
|
|
15
|
+
export declare function mailCategory(id: string): MailCategoryInfo | null;
|
|
16
|
+
export declare function isRequiredCategory(id: string): boolean;
|
|
17
|
+
/** How long a token in a mail keeps working. Long: a person unsubscribes
|
|
18
|
+
* from a mail they kept, often months later, and an unsubscribe link that
|
|
19
|
+
* has died is the one that sends them to the spam button instead. */
|
|
20
|
+
export declare const UNSUBSCRIBE_TTL_MS: number;
|
|
21
|
+
export interface UnsubscribeClaim {
|
|
22
|
+
tenant: string;
|
|
23
|
+
email: string;
|
|
24
|
+
category: MailCategory;
|
|
25
|
+
/** The workspace the mail was about, when its category is per workspace. */
|
|
26
|
+
workspace: string | null;
|
|
27
|
+
/** Epoch ms. */
|
|
28
|
+
expiresAt: number;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* `<payload>.<signature>`: the payload is base64url JSON of who, what and
|
|
32
|
+
* until when; the signature is an HMAC under the install key, so the token
|
|
33
|
+
* is derived, never stored, and a key rotation kills every old one.
|
|
34
|
+
*/
|
|
35
|
+
export declare function unsubscribeToken(c: Omit<UnsubscribeClaim, "expiresAt"> & {
|
|
36
|
+
expiresAt?: number;
|
|
37
|
+
}, now?: number): string;
|
|
38
|
+
/** What a presented token says, or why it says nothing. */
|
|
39
|
+
export declare function readUnsubscribeToken(token: string | null | undefined, now?: number): {
|
|
40
|
+
claim: UnsubscribeClaim;
|
|
41
|
+
} | {
|
|
42
|
+
error: string;
|
|
43
|
+
status: 400 | 401 | 410;
|
|
44
|
+
};
|
|
45
|
+
/** The page a person opens, and the URL a mail client POSTs to. Null when
|
|
46
|
+
* the install does not know its own address — then there is no link. */
|
|
47
|
+
export declare function unsubscribeLinks(claim: Omit<UnsubscribeClaim, "expiresAt">, now?: number): {
|
|
48
|
+
page: string;
|
|
49
|
+
oneClick: string;
|
|
50
|
+
} | null;
|
|
51
|
+
/** The RFC 8058 pair, for a mail whose category may be turned off. */
|
|
52
|
+
export declare function unsubscribeHeaders(claim: Omit<UnsubscribeClaim, "expiresAt">, now?: number): Record<string, string>;
|
|
53
|
+
export interface MailMessage {
|
|
54
|
+
tenant: string;
|
|
55
|
+
/** The Resend key and sender — platformMail(), accountMail() or notifyMail(). */
|
|
56
|
+
mail: {
|
|
57
|
+
key: string;
|
|
58
|
+
from: string;
|
|
59
|
+
};
|
|
60
|
+
to: string | string[];
|
|
61
|
+
subject: string;
|
|
62
|
+
text: string;
|
|
63
|
+
html?: string;
|
|
64
|
+
category: MailCategory;
|
|
65
|
+
/** The workspace the mail is about, for per-workspace preferences. */
|
|
66
|
+
workspace?: string | null;
|
|
67
|
+
}
|
|
68
|
+
export interface MailResult {
|
|
69
|
+
/** True when every recipient who wanted it was handed to the provider —
|
|
70
|
+
* and when nobody wanted it, which is not a failure. */
|
|
71
|
+
ok: boolean;
|
|
72
|
+
/** The HTTP status of the last send, when one was made. */
|
|
73
|
+
status?: number;
|
|
74
|
+
/** The provider's own words on a refusal, trimmed. */
|
|
75
|
+
error?: string;
|
|
76
|
+
sent: string[];
|
|
77
|
+
/** Recipients who turned this category off. */
|
|
78
|
+
suppressed: string[];
|
|
79
|
+
}
|
|
80
|
+
/** Does this person take this category of mail? Required mail: always. */
|
|
81
|
+
export declare function mailWanted(tenant: string, email: string, category: MailCategory, workspace?: string | null): Promise<boolean>;
|
|
82
|
+
/**
|
|
83
|
+
* Send one message, honouring each recipient's preference for its
|
|
84
|
+
* category. Optional mail is sent one recipient at a time so each copy
|
|
85
|
+
* carries that reader's own unsubscribe token; required mail goes as one.
|
|
86
|
+
* Never throws: a mail that fails is an `ok: false` with the reason.
|
|
87
|
+
*/
|
|
88
|
+
export declare function sendMail(m: MailMessage): Promise<MailResult>;
|