factoryhub 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -3
- package/factories/index.json +231 -0
- package/factories/newsletter-note/README.md +11 -0
- package/factories/newsletter-note/agents/reviewer/agent.md +10 -0
- package/factories/newsletter-note/agents/writer/agent.md +11 -0
- package/factories/newsletter-note/factory.yaml +12 -0
- package/factories/newsletter-note/flow.yaml +45 -0
- package/factories/newsletter-note/mindburn.tests.yaml +80 -0
- package/factories/release-desk/README.md +13 -0
- package/factories/release-desk/agents/editor/agent.md +10 -0
- package/factories/release-desk/agents/reviewer/agent.md +10 -0
- package/factories/release-desk/factory.yaml +13 -0
- package/factories/release-desk/flow.yaml +67 -0
- package/factories/release-desk/mindburn.tests.yaml +163 -0
- package/factories/short-video-cut/README.md +11 -0
- package/factories/short-video-cut/agents/reviewer/agent.md +10 -0
- package/factories/short-video-cut/agents/writer/agent.md +11 -0
- package/factories/short-video-cut/factory.yaml +13 -0
- package/factories/short-video-cut/flow.yaml +46 -0
- package/factories/short-video-cut/mindburn.tests.yaml +80 -0
- package/factories/video-facts/README.md +11 -0
- package/factories/video-facts/agents/analyst/agent.md +10 -0
- package/factories/video-facts/agents/reviewer/agent.md +10 -0
- package/factories/video-facts/factory.yaml +13 -0
- package/factories/video-facts/flow.yaml +39 -0
- package/factories/video-facts/mindburn.tests.yaml +34 -0
- package/factories/video-to-posts/README.md +35 -0
- package/factories/video-to-posts/agents/analyst/agent.md +10 -0
- package/factories/video-to-posts/agents/reviewer/agent.md +10 -0
- package/factories/video-to-posts/agents/writer/agent.md +11 -0
- package/factories/video-to-posts/factory.yaml +12 -0
- package/factories/video-to-posts/flow.yaml +62 -0
- package/factories/video-to-posts/mindburn.tests.yaml +34 -0
- package/factories/week-of-posts/README.md +11 -0
- package/factories/week-of-posts/agents/reviewer/agent.md +10 -0
- package/factories/week-of-posts/agents/writer/agent.md +11 -0
- package/factories/week-of-posts/factory.yaml +12 -0
- package/factories/week-of-posts/flow.yaml +46 -0
- package/factories/week-of-posts/mindburn.tests.yaml +80 -0
- package/package.json +15 -2
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/commands/export.mjs +0 -1
- package/packages/cli/src/commands/get.mjs +0 -1
- package/packages/cli/src/commands/inspect.mjs +0 -1
- package/packages/cli/src/commands/list.mjs +1 -6
- package/packages/cli/src/commands/login.mjs +161 -0
- package/packages/cli/src/commands/logout.mjs +43 -0
- package/packages/cli/src/commands/mcp.mjs +0 -1
- package/packages/cli/src/commands/new.mjs +0 -1
- package/packages/cli/src/commands/pack.mjs +0 -1
- package/packages/cli/src/commands/publish.mjs +62 -0
- package/packages/cli/src/commands/pull.mjs +59 -0
- package/packages/cli/src/commands/push.mjs +115 -0
- package/packages/cli/src/commands/resume.mjs +0 -1
- package/packages/cli/src/commands/run.mjs +182 -20
- package/packages/cli/src/commands/runs.mjs +52 -0
- package/packages/cli/src/commands/search.mjs +0 -1
- package/packages/cli/src/commands/validate.mjs +1 -3
- package/packages/cli/src/commands/whoami.mjs +34 -0
- package/packages/cli/src/hosted.mjs +159 -0
- package/packages/cli/src/main.mjs +80 -14
- package/packages/cli/src/tool-sources.mjs +272 -0
- package/packages/cli/src/util.mjs +37 -0
- package/packages/engine/lib/credentials.mjs +121 -0
- package/packages/engine/lib/hosted.mjs +119 -0
- package/packages/engine/lib/hub.mjs +376 -24
- package/packages/engine/lib/nango.mjs +157 -0
- package/packages/engine/lib/node.mjs +161 -33
- package/packages/engine/lib/providers.mjs +8 -3
- package/packages/engine/lib/run.mjs +70 -9
- package/packages/engine/lib/text.mjs +5 -0
- package/packages/engine/package.json +8 -2
- package/packages/engine/src/factory-engine.mjs +415 -68
- package/packages/engine/src/flow-sources.mjs +135 -0
- package/packages/engine/src/mcp-client.mjs +429 -0
- package/packages/engine/src/run.mjs +81 -5
- package/packages/engine/src/tool-sources.mjs +566 -0
- package/packages/mcp/package.json +1 -1
- package/packages/mcp/src/server.mjs +47 -17
- package/packages/sdk/package.json +28 -0
- package/packages/sdk/src/index.d.ts +163 -0
- package/packages/sdk/src/index.mjs +266 -0
- package/spec/README.md +7 -1
- package/spec/factory-format.md +264 -19
- package/spec/schema/factory.yaml.json +815 -0
- package/spec/schema/flow.schema.json +24 -2
- package/spec/skill-export.md +3 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Node.js helpers around the engine: read a factory from a folder, a bundle
|
|
2
2
|
// file or an address, and write one back to disk. The engine itself stays
|
|
3
3
|
// free of file system access so the same code runs in a browser.
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
4
|
+
import * as fsp from 'node:fs/promises';
|
|
5
|
+
import { readFile, mkdir, lstat, open, unlink } from 'node:fs/promises';
|
|
6
|
+
import { existsSync, constants } from 'node:fs';
|
|
6
7
|
import { join, relative, resolve, dirname, sep } from 'node:path';
|
|
7
8
|
import { fileURLToPath } from 'node:url';
|
|
8
9
|
import { ALLOWED_PATH, fromEnvelope, normalizeFiles } from '../src/factory-engine.mjs';
|
|
@@ -18,58 +19,98 @@ const GENERATED = new Set(['RUNNING.md', 'run.mjs', 'factory-engine.mjs', 'autho
|
|
|
18
19
|
/** The house factories that ship in this repository. */
|
|
19
20
|
export const HOUSE_FACTORIES = resolve(dirname(fileURLToPath(import.meta.url)), '../../../factories');
|
|
20
21
|
|
|
22
|
+
// At most `size` tasks given to the returned function run at once; a task
|
|
23
|
+
// that ends hands its place to the next one waiting.
|
|
24
|
+
function limiter(size) {
|
|
25
|
+
let active = 0;
|
|
26
|
+
const waiting = [];
|
|
27
|
+
return async (task) => {
|
|
28
|
+
if (active < size) active += 1;
|
|
29
|
+
else await new Promise((resolve) => waiting.push(resolve));
|
|
30
|
+
try { return await task(); } finally {
|
|
31
|
+
const next = waiting.shift();
|
|
32
|
+
if (next) next();
|
|
33
|
+
else active -= 1;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// File system calls of every folder being read share this limit, so reading
|
|
39
|
+
// many factories at once never opens more files than this.
|
|
40
|
+
const io = limiter(64);
|
|
41
|
+
|
|
21
42
|
/**
|
|
22
43
|
* Reads the files of a factory folder, as the engine admits them.
|
|
23
44
|
* `skipped` lists files that look meant for the factory but are not part of it.
|
|
45
|
+
* Every directory and file is read at the same time; the results are then
|
|
46
|
+
* taken in the order a depth-first walk meets them, so `skipped` and the
|
|
47
|
+
* first error are the same as when they were read one by one. `fs` is for
|
|
48
|
+
* tests.
|
|
24
49
|
*/
|
|
25
|
-
export async function readFolder(root) {
|
|
26
|
-
const files = {};
|
|
27
|
-
const skipped = [];
|
|
50
|
+
export async function readFolder(root, { fs = fsp } = {}) {
|
|
28
51
|
const walk = async (dir) => {
|
|
29
|
-
const entries = await readdir(dir, { withFileTypes: true });
|
|
52
|
+
const entries = await io(() => fs.readdir(dir, { withFileTypes: true }));
|
|
30
53
|
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
31
|
-
|
|
54
|
+
return Promise.all(entries.map(async (entry) => {
|
|
32
55
|
const { name } = entry;
|
|
33
|
-
if (name.startsWith('.'))
|
|
56
|
+
if (name.startsWith('.')) return null;
|
|
34
57
|
const path = join(dir, name);
|
|
35
58
|
const rel = relative(root, path).split(sep).join('/');
|
|
36
|
-
|
|
37
|
-
if (
|
|
38
|
-
|
|
59
|
+
try {
|
|
60
|
+
if (entry.isSymbolicLink()) {
|
|
61
|
+
if (dir !== root || SOURCE_FOLDERS.has(name) || ALLOWED_PATH.test(rel)) throw new Error(`Factory source cannot contain a symbolic link: ${rel}`);
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
if (entry.isDirectory()) return SOURCE_FOLDERS.has(name) || dir !== root ? { items: await walk(path) } : null;
|
|
65
|
+
if (dir === root && GENERATED.has(name)) return null;
|
|
66
|
+
if (ALLOWED_PATH.test(rel)) return { rel, content: await io(() => fs.readFile(path, 'utf8')) };
|
|
67
|
+
return dir !== root || /\.(md|ya?ml)$/i.test(name) ? { skipped: rel } : null;
|
|
68
|
+
} catch (error) {
|
|
69
|
+
return { error };
|
|
39
70
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
71
|
+
}));
|
|
72
|
+
};
|
|
73
|
+
const files = {};
|
|
74
|
+
const skipped = [];
|
|
75
|
+
const take = (items) => {
|
|
76
|
+
for (const item of items) {
|
|
77
|
+
if (!item) continue;
|
|
78
|
+
if (item.error) throw item.error;
|
|
79
|
+
if (item.items) take(item.items);
|
|
80
|
+
else if (item.skipped) skipped.push(item.skipped);
|
|
81
|
+
else files[item.rel] = item.content;
|
|
47
82
|
}
|
|
48
83
|
};
|
|
49
|
-
await walk(root);
|
|
84
|
+
take(await walk(root));
|
|
50
85
|
return { files: normalizeFiles(files), skipped };
|
|
51
86
|
}
|
|
52
87
|
|
|
53
88
|
const isUrl = (value) => /^https?:\/\//i.test(String(value));
|
|
54
89
|
|
|
90
|
+
/** How long reading a bundle from an address may take, as for the hub client. */
|
|
91
|
+
export const BUNDLE_TIMEOUT_MS = 30000;
|
|
92
|
+
|
|
55
93
|
/**
|
|
56
94
|
* Resolves what a person typed to a factory: a folder, a bundle `.json` file,
|
|
57
95
|
* an http(s) address of a bundle, or the id of a house factory.
|
|
58
|
-
* Returns { files, leftOut, source: { kind, location, id } }.
|
|
96
|
+
* Returns { files, leftOut, source: { kind, location, id } }. An address that
|
|
97
|
+
* does not answer, or does not finish its answer, within `timeoutMs` fails.
|
|
59
98
|
*/
|
|
60
|
-
export async function readFactory(target, { cwd = process.cwd(), fetchImpl = fetch, headers = {}, houseDir = HOUSE_FACTORIES } = {}) {
|
|
99
|
+
export async function readFactory(target, { cwd = process.cwd(), fetchImpl = fetch, headers = {}, houseDir = HOUSE_FACTORIES, timeoutMs = BUNDLE_TIMEOUT_MS } = {}) {
|
|
61
100
|
if (!target) throw usage('Name a factory: a folder, a bundle .json file, an address, or a house factory id.');
|
|
62
101
|
if (isUrl(target)) {
|
|
63
|
-
const
|
|
102
|
+
const timedOut = (error) => (error?.name === 'TimeoutError' ? new Error(`${target} did not answer within ${timeoutMs / 1000} seconds.`) : error);
|
|
103
|
+
// The signal also ends a body that stops arriving.
|
|
104
|
+
const response = await fetchImpl(target, { headers: { accept: 'application/json', ...headers }, signal: AbortSignal.timeout(timeoutMs) }).catch((error) => { throw timedOut(error); });
|
|
64
105
|
if (!response.ok) throw new Error(`${target} answered with status ${response.status}.`);
|
|
65
|
-
const read = await fromEnvelope(await response.json());
|
|
106
|
+
const read = await fromEnvelope(await response.json().catch((error) => { throw timedOut(error); }));
|
|
66
107
|
if (read.problems.length) throw new Error(`That bundle could not be read:\n${read.problems.map((problem) => ` - ${problem}`).join('\n')}`);
|
|
67
108
|
return { files: read.files, leftOut: read.leftOut, source: { kind: 'url', location: target } };
|
|
68
109
|
}
|
|
69
110
|
const path = resolve(cwd, target);
|
|
70
111
|
if (existsSync(path) && (await lstat(path)).isFile()) {
|
|
71
112
|
if (!path.endsWith('.json')) throw usage(`${target} is a file. Point at a factory folder or a bundle .json file.`);
|
|
72
|
-
const read = await fromEnvelope(JSON.parse(await readFile(path, 'utf8')));
|
|
113
|
+
const read = await fromEnvelope(JSON.parse(await fsp.readFile(path, 'utf8')));
|
|
73
114
|
if (read.problems.length) throw new Error(`That bundle could not be read:\n${read.problems.map((problem) => ` - ${problem}`).join('\n')}`);
|
|
74
115
|
return { files: read.files, leftOut: read.leftOut, source: { kind: 'bundle', location: path } };
|
|
75
116
|
}
|
|
@@ -89,23 +130,110 @@ export async function readFactory(target, { cwd = process.cwd(), fetchImpl = fet
|
|
|
89
130
|
/** The ids of the factory folders directly inside `dir`, sorted. */
|
|
90
131
|
export async function listFactoryDirs(dir = HOUSE_FACTORIES) {
|
|
91
132
|
if (!existsSync(dir)) return [];
|
|
92
|
-
const entries = await readdir(dir, { withFileTypes: true });
|
|
133
|
+
const entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
93
134
|
return entries.filter((entry) => entry.isDirectory() && existsSync(join(dir, entry.name, 'factory.yaml'))).map((entry) => entry.name).sort();
|
|
94
135
|
}
|
|
95
136
|
|
|
137
|
+
const lstatOrNull = async (path) => {
|
|
138
|
+
try { return await lstat(path); } catch (error) {
|
|
139
|
+
if (error.code === 'ENOENT') return null;
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
// Opens a file for writing without following a symbolic link where the
|
|
144
|
+
// platform can refuse one (O_NOFOLLOW); every folder above it is checked first.
|
|
145
|
+
const WRITE_FLAGS = constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | (constants.O_NOFOLLOW || 0);
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The folder files are written into, made if it is missing. A folder that is
|
|
149
|
+
* itself a symbolic link is refused: writing through it would land elsewhere.
|
|
150
|
+
*/
|
|
151
|
+
async function writableRoot(dir) {
|
|
152
|
+
const root = resolve(dir);
|
|
153
|
+
const info = await lstatOrNull(root);
|
|
154
|
+
if (info?.isSymbolicLink()) throw new Error(`Refusing to write into ${root}: it is a symbolic link.`);
|
|
155
|
+
if (info && !info.isDirectory()) throw new Error(`Refusing to write into ${root}: it is not a folder.`);
|
|
156
|
+
if (!info) await mkdir(root, { recursive: true });
|
|
157
|
+
return root;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The absolute path for one relative file under `root`, with every folder on
|
|
162
|
+
* the way made or checked: none may be a symbolic link, and the file itself,
|
|
163
|
+
* if it exists, must be a plain file.
|
|
164
|
+
*/
|
|
165
|
+
async function safeTarget(root, path) {
|
|
166
|
+
const target = resolve(root, path);
|
|
167
|
+
if (!target.startsWith(root + sep)) throw new Error(`Refusing to write outside ${root}: ${path}`);
|
|
168
|
+
const parts = relative(root, target).split(sep);
|
|
169
|
+
let current = root;
|
|
170
|
+
for (const part of parts.slice(0, -1)) {
|
|
171
|
+
current = join(current, part);
|
|
172
|
+
const info = await lstatOrNull(current);
|
|
173
|
+
if (!info) { await mkdir(current); continue; }
|
|
174
|
+
if (info.isSymbolicLink()) throw new Error(`Refusing to write "${path}": ${relative(root, current).split(sep).join('/')} is a symbolic link.`);
|
|
175
|
+
if (!info.isDirectory()) throw new Error(`Refusing to write "${path}": ${relative(root, current).split(sep).join('/')} is not a folder.`);
|
|
176
|
+
}
|
|
177
|
+
const info = await lstatOrNull(target);
|
|
178
|
+
if (info?.isSymbolicLink()) throw new Error(`Refusing to write "${path}": it is a symbolic link.`);
|
|
179
|
+
if (info && !info.isFile()) throw new Error(`Refusing to write "${path}": it is not a plain file.`);
|
|
180
|
+
return target;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function writeOne(target, content) {
|
|
184
|
+
const handle = await open(target, WRITE_FLAGS, 0o644);
|
|
185
|
+
try { await handle.writeFile(content); } finally { await handle.close(); }
|
|
186
|
+
}
|
|
187
|
+
|
|
96
188
|
/**
|
|
97
189
|
* Writes bundle files under `dir`. Every path must be one the engine admits,
|
|
98
|
-
* so a bundle from elsewhere cannot write outside its folder
|
|
190
|
+
* so a bundle from elsewhere cannot write outside its folder, and no file is
|
|
191
|
+
* written through a symbolic link.
|
|
99
192
|
*/
|
|
100
193
|
export async function writeFiles(dir, files, { allow = (path) => ALLOWED_PATH.test(path) } = {}) {
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
194
|
+
for (const path of Object.keys(files)) if (!allow(path)) throw new Error(`Refusing to write "${path}": it is not a factory file.`);
|
|
195
|
+
const root = await writableRoot(dir);
|
|
196
|
+
for (const [path, content] of Object.entries(files)) await writeOne(await safeTarget(root, path), content);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The record `pull` keeps of the files it wrote, so a later pull removes only those. */
|
|
200
|
+
export const PULL_MANIFEST = '.factoryhub-pull.json';
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Writes a pulled factory into `dir` and records what it wrote in
|
|
204
|
+
* PULL_MANIFEST. With `replace`, files a previous pull wrote (as its
|
|
205
|
+
* manifest lists them) that the new version no longer has are removed; no
|
|
206
|
+
* other file is ever deleted. Returns { removed }.
|
|
207
|
+
*/
|
|
208
|
+
export async function writePulled(dir, files, { replace = false, record = {} } = {}) {
|
|
209
|
+
const root = await writableRoot(dir);
|
|
210
|
+
const removed = [];
|
|
211
|
+
if (replace) {
|
|
212
|
+
const listed = await readPullManifest(root);
|
|
213
|
+
for (const path of listed) {
|
|
214
|
+
if (files[path] !== undefined || !ALLOWED_PATH.test(path)) continue;
|
|
215
|
+
const target = await safeTarget(root, path).catch(() => null);
|
|
216
|
+
if (!target) continue;
|
|
217
|
+
const info = await lstatOrNull(target);
|
|
218
|
+
if (!info?.isFile()) continue;
|
|
219
|
+
await unlink(target);
|
|
220
|
+
removed.push(path);
|
|
221
|
+
}
|
|
108
222
|
}
|
|
223
|
+
await writeFiles(root, files);
|
|
224
|
+
const manifest = await safeTarget(root, PULL_MANIFEST);
|
|
225
|
+
await writeOne(manifest, `${JSON.stringify({ format: 'factoryhub.pull/v1', ...record, files: Object.keys(files) }, null, 2)}\n`);
|
|
226
|
+
return { removed };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function readPullManifest(root) {
|
|
230
|
+
const path = join(root, PULL_MANIFEST);
|
|
231
|
+
const info = await lstatOrNull(path);
|
|
232
|
+
if (!info?.isFile()) return [];
|
|
233
|
+
try {
|
|
234
|
+
const parsed = JSON.parse(await readFile(path, 'utf8'));
|
|
235
|
+
return Array.isArray(parsed?.files) ? parsed.files.filter((file) => typeof file === 'string') : [];
|
|
236
|
+
} catch { return []; }
|
|
109
237
|
}
|
|
110
238
|
|
|
111
239
|
/** An error that means the person asked for something unclear: exit code 2. */
|
|
@@ -25,8 +25,11 @@ const trim = (url) => String(url).replace(/\/+$/, '');
|
|
|
25
25
|
* carry a provider key away.
|
|
26
26
|
* - `model` falls back to FACTORYHUB_MODEL, then to a default for hosted
|
|
27
27
|
* providers. Local servers need a model name.
|
|
28
|
+
* - `effort` (anthropic only) sets output_config.effort; `maxOutput` the
|
|
29
|
+
* room each call leaves for thinking and the answer; `timeoutMs` how long
|
|
30
|
+
* one call may take.
|
|
28
31
|
*/
|
|
29
|
-
export function chooseProvider({ provider, model, baseUrl, apiKeyEnv, env = process.env, fetchImpl = fetch } = {}) {
|
|
32
|
+
export function chooseProvider({ provider, model, baseUrl, apiKeyEnv, effort, maxOutput, timeoutMs, env = process.env, fetchImpl = fetch } = {}) {
|
|
30
33
|
let kind = provider ? String(provider).toLowerCase() : null;
|
|
31
34
|
if (kind && !PROVIDERS.includes(kind)) throw problem(`Unknown provider "${provider}". Use one of: ${PROVIDERS.join(', ')}.`);
|
|
32
35
|
if (!kind) {
|
|
@@ -47,6 +50,8 @@ export function chooseProvider({ provider, model, baseUrl, apiKeyEnv, env = proc
|
|
|
47
50
|
if (!address) throw problem('An OpenAI-compatible server needs a base URL.');
|
|
48
51
|
// The provider's own key for its own address; otherwise only a key the person named.
|
|
49
52
|
const apiKey = apiKeyEnv ? keyFrom(apiKeyEnv) : KEY_OF[kind] && address === OFFICIAL[kind] ? keyFrom(KEY_OF[kind]) : '';
|
|
50
|
-
if (kind
|
|
51
|
-
|
|
53
|
+
if (effort && kind !== 'anthropic') throw problem('Effort is set only for the anthropic provider.');
|
|
54
|
+
const extra = { ...(timeoutMs ? { timeoutMs } : {}) };
|
|
55
|
+
if (kind === 'anthropic') return anthropicProvider({ apiKey, model: chosen, fetchImpl, baseUrl: address, effort: effort || null, ...(maxOutput ? { maxOutput } : {}), ...extra });
|
|
56
|
+
return openaiProvider({ apiKey, model: chosen, fetchImpl, baseUrl: address, ...extra });
|
|
52
57
|
}
|
|
@@ -9,6 +9,58 @@ import { canonicalFiles } from './catalog.mjs';
|
|
|
9
9
|
|
|
10
10
|
export const USER_AGENT = 'Mozilla/5.0 (compatible; FactoryRunner/1.0)';
|
|
11
11
|
|
|
12
|
+
/** How much of a page is read, as on the factoryhub site: the rest is never downloaded. */
|
|
13
|
+
export const PAGE_BYTES = 1500000;
|
|
14
|
+
const CUT_NOTE = `[Page cut here: only the first ${PAGE_BYTES.toLocaleString('en-US')} bytes of the page were read]`;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Reads a response body up to `max` bytes, then stops the download.
|
|
18
|
+
* Returns { text, cut }; the text is decoded as UTF-8, as response.text() does.
|
|
19
|
+
*/
|
|
20
|
+
export async function readCapped(response, max = PAGE_BYTES) {
|
|
21
|
+
const reader = response.body?.getReader();
|
|
22
|
+
const chunks = [];
|
|
23
|
+
let total = 0;
|
|
24
|
+
let cut = false;
|
|
25
|
+
while (reader) {
|
|
26
|
+
const { done, value } = await reader.read();
|
|
27
|
+
if (done) break;
|
|
28
|
+
chunks.push(value);
|
|
29
|
+
total += value.byteLength;
|
|
30
|
+
if (total >= max) {
|
|
31
|
+
// A page that ends exactly at the limit was not cut.
|
|
32
|
+
cut = total > max || !(await reader.read()).done;
|
|
33
|
+
await reader.cancel().catch(() => {});
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const bytes = new Uint8Array(Math.min(total, max));
|
|
38
|
+
let offset = 0;
|
|
39
|
+
for (const chunk of chunks) {
|
|
40
|
+
const part = chunk.subarray(0, bytes.length - offset);
|
|
41
|
+
bytes.set(part, offset);
|
|
42
|
+
offset += part.byteLength;
|
|
43
|
+
}
|
|
44
|
+
return { text: new TextDecoder('utf-8').decode(bytes), cut };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The text an agent gets from a page response: only text, HTML, XML and JSON
|
|
49
|
+
* are read, and at most PAGE_BYTES of them. A cut page says it was cut, so
|
|
50
|
+
* the run records that only part of it was read.
|
|
51
|
+
*/
|
|
52
|
+
export async function pageText(response, url) {
|
|
53
|
+
const type = (response.headers.get('content-type') || '').toLowerCase();
|
|
54
|
+
if (type && !/text\/|json|xml/.test(type)) {
|
|
55
|
+
await response.body?.cancel().catch(() => {});
|
|
56
|
+
throw new Error(`the address is a ${type.split(';')[0].trim()} file, not a page`);
|
|
57
|
+
}
|
|
58
|
+
const { text, cut } = await readCapped(response);
|
|
59
|
+
const page = htmlToText(text, url);
|
|
60
|
+
const note = cut && !/\[Page cut here: [^\]]*\]$/.test(page) ? CUT_NOTE : '';
|
|
61
|
+
return `Address: ${url}\n\n${[page, note].filter(Boolean).join('\n\n')}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
12
64
|
const v4 = (ip) => ip.split('.').map(Number);
|
|
13
65
|
/** True for loopback, private, link-local, shared, reserved and multicast addresses. */
|
|
14
66
|
export function isPrivateIp(ip) {
|
|
@@ -46,7 +98,7 @@ export function nodeHost({ provider, emit = () => {}, fetchImpl = fetch, resolve
|
|
|
46
98
|
const response = await fetchImpl(url, { redirect: 'manual', headers: { 'user-agent': USER_AGENT, accept: 'text/html,*/*;q=0.5' }, signal: AbortSignal.timeout(20000) });
|
|
47
99
|
if (response.status >= 300 && response.status < 400) { url = new URL(response.headers.get('location') || '', url).href; continue; }
|
|
48
100
|
if (!response.ok) throw new Error(`the site answered with status ${response.status}`);
|
|
49
|
-
return
|
|
101
|
+
return pageText(response, url);
|
|
50
102
|
}
|
|
51
103
|
throw new Error('the page redirects too many times');
|
|
52
104
|
},
|
|
@@ -58,16 +110,25 @@ export function nodeHost({ provider, emit = () => {}, fetchImpl = fetch, resolve
|
|
|
58
110
|
* Prepares a run: parses the factory, refuses one that cannot run here, and
|
|
59
111
|
* checks the inputs before any model is called.
|
|
60
112
|
*/
|
|
61
|
-
export function prepareRun({ files, inputs = {}, id, runner = { mode: 'local' } }) {
|
|
62
|
-
|
|
113
|
+
export function prepareRun({ files, inputs = {}, id, runner = { mode: 'local' }, toolSources = null, features = [] }) {
|
|
114
|
+
// `features`: experimental execution features the caller opts in to (tool sources a flow declares).
|
|
115
|
+
const parsed = parseBundle(canonicalFiles(files), features.length ? { features } : undefined);
|
|
63
116
|
if (!parsed.ok) return { ok: false, problems: parsed.errors };
|
|
64
|
-
|
|
65
|
-
|
|
117
|
+
// Experimental, opt-in: tool sources (createToolSources) may supply tools
|
|
118
|
+
// that steps name. Without them nothing here changes.
|
|
119
|
+
const admitted = toolSources ? toolSources.admit(parsed.factory, { exact: features.includes('tool-sources') }) : { factory: parsed.factory, problems: [] };
|
|
120
|
+
const { factory } = admitted;
|
|
121
|
+
if (!factory.runnable) {
|
|
122
|
+
return { ok: false, problems: factory.blockers.length ? factory.blockers : ['This factory has no flow.yaml, so there are no steps to run. Follow agents/foreman/agent.md in an agent instead.'] };
|
|
123
|
+
}
|
|
124
|
+
const checked = checkInputs(factory, inputs);
|
|
125
|
+
if (!checked.ok) return { ok: false, problems: checked.problems, factory };
|
|
126
|
+
const state = newRun({ id: id || `local-${Date.now().toString(36)}`, factory, inputs: checked.inputs, runner });
|
|
127
|
+
if (toolSources) {
|
|
128
|
+
state.experimental = [toolSources.feature];
|
|
129
|
+
if (toolSources.records) state.toolSourceRecords = toolSources.records();
|
|
66
130
|
}
|
|
67
|
-
|
|
68
|
-
if (!checked.ok) return { ok: false, problems: checked.problems, factory: parsed.factory };
|
|
69
|
-
const state = newRun({ id: id || `local-${Date.now().toString(36)}`, factory: parsed.factory, inputs: checked.inputs, runner });
|
|
70
|
-
return { ok: true, state, factory: parsed.factory };
|
|
131
|
+
return { ok: true, state, factory };
|
|
71
132
|
}
|
|
72
133
|
|
|
73
134
|
/**
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Small text checks with no imports, so a caller that needs one of them does
|
|
2
|
+
// not load the hub client or the engine with it.
|
|
3
|
+
|
|
4
|
+
/** True when `text` holds a control character (U+0000 to U+001F, or U+007F). */
|
|
5
|
+
export const hasControl = (text) => [...String(text)].some((char) => char.charCodeAt(0) < 0x20 || char.charCodeAt(0) === 0x7f);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@factoryhub/engine",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "The factory engine: parses factory bundles, runs their flows with your model, and writes and verifies receipts. No dependencies.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -22,12 +22,18 @@
|
|
|
22
22
|
"./diagnostics": "./src/diagnostics.mjs",
|
|
23
23
|
"./markdown": "./src/markdown.mjs",
|
|
24
24
|
"./package": "./src/package.mjs",
|
|
25
|
+
"./mcp-client": "./src/mcp-client.mjs",
|
|
26
|
+
"./tool-sources": "./src/tool-sources.mjs",
|
|
27
|
+
"./flow-sources": "./src/flow-sources.mjs",
|
|
28
|
+
"./nango": "./lib/nango.mjs",
|
|
25
29
|
"./node": "./lib/node.mjs",
|
|
26
30
|
"./skill": "./lib/skill.mjs",
|
|
27
31
|
"./hub": "./lib/hub.mjs",
|
|
28
32
|
"./catalog": "./lib/catalog.mjs",
|
|
29
33
|
"./run": "./lib/run.mjs",
|
|
30
|
-
"./providers": "./lib/providers.mjs"
|
|
34
|
+
"./providers": "./lib/providers.mjs",
|
|
35
|
+
"./credentials": "./lib/credentials.mjs",
|
|
36
|
+
"./hosted": "./lib/hosted.mjs"
|
|
31
37
|
},
|
|
32
38
|
"files": [
|
|
33
39
|
"src",
|