@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
package/src/library.ts
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
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
|
+
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { dataRoot, singleWorkspace } from "./paths.ts";
|
|
17
|
+
import matter from "gray-matter";
|
|
18
|
+
import { KINDS } from "./kinds.ts";
|
|
19
|
+
import { recordRevision, registerTreeReader } from "./history.ts";
|
|
20
|
+
import {
|
|
21
|
+
assertSafeName,
|
|
22
|
+
assertCanonicalCase,
|
|
23
|
+
buildMemoryIndex,
|
|
24
|
+
parseToolDef,
|
|
25
|
+
readToolDir,
|
|
26
|
+
syncBundleFor,
|
|
27
|
+
listWorkspaces,
|
|
28
|
+
listAgents,
|
|
29
|
+
workspaceDir,
|
|
30
|
+
type ToolDef,
|
|
31
|
+
} from "./store.ts";
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
export type LibraryKind = "skills" | "scripts" | "memory" | "knowledge" | "tools";
|
|
35
|
+
// Same order as the workspace nav and asset pages — one list, one order.
|
|
36
|
+
// Order matters and is asserted: it is the order every noun list in the
|
|
37
|
+
// product uses. Tools then scripts, because a script is material a tool
|
|
38
|
+
// points at — see the KINDS table, which is where the order is declared.
|
|
39
|
+
export const LIBRARY_KINDS: LibraryKind[] = ["tools", "knowledge", "memory", "skills", "scripts"];
|
|
40
|
+
|
|
41
|
+
export function libraryDir(tenant: string, kind?: LibraryKind) {
|
|
42
|
+
// Alongside the workspace in single-workspace mode: ./library, so a shared
|
|
43
|
+
// library is still possible on a laptop without inventing a home directory.
|
|
44
|
+
const single = singleWorkspace();
|
|
45
|
+
if (single) {
|
|
46
|
+
const base = path.join(path.resolve(single), "..", "library");
|
|
47
|
+
return kind ? path.join(base, kind) : base;
|
|
48
|
+
}
|
|
49
|
+
assertSafeName(tenant, "tenant");
|
|
50
|
+
return kind
|
|
51
|
+
? path.join(dataRoot(), tenant, "library", kind)
|
|
52
|
+
: path.join(dataRoot(), tenant, "library");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const TEXT_EXT = /\.(md|py|sh|[mc]?js|ts|rb|sql|txt|json|ya?ml|toml)$/i;
|
|
56
|
+
|
|
57
|
+
function assertLibraryPath(kind: LibraryKind, rel: string) {
|
|
58
|
+
const norm = path.normalize(rel);
|
|
59
|
+
assertCanonicalCase(norm);
|
|
60
|
+
if (norm.startsWith("..") || path.isAbsolute(norm)) throw new Error(`illegal path: ${rel}`);
|
|
61
|
+
if (!TEXT_EXT.test(norm)) throw new Error(`unsupported file type: ${rel}`);
|
|
62
|
+
// A flat tool is a lone .md; a folder tool is tool.md plus the code it
|
|
63
|
+
// runs, so anything is allowed one level down inside a tool's own folder.
|
|
64
|
+
if (kind === "tools" && !norm.endsWith(".md") && !norm.includes("/")) {
|
|
65
|
+
throw new Error("a tool is a .md definition, or a folder holding tool.md and its code");
|
|
66
|
+
}
|
|
67
|
+
return norm;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface LibraryEntry {
|
|
71
|
+
/** Path relative to this kind's directory. */
|
|
72
|
+
path: string;
|
|
73
|
+
name: string;
|
|
74
|
+
description: string;
|
|
75
|
+
/** For skills: true when the folder bundles scripts/. */
|
|
76
|
+
hasScripts?: boolean;
|
|
77
|
+
/**
|
|
78
|
+
* For tools: how this one connects — http, script or mcp. Surfaced so a
|
|
79
|
+
* list of tools reads as one vocabulary with three transports, rather
|
|
80
|
+
* than a page of names whose differences are invisible until you open
|
|
81
|
+
* them. For a script tool it also names the file it runs, which is the
|
|
82
|
+
* link between this shelf and the code shelf.
|
|
83
|
+
*/
|
|
84
|
+
transport?: "http" | "script" | "mcp";
|
|
85
|
+
runs?: string;
|
|
86
|
+
updatedAt: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function listLibrary(tenant: string, kind: LibraryKind): LibraryEntry[] {
|
|
90
|
+
const dir = libraryDir(tenant, kind);
|
|
91
|
+
if (!fs.existsSync(dir)) return [];
|
|
92
|
+
const out: LibraryEntry[] = [];
|
|
93
|
+
|
|
94
|
+
const describe = (file: string, fallback: string) => {
|
|
95
|
+
try {
|
|
96
|
+
const { data } = matter(fs.readFileSync(file, "utf8"));
|
|
97
|
+
return { name: data.name ?? fallback, description: data.description ?? "" };
|
|
98
|
+
} catch {
|
|
99
|
+
return { name: fallback, description: "" };
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// A folder tool is one entry named by its manifest, and its code is not a
|
|
104
|
+
// separate row: listing tools/browser/run.mjs beside tools/browser/tool.md
|
|
105
|
+
// says there are two tools called browser, one of which cannot be granted.
|
|
106
|
+
if (kind === "tools") {
|
|
107
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
108
|
+
const full = path.join(dir, entry.name);
|
|
109
|
+
if (entry.isDirectory()) {
|
|
110
|
+
const manifest = path.join(full, "tool.md");
|
|
111
|
+
if (!fs.existsSync(manifest)) continue;
|
|
112
|
+
out.push({
|
|
113
|
+
...describe(manifest, entry.name),
|
|
114
|
+
path: `${entry.name}/tool.md`,
|
|
115
|
+
...toolShape(manifest, entry.name),
|
|
116
|
+
updatedAt: fs.statSync(manifest).mtime.toISOString(),
|
|
117
|
+
});
|
|
118
|
+
} else if (entry.name.endsWith(".md")) {
|
|
119
|
+
out.push({
|
|
120
|
+
...describe(full, entry.name.replace(/\.md$/, "")),
|
|
121
|
+
path: entry.name,
|
|
122
|
+
...toolShape(full, entry.name),
|
|
123
|
+
updatedAt: fs.statSync(full).mtime.toISOString(),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return out.sort((a, b) => a.path.localeCompare(b.path));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (kind === "skills") {
|
|
131
|
+
for (const entry of fs.readdirSync(dir).sort()) {
|
|
132
|
+
const full = path.join(dir, entry);
|
|
133
|
+
if (fs.statSync(full).isDirectory()) {
|
|
134
|
+
const skillMd = path.join(full, "SKILL.md");
|
|
135
|
+
if (!fs.existsSync(skillMd)) continue;
|
|
136
|
+
out.push({
|
|
137
|
+
...describe(skillMd, entry),
|
|
138
|
+
path: `${entry}/SKILL.md`,
|
|
139
|
+
hasScripts: fs.existsSync(path.join(full, "scripts")),
|
|
140
|
+
updatedAt: fs.statSync(skillMd).mtime.toISOString(),
|
|
141
|
+
});
|
|
142
|
+
} else if (entry.endsWith(".md")) {
|
|
143
|
+
out.push({
|
|
144
|
+
...describe(full, entry.replace(/\.md$/, "")),
|
|
145
|
+
path: entry,
|
|
146
|
+
hasScripts: false,
|
|
147
|
+
updatedAt: fs.statSync(full).mtime.toISOString(),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
for (const entry of fs.readdirSync(dir, { recursive: true })) {
|
|
155
|
+
const rel = String(entry);
|
|
156
|
+
const full = path.join(dir, rel);
|
|
157
|
+
if (!fs.statSync(full).isFile() || !TEXT_EXT.test(rel)) continue;
|
|
158
|
+
// The memory index is generated, so it isn't an entry in its own list.
|
|
159
|
+
if ((kind === "memory" || kind === "knowledge") &&
|
|
160
|
+
["index.md", "log.md"].includes(rel)) continue;
|
|
161
|
+
out.push({
|
|
162
|
+
...describe(full, rel),
|
|
163
|
+
path: rel,
|
|
164
|
+
updatedAt: fs.statSync(full).mtime.toISOString(),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
return out.sort((a, b) => a.path.localeCompare(b.path));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** How a tool connects, and (for a script tool) the code it runs. */
|
|
171
|
+
function toolShape(file: string, rel: string): { transport?: LibraryEntry["transport"]; runs?: string } {
|
|
172
|
+
try {
|
|
173
|
+
const { data } = matter(fs.readFileSync(file, "utf8"));
|
|
174
|
+
const def = parseToolDef(data as Record<string, unknown>, rel.replace(/\.md$/, ""));
|
|
175
|
+
if (!def) return {};
|
|
176
|
+
return {
|
|
177
|
+
transport: def.kind,
|
|
178
|
+
...(def.kind === "script" ? { runs: (def.spec as { run?: string }).run } : {}),
|
|
179
|
+
};
|
|
180
|
+
} catch {
|
|
181
|
+
return {}; // a malformed definition gets no badge, not a broken page
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function readLibraryFile(tenant: string, kind: LibraryKind, rel: string): string {
|
|
186
|
+
const p = path.join(libraryDir(tenant, kind), assertLibraryPath(kind, rel));
|
|
187
|
+
if (!fs.existsSync(p)) throw new Error(`not found: ${rel}`);
|
|
188
|
+
return fs.readFileSync(p, "utf8");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Is there already something here? For refusing to create over it. */
|
|
192
|
+
export function libraryFileExists(tenant: string, kind: LibraryKind, rel: string): boolean {
|
|
193
|
+
try {
|
|
194
|
+
return fs.existsSync(path.join(libraryDir(tenant, kind), assertLibraryPath(kind, rel)));
|
|
195
|
+
} catch {
|
|
196
|
+
return false; // an illegal path does not exist, and write will refuse it
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function writeLibraryFile(
|
|
201
|
+
tenant: string,
|
|
202
|
+
kind: LibraryKind,
|
|
203
|
+
rel: string,
|
|
204
|
+
content: string,
|
|
205
|
+
meta: { by?: string; message?: string } = {},
|
|
206
|
+
) {
|
|
207
|
+
if (content.length > 256 * 1024) throw new Error("file too large");
|
|
208
|
+
const norm = assertLibraryPath(kind, rel);
|
|
209
|
+
if (norm.endsWith(".md")) matter(content); // reject broken frontmatter
|
|
210
|
+
const p = path.join(libraryDir(tenant, kind), norm);
|
|
211
|
+
const existed = fs.existsSync(p);
|
|
212
|
+
const before = existed ? fs.readFileSync(p, "utf8") : null;
|
|
213
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
214
|
+
fs.writeFileSync(p, content);
|
|
215
|
+
// Executable where code lives: the scripts shelf, a skill's bundled
|
|
216
|
+
// scripts/, and now a folder tool's own code — a tool whose run.mjs is not
|
|
217
|
+
// executable fails at the exec, which reads as "the tool is broken".
|
|
218
|
+
if (kind === "scripts" || /scripts\//.test(norm) || (kind === "tools" && !norm.endsWith(".md"))) {
|
|
219
|
+
fs.chmodSync(p, 0o755);
|
|
220
|
+
}
|
|
221
|
+
syncBundleFor(p, existed ? "Update" : "Creation");
|
|
222
|
+
// The account shelf has one history, keyed by kind/path.
|
|
223
|
+
recordRevision(tenant, "@library", [{ path: `${kind}/${norm}`, before, after: content }], meta);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function deleteLibraryPath(
|
|
227
|
+
tenant: string,
|
|
228
|
+
kind: LibraryKind,
|
|
229
|
+
rel: string,
|
|
230
|
+
meta: { by?: string; message?: string } = {},
|
|
231
|
+
) {
|
|
232
|
+
const norm = path.normalize(rel);
|
|
233
|
+
if (norm.startsWith("..") || path.isAbsolute(norm)) throw new Error(`illegal path: ${rel}`);
|
|
234
|
+
const target = path.join(libraryDir(tenant, kind), norm);
|
|
235
|
+
const gone: { path: string; before: string | null; after: null }[] = [];
|
|
236
|
+
if (fs.existsSync(target)) {
|
|
237
|
+
const entries = fs.statSync(target).isFile()
|
|
238
|
+
? [target]
|
|
239
|
+
: fs.readdirSync(target, { recursive: true }).map((e) => path.join(target, String(e)));
|
|
240
|
+
for (const e of entries) {
|
|
241
|
+
try {
|
|
242
|
+
if (fs.statSync(e).isFile()) {
|
|
243
|
+
gone.push({ path: `${kind}/${path.relative(libraryDir(tenant, kind), e).split(path.sep).join("/")}`, before: fs.readFileSync(e, "utf8"), after: null });
|
|
244
|
+
}
|
|
245
|
+
} catch {
|
|
246
|
+
// skip
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
251
|
+
recordRevision(tenant, "@library", gone, { message: `deleted ${kind}/${norm}`, ...meta });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---------- consumed by the runner ----------
|
|
255
|
+
|
|
256
|
+
/** Library tool definitions, keyed by name — http or script, both grantable by name in `tools:`. */
|
|
257
|
+
export function libraryTools(tenant: string): Record<string, ToolDef> {
|
|
258
|
+
return readToolDir(libraryDir(tenant, "tools"), "account");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Shared memory index — derived from the files, like every other scope. */
|
|
262
|
+
export function libraryMemoryIndex(tenant: string): string | null {
|
|
263
|
+
// The prefix matters: without it the index names bare filenames that don't
|
|
264
|
+
// exist relative to the agent, so it guesses and gets denied.
|
|
265
|
+
return buildMemoryIndex(libraryDir(tenant, "memory"), "../../../../library/memory/");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Account-scope creation, expressed as a path inside `library/<kind>/`.
|
|
270
|
+
*
|
|
271
|
+
* The content comes from KINDS — an account skill and a workspace skill are
|
|
272
|
+
* the same document in a different place, and used to be two templates that
|
|
273
|
+
* drifted. `file()` returns a path from the scope root (`skills/x/SKILL.md`);
|
|
274
|
+
* here the kind directory is already the root, so the leading segment goes.
|
|
275
|
+
*/
|
|
276
|
+
export const LIBRARY_TEMPLATES: Record<LibraryKind, (name: string) => { path: string; content: string }> =
|
|
277
|
+
Object.fromEntries(
|
|
278
|
+
LIBRARY_KINDS.map((kind) => [
|
|
279
|
+
kind,
|
|
280
|
+
(name: string) => ({
|
|
281
|
+
path: KINDS[kind].file(name).replace(new RegExp(`^${kind}/`), ""),
|
|
282
|
+
content: KINDS[kind].template(name),
|
|
283
|
+
}),
|
|
284
|
+
]),
|
|
285
|
+
) as Record<LibraryKind, (name: string) => { path: string; content: string }>;
|
|
286
|
+
|
|
287
|
+
// ---------------------------------------------------------------- usage
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Who actually depends on a shared file.
|
|
291
|
+
*
|
|
292
|
+
* The account library is the one place where editing a single file changes
|
|
293
|
+
* behaviour in workspaces you are not looking at, and nothing in the product
|
|
294
|
+
* could answer "if I rotate this credential, what breaks?" The graph shows
|
|
295
|
+
* containment — a library tool hangs off the account node — never consumption,
|
|
296
|
+
* so the edge that matters was the one edge missing.
|
|
297
|
+
*
|
|
298
|
+
* Two relations, because the shared scopes work in two different ways:
|
|
299
|
+
*
|
|
300
|
+
* use an explicit grant. Only tools have one (`tools: [name]`), which
|
|
301
|
+
* is exactly why tools are the kind worth tracing: a credential
|
|
302
|
+
* reaches precisely the agents that named it, and no others.
|
|
303
|
+
*
|
|
304
|
+
* shadowed a nearer file of the same name wins. This is the quiet one — the
|
|
305
|
+
* account file still exists, still looks live on this page, and is
|
|
306
|
+
* not what runs in that workspace. Resolution is nearest-wins, so
|
|
307
|
+
* a name collision is a silent override rather than an error.
|
|
308
|
+
*
|
|
309
|
+
* Everything else (memory, knowledge) is indexed for every agent in scope with
|
|
310
|
+
* no per-agent opt-in, so "used by" is "everyone" — true, and not information.
|
|
311
|
+
* Those kinds report shadowing only.
|
|
312
|
+
*/
|
|
313
|
+
export interface LibraryUse {
|
|
314
|
+
workspace: string;
|
|
315
|
+
/** The agent that opted in or overrides it; null when it is workspace-wide. */
|
|
316
|
+
agent: string | null;
|
|
317
|
+
relation: "use" | "shadowed";
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** The file a kind would occupy at a given scope root, relative to that root. */
|
|
321
|
+
function localPath(kind: LibraryKind, name: string): string {
|
|
322
|
+
return KINDS[kind].file(name);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function libraryUsage(tenant: string, kind: LibraryKind, name: string): LibraryUse[] {
|
|
326
|
+
const out: LibraryUse[] = [];
|
|
327
|
+
|
|
328
|
+
for (const ws of listWorkspaces(tenant)) {
|
|
329
|
+
const dir = workspaceDir(tenant, ws.name);
|
|
330
|
+
const agents = listAgents(tenant, ws.name);
|
|
331
|
+
|
|
332
|
+
// Explicit grant. Only tools have one.
|
|
333
|
+
if (kind === "tools") {
|
|
334
|
+
for (const a of agents) {
|
|
335
|
+
if (a.ownTools.includes(name)) out.push({ workspace: ws.name, agent: a.name, relation: "use" });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Nearest-wins: a workspace file of the same name, or an agent's own.
|
|
340
|
+
if (fs.existsSync(path.join(dir, localPath(kind, name)))) {
|
|
341
|
+
out.push({ workspace: ws.name, agent: null, relation: "shadowed" });
|
|
342
|
+
}
|
|
343
|
+
for (const a of agents) {
|
|
344
|
+
const own = path.join(dir, "agents", a.name, localPath(kind, name));
|
|
345
|
+
if (fs.existsSync(own)) {
|
|
346
|
+
out.push({ workspace: ws.name, agent: a.name, relation: "shadowed" });
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return out;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// The account shelf as one tree, kind/path — see registerTreeReader.
|
|
355
|
+
registerTreeReader((tenant, scope) => {
|
|
356
|
+
if (scope !== "@library") return null;
|
|
357
|
+
const out: { path: string; before: null; after: string }[] = [];
|
|
358
|
+
for (const kind of LIBRARY_KINDS) {
|
|
359
|
+
const dir = libraryDir(tenant, kind);
|
|
360
|
+
if (!fs.existsSync(dir)) continue;
|
|
361
|
+
for (const entry of fs.readdirSync(dir, { recursive: true })) {
|
|
362
|
+
const rel = String(entry).split(path.sep).join("/");
|
|
363
|
+
const full = path.join(dir, rel);
|
|
364
|
+
try {
|
|
365
|
+
if (!fs.statSync(full).isFile() || !TEXT_EXT.test(rel)) continue;
|
|
366
|
+
out.push({ path: `${kind}/${rel}`, before: null, after: fs.readFileSync(full, "utf8") });
|
|
367
|
+
} catch {
|
|
368
|
+
// skip
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return out;
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Make the shelf match a tree — what a push to the library repository does.
|
|
378
|
+
* Written with plain fs and recorded as ONE revision at the end: going
|
|
379
|
+
* through writeLibraryFile per file would commit each one on top of the
|
|
380
|
+
* pushed commit, and the history would show a push as fifty edits.
|
|
381
|
+
*/
|
|
382
|
+
export function syncLibraryFromTree(tenant: string, files: { path: string; content: string }[], meta: { by?: string; commit?: string | null } = {}) {
|
|
383
|
+
const wanted = new Map<string, string>();
|
|
384
|
+
for (const f of files) {
|
|
385
|
+
const [kind, ...rest] = f.path.split("/");
|
|
386
|
+
if (!LIBRARY_KINDS.includes(kind as LibraryKind) || rest.length === 0) continue;
|
|
387
|
+
wanted.set(f.path, f.content);
|
|
388
|
+
}
|
|
389
|
+
const changes: { path: string; before: string | null; after: string | null }[] = [];
|
|
390
|
+
for (const kind of LIBRARY_KINDS) {
|
|
391
|
+
const dir = libraryDir(tenant, kind);
|
|
392
|
+
if (!fs.existsSync(dir)) continue;
|
|
393
|
+
for (const entry of fs.readdirSync(dir, { recursive: true })) {
|
|
394
|
+
const rel = `${kind}/${String(entry).split(path.sep).join("/")}`;
|
|
395
|
+
const full = path.join(dir, String(entry));
|
|
396
|
+
try {
|
|
397
|
+
if (!fs.statSync(full).isFile() || !TEXT_EXT.test(rel)) continue;
|
|
398
|
+
} catch {
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
if (!wanted.has(rel)) {
|
|
402
|
+
changes.push({ path: rel, before: fs.readFileSync(full, "utf8"), after: null });
|
|
403
|
+
fs.rmSync(full, { force: true });
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
for (const [rel, content] of wanted) {
|
|
408
|
+
const [kind, ...rest] = rel.split("/");
|
|
409
|
+
const full = path.join(libraryDir(tenant, kind as LibraryKind), assertLibraryPath(kind as LibraryKind, rest.join("/")));
|
|
410
|
+
const before = fs.existsSync(full) ? fs.readFileSync(full, "utf8") : null;
|
|
411
|
+
if (before === content) continue;
|
|
412
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
413
|
+
fs.writeFileSync(full, content);
|
|
414
|
+
if (kind === "scripts" || /scripts\//.test(rel) || (kind === "tools" && !rel.endsWith(".md"))) fs.chmodSync(full, 0o755);
|
|
415
|
+
changes.push({ path: rel, before, after: content });
|
|
416
|
+
}
|
|
417
|
+
recordRevision(tenant, "@library", changes, { by: meta.by ?? "deploy", message: meta.commit ? `deployed ${meta.commit.slice(0, 7)}` : "synced from push", commit: meta.commit ?? null });
|
|
418
|
+
return changes.length;
|
|
419
|
+
}
|
package/src/linkable.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
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
|
+
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import matter from "gray-matter";
|
|
18
|
+
|
|
19
|
+
import { STORAGE_DIR } from "./store.ts";
|
|
20
|
+
|
|
21
|
+
export interface LinkableDoc {
|
|
22
|
+
/** What a person types between the brackets. */
|
|
23
|
+
name: string;
|
|
24
|
+
/** Shown beside it in the list. */
|
|
25
|
+
hint: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The directories a bare [[name]] may point at, matching the resolver. */
|
|
29
|
+
export const LINKABLE_DIRS = [STORAGE_DIR, "state", "knowledge", "memory", "skills", "outputs"];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Everything [[ ]] resolves to in this workspace, in the order a person
|
|
33
|
+
* would want it: documents first, then the file store, then the folders.
|
|
34
|
+
*
|
|
35
|
+
* `workspaceRoot` is the workspace directory itself. `storageFiles` is the
|
|
36
|
+
* hosted store's listing when there is one; local installs pass nothing and
|
|
37
|
+
* the plain `storage/` directory on disk is walked instead — the same two
|
|
38
|
+
* sources, in the same order, that the resolver consults.
|
|
39
|
+
*/
|
|
40
|
+
export function linkableDocs(
|
|
41
|
+
workspaceRoot: string,
|
|
42
|
+
storageFiles: { path: string }[] = [],
|
|
43
|
+
limit = 300,
|
|
44
|
+
): LinkableDoc[] {
|
|
45
|
+
const out: LinkableDoc[] = [];
|
|
46
|
+
|
|
47
|
+
for (const kind of ["knowledge", "memory"] as const) {
|
|
48
|
+
const dir = path.join(workspaceRoot, kind);
|
|
49
|
+
if (!fs.existsSync(dir)) continue;
|
|
50
|
+
for (const entry of fs.readdirSync(dir, { recursive: true }).map(String)) {
|
|
51
|
+
if (!entry.endsWith(".md")) continue;
|
|
52
|
+
// index.md and log.md are generated views of the bundle, not concepts
|
|
53
|
+
// to link at — offering them invites a link to a file that will be
|
|
54
|
+
// rewritten under it.
|
|
55
|
+
const base = path.basename(entry);
|
|
56
|
+
if (base === "index.md" || base === "log.md") continue;
|
|
57
|
+
let title = "";
|
|
58
|
+
try {
|
|
59
|
+
const data = matter(fs.readFileSync(path.join(dir, entry), "utf8")).data as Record<string, unknown>;
|
|
60
|
+
for (const key of ["title", "name"] as const) {
|
|
61
|
+
if (typeof data?.[key] === "string" && data[key]) {
|
|
62
|
+
title = data[key] as string;
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
// unparseable frontmatter still links by filename
|
|
68
|
+
}
|
|
69
|
+
out.push({
|
|
70
|
+
name: entry.split(path.sep).join("/").replace(/\.md$/, ""),
|
|
71
|
+
hint: `${kind}${title ? ` — ${title}` : ""}`,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// state/ — what a recurring flow reads from its own past runs. Offered by
|
|
77
|
+
// path, like the file store and unlike knowledge/, because these are data
|
|
78
|
+
// files rather than documents with a title.
|
|
79
|
+
const stateDir = path.join(workspaceRoot, "state");
|
|
80
|
+
if (fs.existsSync(stateDir)) {
|
|
81
|
+
for (const entry of fs.readdirSync(stateDir, { recursive: true }).map(String)) {
|
|
82
|
+
try {
|
|
83
|
+
if (!fs.statSync(path.join(stateDir, entry)).isFile()) continue;
|
|
84
|
+
} catch {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
out.push({ name: `state/${entry.split(path.sep).join("/")}`, hint: "state" });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The file store, under the directory the resolver actually indexes.
|
|
92
|
+
const seen = new Set<string>();
|
|
93
|
+
for (const f of storageFiles) {
|
|
94
|
+
if (seen.has(f.path)) continue;
|
|
95
|
+
seen.add(f.path);
|
|
96
|
+
out.push({ name: `${STORAGE_DIR}/${f.path}`, hint: "file" });
|
|
97
|
+
}
|
|
98
|
+
const filesDir = path.join(workspaceRoot, STORAGE_DIR);
|
|
99
|
+
if (fs.existsSync(filesDir)) {
|
|
100
|
+
for (const entry of fs.readdirSync(filesDir, { recursive: true }).map(String)) {
|
|
101
|
+
const fwd = entry.split(path.sep).join("/");
|
|
102
|
+
if (fwd.startsWith(".store/") || seen.has(fwd)) continue;
|
|
103
|
+
try {
|
|
104
|
+
if (!fs.statSync(path.join(filesDir, entry)).isFile()) continue;
|
|
105
|
+
} catch {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
seen.add(fwd);
|
|
109
|
+
out.push({ name: `${STORAGE_DIR}/${fwd}`, hint: "file" });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// The folders themselves. The resolver has always accepted these — an
|
|
114
|
+
// instruction that says WHERE without naming a file is a real thing to
|
|
115
|
+
// write — and nothing offered them, so the capability was undiscoverable.
|
|
116
|
+
for (const dir of LINKABLE_DIRS) {
|
|
117
|
+
if (fs.existsSync(path.join(workspaceRoot, dir))) {
|
|
118
|
+
out.push({ name: dir, hint: "the whole folder" });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return out.slice(0, limit);
|
|
123
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
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
|
+
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { randomBytes } from "node:crypto";
|
|
19
|
+
import { tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import { executeStep } from "./step-exec.ts";
|
|
22
|
+
import type { Effort } from "./store.ts";
|
|
23
|
+
|
|
24
|
+
export interface ProbeReport {
|
|
25
|
+
model: string;
|
|
26
|
+
/** The model made a real tool call — the handler ran. */
|
|
27
|
+
calledTool: boolean;
|
|
28
|
+
/** The nonce came back in the reply: the loop closed end to end. */
|
|
29
|
+
echoedNonce: boolean;
|
|
30
|
+
/** calledTool && echoedNonce — what "can drive an agent" actually means. */
|
|
31
|
+
ok: boolean;
|
|
32
|
+
status: "completed" | "failed";
|
|
33
|
+
costUsd: number | null;
|
|
34
|
+
durationMs: number;
|
|
35
|
+
/** The reply itself, for the human reading a failure. */
|
|
36
|
+
reply: string | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const PROBE_TIMEOUT_SEC = 120;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Run the tool-loop probe against one model, through whatever endpoint the
|
|
43
|
+
* env points at — pass the same env a step would get (provider base URL,
|
|
44
|
+
* token, tier remaps) and the probe exercises the exact path a run takes.
|
|
45
|
+
*/
|
|
46
|
+
export async function probeModel(
|
|
47
|
+
model: string,
|
|
48
|
+
env: Record<string, string | undefined>,
|
|
49
|
+
effort: Effort | null = null,
|
|
50
|
+
): Promise<ProbeReport> {
|
|
51
|
+
const nonce = `probe-${randomBytes(6).toString("hex")}`;
|
|
52
|
+
let calledTool = false;
|
|
53
|
+
|
|
54
|
+
const probeTool = tool(
|
|
55
|
+
"probe_fetch_token",
|
|
56
|
+
"Returns a secret token. Call it exactly once.",
|
|
57
|
+
{
|
|
58
|
+
// One required argument, because a loop that can't pass arguments
|
|
59
|
+
// can't run real tools either — echoing a fixed call is the easiest
|
|
60
|
+
// possible tool use, and we deliberately don't test the easiest.
|
|
61
|
+
purpose: z.string().describe("Why you are fetching the token, in a few words"),
|
|
62
|
+
},
|
|
63
|
+
async () => {
|
|
64
|
+
calledTool = true;
|
|
65
|
+
return { content: [{ type: "text" as const, text: nonce }] };
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
// Homeless on purpose, like a consult: the probe reads and writes nothing,
|
|
70
|
+
// and a throwaway cwd means even a confused model call has no tree to touch.
|
|
71
|
+
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-probe-"));
|
|
72
|
+
const started = Date.now();
|
|
73
|
+
try {
|
|
74
|
+
const outcome = await executeStep({
|
|
75
|
+
agentDir: scratch,
|
|
76
|
+
workspaceRoot: scratch,
|
|
77
|
+
libraryRoot: scratch,
|
|
78
|
+
prompt:
|
|
79
|
+
"Call the probe_fetch_token tool once, then reply with exactly the token it " +
|
|
80
|
+
"returns and nothing else. If you cannot call tools, reply CANNOT.",
|
|
81
|
+
model,
|
|
82
|
+
effort,
|
|
83
|
+
systemPrompt:
|
|
84
|
+
"You are a connectivity probe. Follow the instruction literally. Do not explain.",
|
|
85
|
+
allowed: [],
|
|
86
|
+
mcpNames: ["foldrun_probe"],
|
|
87
|
+
mcpServers: {
|
|
88
|
+
foldrun_probe: createSdkMcpServer({
|
|
89
|
+
name: "foldrun_probe",
|
|
90
|
+
version: "1.0.0",
|
|
91
|
+
tools: [probeTool],
|
|
92
|
+
}),
|
|
93
|
+
},
|
|
94
|
+
env,
|
|
95
|
+
timeoutSec: PROBE_TIMEOUT_SEC,
|
|
96
|
+
emit: () => {}, // the probe's transcript is the report, not a run log
|
|
97
|
+
});
|
|
98
|
+
const echoedNonce = (outcome.result ?? "").includes(nonce);
|
|
99
|
+
return {
|
|
100
|
+
model,
|
|
101
|
+
calledTool,
|
|
102
|
+
echoedNonce,
|
|
103
|
+
ok: calledTool && echoedNonce,
|
|
104
|
+
status: outcome.status,
|
|
105
|
+
costUsd: outcome.costUsd,
|
|
106
|
+
durationMs: Date.now() - started,
|
|
107
|
+
reply: outcome.result,
|
|
108
|
+
};
|
|
109
|
+
} finally {
|
|
110
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
111
|
+
}
|
|
112
|
+
}
|