@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.
Files changed (170) hide show
  1. package/README.md +7 -4
  2. package/dist/index.d.ts +4 -0
  3. package/dist/index.js +4 -0
  4. package/dist/src/account.d.ts +45 -0
  5. package/dist/src/account.js +130 -0
  6. package/dist/src/api-tools.d.ts +8 -0
  7. package/dist/src/api-tools.js +22 -4
  8. package/dist/src/browser-pod.d.ts +148 -0
  9. package/dist/src/browser-pod.js +333 -0
  10. package/dist/src/catalog.js +2 -1
  11. package/dist/src/clock.d.ts +49 -0
  12. package/dist/src/clock.js +121 -0
  13. package/dist/src/completions.js +17 -8
  14. package/dist/src/confine.d.ts +22 -0
  15. package/dist/src/confine.js +218 -3
  16. package/dist/src/container.d.ts +9 -0
  17. package/dist/src/container.js +115 -5
  18. package/dist/src/context-tools.d.ts +1 -1
  19. package/dist/src/deploy.d.ts +33 -0
  20. package/dist/src/deploy.js +145 -6
  21. package/dist/src/egress.d.ts +7 -0
  22. package/dist/src/egress.js +6 -0
  23. package/dist/src/eol.d.ts +2 -0
  24. package/dist/src/eol.js +7 -0
  25. package/dist/src/evals.d.ts +5 -0
  26. package/dist/src/evals.js +9 -2
  27. package/dist/src/flow-lint.d.ts +8 -0
  28. package/dist/src/flow-lint.js +223 -1
  29. package/dist/src/flow-patterns.d.ts +141 -0
  30. package/dist/src/flow-patterns.js +568 -0
  31. package/dist/src/gitrepo.d.ts +18 -0
  32. package/dist/src/gitrepo.js +49 -0
  33. package/dist/src/host-env.js +5 -0
  34. package/dist/src/kinds.js +1 -1
  35. package/dist/src/language.d.ts +17 -0
  36. package/dist/src/language.js +55 -0
  37. package/dist/src/layout.d.ts +69 -0
  38. package/dist/src/layout.js +200 -0
  39. package/dist/src/library.d.ts +9 -0
  40. package/dist/src/library.js +30 -3
  41. package/dist/src/limits.d.ts +84 -0
  42. package/dist/src/limits.js +287 -0
  43. package/dist/src/locale.d.ts +47 -0
  44. package/dist/src/locale.js +177 -0
  45. package/dist/src/mail.d.ts +88 -0
  46. package/dist/src/mail.js +247 -0
  47. package/dist/src/model-credential.d.ts +14 -0
  48. package/dist/src/model-credential.js +58 -0
  49. package/dist/src/model-probe.d.ts +3 -0
  50. package/dist/src/model-probe.js +1 -0
  51. package/dist/src/notify.d.ts +53 -7
  52. package/dist/src/notify.js +194 -126
  53. package/dist/src/openapi.js +5 -3
  54. package/dist/src/operator.d.ts +201 -0
  55. package/dist/src/operator.js +251 -0
  56. package/dist/src/paths.d.ts +28 -0
  57. package/dist/src/paths.js +55 -0
  58. package/dist/src/platform.d.ts +43 -3
  59. package/dist/src/platform.js +9 -1
  60. package/dist/src/provider-health.js +3 -2
  61. package/dist/src/providers.d.ts +481 -0
  62. package/dist/src/providers.js +1105 -6
  63. package/dist/src/refusal.d.ts +83 -0
  64. package/dist/src/refusal.js +281 -0
  65. package/dist/src/run-container.d.ts +149 -13
  66. package/dist/src/run-container.js +424 -43
  67. package/dist/src/runner.d.ts +177 -9
  68. package/dist/src/runner.js +1146 -154
  69. package/dist/src/runtime.d.ts +52 -0
  70. package/dist/src/runtime.js +493 -58
  71. package/dist/src/script-tools.d.ts +17 -2
  72. package/dist/src/script-tools.js +82 -10
  73. package/dist/src/secret-files.d.ts +6 -5
  74. package/dist/src/secret-files.js +24 -11
  75. package/dist/src/secret-health.d.ts +30 -6
  76. package/dist/src/secret-health.js +42 -20
  77. package/dist/src/secrets.d.ts +19 -0
  78. package/dist/src/secrets.js +155 -86
  79. package/dist/src/starter.d.ts +9 -0
  80. package/dist/src/starter.js +92 -11
  81. package/dist/src/step-exec.d.ts +72 -2
  82. package/dist/src/step-exec.js +431 -37
  83. package/dist/src/step-removal.d.ts +15 -0
  84. package/dist/src/step-removal.js +49 -0
  85. package/dist/src/storage.d.ts +23 -4
  86. package/dist/src/storage.js +125 -23
  87. package/dist/src/store.d.ts +222 -37
  88. package/dist/src/store.js +349 -62
  89. package/dist/src/subagents.d.ts +75 -0
  90. package/dist/src/subagents.js +149 -0
  91. package/dist/src/test-mode.d.ts +17 -2
  92. package/dist/src/test-mode.js +53 -13
  93. package/dist/src/tool-names.d.ts +13 -1
  94. package/dist/src/tool-names.js +38 -11
  95. package/dist/src/tool-programs.d.ts +26 -0
  96. package/dist/src/tool-programs.js +172 -0
  97. package/dist/src/tool-test.js +104 -12
  98. package/dist/src/translator.d.ts +8 -2
  99. package/dist/src/translator.js +69 -23
  100. package/dist/src/trigger-log.d.ts +1 -1
  101. package/dist/src/triggers.d.ts +3 -0
  102. package/dist/src/triggers.js +10 -2
  103. package/dist/src/untrusted-fetch.d.ts +11 -0
  104. package/dist/src/untrusted-fetch.js +77 -0
  105. package/dist/src/web-actions.d.ts +91 -0
  106. package/dist/src/web-actions.js +199 -0
  107. package/dist/src/webhook.d.ts +4 -0
  108. package/dist/src/webhook.js +39 -0
  109. package/index.ts +4 -0
  110. package/package.json +3 -3
  111. package/src/account.ts +149 -0
  112. package/src/api-tools.ts +32 -4
  113. package/src/browser-pod.ts +350 -0
  114. package/src/catalog.ts +2 -1
  115. package/src/clock.ts +149 -0
  116. package/src/completions.ts +17 -8
  117. package/src/confine.ts +218 -3
  118. package/src/container.ts +112 -5
  119. package/src/context-tools.ts +1 -1
  120. package/src/deploy.ts +137 -6
  121. package/src/egress.ts +13 -1
  122. package/src/eol.ts +7 -0
  123. package/src/evals.ts +14 -2
  124. package/src/flow-lint.ts +253 -1
  125. package/src/flow-patterns.ts +597 -0
  126. package/src/gitrepo.ts +48 -0
  127. package/src/host-env.ts +5 -0
  128. package/src/kinds.ts +1 -1
  129. package/src/language.ts +62 -0
  130. package/src/layout.ts +228 -0
  131. package/src/library.ts +37 -3
  132. package/src/limits.ts +290 -0
  133. package/src/locale.ts +217 -0
  134. package/src/mail.ts +318 -0
  135. package/src/model-credential.ts +71 -0
  136. package/src/model-probe.ts +4 -0
  137. package/src/notify.ts +189 -103
  138. package/src/openapi.ts +5 -3
  139. package/src/operator.ts +300 -0
  140. package/src/paths.ts +54 -0
  141. package/src/platform.ts +52 -3
  142. package/src/provider-health.ts +3 -2
  143. package/src/providers.ts +1368 -6
  144. package/src/refusal.ts +320 -0
  145. package/src/run-container.ts +460 -44
  146. package/src/runner.ts +1168 -155
  147. package/src/runtime.ts +487 -55
  148. package/src/script-tools.ts +81 -10
  149. package/src/secret-files.ts +22 -10
  150. package/src/secret-health.ts +64 -19
  151. package/src/secrets.ts +149 -87
  152. package/src/starter.ts +93 -11
  153. package/src/step-exec.ts +447 -38
  154. package/src/step-removal.ts +59 -0
  155. package/src/storage.ts +141 -22
  156. package/src/store.ts +506 -70
  157. package/src/subagents.ts +189 -0
  158. package/src/test-mode.ts +51 -11
  159. package/src/tool-names.ts +40 -11
  160. package/src/tool-programs.ts +179 -0
  161. package/src/tool-test.ts +110 -12
  162. package/src/translator.ts +72 -22
  163. package/src/trigger-log.ts +1 -0
  164. package/src/triggers.ts +10 -2
  165. package/src/untrusted-fetch.ts +88 -0
  166. package/src/web-actions.ts +237 -0
  167. package/src/webhook.ts +41 -0
  168. package/templates/blog-desk/agents/writer/agent.md +1 -1
  169. package/templates/hello/agents/curator/agent.md +4 -4
  170. package/templates/hello/agents/notetaker/agent.md +3 -3
@@ -0,0 +1,62 @@
1
+ // The language an agent works in — the same cascade as its clock, for the
2
+ // same reason: a value written once at the level it belongs to, inherited
3
+ // below it, and never a hardcoded default inside a tool.
4
+ //
5
+ // Prose in AGENTS.md reaches the model and nothing else. A search engine
6
+ // asked in English answers in English whatever the agent was told, a
7
+ // browser reports en-US to every page, a fetch sends no Accept-Language.
8
+ // `language:` is what those read; the prompt sentence it adds is the small
9
+ // part. English is the default, so nothing changes for an agent that never
10
+ // says.
11
+
12
+ export interface LanguageChoice {
13
+ /** BCP-47, normalised: `en`, `en-AU`, `fa`, `pt-BR`. */
14
+ language: string;
15
+ from: "agent" | "flow" | "workspace" | "account" | "env" | "default";
16
+ /** A level that wrote something unreadable, said once and skipped. */
17
+ lines: string[];
18
+ }
19
+
20
+ /** `en`, `en-AU`, `en_au`, ` FA-ir ` → `en`, `en-AU`, `en-AU`, `fa-IR`; anything else null. */
21
+ export function normalizeLanguage(raw: unknown): string | null {
22
+ if (typeof raw !== "string") return null;
23
+ const m = /^([a-z]{2,3})(?:[-_]([a-z]{2}))?$/i.exec(raw.trim());
24
+ if (!m) return null;
25
+ return m[2] ? `${m[1].toLowerCase()}-${m[2].toUpperCase()}` : m[1].toLowerCase();
26
+ }
27
+
28
+ /** Null when unset (inherit) or readable; else the sentence check prints. */
29
+ export function languageProblem(raw: unknown): string | null {
30
+ if (raw === undefined || raw === null || raw === "") return null;
31
+ if (normalizeLanguage(raw)) return null;
32
+ return `language: ${JSON.stringify(raw)} is not a language tag — write it like \`en\`, \`en-AU\` or \`fa\` (a two-letter language, and optionally a two-letter region).`;
33
+ }
34
+
35
+ export function resolveLanguage(
36
+ levels: { level: LanguageChoice["from"]; value: unknown }[],
37
+ env: Record<string, string | undefined> = process.env,
38
+ ): LanguageChoice {
39
+ const lines: string[] = [];
40
+ for (const { level, value } of levels) {
41
+ if (value === undefined || value === null || value === "") continue;
42
+ const tag = normalizeLanguage(value);
43
+ if (tag) return { language: tag, from: level, lines };
44
+ lines.push(`language: ${level} says ${JSON.stringify(value)}, which is not a language tag — using the next level`);
45
+ }
46
+ const fromEnv = normalizeLanguage(env.FOLDRUN_LANGUAGE);
47
+ if (fromEnv) return { language: fromEnv, from: "env", lines };
48
+ return { language: "en", from: "default", lines };
49
+ }
50
+
51
+ /** How a person would name it in a prompt: `fa-IR` → "Persian (Iran)". */
52
+ export function languageName(tag: string): string {
53
+ try {
54
+ const [lang, region] = tag.split("-");
55
+ const names = new Intl.DisplayNames(["en"], { type: "language" });
56
+ const regions = new Intl.DisplayNames(["en"], { type: "region" });
57
+ const base = names.of(lang) ?? lang;
58
+ return region ? `${base} (${regions.of(region) ?? region})` : base;
59
+ } catch {
60
+ return tag;
61
+ }
62
+ }
package/src/layout.ts ADDED
@@ -0,0 +1,228 @@
1
+ // What shape is this folder?
2
+ //
3
+ // There are two shapes a foldrun tree on a laptop can have, and every command
4
+ // has to answer the same question about the one it was pointed at.
5
+ //
6
+ // flat account
7
+ // my-desk/ my-account/
8
+ // ├── AGENTS.md ├── AGENTS.md ← account scope
9
+ // ├── agents/ ├── library/ ← shared by every workspace
10
+ // └── flows/ └── workspaces/
11
+ // └── my-desk/
12
+ // ├── AGENTS.md
13
+ // ├── agents/
14
+ // └── flows/
15
+ //
16
+ // The second is the platform's own layout: `<data>/<account>/library` and
17
+ // `<data>/<account>/workspaces/<name>` are exactly these directories with a
18
+ // tenant directory around them. The first is what `foldrun init` made until
19
+ // today, so it is what every existing user has, and it must keep working
20
+ // forever — a folder shape is a file format.
21
+ //
22
+ // This module is the ONE place that decides. Everything else — the CLI's
23
+ // commands, `accountDir`, `libraryDir` — asks here rather than testing for a
24
+ // `workspaces` directory itself, because the last time that test was written
25
+ // inline it was written as "is my parent called workspaces", which is true of
26
+ // a real installation and of this layout and means something different in
27
+ // each.
28
+ //
29
+ // Deliberately free of every other import: `paths.ts` needs it, and `paths.ts`
30
+ // is what the rest of the codebase imports first.
31
+
32
+ import fs from "node:fs";
33
+ import path from "node:path";
34
+
35
+ /** Where an account keeps its workspaces. `projects/` is the pre-rename name,
36
+ * still read so an existing tree keeps working. */
37
+ export const WORKSPACES_DIRS = ["workspaces", "projects"] as const;
38
+
39
+ function isDir(p: string): boolean {
40
+ try {
41
+ return fs.statSync(p).isDirectory();
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * A workspace is a directory holding `agents/` or `flows/`.
49
+ *
50
+ * Not AGENTS.md: an account root has one of those too, and a test that cannot
51
+ * tell the two apart is the bug this module exists to prevent.
52
+ */
53
+ export function isWorkspaceDir(dir: string): boolean {
54
+ return isDir(path.join(dir, "agents")) || isDir(path.join(dir, "flows"));
55
+ }
56
+
57
+ /** `<dir>/workspaces` when `dir` is an account root, else null. */
58
+ export function accountWorkspacesDir(dir: string): string | null {
59
+ for (const name of WORKSPACES_DIRS) {
60
+ const full = path.join(dir, name);
61
+ if (isDir(full)) return full;
62
+ }
63
+ return null;
64
+ }
65
+
66
+ /** The workspaces under an account root, by name, sorted. */
67
+ export function listWorkspaceDirs(accountRoot: string): string[] {
68
+ const dir = accountWorkspacesDir(accountRoot);
69
+ if (!dir) return [];
70
+ try {
71
+ return fs
72
+ .readdirSync(dir)
73
+ .filter((n) => !n.startsWith("."))
74
+ .filter((n) => isDir(path.join(dir, n)))
75
+ .sort();
76
+ } catch {
77
+ return [];
78
+ }
79
+ }
80
+
81
+ export type LayoutKind = "account" | "workspace-in-account" | "flat" | "empty";
82
+
83
+ export interface Layout {
84
+ /**
85
+ * `account` — an account folder, pointed at its root.
86
+ * `workspace-in-account` — inside one of its workspaces.
87
+ * `flat` — a lone workspace, the shape `foldrun init` made until today.
88
+ * `empty` — neither: a bare or brand-new directory.
89
+ */
90
+ kind: LayoutKind;
91
+ /** Where AGENTS.md and `library/` live — the account scope. */
92
+ accountRoot: string;
93
+ /** `<accountRoot>/workspaces`, or null when there is no account layout. */
94
+ workspacesDir: string | null;
95
+ /** The workspace we are in, absolute. Null at an account root. */
96
+ workspaceDir: string | null;
97
+ /** Its name. Null at an account root. */
98
+ workspace: string | null;
99
+ /** Every workspace this account holds, by name. A flat folder holds one. */
100
+ workspaces: string[];
101
+ }
102
+
103
+ /** How far up to look before giving up: a project root (`.git`) is a boundary
104
+ * a layout does not cross, and so is the filesystem root. */
105
+ function* upward(from: string): Generator<string> {
106
+ let dir = path.resolve(from);
107
+ for (;;) {
108
+ yield dir;
109
+ if (isDir(path.join(dir, ".git"))) return;
110
+ const up = path.dirname(dir);
111
+ if (up === dir) return;
112
+ dir = up;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Which of the two shapes `from` is in, and the directories that follow from
118
+ * the answer.
119
+ *
120
+ * Walks up, remembering the first workspace directory it passes and stopping
121
+ * at the first account root. A path anywhere inside a workspace resolves to
122
+ * that workspace, so `foldrun status` works from `agents/writer/` the way git
123
+ * works from anywhere in a checkout.
124
+ */
125
+ export function detectLayout(from: string): Layout {
126
+ const start = path.resolve(from);
127
+ let workspaceDir: string | null = null;
128
+ let accountRoot: string | null = null;
129
+
130
+ for (const dir of upward(start)) {
131
+ if (!workspaceDir && isWorkspaceDir(dir)) workspaceDir = dir;
132
+ const ws = accountWorkspacesDir(dir);
133
+ // An account root holds `workspaces/`. The workspace directory we may
134
+ // have just found must be *inside* it — a workspace that happens to have
135
+ // its own `workspaces/` folder of something else is not an account.
136
+ if (ws && (!workspaceDir || workspaceDir.startsWith(ws + path.sep))) {
137
+ accountRoot = dir;
138
+ break;
139
+ }
140
+ }
141
+
142
+ if (accountRoot) {
143
+ const workspaces = listWorkspaceDirs(accountRoot);
144
+ if (workspaceDir) {
145
+ return {
146
+ kind: "workspace-in-account",
147
+ accountRoot,
148
+ workspacesDir: accountWorkspacesDir(accountRoot),
149
+ workspaceDir,
150
+ workspace: path.basename(workspaceDir),
151
+ workspaces,
152
+ };
153
+ }
154
+ return {
155
+ kind: "account",
156
+ accountRoot,
157
+ workspacesDir: accountWorkspacesDir(accountRoot),
158
+ workspaceDir: null,
159
+ workspace: null,
160
+ workspaces,
161
+ };
162
+ }
163
+
164
+ if (workspaceDir) {
165
+ return {
166
+ kind: "flat",
167
+ accountRoot: path.dirname(workspaceDir),
168
+ workspacesDir: null,
169
+ workspaceDir,
170
+ workspace: path.basename(workspaceDir),
171
+ workspaces: [path.basename(workspaceDir)],
172
+ };
173
+ }
174
+
175
+ return {
176
+ kind: "empty",
177
+ accountRoot: path.dirname(start),
178
+ workspacesDir: null,
179
+ workspaceDir: start,
180
+ workspace: path.basename(start),
181
+ workspaces: [],
182
+ };
183
+ }
184
+
185
+ /**
186
+ * The account scope for a workspace directory.
187
+ *
188
+ * For a flat workspace that is simply its parent — `my-desk/` sits beside the
189
+ * `AGENTS.md` and `library/` that cover it, which is what `libraryDir` has
190
+ * always assumed. For a workspace inside an account it is two levels up, NOT
191
+ * one: one level up is `workspaces/`, which holds no library and no config,
192
+ * and pointing the account scope there is how a local account folder used to
193
+ * lose its shared library the moment the runtime asked for it.
194
+ */
195
+ export function accountRootFor(workspaceDir: string): string {
196
+ const dir = path.resolve(workspaceDir);
197
+ const parent = path.dirname(dir);
198
+ if ((WORKSPACES_DIRS as readonly string[]).includes(path.basename(parent))) {
199
+ return path.dirname(parent);
200
+ }
201
+ return parent;
202
+ }
203
+
204
+ /**
205
+ * The data root of the installation this account belongs to, or null when the
206
+ * account is a folder someone made on their laptop.
207
+ *
208
+ * `<data>/<tenant>/workspaces/<name>` and `<my-account>/workspaces/<name>` are
209
+ * the same shape, so the shape cannot tell them apart. What can is the
210
+ * installation's own marker, which sits at the data root and nowhere else:
211
+ * `.foldrun-install`, written by the platform at boot. `.secret-key` and
212
+ * `keys.json` are the older markers — kept for installs from before it, and
213
+ * no longer enough alone, because keys move into the database and a key held
214
+ * in FOLDRUN_SECRET_KEY never had a file.
215
+ */
216
+ export const INSTALL_MARKER = ".foldrun-install";
217
+
218
+ export function installationDataRoot(accountRoot: string): string | null {
219
+ const above = path.dirname(path.resolve(accountRoot));
220
+ for (const marker of [INSTALL_MARKER, ".secret-key", "keys.json"]) {
221
+ try {
222
+ if (fs.statSync(path.join(above, marker)).isFile()) return above;
223
+ } catch {
224
+ /* not this one */
225
+ }
226
+ }
227
+ return null;
228
+ }
package/src/library.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  import { platform } from "./platform.ts";
15
15
  import fs from "node:fs";
16
16
  import path from "node:path";
17
- import { dataRoot, singleWorkspace } from "./paths.ts";
17
+ import { dataRoot, singleWorkspace, singleAccountRoot, MAX_EDITABLE_FILE } from "./paths.ts";
18
18
  import matter from "gray-matter";
19
19
  import { KINDS } from "./kinds.ts";
20
20
  import { recordRevision, registerTreeReader } from "./history.ts";
@@ -44,7 +44,9 @@ export function libraryDir(tenant: string, kind?: LibraryKind) {
44
44
  // library is still possible on a laptop without inventing a home directory.
45
45
  const single = singleWorkspace();
46
46
  if (single) {
47
- const base = path.join(path.resolve(single), "..", "library");
47
+ // The account scope, which is the workspace's parent only when the
48
+ // workspace is a flat one — see singleAccountRoot.
49
+ const base = path.join(singleAccountRoot()!, "library");
48
50
  return kind ? path.join(base, kind) : base;
49
51
  }
50
52
  assertSafeName(tenant, "tenant");
@@ -75,6 +77,15 @@ export interface LibraryEntry {
75
77
  description: string;
76
78
  /** For skills: true when the folder bundles scripts/. */
77
79
  hasScripts?: boolean;
80
+ /**
81
+ * Every file this entry is made of, relative to the kind's directory,
82
+ * `path` included. One entry stays one tool — the shelf must not grow a
83
+ * second row called browser — but a caller copying the library needs the
84
+ * code beside the manifest, and the listing is the only place that knows
85
+ * what is in the folder. Absent for a single-file entry, where `path` is
86
+ * already the whole of it.
87
+ */
88
+ files?: string[];
78
89
  /**
79
90
  * For tools: how this one connects — http, script or mcp. Surfaced so a
80
91
  * list of tools reads as one vocabulary with three transports, rather
@@ -87,6 +98,25 @@ export interface LibraryEntry {
87
98
  updatedAt: string;
88
99
  }
89
100
 
101
+ /** Every file inside a folder entry, as paths relative to the kind's
102
+ * directory. Depth-first so a nested `scripts/` comes with it. */
103
+ function folderFiles(dir: string, prefix: string): string[] {
104
+ const out: string[] = [];
105
+ const walk = (at: string, rel: string) => {
106
+ for (const e of fs.readdirSync(at, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
107
+ const here = rel ? `${rel}/${e.name}` : e.name;
108
+ if (e.isDirectory()) walk(path.join(at, e.name), here);
109
+ else out.push(`${prefix}/${here}`);
110
+ }
111
+ };
112
+ try {
113
+ walk(dir, "");
114
+ } catch {
115
+ return [`${prefix}/tool.md`];
116
+ }
117
+ return out;
118
+ }
119
+
90
120
  export function listLibrary(tenant: string, kind: LibraryKind): LibraryEntry[] {
91
121
  const dir = libraryDir(tenant, kind);
92
122
  if (!fs.existsSync(dir)) return [];
@@ -113,6 +143,7 @@ export function listLibrary(tenant: string, kind: LibraryKind): LibraryEntry[] {
113
143
  out.push({
114
144
  ...describe(manifest, entry.name),
115
145
  path: `${entry.name}/tool.md`,
146
+ files: folderFiles(full, entry.name),
116
147
  ...toolShape(manifest, entry.name),
117
148
  updatedAt: fs.statSync(manifest).mtime.toISOString(),
118
149
  });
@@ -138,6 +169,7 @@ export function listLibrary(tenant: string, kind: LibraryKind): LibraryEntry[] {
138
169
  ...describe(skillMd, entry),
139
170
  path: `${entry}/SKILL.md`,
140
171
  hasScripts: fs.existsSync(path.join(full, "scripts")),
172
+ files: folderFiles(full, entry),
141
173
  updatedAt: fs.statSync(skillMd).mtime.toISOString(),
142
174
  });
143
175
  } else if (entry.endsWith(".md")) {
@@ -205,7 +237,9 @@ export function writeLibraryFile(
205
237
  content: string,
206
238
  meta: { by?: string; message?: string } = {},
207
239
  ) {
208
- if (content.length > 256 * 1024) throw new Error("file too large");
240
+ // 512 KB: a folder tool's program is one file, and the gallery browser's is
241
+ // past 256 KB. Still a file a person edits, not a store for data.
242
+ if (content.length > MAX_EDITABLE_FILE) throw new Error("file too large");
209
243
  const norm = assertLibraryPath(kind, rel);
210
244
  if (norm.endsWith(".md")) matter(content); // reject broken frontmatter
211
245
  const p = path.join(libraryDir(tenant, kind), norm);
package/src/limits.ts ADDED
@@ -0,0 +1,290 @@
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
+
21
+ import { WEB_ACTIONS } from "./providers.ts";
22
+ import { DELEGATE_TOOLS } from "./subagents.ts";
23
+
24
+ export type Limits = Record<string, number>;
25
+
26
+ /** The key that caps every call in the step. */
27
+ export const TOTAL_KEY = "calls";
28
+
29
+ /** SDK built-ins, counted under the foldrun group that grants them — and
30
+ * under their own SDK name, so `Bash: 5` works for a Claude Code file. */
31
+ const BUILTIN_GROUP: Record<string, string> = {
32
+ Read: "read", Glob: "read", Grep: "read",
33
+ Write: "write", Edit: "write", MultiEdit: "write", NotebookEdit: "write",
34
+ Bash: "code",
35
+ };
36
+
37
+ /** Retired spellings an author may still write as a key. */
38
+ const KEY_ALIASES: Record<string, string> = { files: "write", bash: "code" };
39
+
40
+ /** The platform's own in-process servers and the name each is granted as. */
41
+ const PLATFORM_SERVERS: Record<string, string> = {
42
+ foldrun_search: "search",
43
+ foldrun_history: "history",
44
+ foldrun_desks: "desks",
45
+ foldrun_ask: "ask",
46
+ };
47
+
48
+ /** One spelling for comparing: `desk-email` and `desk_email` are the same
49
+ * tool (a script tool's SDK name has its dashes made underscores). */
50
+ export function normKey(key: string): string {
51
+ const k = key.trim();
52
+ return (KEY_ALIASES[k] ?? k).replace(/-/g, "_");
53
+ }
54
+
55
+ const KEY_RE = /^[A-Za-z0-9_-]+(\.[a-z]+)?$/;
56
+
57
+ /**
58
+ * A `limits:` block as written: the values that can be read, and a sentence
59
+ * for each that cannot. Shape only — whether a key names a tool this agent
60
+ * has is `limitKeyProblems`, which needs the grants.
61
+ */
62
+ export function readLimits(raw: unknown, where = "limits"): { limits: Limits; problems: string[] } {
63
+ const limits: Limits = {};
64
+ const problems: string[] = [];
65
+ if (raw === undefined || raw === null) return { limits, problems };
66
+ if (typeof raw !== "object" || Array.isArray(raw)) {
67
+ problems.push(`${where}: is a block of tool: count, e.g. \`limits: {web.search: 40, calls: 300}\`.`);
68
+ return { limits, problems };
69
+ }
70
+ // A YAML block written `web: {search: 40}` arrives nested; read it as the
71
+ // dotted keys it means rather than refusing a natural spelling.
72
+ const flat: [string, unknown][] = [];
73
+ for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
74
+ if (v && typeof v === "object" && !Array.isArray(v)) {
75
+ for (const [sub, n] of Object.entries(v as Record<string, unknown>)) flat.push([`${k}.${sub}`, n]);
76
+ } else flat.push([k, v]);
77
+ }
78
+ for (const [key, value] of flat) {
79
+ const k = key.trim();
80
+ if (!KEY_RE.test(k)) {
81
+ problems.push(`${where}: "${k}" is not a key — a tool's name, web.<action>, or calls.`);
82
+ continue;
83
+ }
84
+ if (k.includes(".")) {
85
+ const [head, action] = k.split(".");
86
+ if (head !== "web" || !(WEB_ACTIONS as readonly string[]).includes(action)) {
87
+ problems.push(`${where}: "${k}" is not a key — the dotted keys are web.<action>: ${WEB_ACTIONS.map((a) => `web.${a}`).join(", ")}.`);
88
+ continue;
89
+ }
90
+ }
91
+ const n = typeof value === "number" ? value : typeof value === "string" && /^\s*\d+\s*$/.test(value) ? Number(value) : NaN;
92
+ if (!Number.isInteger(n) || n < 1) {
93
+ problems.push(`${where}: ${k}: ${String(value)} — a whole number of calls, 1 or more. To withhold a tool, leave it out of tools:.`);
94
+ continue;
95
+ }
96
+ limits[k] = n;
97
+ }
98
+ return { limits, problems };
99
+ }
100
+
101
+ /**
102
+ * The limits a step runs under, nearest last: account, workspace, agent,
103
+ * step. A key set nearer replaces the same key further out; the others
104
+ * stand. Unreadable values are dropped here — `check` and the deploy gate
105
+ * say so where they were written.
106
+ */
107
+ export function cascadeLimits(levels: unknown[]): Limits {
108
+ const out: Limits = {};
109
+ const at = new Map<string, string>(); // normalised → the key as last written
110
+ for (const level of levels) {
111
+ for (const [k, n] of Object.entries(readLimits(level).limits)) {
112
+ const nk = normKey(k);
113
+ const prev = at.get(nk);
114
+ if (prev !== undefined) delete out[prev];
115
+ out[k] = n;
116
+ at.set(nk, k);
117
+ }
118
+ }
119
+ return out;
120
+ }
121
+
122
+ /** SDK tool name → the foldrun name it is granted as, for the tools whose
123
+ * SDK name does not say (an openapi tool's operations, a script whose name
124
+ * was made safe). `apiOwners` is api-tools' own record of which API each
125
+ * tool was built for — not guessed from the name, which cannot tell `crm`'s
126
+ * operation `admin_list` from `crm_admin`'s `list`. Built host-side; plain
127
+ * JSON so it crosses into a pod. */
128
+ export function toolOwners(apiOwners: Record<string, string>, scripts: { name: string }[]): Record<string, string> {
129
+ const out: Record<string, string> = { ...apiOwners };
130
+ for (const s of scripts) out[`mcp__foldrun_scripts__${s.name}`] = s.name;
131
+ return out;
132
+ }
133
+
134
+ /**
135
+ * Every key one call counts under, `calls` included. Pure: the SDK's tool
136
+ * name, its input, and the owners map.
137
+ */
138
+ export function limitKeysFor(toolName: string, input: Record<string, unknown> | undefined, owners: Record<string, string> = {}): string[] {
139
+ const keys = [TOTAL_KEY];
140
+ const webAction = (action: unknown) => {
141
+ keys.push("web");
142
+ const a = typeof action === "string" ? action.trim().toLowerCase() : "";
143
+ if (a) keys.push(`web.${a}`);
144
+ };
145
+ // A model provider's own search or fetch, standing in for the web tool's.
146
+ if (toolName === "WebSearch") { webAction("search"); return keys; }
147
+ if (toolName === "WebFetch") { webAction("fetch"); return keys; }
148
+ if ((DELEGATE_TOOLS as readonly string[]).includes(toolName)) return keys;
149
+ const owner = owners[toolName];
150
+ const mcp = /^mcp__(.+?)__(.+)$/.exec(toolName);
151
+ const name = owner
152
+ ?? (mcp
153
+ ? mcp[1] === "foldrun_scripts" ? mcp[2]
154
+ : mcp[1] === "foldrun_apis" ? mcp[2].replace(/^call_/, "")
155
+ : mcp[1] === "foldrun_agents" ? mcp[2].replace(/^consult_/, "")
156
+ : PLATFORM_SERVERS[mcp[1]] ?? mcp[1]
157
+ : null);
158
+ if (name !== null) {
159
+ if (normKey(name) === "web") webAction(input?.action);
160
+ else keys.push(name);
161
+ return keys;
162
+ }
163
+ const group = BUILTIN_GROUP[toolName];
164
+ if (group) keys.push(group);
165
+ keys.push(toolName);
166
+ return keys;
167
+ }
168
+
169
+ export interface LimitRefusal {
170
+ key: string;
171
+ used: number;
172
+ limit: number;
173
+ message: string;
174
+ }
175
+
176
+ /**
177
+ * One step's counts. `take` checks every key the call counts under and
178
+ * either refuses — counting nothing — or counts it under all of them.
179
+ */
180
+ export class CallCounter {
181
+ private readonly limits = new Map<string, { key: string; limit: number }>();
182
+ private readonly used = new Map<string, number>();
183
+ refused = 0;
184
+
185
+ constructor(limits: Limits | undefined | null) {
186
+ for (const [k, n] of Object.entries(limits ?? {})) this.limits.set(normKey(k), { key: k, limit: n });
187
+ }
188
+
189
+ get active(): boolean {
190
+ return this.limits.size > 0;
191
+ }
192
+
193
+ take(keys: string[]): LimitRefusal | null {
194
+ const norm = [...new Set(keys.map(normKey))];
195
+ for (const k of norm) {
196
+ const l = this.limits.get(k);
197
+ if (!l) continue;
198
+ const used = this.used.get(k) ?? 0;
199
+ if (used >= l.limit) {
200
+ this.refused++;
201
+ return {
202
+ key: l.key, used, limit: l.limit,
203
+ message: `limit reached: ${l.key} ${used} of ${l.limit} in this step — work with what you have, or say what more you would need`,
204
+ };
205
+ }
206
+ }
207
+ for (const k of norm) this.used.set(k, (this.used.get(k) ?? 0) + 1);
208
+ return null;
209
+ }
210
+
211
+ /** Give back a call that was counted and then refused by a later check. */
212
+ refund(keys: string[]): void {
213
+ for (const k of new Set(keys.map(normKey))) {
214
+ const n = this.used.get(k) ?? 0;
215
+ if (n > 0) this.used.set(k, n - 1);
216
+ }
217
+ }
218
+
219
+ count(key: string): number {
220
+ return this.used.get(normKey(key)) ?? 0;
221
+ }
222
+
223
+ /** `limits: web.search 40/40, crm 3/20` — null when no limits are set. */
224
+ summary(): string | null {
225
+ if (!this.active) return null;
226
+ const parts = [...this.limits.entries()].map(([k, l]) => `${l.key} ${this.used.get(k) ?? 0}/${l.limit}`);
227
+ return `limits: ${parts.join(", ")}${this.refused ? ` — ${this.refused} call${this.refused === 1 ? "" : "s"} refused` : ""}`;
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Keys an agent's `limits:` names that it cannot use. `known` is every tool
233
+ * name in reach (built-ins, the workspace's and library's tools, this
234
+ * agent's APIs, scripts and MCP servers); `granted` is what this agent's
235
+ * `tools:` (and `apis:`, `scripts:`, `mcpServers:`) gives it. A key that
236
+ * names nothing is an error; a real tool this agent does not hold is a
237
+ * warning — the limit is harmless, and probably a leftover.
238
+ */
239
+ export function limitKeyProblems(
240
+ limits: Limits,
241
+ ctx: { known: Iterable<string>; granted: Iterable<string> },
242
+ ): { level: "error" | "warn"; message: string }[] {
243
+ const out: { level: "error" | "warn"; message: string }[] = [];
244
+ const builtinKeys = ["read", "write", "code", ...Object.keys(BUILTIN_GROUP), "TodoWrite", "search", "history", "desks", "ask", "web"];
245
+ const known = new Set([...builtinKeys, ...ctx.known].map(normKey));
246
+ const granted = new Set([...ctx.granted].flatMap((g) => {
247
+ const n = normKey(g);
248
+ // A group grants its members' keys, and `write` holds Read too.
249
+ if (n === "write") return [n, "read", "Read", "Write", "Edit", "MultiEdit", "NotebookEdit", "Glob", "Grep"];
250
+ if (n === "read") return [n, "Read", "Glob", "Grep"];
251
+ if (n === "code") return [n, "Bash"];
252
+ if (BUILTIN_GROUP[g]) return [n, BUILTIN_GROUP[g]];
253
+ return [n];
254
+ }).map(normKey));
255
+ for (const key of Object.keys(limits)) {
256
+ if (key === TOTAL_KEY) continue;
257
+ const base = key.startsWith("web.") ? "web" : key;
258
+ const n = normKey(base);
259
+ if (!known.has(n)) {
260
+ const valid = [TOTAL_KEY, "web", ...WEB_ACTIONS.map((a) => `web.${a}`), ...[...new Set([...ctx.granted].map(String))].filter((g) => g !== "web")];
261
+ out.push({ level: "error", message: `limits: ${key} — not a tool this agent could call. The keys: ${[...new Set(valid)].join(", ")}.` });
262
+ } else if (!granted.has(n)) {
263
+ out.push({ level: "warn", message: `limits: ${key} — this agent is not granted ${base}, so the limit never applies` });
264
+ }
265
+ }
266
+ return out;
267
+ }
268
+
269
+ /**
270
+ * A flow step's `limits:` option, written on one line:
271
+ * `limits: {web.search: 10, calls: 50}` (the braces are optional). Read by
272
+ * hand rather than as YAML, because `web.search` is a plain key here and one
273
+ * line is all a step option has.
274
+ */
275
+ export function parseStepLimits(text: string): { limits: Limits; problems: string[] } {
276
+ const body = text.trim().replace(/^\{/, "").replace(/\}$/, "").trim();
277
+ if (!body) return { limits: {}, problems: ["a block of tool: count on one line, e.g. {web.search: 10, calls: 50}"] };
278
+ const raw: Record<string, unknown> = {};
279
+ const problems: string[] = [];
280
+ for (const part of body.split(",")) {
281
+ const m = /^\s*([^:\s]+)\s*:\s*(\S+)\s*$/.exec(part);
282
+ if (!m) {
283
+ problems.push(`"${part.trim()}" is not tool: count`);
284
+ continue;
285
+ }
286
+ raw[m[1]] = m[2];
287
+ }
288
+ const read = readLimits(raw, "limits");
289
+ return { limits: read.limits, problems: [...problems, ...read.problems.map((p) => p.replace(/^limits: /, ""))] };
290
+ }