@foldrun/core 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.
Files changed (166) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +239 -0
  3. package/dist/index.d.ts +32 -0
  4. package/dist/index.js +43 -0
  5. package/dist/src/agent-tools.d.ts +31 -0
  6. package/dist/src/agent-tools.js +99 -0
  7. package/dist/src/agents-md.d.ts +9 -0
  8. package/dist/src/agents-md.js +83 -0
  9. package/dist/src/api-tools.d.ts +38 -0
  10. package/dist/src/api-tools.js +313 -0
  11. package/dist/src/approvals.d.ts +39 -0
  12. package/dist/src/approvals.js +103 -0
  13. package/dist/src/arrange.d.ts +8 -0
  14. package/dist/src/arrange.js +40 -0
  15. package/dist/src/catalog.d.ts +108 -0
  16. package/dist/src/catalog.js +255 -0
  17. package/dist/src/completions.d.ts +61 -0
  18. package/dist/src/completions.js +578 -0
  19. package/dist/src/confine.d.ts +31 -0
  20. package/dist/src/confine.js +298 -0
  21. package/dist/src/container.d.ts +30 -0
  22. package/dist/src/container.js +172 -0
  23. package/dist/src/context-tools.d.ts +81 -0
  24. package/dist/src/context-tools.js +347 -0
  25. package/dist/src/deploy.d.ts +100 -0
  26. package/dist/src/deploy.js +377 -0
  27. package/dist/src/egress.d.ts +75 -0
  28. package/dist/src/egress.js +133 -0
  29. package/dist/src/evals.d.ts +150 -0
  30. package/dist/src/evals.js +651 -0
  31. package/dist/src/flow-lint.d.ts +16 -0
  32. package/dist/src/flow-lint.js +177 -0
  33. package/dist/src/git-hook.d.ts +1 -0
  34. package/dist/src/git-hook.js +70 -0
  35. package/dist/src/git.d.ts +37 -0
  36. package/dist/src/git.js +101 -0
  37. package/dist/src/gitrepo.d.ts +95 -0
  38. package/dist/src/gitrepo.js +355 -0
  39. package/dist/src/history.d.ts +82 -0
  40. package/dist/src/history.js +259 -0
  41. package/dist/src/kinds.d.ts +95 -0
  42. package/dist/src/kinds.js +421 -0
  43. package/dist/src/library.d.ts +94 -0
  44. package/dist/src/library.js +341 -0
  45. package/dist/src/linkable.d.ts +20 -0
  46. package/dist/src/linkable.js +113 -0
  47. package/dist/src/model-probe.d.ts +21 -0
  48. package/dist/src/model-probe.js +81 -0
  49. package/dist/src/notify.d.ts +61 -0
  50. package/dist/src/notify.js +252 -0
  51. package/dist/src/oauth-presets.d.ts +13 -0
  52. package/dist/src/oauth-presets.js +39 -0
  53. package/dist/src/okf.d.ts +220 -0
  54. package/dist/src/okf.js +656 -0
  55. package/dist/src/openapi.d.ts +66 -0
  56. package/dist/src/openapi.js +342 -0
  57. package/dist/src/paths.d.ts +33 -0
  58. package/dist/src/paths.js +78 -0
  59. package/dist/src/platform.d.ts +32 -0
  60. package/dist/src/platform.js +48 -0
  61. package/dist/src/providers.d.ts +47 -0
  62. package/dist/src/providers.js +114 -0
  63. package/dist/src/refs.d.ts +20 -0
  64. package/dist/src/refs.js +78 -0
  65. package/dist/src/run-container.d.ts +227 -0
  66. package/dist/src/run-container.js +760 -0
  67. package/dist/src/runner.d.ts +241 -0
  68. package/dist/src/runner.js +3215 -0
  69. package/dist/src/runtime.d.ts +41 -0
  70. package/dist/src/runtime.js +357 -0
  71. package/dist/src/script-tools.d.ts +62 -0
  72. package/dist/src/script-tools.js +296 -0
  73. package/dist/src/secret-files.d.ts +12 -0
  74. package/dist/src/secret-files.js +92 -0
  75. package/dist/src/secrets.d.ts +147 -0
  76. package/dist/src/secrets.js +626 -0
  77. package/dist/src/starter.d.ts +21 -0
  78. package/dist/src/starter.js +399 -0
  79. package/dist/src/step-exec.d.ts +136 -0
  80. package/dist/src/step-exec.js +391 -0
  81. package/dist/src/storage.d.ts +123 -0
  82. package/dist/src/storage.js +640 -0
  83. package/dist/src/store.d.ts +1052 -0
  84. package/dist/src/store.js +2378 -0
  85. package/dist/src/tar.d.ts +30 -0
  86. package/dist/src/tar.js +111 -0
  87. package/dist/src/tool-names.d.ts +39 -0
  88. package/dist/src/tool-names.js +79 -0
  89. package/dist/src/tool-programs.d.ts +18 -0
  90. package/dist/src/tool-programs.js +52 -0
  91. package/dist/src/tool-test.d.ts +19 -0
  92. package/dist/src/tool-test.js +335 -0
  93. package/dist/src/translator.d.ts +159 -0
  94. package/dist/src/translator.js +986 -0
  95. package/dist/src/triggers.d.ts +60 -0
  96. package/dist/src/triggers.js +270 -0
  97. package/dist/src/webhook.d.ts +47 -0
  98. package/dist/src/webhook.js +148 -0
  99. package/index.ts +44 -0
  100. package/package.json +64 -0
  101. package/src/agent-tools.ts +138 -0
  102. package/src/agents-md.ts +81 -0
  103. package/src/api-tools.ts +389 -0
  104. package/src/approvals.ts +129 -0
  105. package/src/arrange.ts +42 -0
  106. package/src/catalog.ts +321 -0
  107. package/src/completions.ts +652 -0
  108. package/src/confine.ts +350 -0
  109. package/src/container.ts +220 -0
  110. package/src/context-tools.ts +435 -0
  111. package/src/deploy.ts +458 -0
  112. package/src/egress.ts +176 -0
  113. package/src/evals.ts +812 -0
  114. package/src/flow-lint.ts +215 -0
  115. package/src/git-hook.ts +73 -0
  116. package/src/git.ts +117 -0
  117. package/src/gitrepo.ts +391 -0
  118. package/src/history.ts +329 -0
  119. package/src/kinds.ts +507 -0
  120. package/src/library.ts +419 -0
  121. package/src/linkable.ts +123 -0
  122. package/src/model-probe.ts +112 -0
  123. package/src/notify.ts +281 -0
  124. package/src/oauth-presets.ts +54 -0
  125. package/src/okf.ts +768 -0
  126. package/src/openapi.ts +383 -0
  127. package/src/paths.ts +79 -0
  128. package/src/platform.ts +87 -0
  129. package/src/providers.ts +147 -0
  130. package/src/refs.ts +83 -0
  131. package/src/run-container.ts +883 -0
  132. package/src/runner.ts +3446 -0
  133. package/src/runtime.ts +404 -0
  134. package/src/script-tools.ts +383 -0
  135. package/src/secret-files.ts +101 -0
  136. package/src/secrets.ts +799 -0
  137. package/src/starter.ts +415 -0
  138. package/src/step-exec.ts +498 -0
  139. package/src/storage.ts +771 -0
  140. package/src/store.ts +2985 -0
  141. package/src/tar.ts +124 -0
  142. package/src/tool-names.ts +91 -0
  143. package/src/tool-programs.ts +65 -0
  144. package/src/tool-test.ts +401 -0
  145. package/src/translator.ts +1029 -0
  146. package/src/triggers.ts +287 -0
  147. package/src/webhook.ts +183 -0
  148. package/templates/blog-desk/AGENTS.md +13 -0
  149. package/templates/blog-desk/agents/researcher/agent.md +11 -0
  150. package/templates/blog-desk/agents/writer/agent.md +8 -0
  151. package/templates/blog-desk/evals/writer-quality.md +10 -0
  152. package/templates/blog-desk/flows/publish.md +10 -0
  153. package/templates/blog-desk/knowledge/house-style.md +9 -0
  154. package/templates/blog-desk/knowledge/index.md +9 -0
  155. package/templates/hello/AGENTS.md +18 -0
  156. package/templates/hello/agents/curator/agent.md +32 -0
  157. package/templates/hello/agents/notetaker/agent.md +24 -0
  158. package/templates/hello/evals/house-style.md +16 -0
  159. package/templates/hello/flows/memory-review.md +7 -0
  160. package/templates/hello/flows/note.md +7 -0
  161. package/templates/hello/knowledge/house-style.md +15 -0
  162. package/templates/hello/knowledge/index.md +9 -0
  163. package/templates/hello/skills/plain-english/SKILL.md +11 -0
  164. package/templates/hello/state/publishing.md +7 -0
  165. package/templates/hello/tools/wordcount/run.py +13 -0
  166. package/templates/hello/tools/wordcount/tool.md +22 -0
@@ -0,0 +1,341 @@
1
+ // The account library: shared skills, scripts, memory and tool definitions
2
+ // that every workspace in the account can use.
3
+ //
4
+ // data/<tenant>/library/
5
+ // ├── skills/<name>/SKILL.md Agent Skills format, shared
6
+ // ├── scripts/<file> shared executables
7
+ // ├── memory/*.md organisation-wide knowledge
8
+ // └── tools/<name>.md reusable API definitions (frontmatter = ApiSpec)
9
+ //
10
+ // Resolution is nearest-wins, the same rule OKF uses for knowledge: an agent's
11
+ // own file beats its workspace's, which beats the account's. So a team can set
12
+ // a house style once and one agent can still override it.
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import { dataRoot, singleWorkspace } from "./paths.js";
16
+ import matter from "gray-matter";
17
+ import { KINDS } from "./kinds.js";
18
+ import { recordRevision, registerTreeReader } from "./history.js";
19
+ import { assertSafeName, assertCanonicalCase, buildMemoryIndex, parseToolDef, readToolDir, syncBundleFor, listWorkspaces, listAgents, workspaceDir, } from "./store.js";
20
+ // Same order as the workspace nav and asset pages — one list, one order.
21
+ // Order matters and is asserted: it is the order every noun list in the
22
+ // product uses. Tools then scripts, because a script is material a tool
23
+ // points at — see the KINDS table, which is where the order is declared.
24
+ export const LIBRARY_KINDS = ["tools", "knowledge", "memory", "skills", "scripts"];
25
+ export function libraryDir(tenant, kind) {
26
+ // Alongside the workspace in single-workspace mode: ./library, so a shared
27
+ // library is still possible on a laptop without inventing a home directory.
28
+ const single = singleWorkspace();
29
+ if (single) {
30
+ const base = path.join(path.resolve(single), "..", "library");
31
+ return kind ? path.join(base, kind) : base;
32
+ }
33
+ assertSafeName(tenant, "tenant");
34
+ return kind
35
+ ? path.join(dataRoot(), tenant, "library", kind)
36
+ : path.join(dataRoot(), tenant, "library");
37
+ }
38
+ const TEXT_EXT = /\.(md|py|sh|[mc]?js|ts|rb|sql|txt|json|ya?ml|toml)$/i;
39
+ function assertLibraryPath(kind, rel) {
40
+ const norm = path.normalize(rel);
41
+ assertCanonicalCase(norm);
42
+ if (norm.startsWith("..") || path.isAbsolute(norm))
43
+ throw new Error(`illegal path: ${rel}`);
44
+ if (!TEXT_EXT.test(norm))
45
+ throw new Error(`unsupported file type: ${rel}`);
46
+ // A flat tool is a lone .md; a folder tool is tool.md plus the code it
47
+ // runs, so anything is allowed one level down inside a tool's own folder.
48
+ if (kind === "tools" && !norm.endsWith(".md") && !norm.includes("/")) {
49
+ throw new Error("a tool is a .md definition, or a folder holding tool.md and its code");
50
+ }
51
+ return norm;
52
+ }
53
+ export function listLibrary(tenant, kind) {
54
+ const dir = libraryDir(tenant, kind);
55
+ if (!fs.existsSync(dir))
56
+ return [];
57
+ const out = [];
58
+ const describe = (file, fallback) => {
59
+ try {
60
+ const { data } = matter(fs.readFileSync(file, "utf8"));
61
+ return { name: data.name ?? fallback, description: data.description ?? "" };
62
+ }
63
+ catch {
64
+ return { name: fallback, description: "" };
65
+ }
66
+ };
67
+ // A folder tool is one entry named by its manifest, and its code is not a
68
+ // separate row: listing tools/browser/run.mjs beside tools/browser/tool.md
69
+ // says there are two tools called browser, one of which cannot be granted.
70
+ if (kind === "tools") {
71
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
72
+ const full = path.join(dir, entry.name);
73
+ if (entry.isDirectory()) {
74
+ const manifest = path.join(full, "tool.md");
75
+ if (!fs.existsSync(manifest))
76
+ continue;
77
+ out.push({
78
+ ...describe(manifest, entry.name),
79
+ path: `${entry.name}/tool.md`,
80
+ ...toolShape(manifest, entry.name),
81
+ updatedAt: fs.statSync(manifest).mtime.toISOString(),
82
+ });
83
+ }
84
+ else if (entry.name.endsWith(".md")) {
85
+ out.push({
86
+ ...describe(full, entry.name.replace(/\.md$/, "")),
87
+ path: entry.name,
88
+ ...toolShape(full, entry.name),
89
+ updatedAt: fs.statSync(full).mtime.toISOString(),
90
+ });
91
+ }
92
+ }
93
+ return out.sort((a, b) => a.path.localeCompare(b.path));
94
+ }
95
+ if (kind === "skills") {
96
+ for (const entry of fs.readdirSync(dir).sort()) {
97
+ const full = path.join(dir, entry);
98
+ if (fs.statSync(full).isDirectory()) {
99
+ const skillMd = path.join(full, "SKILL.md");
100
+ if (!fs.existsSync(skillMd))
101
+ continue;
102
+ out.push({
103
+ ...describe(skillMd, entry),
104
+ path: `${entry}/SKILL.md`,
105
+ hasScripts: fs.existsSync(path.join(full, "scripts")),
106
+ updatedAt: fs.statSync(skillMd).mtime.toISOString(),
107
+ });
108
+ }
109
+ else if (entry.endsWith(".md")) {
110
+ out.push({
111
+ ...describe(full, entry.replace(/\.md$/, "")),
112
+ path: entry,
113
+ hasScripts: false,
114
+ updatedAt: fs.statSync(full).mtime.toISOString(),
115
+ });
116
+ }
117
+ }
118
+ return out;
119
+ }
120
+ for (const entry of fs.readdirSync(dir, { recursive: true })) {
121
+ const rel = String(entry);
122
+ const full = path.join(dir, rel);
123
+ if (!fs.statSync(full).isFile() || !TEXT_EXT.test(rel))
124
+ continue;
125
+ // The memory index is generated, so it isn't an entry in its own list.
126
+ if ((kind === "memory" || kind === "knowledge") &&
127
+ ["index.md", "log.md"].includes(rel))
128
+ continue;
129
+ out.push({
130
+ ...describe(full, rel),
131
+ path: rel,
132
+ updatedAt: fs.statSync(full).mtime.toISOString(),
133
+ });
134
+ }
135
+ return out.sort((a, b) => a.path.localeCompare(b.path));
136
+ }
137
+ /** How a tool connects, and (for a script tool) the code it runs. */
138
+ function toolShape(file, rel) {
139
+ try {
140
+ const { data } = matter(fs.readFileSync(file, "utf8"));
141
+ const def = parseToolDef(data, rel.replace(/\.md$/, ""));
142
+ if (!def)
143
+ return {};
144
+ return {
145
+ transport: def.kind,
146
+ ...(def.kind === "script" ? { runs: def.spec.run } : {}),
147
+ };
148
+ }
149
+ catch {
150
+ return {}; // a malformed definition gets no badge, not a broken page
151
+ }
152
+ }
153
+ export function readLibraryFile(tenant, kind, rel) {
154
+ const p = path.join(libraryDir(tenant, kind), assertLibraryPath(kind, rel));
155
+ if (!fs.existsSync(p))
156
+ throw new Error(`not found: ${rel}`);
157
+ return fs.readFileSync(p, "utf8");
158
+ }
159
+ /** Is there already something here? For refusing to create over it. */
160
+ export function libraryFileExists(tenant, kind, rel) {
161
+ try {
162
+ return fs.existsSync(path.join(libraryDir(tenant, kind), assertLibraryPath(kind, rel)));
163
+ }
164
+ catch {
165
+ return false; // an illegal path does not exist, and write will refuse it
166
+ }
167
+ }
168
+ export function writeLibraryFile(tenant, kind, rel, content, meta = {}) {
169
+ if (content.length > 256 * 1024)
170
+ throw new Error("file too large");
171
+ const norm = assertLibraryPath(kind, rel);
172
+ if (norm.endsWith(".md"))
173
+ matter(content); // reject broken frontmatter
174
+ const p = path.join(libraryDir(tenant, kind), norm);
175
+ const existed = fs.existsSync(p);
176
+ const before = existed ? fs.readFileSync(p, "utf8") : null;
177
+ fs.mkdirSync(path.dirname(p), { recursive: true });
178
+ fs.writeFileSync(p, content);
179
+ // Executable where code lives: the scripts shelf, a skill's bundled
180
+ // scripts/, and now a folder tool's own code — a tool whose run.mjs is not
181
+ // executable fails at the exec, which reads as "the tool is broken".
182
+ if (kind === "scripts" || /scripts\//.test(norm) || (kind === "tools" && !norm.endsWith(".md"))) {
183
+ fs.chmodSync(p, 0o755);
184
+ }
185
+ syncBundleFor(p, existed ? "Update" : "Creation");
186
+ // The account shelf has one history, keyed by kind/path.
187
+ recordRevision(tenant, "@library", [{ path: `${kind}/${norm}`, before, after: content }], meta);
188
+ }
189
+ export function deleteLibraryPath(tenant, kind, rel, meta = {}) {
190
+ const norm = path.normalize(rel);
191
+ if (norm.startsWith("..") || path.isAbsolute(norm))
192
+ throw new Error(`illegal path: ${rel}`);
193
+ const target = path.join(libraryDir(tenant, kind), norm);
194
+ const gone = [];
195
+ if (fs.existsSync(target)) {
196
+ const entries = fs.statSync(target).isFile()
197
+ ? [target]
198
+ : fs.readdirSync(target, { recursive: true }).map((e) => path.join(target, String(e)));
199
+ for (const e of entries) {
200
+ try {
201
+ if (fs.statSync(e).isFile()) {
202
+ gone.push({ path: `${kind}/${path.relative(libraryDir(tenant, kind), e).split(path.sep).join("/")}`, before: fs.readFileSync(e, "utf8"), after: null });
203
+ }
204
+ }
205
+ catch {
206
+ // skip
207
+ }
208
+ }
209
+ }
210
+ fs.rmSync(target, { recursive: true, force: true });
211
+ recordRevision(tenant, "@library", gone, { message: `deleted ${kind}/${norm}`, ...meta });
212
+ }
213
+ // ---------- consumed by the runner ----------
214
+ /** Library tool definitions, keyed by name — http or script, both grantable by name in `tools:`. */
215
+ export function libraryTools(tenant) {
216
+ return readToolDir(libraryDir(tenant, "tools"), "account");
217
+ }
218
+ /** Shared memory index — derived from the files, like every other scope. */
219
+ export function libraryMemoryIndex(tenant) {
220
+ // The prefix matters: without it the index names bare filenames that don't
221
+ // exist relative to the agent, so it guesses and gets denied.
222
+ return buildMemoryIndex(libraryDir(tenant, "memory"), "../../../../library/memory/");
223
+ }
224
+ /**
225
+ * Account-scope creation, expressed as a path inside `library/<kind>/`.
226
+ *
227
+ * The content comes from KINDS — an account skill and a workspace skill are
228
+ * the same document in a different place, and used to be two templates that
229
+ * drifted. `file()` returns a path from the scope root (`skills/x/SKILL.md`);
230
+ * here the kind directory is already the root, so the leading segment goes.
231
+ */
232
+ export const LIBRARY_TEMPLATES = Object.fromEntries(LIBRARY_KINDS.map((kind) => [
233
+ kind,
234
+ (name) => ({
235
+ path: KINDS[kind].file(name).replace(new RegExp(`^${kind}/`), ""),
236
+ content: KINDS[kind].template(name),
237
+ }),
238
+ ]));
239
+ /** The file a kind would occupy at a given scope root, relative to that root. */
240
+ function localPath(kind, name) {
241
+ return KINDS[kind].file(name);
242
+ }
243
+ export function libraryUsage(tenant, kind, name) {
244
+ const out = [];
245
+ for (const ws of listWorkspaces(tenant)) {
246
+ const dir = workspaceDir(tenant, ws.name);
247
+ const agents = listAgents(tenant, ws.name);
248
+ // Explicit grant. Only tools have one.
249
+ if (kind === "tools") {
250
+ for (const a of agents) {
251
+ if (a.ownTools.includes(name))
252
+ out.push({ workspace: ws.name, agent: a.name, relation: "use" });
253
+ }
254
+ }
255
+ // Nearest-wins: a workspace file of the same name, or an agent's own.
256
+ if (fs.existsSync(path.join(dir, localPath(kind, name)))) {
257
+ out.push({ workspace: ws.name, agent: null, relation: "shadowed" });
258
+ }
259
+ for (const a of agents) {
260
+ const own = path.join(dir, "agents", a.name, localPath(kind, name));
261
+ if (fs.existsSync(own)) {
262
+ out.push({ workspace: ws.name, agent: a.name, relation: "shadowed" });
263
+ }
264
+ }
265
+ }
266
+ return out;
267
+ }
268
+ // The account shelf as one tree, kind/path — see registerTreeReader.
269
+ registerTreeReader((tenant, scope) => {
270
+ if (scope !== "@library")
271
+ return null;
272
+ const out = [];
273
+ for (const kind of LIBRARY_KINDS) {
274
+ const dir = libraryDir(tenant, kind);
275
+ if (!fs.existsSync(dir))
276
+ continue;
277
+ for (const entry of fs.readdirSync(dir, { recursive: true })) {
278
+ const rel = String(entry).split(path.sep).join("/");
279
+ const full = path.join(dir, rel);
280
+ try {
281
+ if (!fs.statSync(full).isFile() || !TEXT_EXT.test(rel))
282
+ continue;
283
+ out.push({ path: `${kind}/${rel}`, before: null, after: fs.readFileSync(full, "utf8") });
284
+ }
285
+ catch {
286
+ // skip
287
+ }
288
+ }
289
+ }
290
+ return out;
291
+ });
292
+ /**
293
+ * Make the shelf match a tree — what a push to the library repository does.
294
+ * Written with plain fs and recorded as ONE revision at the end: going
295
+ * through writeLibraryFile per file would commit each one on top of the
296
+ * pushed commit, and the history would show a push as fifty edits.
297
+ */
298
+ export function syncLibraryFromTree(tenant, files, meta = {}) {
299
+ const wanted = new Map();
300
+ for (const f of files) {
301
+ const [kind, ...rest] = f.path.split("/");
302
+ if (!LIBRARY_KINDS.includes(kind) || rest.length === 0)
303
+ continue;
304
+ wanted.set(f.path, f.content);
305
+ }
306
+ const changes = [];
307
+ for (const kind of LIBRARY_KINDS) {
308
+ const dir = libraryDir(tenant, kind);
309
+ if (!fs.existsSync(dir))
310
+ continue;
311
+ for (const entry of fs.readdirSync(dir, { recursive: true })) {
312
+ const rel = `${kind}/${String(entry).split(path.sep).join("/")}`;
313
+ const full = path.join(dir, String(entry));
314
+ try {
315
+ if (!fs.statSync(full).isFile() || !TEXT_EXT.test(rel))
316
+ continue;
317
+ }
318
+ catch {
319
+ continue;
320
+ }
321
+ if (!wanted.has(rel)) {
322
+ changes.push({ path: rel, before: fs.readFileSync(full, "utf8"), after: null });
323
+ fs.rmSync(full, { force: true });
324
+ }
325
+ }
326
+ }
327
+ for (const [rel, content] of wanted) {
328
+ const [kind, ...rest] = rel.split("/");
329
+ const full = path.join(libraryDir(tenant, kind), assertLibraryPath(kind, rest.join("/")));
330
+ const before = fs.existsSync(full) ? fs.readFileSync(full, "utf8") : null;
331
+ if (before === content)
332
+ continue;
333
+ fs.mkdirSync(path.dirname(full), { recursive: true });
334
+ fs.writeFileSync(full, content);
335
+ if (kind === "scripts" || /scripts\//.test(rel) || (kind === "tools" && !rel.endsWith(".md")))
336
+ fs.chmodSync(full, 0o755);
337
+ changes.push({ path: rel, before, after: content });
338
+ }
339
+ recordRevision(tenant, "@library", changes, { by: meta.by ?? "deploy", message: meta.commit ? `deployed ${meta.commit.slice(0, 7)}` : "synced from push", commit: meta.commit ?? null });
340
+ return changes.length;
341
+ }
@@ -0,0 +1,20 @@
1
+ export interface LinkableDoc {
2
+ /** What a person types between the brackets. */
3
+ name: string;
4
+ /** Shown beside it in the list. */
5
+ hint: string;
6
+ }
7
+ /** The directories a bare [[name]] may point at, matching the resolver. */
8
+ export declare const LINKABLE_DIRS: string[];
9
+ /**
10
+ * Everything [[ ]] resolves to in this workspace, in the order a person
11
+ * would want it: documents first, then the file store, then the folders.
12
+ *
13
+ * `workspaceRoot` is the workspace directory itself. `storageFiles` is the
14
+ * hosted store's listing when there is one; local installs pass nothing and
15
+ * the plain `storage/` directory on disk is walked instead — the same two
16
+ * sources, in the same order, that the resolver consults.
17
+ */
18
+ export declare function linkableDocs(workspaceRoot: string, storageFiles?: {
19
+ path: string;
20
+ }[], limit?: number): LinkableDoc[];
@@ -0,0 +1,113 @@
1
+ // What [[ ]] can name, as one list.
2
+ //
3
+ // This existed twice and the two copies disagreed. The runner's resolver
4
+ // indexed the file store under its real directory name (`storage/`), while
5
+ // the editor offered the same files as `files/…` — a label left over from
6
+ // before the rename. Accepting that suggestion produced a link that matched
7
+ // nothing, so the model received two literal brackets where a path was meant.
8
+ // The same rename had already caused one silent failure elsewhere; this is
9
+ // the second, and the reason the list now has one definition.
10
+ //
11
+ // The rule this encodes: the editor may only offer what the runner can
12
+ // resolve. tests/doclinks-consistency.test.ts asserts the round trip on a
13
+ // real workspace, so a future rename fails a test instead of a run.
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import matter from "gray-matter";
17
+ import { STORAGE_DIR } from "./store.js";
18
+ /** The directories a bare [[name]] may point at, matching the resolver. */
19
+ export const LINKABLE_DIRS = [STORAGE_DIR, "state", "knowledge", "memory", "skills", "outputs"];
20
+ /**
21
+ * Everything [[ ]] resolves to in this workspace, in the order a person
22
+ * would want it: documents first, then the file store, then the folders.
23
+ *
24
+ * `workspaceRoot` is the workspace directory itself. `storageFiles` is the
25
+ * hosted store's listing when there is one; local installs pass nothing and
26
+ * the plain `storage/` directory on disk is walked instead — the same two
27
+ * sources, in the same order, that the resolver consults.
28
+ */
29
+ export function linkableDocs(workspaceRoot, storageFiles = [], limit = 300) {
30
+ const out = [];
31
+ for (const kind of ["knowledge", "memory"]) {
32
+ const dir = path.join(workspaceRoot, kind);
33
+ if (!fs.existsSync(dir))
34
+ continue;
35
+ for (const entry of fs.readdirSync(dir, { recursive: true }).map(String)) {
36
+ if (!entry.endsWith(".md"))
37
+ continue;
38
+ // index.md and log.md are generated views of the bundle, not concepts
39
+ // to link at — offering them invites a link to a file that will be
40
+ // rewritten under it.
41
+ const base = path.basename(entry);
42
+ if (base === "index.md" || base === "log.md")
43
+ continue;
44
+ let title = "";
45
+ try {
46
+ const data = matter(fs.readFileSync(path.join(dir, entry), "utf8")).data;
47
+ for (const key of ["title", "name"]) {
48
+ if (typeof data?.[key] === "string" && data[key]) {
49
+ title = data[key];
50
+ break;
51
+ }
52
+ }
53
+ }
54
+ catch {
55
+ // unparseable frontmatter still links by filename
56
+ }
57
+ out.push({
58
+ name: entry.split(path.sep).join("/").replace(/\.md$/, ""),
59
+ hint: `${kind}${title ? ` — ${title}` : ""}`,
60
+ });
61
+ }
62
+ }
63
+ // state/ — what a recurring flow reads from its own past runs. Offered by
64
+ // path, like the file store and unlike knowledge/, because these are data
65
+ // files rather than documents with a title.
66
+ const stateDir = path.join(workspaceRoot, "state");
67
+ if (fs.existsSync(stateDir)) {
68
+ for (const entry of fs.readdirSync(stateDir, { recursive: true }).map(String)) {
69
+ try {
70
+ if (!fs.statSync(path.join(stateDir, entry)).isFile())
71
+ continue;
72
+ }
73
+ catch {
74
+ continue;
75
+ }
76
+ out.push({ name: `state/${entry.split(path.sep).join("/")}`, hint: "state" });
77
+ }
78
+ }
79
+ // The file store, under the directory the resolver actually indexes.
80
+ const seen = new Set();
81
+ for (const f of storageFiles) {
82
+ if (seen.has(f.path))
83
+ continue;
84
+ seen.add(f.path);
85
+ out.push({ name: `${STORAGE_DIR}/${f.path}`, hint: "file" });
86
+ }
87
+ const filesDir = path.join(workspaceRoot, STORAGE_DIR);
88
+ if (fs.existsSync(filesDir)) {
89
+ for (const entry of fs.readdirSync(filesDir, { recursive: true }).map(String)) {
90
+ const fwd = entry.split(path.sep).join("/");
91
+ if (fwd.startsWith(".store/") || seen.has(fwd))
92
+ continue;
93
+ try {
94
+ if (!fs.statSync(path.join(filesDir, entry)).isFile())
95
+ continue;
96
+ }
97
+ catch {
98
+ continue;
99
+ }
100
+ seen.add(fwd);
101
+ out.push({ name: `${STORAGE_DIR}/${fwd}`, hint: "file" });
102
+ }
103
+ }
104
+ // The folders themselves. The resolver has always accepted these — an
105
+ // instruction that says WHERE without naming a file is a real thing to
106
+ // write — and nothing offered them, so the capability was undiscoverable.
107
+ for (const dir of LINKABLE_DIRS) {
108
+ if (fs.existsSync(path.join(workspaceRoot, dir))) {
109
+ out.push({ name: dir, hint: "the whole folder" });
110
+ }
111
+ }
112
+ return out.slice(0, limit);
113
+ }
@@ -0,0 +1,21 @@
1
+ import type { Effort } from "./store.ts";
2
+ export interface ProbeReport {
3
+ model: string;
4
+ /** The model made a real tool call — the handler ran. */
5
+ calledTool: boolean;
6
+ /** The nonce came back in the reply: the loop closed end to end. */
7
+ echoedNonce: boolean;
8
+ /** calledTool && echoedNonce — what "can drive an agent" actually means. */
9
+ ok: boolean;
10
+ status: "completed" | "failed";
11
+ costUsd: number | null;
12
+ durationMs: number;
13
+ /** The reply itself, for the human reading a failure. */
14
+ reply: string | null;
15
+ }
16
+ /**
17
+ * Run the tool-loop probe against one model, through whatever endpoint the
18
+ * env points at — pass the same env a step would get (provider base URL,
19
+ * token, tier remaps) and the probe exercises the exact path a run takes.
20
+ */
21
+ export declare function probeModel(model: string, env: Record<string, string | undefined>, effort?: Effort | null): Promise<ProbeReport>;
@@ -0,0 +1,81 @@
1
+ // "Can this model hold a tool loop?" — answered by running one, not by
2
+ // reading a spec sheet.
3
+ //
4
+ // The catalogue's `tools` flag is the gateway's claim; this is the ground
5
+ // truth. The probe hands the model exactly one tool, whose result is a nonce
6
+ // invented per run, and asks it to call the tool and repeat the nonce back.
7
+ // A model that can drive an agent loop does this in two turns. A model that
8
+ // cannot — no tool support, tool calls narrated as prose, a gateway route
9
+ // that strips tool blocks — has no other way to learn the nonce, so the
10
+ // verdict cannot be faked by a model that merely talks about calling tools.
11
+ // That distinction is the entire reason the probe exists: it is the same
12
+ // failure the run-start gate guards against, demonstrated live through
13
+ // whatever provider env the caller passes.
14
+ import fs from "node:fs";
15
+ import os from "node:os";
16
+ import path from "node:path";
17
+ import { randomBytes } from "node:crypto";
18
+ import { tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
19
+ import { z } from "zod";
20
+ import { executeStep } from "./step-exec.js";
21
+ const PROBE_TIMEOUT_SEC = 120;
22
+ /**
23
+ * Run the tool-loop probe against one model, through whatever endpoint the
24
+ * env points at — pass the same env a step would get (provider base URL,
25
+ * token, tier remaps) and the probe exercises the exact path a run takes.
26
+ */
27
+ export async function probeModel(model, env, effort = null) {
28
+ const nonce = `probe-${randomBytes(6).toString("hex")}`;
29
+ let calledTool = false;
30
+ const probeTool = tool("probe_fetch_token", "Returns a secret token. Call it exactly once.", {
31
+ // One required argument, because a loop that can't pass arguments
32
+ // can't run real tools either — echoing a fixed call is the easiest
33
+ // possible tool use, and we deliberately don't test the easiest.
34
+ purpose: z.string().describe("Why you are fetching the token, in a few words"),
35
+ }, async () => {
36
+ calledTool = true;
37
+ return { content: [{ type: "text", text: nonce }] };
38
+ });
39
+ // Homeless on purpose, like a consult: the probe reads and writes nothing,
40
+ // and a throwaway cwd means even a confused model call has no tree to touch.
41
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-probe-"));
42
+ const started = Date.now();
43
+ try {
44
+ const outcome = await executeStep({
45
+ agentDir: scratch,
46
+ workspaceRoot: scratch,
47
+ libraryRoot: scratch,
48
+ prompt: "Call the probe_fetch_token tool once, then reply with exactly the token it " +
49
+ "returns and nothing else. If you cannot call tools, reply CANNOT.",
50
+ model,
51
+ effort,
52
+ systemPrompt: "You are a connectivity probe. Follow the instruction literally. Do not explain.",
53
+ allowed: [],
54
+ mcpNames: ["foldrun_probe"],
55
+ mcpServers: {
56
+ foldrun_probe: createSdkMcpServer({
57
+ name: "foldrun_probe",
58
+ version: "1.0.0",
59
+ tools: [probeTool],
60
+ }),
61
+ },
62
+ env,
63
+ timeoutSec: PROBE_TIMEOUT_SEC,
64
+ emit: () => { }, // the probe's transcript is the report, not a run log
65
+ });
66
+ const echoedNonce = (outcome.result ?? "").includes(nonce);
67
+ return {
68
+ model,
69
+ calledTool,
70
+ echoedNonce,
71
+ ok: calledTool && echoedNonce,
72
+ status: outcome.status,
73
+ costUsd: outcome.costUsd,
74
+ durationMs: Date.now() - started,
75
+ reply: outcome.result,
76
+ };
77
+ }
78
+ finally {
79
+ fs.rmSync(scratch, { recursive: true, force: true });
80
+ }
81
+ }
@@ -0,0 +1,61 @@
1
+ import { type RunRecord } from "./store.ts";
2
+ /**
3
+ * The platform's own mail: an invite, a low balance.
4
+ *
5
+ * These are from foldrun, about the platform, to the account's owner — so
6
+ * they go through the platform's Resend key and sender (FOLDRUN_RESEND_API_KEY,
7
+ * FOLDRUN_EMAIL_FROM), never through anything the customer configured.
8
+ *
9
+ * Without a platform key — the CLI on a laptop, a test — the account's own
10
+ * key and EMAIL_FROM are the fallback.
11
+ */
12
+ export declare function platformMail(tenant: string): {
13
+ key: string;
14
+ from: string;
15
+ } | null;
16
+ /** The account's own Resend key and sender, or null when it has none. */
17
+ export declare function accountMail(tenant: string): {
18
+ key: string;
19
+ from: string;
20
+ } | null;
21
+ /**
22
+ * A run notification is the account's mail, not the platform's: it is a
23
+ * desk telling its owner what it found, and the owner chose the sender —
24
+ * their RESEND_API_KEY and EMAIL_FROM on the account, the same connection
25
+ * their agents' `email` tool uses. So it comes from the address they
26
+ * expect, under their own domain's reputation and their own inbox rules;
27
+ * 2026-09-06, every notification from the platform's address was in the
28
+ * owner's bin. The platform's key is the fallback for an account that set
29
+ * none, so `notify:` works before anyone has configured mail at all.
30
+ */
31
+ export declare function notifyMail(tenant: string): {
32
+ key: string;
33
+ from: string;
34
+ } | null;
35
+ export interface NotifyConfig {
36
+ url?: string;
37
+ email?: string;
38
+ events: string[];
39
+ }
40
+ export declare function notifyConfig(tenant: string, workspace: string): NotifyConfig | null;
41
+ /**
42
+ * The approve/reject links for a run waiting on a person, or null when the
43
+ * install cannot say where it lives. A notification that only says "waiting
44
+ * for you" sends the reader to find a laptop; one that carries the decision
45
+ * lets them make it where they read it.
46
+ *
47
+ * The reject link goes to the same page with the choice pre-selected — a
48
+ * GET must not decide anything, because inbox link-checkers follow links.
49
+ */
50
+ export declare function approvalLinks(tenant: string, workspace: string, runId: string): {
51
+ approveUrl: string;
52
+ rejectUrl: string;
53
+ } | null;
54
+ /**
55
+ * Fire the webhook for a run's state, if the workspace asked to hear about
56
+ * it. Failures are logged and swallowed — a broken Slack hook must never
57
+ * fail the run it is reporting on.
58
+ */
59
+ /** Flows whose completion is nobody's news: eval cases and adhoc runs. */
60
+ export declare function isQuietFlow(flow: string): boolean;
61
+ export declare function sendRunNotification(tenant: string, workspace: string, run: RunRecord): Promise<boolean>;