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.
Files changed (87) hide show
  1. package/README.md +9 -3
  2. package/factories/index.json +231 -0
  3. package/factories/newsletter-note/README.md +11 -0
  4. package/factories/newsletter-note/agents/reviewer/agent.md +10 -0
  5. package/factories/newsletter-note/agents/writer/agent.md +11 -0
  6. package/factories/newsletter-note/factory.yaml +12 -0
  7. package/factories/newsletter-note/flow.yaml +45 -0
  8. package/factories/newsletter-note/mindburn.tests.yaml +80 -0
  9. package/factories/release-desk/README.md +13 -0
  10. package/factories/release-desk/agents/editor/agent.md +10 -0
  11. package/factories/release-desk/agents/reviewer/agent.md +10 -0
  12. package/factories/release-desk/factory.yaml +13 -0
  13. package/factories/release-desk/flow.yaml +67 -0
  14. package/factories/release-desk/mindburn.tests.yaml +163 -0
  15. package/factories/short-video-cut/README.md +11 -0
  16. package/factories/short-video-cut/agents/reviewer/agent.md +10 -0
  17. package/factories/short-video-cut/agents/writer/agent.md +11 -0
  18. package/factories/short-video-cut/factory.yaml +13 -0
  19. package/factories/short-video-cut/flow.yaml +46 -0
  20. package/factories/short-video-cut/mindburn.tests.yaml +80 -0
  21. package/factories/video-facts/README.md +11 -0
  22. package/factories/video-facts/agents/analyst/agent.md +10 -0
  23. package/factories/video-facts/agents/reviewer/agent.md +10 -0
  24. package/factories/video-facts/factory.yaml +13 -0
  25. package/factories/video-facts/flow.yaml +39 -0
  26. package/factories/video-facts/mindburn.tests.yaml +34 -0
  27. package/factories/video-to-posts/README.md +35 -0
  28. package/factories/video-to-posts/agents/analyst/agent.md +10 -0
  29. package/factories/video-to-posts/agents/reviewer/agent.md +10 -0
  30. package/factories/video-to-posts/agents/writer/agent.md +11 -0
  31. package/factories/video-to-posts/factory.yaml +12 -0
  32. package/factories/video-to-posts/flow.yaml +62 -0
  33. package/factories/video-to-posts/mindburn.tests.yaml +34 -0
  34. package/factories/week-of-posts/README.md +11 -0
  35. package/factories/week-of-posts/agents/reviewer/agent.md +10 -0
  36. package/factories/week-of-posts/agents/writer/agent.md +11 -0
  37. package/factories/week-of-posts/factory.yaml +12 -0
  38. package/factories/week-of-posts/flow.yaml +46 -0
  39. package/factories/week-of-posts/mindburn.tests.yaml +80 -0
  40. package/package.json +15 -2
  41. package/packages/cli/package.json +1 -1
  42. package/packages/cli/src/commands/export.mjs +0 -1
  43. package/packages/cli/src/commands/get.mjs +0 -1
  44. package/packages/cli/src/commands/inspect.mjs +0 -1
  45. package/packages/cli/src/commands/list.mjs +1 -6
  46. package/packages/cli/src/commands/login.mjs +161 -0
  47. package/packages/cli/src/commands/logout.mjs +43 -0
  48. package/packages/cli/src/commands/mcp.mjs +0 -1
  49. package/packages/cli/src/commands/new.mjs +0 -1
  50. package/packages/cli/src/commands/pack.mjs +0 -1
  51. package/packages/cli/src/commands/publish.mjs +62 -0
  52. package/packages/cli/src/commands/pull.mjs +59 -0
  53. package/packages/cli/src/commands/push.mjs +115 -0
  54. package/packages/cli/src/commands/resume.mjs +0 -1
  55. package/packages/cli/src/commands/run.mjs +182 -20
  56. package/packages/cli/src/commands/runs.mjs +52 -0
  57. package/packages/cli/src/commands/search.mjs +0 -1
  58. package/packages/cli/src/commands/validate.mjs +1 -3
  59. package/packages/cli/src/commands/whoami.mjs +34 -0
  60. package/packages/cli/src/hosted.mjs +159 -0
  61. package/packages/cli/src/main.mjs +80 -14
  62. package/packages/cli/src/tool-sources.mjs +272 -0
  63. package/packages/cli/src/util.mjs +37 -0
  64. package/packages/engine/lib/credentials.mjs +121 -0
  65. package/packages/engine/lib/hosted.mjs +119 -0
  66. package/packages/engine/lib/hub.mjs +376 -24
  67. package/packages/engine/lib/nango.mjs +157 -0
  68. package/packages/engine/lib/node.mjs +161 -33
  69. package/packages/engine/lib/providers.mjs +8 -3
  70. package/packages/engine/lib/run.mjs +70 -9
  71. package/packages/engine/lib/text.mjs +5 -0
  72. package/packages/engine/package.json +8 -2
  73. package/packages/engine/src/factory-engine.mjs +415 -68
  74. package/packages/engine/src/flow-sources.mjs +135 -0
  75. package/packages/engine/src/mcp-client.mjs +429 -0
  76. package/packages/engine/src/run.mjs +81 -5
  77. package/packages/engine/src/tool-sources.mjs +566 -0
  78. package/packages/mcp/package.json +1 -1
  79. package/packages/mcp/src/server.mjs +47 -17
  80. package/packages/sdk/package.json +28 -0
  81. package/packages/sdk/src/index.d.ts +163 -0
  82. package/packages/sdk/src/index.mjs +266 -0
  83. package/spec/README.md +7 -1
  84. package/spec/factory-format.md +264 -19
  85. package/spec/schema/factory.yaml.json +815 -0
  86. package/spec/schema/flow.schema.json +24 -2
  87. 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 { readFile, writeFile, readdir, mkdir, lstat } from 'node:fs/promises';
5
- import { existsSync } from 'node:fs';
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
- for (const entry of entries) {
54
+ return Promise.all(entries.map(async (entry) => {
32
55
  const { name } = entry;
33
- if (name.startsWith('.')) continue;
56
+ if (name.startsWith('.')) return null;
34
57
  const path = join(dir, name);
35
58
  const rel = relative(root, path).split(sep).join('/');
36
- if (entry.isSymbolicLink()) {
37
- if (dir !== root || SOURCE_FOLDERS.has(name) || ALLOWED_PATH.test(rel)) throw new Error(`Factory source cannot contain a symbolic link: ${rel}`);
38
- continue;
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
- if (entry.isDirectory()) {
41
- if (SOURCE_FOLDERS.has(name) || dir !== root) await walk(path);
42
- continue;
43
- }
44
- if (dir === root && GENERATED.has(name)) continue;
45
- if (ALLOWED_PATH.test(rel)) files[rel] = await readFile(path, 'utf8');
46
- else if (dir !== root || /\.(md|ya?ml)$/i.test(name)) skipped.push(rel);
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 response = await fetchImpl(target, { headers: { accept: 'application/json', ...headers } });
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 root = resolve(dir);
102
- for (const [path, content] of Object.entries(files)) {
103
- if (!allow(path)) throw new Error(`Refusing to write "${path}": it is not a factory file.`);
104
- const target = resolve(root, path);
105
- if (!target.startsWith(root + sep)) throw new Error(`Refusing to write outside ${root}: ${path}`);
106
- await mkdir(dirname(target), { recursive: true });
107
- await writeFile(target, content);
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 === 'anthropic') return anthropicProvider({ apiKey, model: chosen, fetchImpl, baseUrl: address });
51
- return openaiProvider({ apiKey, model: chosen, fetchImpl, baseUrl: address });
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 `Address: ${url}\n\n${htmlToText(await response.text(), url)}`;
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
- const parsed = parseBundle(canonicalFiles(files));
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
- if (!parsed.factory.runnable) {
65
- return { ok: false, problems: parsed.factory.blockers.length ? parsed.factory.blockers : ['This factory has no flow.yaml, so there are no steps to run. Follow agents/foreman/agent.md in an agent instead.'] };
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
- const checked = checkInputs(parsed.factory, inputs);
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.0",
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",