@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.
- package/LICENSE +202 -0
- package/README.md +239 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +43 -0
- package/dist/src/agent-tools.d.ts +31 -0
- package/dist/src/agent-tools.js +99 -0
- package/dist/src/agents-md.d.ts +9 -0
- package/dist/src/agents-md.js +83 -0
- package/dist/src/api-tools.d.ts +38 -0
- package/dist/src/api-tools.js +313 -0
- package/dist/src/approvals.d.ts +39 -0
- package/dist/src/approvals.js +103 -0
- package/dist/src/arrange.d.ts +8 -0
- package/dist/src/arrange.js +40 -0
- package/dist/src/catalog.d.ts +108 -0
- package/dist/src/catalog.js +255 -0
- package/dist/src/completions.d.ts +61 -0
- package/dist/src/completions.js +578 -0
- package/dist/src/confine.d.ts +31 -0
- package/dist/src/confine.js +298 -0
- package/dist/src/container.d.ts +30 -0
- package/dist/src/container.js +172 -0
- package/dist/src/context-tools.d.ts +81 -0
- package/dist/src/context-tools.js +347 -0
- package/dist/src/deploy.d.ts +100 -0
- package/dist/src/deploy.js +377 -0
- package/dist/src/egress.d.ts +75 -0
- package/dist/src/egress.js +133 -0
- package/dist/src/evals.d.ts +150 -0
- package/dist/src/evals.js +651 -0
- package/dist/src/flow-lint.d.ts +16 -0
- package/dist/src/flow-lint.js +177 -0
- package/dist/src/git-hook.d.ts +1 -0
- package/dist/src/git-hook.js +70 -0
- package/dist/src/git.d.ts +37 -0
- package/dist/src/git.js +101 -0
- package/dist/src/gitrepo.d.ts +95 -0
- package/dist/src/gitrepo.js +355 -0
- package/dist/src/history.d.ts +82 -0
- package/dist/src/history.js +259 -0
- package/dist/src/kinds.d.ts +95 -0
- package/dist/src/kinds.js +421 -0
- package/dist/src/library.d.ts +94 -0
- package/dist/src/library.js +341 -0
- package/dist/src/linkable.d.ts +20 -0
- package/dist/src/linkable.js +113 -0
- package/dist/src/model-probe.d.ts +21 -0
- package/dist/src/model-probe.js +81 -0
- package/dist/src/notify.d.ts +61 -0
- package/dist/src/notify.js +252 -0
- package/dist/src/oauth-presets.d.ts +13 -0
- package/dist/src/oauth-presets.js +39 -0
- package/dist/src/okf.d.ts +220 -0
- package/dist/src/okf.js +656 -0
- package/dist/src/openapi.d.ts +66 -0
- package/dist/src/openapi.js +342 -0
- package/dist/src/paths.d.ts +33 -0
- package/dist/src/paths.js +78 -0
- package/dist/src/platform.d.ts +32 -0
- package/dist/src/platform.js +48 -0
- package/dist/src/providers.d.ts +47 -0
- package/dist/src/providers.js +114 -0
- package/dist/src/refs.d.ts +20 -0
- package/dist/src/refs.js +78 -0
- package/dist/src/run-container.d.ts +227 -0
- package/dist/src/run-container.js +760 -0
- package/dist/src/runner.d.ts +241 -0
- package/dist/src/runner.js +3215 -0
- package/dist/src/runtime.d.ts +41 -0
- package/dist/src/runtime.js +357 -0
- package/dist/src/script-tools.d.ts +62 -0
- package/dist/src/script-tools.js +296 -0
- package/dist/src/secret-files.d.ts +12 -0
- package/dist/src/secret-files.js +92 -0
- package/dist/src/secrets.d.ts +147 -0
- package/dist/src/secrets.js +626 -0
- package/dist/src/starter.d.ts +21 -0
- package/dist/src/starter.js +399 -0
- package/dist/src/step-exec.d.ts +136 -0
- package/dist/src/step-exec.js +391 -0
- package/dist/src/storage.d.ts +123 -0
- package/dist/src/storage.js +640 -0
- package/dist/src/store.d.ts +1052 -0
- package/dist/src/store.js +2378 -0
- package/dist/src/tar.d.ts +30 -0
- package/dist/src/tar.js +111 -0
- package/dist/src/tool-names.d.ts +39 -0
- package/dist/src/tool-names.js +79 -0
- package/dist/src/tool-programs.d.ts +18 -0
- package/dist/src/tool-programs.js +52 -0
- package/dist/src/tool-test.d.ts +19 -0
- package/dist/src/tool-test.js +335 -0
- package/dist/src/translator.d.ts +159 -0
- package/dist/src/translator.js +986 -0
- package/dist/src/triggers.d.ts +60 -0
- package/dist/src/triggers.js +270 -0
- package/dist/src/webhook.d.ts +47 -0
- package/dist/src/webhook.js +148 -0
- package/index.ts +44 -0
- package/package.json +64 -0
- package/src/agent-tools.ts +138 -0
- package/src/agents-md.ts +81 -0
- package/src/api-tools.ts +389 -0
- package/src/approvals.ts +129 -0
- package/src/arrange.ts +42 -0
- package/src/catalog.ts +321 -0
- package/src/completions.ts +652 -0
- package/src/confine.ts +350 -0
- package/src/container.ts +220 -0
- package/src/context-tools.ts +435 -0
- package/src/deploy.ts +458 -0
- package/src/egress.ts +176 -0
- package/src/evals.ts +812 -0
- package/src/flow-lint.ts +215 -0
- package/src/git-hook.ts +73 -0
- package/src/git.ts +117 -0
- package/src/gitrepo.ts +391 -0
- package/src/history.ts +329 -0
- package/src/kinds.ts +507 -0
- package/src/library.ts +419 -0
- package/src/linkable.ts +123 -0
- package/src/model-probe.ts +112 -0
- package/src/notify.ts +281 -0
- package/src/oauth-presets.ts +54 -0
- package/src/okf.ts +768 -0
- package/src/openapi.ts +383 -0
- package/src/paths.ts +79 -0
- package/src/platform.ts +87 -0
- package/src/providers.ts +147 -0
- package/src/refs.ts +83 -0
- package/src/run-container.ts +883 -0
- package/src/runner.ts +3446 -0
- package/src/runtime.ts +404 -0
- package/src/script-tools.ts +383 -0
- package/src/secret-files.ts +101 -0
- package/src/secrets.ts +799 -0
- package/src/starter.ts +415 -0
- package/src/step-exec.ts +498 -0
- package/src/storage.ts +771 -0
- package/src/store.ts +2985 -0
- package/src/tar.ts +124 -0
- package/src/tool-names.ts +91 -0
- package/src/tool-programs.ts +65 -0
- package/src/tool-test.ts +401 -0
- package/src/translator.ts +1029 -0
- package/src/triggers.ts +287 -0
- package/src/webhook.ts +183 -0
- package/templates/blog-desk/AGENTS.md +13 -0
- package/templates/blog-desk/agents/researcher/agent.md +11 -0
- package/templates/blog-desk/agents/writer/agent.md +8 -0
- package/templates/blog-desk/evals/writer-quality.md +10 -0
- package/templates/blog-desk/flows/publish.md +10 -0
- package/templates/blog-desk/knowledge/house-style.md +9 -0
- package/templates/blog-desk/knowledge/index.md +9 -0
- package/templates/hello/AGENTS.md +18 -0
- package/templates/hello/agents/curator/agent.md +32 -0
- package/templates/hello/agents/notetaker/agent.md +24 -0
- package/templates/hello/evals/house-style.md +16 -0
- package/templates/hello/flows/memory-review.md +7 -0
- package/templates/hello/flows/note.md +7 -0
- package/templates/hello/knowledge/house-style.md +15 -0
- package/templates/hello/knowledge/index.md +9 -0
- package/templates/hello/skills/plain-english/SKILL.md +11 -0
- package/templates/hello/state/publishing.md +7 -0
- package/templates/hello/tools/wordcount/run.py +13 -0
- 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>;
|