@flue/sdk 0.2.0 → 0.3.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/README.md +135 -15
- package/package.json +2 -2
- package/dist/agent-BYG0nVbQ.mjs +0 -432
- package/dist/client.d.mts +0 -25
- package/dist/client.mjs +0 -59
- package/dist/cloudflare/index.d.mts +0 -40
- package/dist/cloudflare/index.mjs +0 -230
- package/dist/command-helpers-C8SHLdaA.d.mts +0 -21
- package/dist/command-helpers-CxRhK1my.mjs +0 -37
- package/dist/index.d.mts +0 -35
- package/dist/index.mjs +0 -1001
- package/dist/internal.d.mts +0 -15
- package/dist/internal.mjs +0 -6
- package/dist/node/index.d.mts +0 -14
- package/dist/node/index.mjs +0 -75
- package/dist/sandbox.d.mts +0 -28
- package/dist/sandbox.mjs +0 -102
- package/dist/session-CiAMTsLZ.mjs +0 -943
- package/dist/types-C0nqbu6Z.d.mts +0 -358
package/dist/index.mjs
DELETED
|
@@ -1,1001 +0,0 @@
|
|
|
1
|
-
import { a as parseFrontmatterFile, n as createTools, t as BUILTIN_TOOL_NAMES } from "./agent-BYG0nVbQ.mjs";
|
|
2
|
-
import * as esbuild from "esbuild";
|
|
3
|
-
import * as fs from "node:fs";
|
|
4
|
-
import * as path from "node:path";
|
|
5
|
-
import { packageUpSync } from "package-up";
|
|
6
|
-
import { parse } from "jsonc-parser";
|
|
7
|
-
|
|
8
|
-
//#region src/cloudflare-wrangler-merge.ts
|
|
9
|
-
/**
|
|
10
|
-
* Merge Flue's Cloudflare additions into the user's wrangler config.
|
|
11
|
-
*
|
|
12
|
-
* Philosophy: the user's wrangler.jsonc is the source of truth. Flue contributes
|
|
13
|
-
* the pieces it owns (the Worker entrypoint, its per-agent Durable Object
|
|
14
|
-
* bindings, the Sandbox DO, the migration tag) and leaves everything else
|
|
15
|
-
* untouched. The merged result is written to `dist/wrangler.jsonc` so the
|
|
16
|
-
* deployed Worker sees both.
|
|
17
|
-
*
|
|
18
|
-
* We use `jsonc-parser` (the same library wrangler uses internally) for
|
|
19
|
-
* reading. TOML is intentionally unsupported — Cloudflare itself recommends
|
|
20
|
-
* wrangler.jsonc for new projects, and supporting both formats here would
|
|
21
|
-
* double the surface area for no real benefit. Users with wrangler.toml get a
|
|
22
|
-
* clear error directing them to convert.
|
|
23
|
-
*/
|
|
24
|
-
/** Minimum compatibility_date Flue supports. */
|
|
25
|
-
const MIN_COMPATIBILITY_DATE = "2024-04-03";
|
|
26
|
-
/** compatibility_flag Flue requires for pi-ai's process.env-based API key lookup. */
|
|
27
|
-
const REQUIRED_COMPAT_FLAG = "nodejs_compat";
|
|
28
|
-
/**
|
|
29
|
-
* Read the user's wrangler config from `outputDir` if present.
|
|
30
|
-
*
|
|
31
|
-
* Looks for `wrangler.jsonc` then `wrangler.json` (in that order — jsonc is the
|
|
32
|
-
* recommended format). If a `wrangler.toml` is present instead, throws with a
|
|
33
|
-
* clear conversion hint. Returns an empty config if no file is present.
|
|
34
|
-
*/
|
|
35
|
-
function readUserWranglerConfig(outputDir) {
|
|
36
|
-
const jsoncPath = path.join(outputDir, "wrangler.jsonc");
|
|
37
|
-
const jsonPath = path.join(outputDir, "wrangler.json");
|
|
38
|
-
const tomlPath = path.join(outputDir, "wrangler.toml");
|
|
39
|
-
const foundPath = fs.existsSync(jsoncPath) ? jsoncPath : fs.existsSync(jsonPath) ? jsonPath : null;
|
|
40
|
-
if (!foundPath) {
|
|
41
|
-
if (fs.existsSync(tomlPath)) throw new Error(`[flue] Found wrangler.toml at ${tomlPath}. Flue only supports wrangler.jsonc (the format Cloudflare recommends for new projects). Convert your config to wrangler.jsonc — you can use any online TOML-to-JSON converter, or copy the fields over by hand.`);
|
|
42
|
-
return {
|
|
43
|
-
config: {},
|
|
44
|
-
path: null
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
const source = fs.readFileSync(foundPath, "utf-8");
|
|
48
|
-
const errors = [];
|
|
49
|
-
const parsed = parse(source, errors, { allowTrailingComma: true });
|
|
50
|
-
if (errors.length > 0) {
|
|
51
|
-
const summary = errors.slice(0, 3).map((e) => `offset ${e.offset}: error code ${e.error}`).join("; ");
|
|
52
|
-
throw new Error(`[flue] Failed to parse ${foundPath}: ${summary}. Please fix syntax errors in your wrangler config before building.`);
|
|
53
|
-
}
|
|
54
|
-
if (parsed === void 0 || parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`[flue] ${foundPath} did not contain a JSON object at the top level. A wrangler config must be an object.`);
|
|
55
|
-
return {
|
|
56
|
-
config: parsed,
|
|
57
|
-
path: foundPath
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
/**
|
|
61
|
-
* Validate that the user's wrangler config meets Flue's minimum runtime
|
|
62
|
-
* requirements. Throws a clear error describing the fix if it doesn't.
|
|
63
|
-
*
|
|
64
|
-
* We're intentionally strict here rather than silently massaging bad configs —
|
|
65
|
-
* the failure modes when these are wrong (missing nodejs_compat, old
|
|
66
|
-
* compat_date) produce confusing runtime errors, and surfacing the problem at
|
|
67
|
-
* build time is much friendlier.
|
|
68
|
-
*/
|
|
69
|
-
function validateUserWranglerConfig(config) {
|
|
70
|
-
if (Array.isArray(config.compatibility_flags)) {
|
|
71
|
-
if (!config.compatibility_flags.includes(REQUIRED_COMPAT_FLAG)) throw new Error(`[flue] Your wrangler config's "compatibility_flags" is missing "${REQUIRED_COMPAT_FLAG}". Flue relies on it at runtime (e.g. for API key resolution via process.env). Add "${REQUIRED_COMPAT_FLAG}" to the list.`);
|
|
72
|
-
}
|
|
73
|
-
if (typeof config.compatibility_date === "string") {
|
|
74
|
-
const userDate = config.compatibility_date;
|
|
75
|
-
if (!/^\d{4}-\d{2}-\d{2}$/.test(userDate)) throw new Error(`[flue] Your wrangler config's "compatibility_date" ("${userDate}") is not in YYYY-MM-DD format.`);
|
|
76
|
-
if (userDate < MIN_COMPATIBILITY_DATE) throw new Error(`[flue] Your wrangler config's "compatibility_date" is "${userDate}". Flue requires at least "${MIN_COMPATIBILITY_DATE}" for SQLite-backed Durable Object support and nodejs_compat v2. Bump the date (set it to today unless you have a specific reason).`);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
/**
|
|
80
|
-
* Produce the merged wrangler config: start from the user's, layer Flue's
|
|
81
|
-
* contributions on top. Pure function — caller handles reading and writing.
|
|
82
|
-
*/
|
|
83
|
-
function mergeFlueAdditions(userConfig, additions) {
|
|
84
|
-
const merged = { ...userConfig };
|
|
85
|
-
merged.main = additions.main;
|
|
86
|
-
if (typeof merged.name !== "string" || merged.name.length === 0) merged.name = additions.defaultName;
|
|
87
|
-
if (typeof merged.compatibility_date !== "string") merged.compatibility_date = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
88
|
-
const existingFlags = Array.isArray(merged.compatibility_flags) ? merged.compatibility_flags.filter((f) => typeof f === "string") : [];
|
|
89
|
-
if (!existingFlags.includes(REQUIRED_COMPAT_FLAG)) existingFlags.push(REQUIRED_COMPAT_FLAG);
|
|
90
|
-
merged.compatibility_flags = existingFlags;
|
|
91
|
-
const existingDo = typeof merged.durable_objects === "object" && merged.durable_objects !== null ? merged.durable_objects : {};
|
|
92
|
-
const existingBindings = Array.isArray(existingDo.bindings) ? existingDo.bindings : [];
|
|
93
|
-
const existingBindingNames = new Set(existingBindings.filter((b) => typeof b === "object" && b !== null).map((b) => b.name).filter((n) => typeof n === "string"));
|
|
94
|
-
const flueBindingsToAdd = additions.doBindings.filter((b) => !existingBindingNames.has(b.name));
|
|
95
|
-
merged.durable_objects = {
|
|
96
|
-
...existingDo,
|
|
97
|
-
bindings: [...existingBindings, ...flueBindingsToAdd]
|
|
98
|
-
};
|
|
99
|
-
const existingMigrations = Array.isArray(merged.migrations) ? merged.migrations : [];
|
|
100
|
-
const existingMigrationTags = new Set(existingMigrations.filter((m) => typeof m === "object" && m !== null).map((m) => m.tag).filter((t) => typeof t === "string"));
|
|
101
|
-
const migrationsOut = [...existingMigrations];
|
|
102
|
-
if (!existingMigrationTags.has(additions.migration.tag)) migrationsOut.push(additions.migration);
|
|
103
|
-
merged.migrations = migrationsOut;
|
|
104
|
-
return merged;
|
|
105
|
-
}
|
|
106
|
-
/**
|
|
107
|
-
* Return the list of `class_name`s declared in the user's wrangler
|
|
108
|
-
* `durable_objects.bindings` that contain the literal substring `Sandbox`
|
|
109
|
-
* (case-sensitive).
|
|
110
|
-
*
|
|
111
|
-
* This is Flue's convention for wiring `@cloudflare/sandbox`: any DO binding
|
|
112
|
-
* whose class name contains `Sandbox` triggers an automatic re-export in the
|
|
113
|
-
* generated Worker entry:
|
|
114
|
-
*
|
|
115
|
-
* export { Sandbox as <class_name> } from '@cloudflare/sandbox';
|
|
116
|
-
*
|
|
117
|
-
* The alias lets users pick arbitrary class names (e.g. `PyBoxSandbox`,
|
|
118
|
-
* `SupportSandbox`) while still pointing at the single class shipped by the
|
|
119
|
-
* `@cloudflare/sandbox` package. Each distinct `class_name` can be paired with
|
|
120
|
-
* a different container image in the user's `containers[]` config.
|
|
121
|
-
*
|
|
122
|
-
* Returns unique, sorted class names. Non-object bindings or bindings without
|
|
123
|
-
* a string `class_name` are ignored.
|
|
124
|
-
*/
|
|
125
|
-
function detectSandboxBindings(userConfig) {
|
|
126
|
-
const doObj = userConfig.durable_objects;
|
|
127
|
-
if (typeof doObj !== "object" || doObj === null) return [];
|
|
128
|
-
const bindings = doObj.bindings;
|
|
129
|
-
if (!Array.isArray(bindings)) return [];
|
|
130
|
-
const found = /* @__PURE__ */ new Set();
|
|
131
|
-
for (const entry of bindings) {
|
|
132
|
-
if (typeof entry !== "object" || entry === null) continue;
|
|
133
|
-
const className = entry.class_name;
|
|
134
|
-
if (typeof className !== "string") continue;
|
|
135
|
-
if (className.includes("Sandbox")) found.add(className);
|
|
136
|
-
}
|
|
137
|
-
return Array.from(found).sort();
|
|
138
|
-
}
|
|
139
|
-
/**
|
|
140
|
-
* When the user has declared one or more `Sandbox`-named DO bindings, verify
|
|
141
|
-
* that `@cloudflare/sandbox` is declared in the nearest package.json. Surfaces
|
|
142
|
-
* a friendly, actionable error at build time rather than letting esbuild emit
|
|
143
|
-
* a confusing module-resolution failure.
|
|
144
|
-
*
|
|
145
|
-
* The check is lenient: if no package.json can be located or parsed, we skip
|
|
146
|
-
* silently and let esbuild's own error path take over. This avoids false
|
|
147
|
-
* positives in unusual project layouts.
|
|
148
|
-
*/
|
|
149
|
-
function assertSandboxPackageInstalled(sandboxClassNames, searchDirs) {
|
|
150
|
-
if (sandboxClassNames.length === 0) return;
|
|
151
|
-
for (const dir of searchDirs) {
|
|
152
|
-
let current = dir;
|
|
153
|
-
while (current !== path.dirname(current)) {
|
|
154
|
-
const pkgPath = path.join(current, "package.json");
|
|
155
|
-
if (fs.existsSync(pkgPath)) try {
|
|
156
|
-
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
157
|
-
if ("@cloudflare/sandbox" in {
|
|
158
|
-
...pkg.dependencies ?? {},
|
|
159
|
-
...pkg.devDependencies ?? {},
|
|
160
|
-
...pkg.peerDependencies ?? {},
|
|
161
|
-
...pkg.optionalDependencies ?? {}
|
|
162
|
-
}) return;
|
|
163
|
-
} catch {
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
166
|
-
current = path.dirname(current);
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
throw new Error(`[flue] Your wrangler config declares DO binding(s) whose class_name contains "Sandbox" (${sandboxClassNames.join(", ")}), but @cloudflare/sandbox is not in your package.json. Install it: \`npm install @cloudflare/sandbox\`.`);
|
|
170
|
-
}
|
|
171
|
-
/**
|
|
172
|
-
* Write the wrangler deploy-redirect file at `<outputDir>/.wrangler/deploy/config.json`
|
|
173
|
-
* so that `wrangler deploy` run from `outputDir` automatically picks up the
|
|
174
|
-
* generated `dist/wrangler.jsonc`.
|
|
175
|
-
*
|
|
176
|
-
* This is wrangler's own native redirection mechanism (the same one Astro's
|
|
177
|
-
* Cloudflare adapter uses). We only write the file if one doesn't already
|
|
178
|
-
* exist — if the user has set one up, respect their intent.
|
|
179
|
-
*/
|
|
180
|
-
function writeDeployRedirectIfMissing(outputDir) {
|
|
181
|
-
const redirectDir = path.join(outputDir, ".wrangler", "deploy");
|
|
182
|
-
const redirectPath = path.join(redirectDir, "config.json");
|
|
183
|
-
if (fs.existsSync(redirectPath)) return;
|
|
184
|
-
fs.mkdirSync(redirectDir, { recursive: true });
|
|
185
|
-
fs.writeFileSync(redirectPath, JSON.stringify({ configPath: "../../dist/wrangler.jsonc" }, null, 2) + "\n", "utf-8");
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
//#endregion
|
|
189
|
-
//#region src/build-plugin-cloudflare.ts
|
|
190
|
-
var CloudflarePlugin = class {
|
|
191
|
-
name = "cloudflare";
|
|
192
|
-
generateEntryPoint(ctx) {
|
|
193
|
-
const { agents, roles } = ctx;
|
|
194
|
-
const rolesJson = JSON.stringify(roles);
|
|
195
|
-
const webhookAgents = agents.filter((a) => a.triggers.webhook);
|
|
196
|
-
const agentImports = agents.map((a) => {
|
|
197
|
-
return `import ${agentVarName$1(a.name)} from '${a.filePath.replace(/\\/g, "/")}';`;
|
|
198
|
-
}).join("\n");
|
|
199
|
-
const manifest = JSON.stringify({ agents: agents.map((a) => ({
|
|
200
|
-
name: a.name,
|
|
201
|
-
triggers: a.triggers
|
|
202
|
-
})) }, null, 2);
|
|
203
|
-
const agentClasses = webhookAgents.map((a) => {
|
|
204
|
-
const className = agentClassName(a.name);
|
|
205
|
-
const handlerVar = agentVarName$1(a.name);
|
|
206
|
-
return `export class ${className} extends Agent {
|
|
207
|
-
async onRequest(request) {
|
|
208
|
-
return handleAgentRequest(request, this, ${JSON.stringify(a.name)}, ${handlerVar});
|
|
209
|
-
}
|
|
210
|
-
}`;
|
|
211
|
-
}).join("\n\n");
|
|
212
|
-
const { config: userConfig } = readUserWranglerConfig(ctx.outputDir);
|
|
213
|
-
return `
|
|
214
|
-
// Auto-generated by @flue/sdk build (cloudflare)
|
|
215
|
-
import { Agent, routeAgentRequest } from 'agents';
|
|
216
|
-
import { Bash, InMemoryFs } from 'just-bash';
|
|
217
|
-
import { getModel } from '@mariozechner/pi-ai';
|
|
218
|
-
import { createFlueContext, InMemorySessionStore, bashToSessionEnv } from '@flue/sdk/internal';
|
|
219
|
-
import { setCloudflareContext, clearCloudflareContext, cfSandboxToSessionEnv } from '@flue/sdk/cloudflare';
|
|
220
|
-
|
|
221
|
-
${agentImports}
|
|
222
|
-
|
|
223
|
-
// ─── Config ─────────────────────────────────────────────────────────────────
|
|
224
|
-
|
|
225
|
-
const roles = ${rolesJson};
|
|
226
|
-
const skills = {};
|
|
227
|
-
const systemPrompt = '';
|
|
228
|
-
const manifest = ${manifest};
|
|
229
|
-
|
|
230
|
-
// ─── Infrastructure ─────────────────────────────────────────────────────────
|
|
231
|
-
|
|
232
|
-
// No build-time model default. The user sets model at runtime via
|
|
233
|
-
// \`init({ model: "provider/model-id" })\` for a session default, or via
|
|
234
|
-
// \`{ model: "provider/model-id" }\` on any individual prompt/skill/task call.
|
|
235
|
-
const model = undefined;
|
|
236
|
-
|
|
237
|
-
function resolveModel(modelString) {
|
|
238
|
-
const slash = modelString.indexOf('/');
|
|
239
|
-
if (slash === -1) {
|
|
240
|
-
throw new Error(
|
|
241
|
-
'[flue] Invalid model "' + modelString + '". ' +
|
|
242
|
-
'Use the "provider/model-id" format (e.g. "anthropic/claude-haiku-4-5").'
|
|
243
|
-
);
|
|
244
|
-
}
|
|
245
|
-
const provider = modelString.slice(0, slash);
|
|
246
|
-
const modelId = modelString.slice(slash + 1);
|
|
247
|
-
const resolved = getModel(provider, modelId);
|
|
248
|
-
if (!resolved) {
|
|
249
|
-
throw new Error(
|
|
250
|
-
'[flue] Unknown model "' + modelString + '". ' +
|
|
251
|
-
'Provider "' + provider + '" / model id "' + modelId + '" ' +
|
|
252
|
-
'is not registered with @mariozechner/pi-ai.'
|
|
253
|
-
);
|
|
254
|
-
}
|
|
255
|
-
return resolved;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// ─── Sandbox Environments ───────────────────────────────────────────────────
|
|
259
|
-
|
|
260
|
-
/**
|
|
261
|
-
* Create an empty in-memory sandbox (default).
|
|
262
|
-
*/
|
|
263
|
-
async function createDefaultEnv() {
|
|
264
|
-
const bash = new Bash({
|
|
265
|
-
fs: new InMemoryFs(),
|
|
266
|
-
network: { dangerouslyAllowFullInternetAccess: true },
|
|
267
|
-
});
|
|
268
|
-
return bashToSessionEnv(bash);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* 'local' sandbox is not available on Cloudflare Workers.
|
|
273
|
-
*/
|
|
274
|
-
async function createLocalEnv() {
|
|
275
|
-
throw new Error(
|
|
276
|
-
"[flue] 'local' sandbox is not supported on Cloudflare Workers. " +
|
|
277
|
-
"Use the default empty sandbox, pass a custom Bash instance, " +
|
|
278
|
-
"or pass a sandbox instance (from any SDK — e.g. @cloudflare/sandbox " +
|
|
279
|
-
"or a Flue connector) to init({ sandbox })."
|
|
280
|
-
);
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/**
|
|
284
|
-
* Detect and wrap external sandbox instances (e.g. from @cloudflare/sandbox's
|
|
285
|
-
* getSandbox()). Returns SessionEnv if the object quacks like a container
|
|
286
|
-
* sandbox, null otherwise.
|
|
287
|
-
*/
|
|
288
|
-
function resolveSandbox(sandbox) {
|
|
289
|
-
if (
|
|
290
|
-
sandbox && typeof sandbox === 'object' &&
|
|
291
|
-
typeof sandbox.exec === 'function' &&
|
|
292
|
-
typeof sandbox.readFile === 'function' &&
|
|
293
|
-
typeof sandbox.destroy === 'function' &&
|
|
294
|
-
!('getCwd' in sandbox) && !('fs' in sandbox)
|
|
295
|
-
) {
|
|
296
|
-
return cfSandboxToSessionEnv(sandbox);
|
|
297
|
-
}
|
|
298
|
-
return null;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// Fallback in-memory store (used if no DO storage is available).
|
|
302
|
-
const memoryStore = new InMemorySessionStore();
|
|
303
|
-
|
|
304
|
-
// Create a DO-backed session store from the Durable Object's SQL storage.
|
|
305
|
-
function createDOStore(sql) {
|
|
306
|
-
// Ensure the table exists
|
|
307
|
-
sql.exec(
|
|
308
|
-
'CREATE TABLE IF NOT EXISTS flue_sessions (id TEXT PRIMARY KEY, data TEXT NOT NULL, updated_at INTEGER NOT NULL)'
|
|
309
|
-
);
|
|
310
|
-
return {
|
|
311
|
-
async save(id, data) {
|
|
312
|
-
const json = JSON.stringify(data);
|
|
313
|
-
sql.exec(
|
|
314
|
-
'INSERT OR REPLACE INTO flue_sessions (id, data, updated_at) VALUES (?, ?, ?)',
|
|
315
|
-
id, json, Date.now()
|
|
316
|
-
);
|
|
317
|
-
},
|
|
318
|
-
async load(id) {
|
|
319
|
-
const rows = sql.exec('SELECT data FROM flue_sessions WHERE id = ?', id).toArray();
|
|
320
|
-
if (rows.length === 0) return null;
|
|
321
|
-
return JSON.parse(rows[0].data);
|
|
322
|
-
},
|
|
323
|
-
async delete(id) {
|
|
324
|
-
sql.exec('DELETE FROM flue_sessions WHERE id = ?', id);
|
|
325
|
-
},
|
|
326
|
-
};
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
function createContextForRequest(sessionId, payload, doInstance) {
|
|
330
|
-
// Use DO SQLite storage by default, fall back to in-memory
|
|
331
|
-
const defaultStore = doInstance?.ctx?.storage?.sql
|
|
332
|
-
? createDOStore(doInstance.ctx.storage.sql)
|
|
333
|
-
: memoryStore;
|
|
334
|
-
|
|
335
|
-
return createFlueContext({
|
|
336
|
-
sessionId,
|
|
337
|
-
payload,
|
|
338
|
-
env: doInstance?.env ?? {},
|
|
339
|
-
agentConfig: {
|
|
340
|
-
systemPrompt, skills, roles, model, resolveModel,
|
|
341
|
-
},
|
|
342
|
-
createDefaultEnv,
|
|
343
|
-
createLocalEnv,
|
|
344
|
-
defaultStore,
|
|
345
|
-
resolveSandbox,
|
|
346
|
-
});
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
// ─── Shared Request Handler ────────────────────────────────────────────────
|
|
350
|
-
|
|
351
|
-
async function handleAgentRequest(request, doInstance, agentName, handler) {
|
|
352
|
-
// Session ID is the DO "room name" set by routeAgentRequest
|
|
353
|
-
const sessionId = doInstance.name;
|
|
354
|
-
|
|
355
|
-
// Parse payload
|
|
356
|
-
let payload;
|
|
357
|
-
try {
|
|
358
|
-
payload = await request.json();
|
|
359
|
-
} catch {
|
|
360
|
-
payload = {};
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// Set up Cloudflare context for runtime primitives
|
|
364
|
-
setCloudflareContext({ env: doInstance.env, agentInstance: doInstance, storage: doInstance.ctx.storage });
|
|
365
|
-
|
|
366
|
-
const accept = request.headers.get('accept') || '';
|
|
367
|
-
const isWebhook = request.headers.get('x-webhook') === 'true';
|
|
368
|
-
const isSSE = accept.includes('text/event-stream') && !isWebhook;
|
|
369
|
-
|
|
370
|
-
try {
|
|
371
|
-
// Fire-and-forget (webhook mode)
|
|
372
|
-
if (isWebhook) {
|
|
373
|
-
const requestId = crypto.randomUUID();
|
|
374
|
-
const ctx = createContextForRequest(sessionId, payload, doInstance);
|
|
375
|
-
handler(ctx).then(
|
|
376
|
-
(result) => {
|
|
377
|
-
ctx.setEventCallback(undefined);
|
|
378
|
-
clearCloudflareContext();
|
|
379
|
-
console.log('[flue] Webhook handler complete:', agentName,
|
|
380
|
-
result !== undefined ? JSON.stringify(result) : '(no return)');
|
|
381
|
-
},
|
|
382
|
-
(err) => {
|
|
383
|
-
ctx.setEventCallback(undefined);
|
|
384
|
-
clearCloudflareContext();
|
|
385
|
-
console.error('[flue] Webhook handler error:', agentName, err);
|
|
386
|
-
},
|
|
387
|
-
);
|
|
388
|
-
return new Response(JSON.stringify({ status: 'accepted', requestId }), {
|
|
389
|
-
status: 202,
|
|
390
|
-
headers: { 'content-type': 'application/json' },
|
|
391
|
-
});
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
// SSE streaming mode
|
|
395
|
-
if (isSSE) {
|
|
396
|
-
const { readable, writable } = new TransformStream();
|
|
397
|
-
const writer = writable.getWriter();
|
|
398
|
-
const encoder = new TextEncoder();
|
|
399
|
-
let eventId = 0;
|
|
400
|
-
|
|
401
|
-
const writeSSE = async (data, event) => {
|
|
402
|
-
const lines = [];
|
|
403
|
-
if (event) lines.push('event: ' + event);
|
|
404
|
-
lines.push('id: ' + eventId++);
|
|
405
|
-
lines.push('data: ' + JSON.stringify(data));
|
|
406
|
-
lines.push('', '');
|
|
407
|
-
await writer.write(encoder.encode(lines.join('\\n')));
|
|
408
|
-
};
|
|
409
|
-
|
|
410
|
-
const ctx = createContextForRequest(sessionId, payload, doInstance);
|
|
411
|
-
ctx.setEventCallback((event) => {
|
|
412
|
-
writeSSE(event, event.type).catch(() => {});
|
|
413
|
-
});
|
|
414
|
-
|
|
415
|
-
(async () => {
|
|
416
|
-
try {
|
|
417
|
-
const result = await handler(ctx);
|
|
418
|
-
await writeSSE(
|
|
419
|
-
{ type: 'result', data: result !== undefined ? result : null },
|
|
420
|
-
'result',
|
|
421
|
-
);
|
|
422
|
-
} catch (err) {
|
|
423
|
-
await writeSSE(
|
|
424
|
-
{ type: 'error', error: String(err) },
|
|
425
|
-
'error',
|
|
426
|
-
);
|
|
427
|
-
} finally {
|
|
428
|
-
ctx.setEventCallback(undefined);
|
|
429
|
-
clearCloudflareContext();
|
|
430
|
-
await writer.close();
|
|
431
|
-
}
|
|
432
|
-
})();
|
|
433
|
-
|
|
434
|
-
return new Response(readable, {
|
|
435
|
-
headers: {
|
|
436
|
-
'content-type': 'text/event-stream',
|
|
437
|
-
'cache-control': 'no-cache',
|
|
438
|
-
'connection': 'keep-alive',
|
|
439
|
-
},
|
|
440
|
-
});
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
// Sync mode (default)
|
|
444
|
-
const ctx = createContextForRequest(sessionId, payload, doInstance);
|
|
445
|
-
const result = await handler(ctx);
|
|
446
|
-
ctx.setEventCallback(undefined);
|
|
447
|
-
clearCloudflareContext();
|
|
448
|
-
return new Response(
|
|
449
|
-
JSON.stringify({ result: result !== undefined ? result : null }),
|
|
450
|
-
{ headers: { 'content-type': 'application/json' } },
|
|
451
|
-
);
|
|
452
|
-
} catch (err) {
|
|
453
|
-
clearCloudflareContext();
|
|
454
|
-
console.error('[flue] Agent error:', agentName, err);
|
|
455
|
-
return new Response(
|
|
456
|
-
JSON.stringify({ error: String(err) }),
|
|
457
|
-
{ status: 500, headers: { 'content-type': 'application/json' } },
|
|
458
|
-
);
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
// ─── Per-Agent Durable Object Classes ──────────────────────────────────────
|
|
463
|
-
|
|
464
|
-
${agentClasses}
|
|
465
|
-
|
|
466
|
-
// ─── User-declared Sandbox re-exports ──────────────────────────────────────
|
|
467
|
-
// One line per DO binding in the user's wrangler.jsonc whose class_name
|
|
468
|
-
// contains "Sandbox". Flue aliases the single \`Sandbox\` class shipped by
|
|
469
|
-
// \`@cloudflare/sandbox\` so each user-chosen class_name resolves at the
|
|
470
|
-
// bundle's top level. The binding + container image configuration is owned
|
|
471
|
-
// by the user's wrangler.jsonc.
|
|
472
|
-
${detectSandboxBindings(userConfig).map((name) => `export { Sandbox as ${name} } from '@cloudflare/sandbox';`).join("\n")}
|
|
473
|
-
|
|
474
|
-
// ─── Worker Fetch Handler ───────────────────────────────────────────────────
|
|
475
|
-
|
|
476
|
-
export default {
|
|
477
|
-
async fetch(request, env) {
|
|
478
|
-
const url = new URL(request.url);
|
|
479
|
-
|
|
480
|
-
// Health check
|
|
481
|
-
if (url.pathname === '/health') {
|
|
482
|
-
return new Response(JSON.stringify({ status: 'ok' }), {
|
|
483
|
-
headers: { 'content-type': 'application/json' },
|
|
484
|
-
});
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
// Agent manifest
|
|
488
|
-
if (url.pathname === '/agents' && request.method === 'GET') {
|
|
489
|
-
return new Response(JSON.stringify(manifest), {
|
|
490
|
-
headers: { 'content-type': 'application/json' },
|
|
491
|
-
});
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
// Route to per-agent DOs via the Agents SDK
|
|
495
|
-
// URL: /agents/<agent-name>/<session-id>
|
|
496
|
-
const response = await routeAgentRequest(request, env);
|
|
497
|
-
if (response) return response;
|
|
498
|
-
|
|
499
|
-
return new Response('Not found', { status: 404 });
|
|
500
|
-
},
|
|
501
|
-
};
|
|
502
|
-
`;
|
|
503
|
-
}
|
|
504
|
-
esbuildOptions(_ctx) {
|
|
505
|
-
return {
|
|
506
|
-
target: "esnext",
|
|
507
|
-
external: [
|
|
508
|
-
"node:*",
|
|
509
|
-
"cloudflare:*",
|
|
510
|
-
"node-liblzma",
|
|
511
|
-
"@mongodb-js/zstd"
|
|
512
|
-
]
|
|
513
|
-
};
|
|
514
|
-
}
|
|
515
|
-
additionalOutputs(ctx) {
|
|
516
|
-
const outputs = {};
|
|
517
|
-
const flueBindings = ctx.agents.filter((a) => a.triggers.webhook).map((a) => ({
|
|
518
|
-
class_name: agentClassName(a.name),
|
|
519
|
-
name: agentClassName(a.name)
|
|
520
|
-
}));
|
|
521
|
-
const flueSqliteClasses = flueBindings.map((b) => b.class_name);
|
|
522
|
-
const additions = {
|
|
523
|
-
defaultName: ctx.outputDir.split("/").pop() ?? "flue-agents",
|
|
524
|
-
main: "server.mjs",
|
|
525
|
-
doBindings: flueBindings,
|
|
526
|
-
migration: {
|
|
527
|
-
tag: "flue-v1",
|
|
528
|
-
new_sqlite_classes: flueSqliteClasses
|
|
529
|
-
}
|
|
530
|
-
};
|
|
531
|
-
const { config: userConfig, path: userConfigPath } = readUserWranglerConfig(ctx.outputDir);
|
|
532
|
-
if (userConfigPath) console.log(`[flue] Merging with user wrangler config: ${userConfigPath}`);
|
|
533
|
-
validateUserWranglerConfig(userConfig);
|
|
534
|
-
const sandboxClassNames = detectSandboxBindings(userConfig);
|
|
535
|
-
if (sandboxClassNames.length > 0) {
|
|
536
|
-
assertSandboxPackageInstalled(sandboxClassNames, [ctx.outputDir, ctx.workspaceDir]);
|
|
537
|
-
for (const className of sandboxClassNames) console.log(`[flue] Detected Sandbox-named DO binding "${className}" — re-exporting from @cloudflare/sandbox.`);
|
|
538
|
-
}
|
|
539
|
-
const merged = mergeFlueAdditions(userConfig, additions);
|
|
540
|
-
if (typeof merged.$schema !== "string") merged.$schema = "https://workers.cloudflare.com/schema/wrangler.json";
|
|
541
|
-
outputs["wrangler.jsonc"] = JSON.stringify(merged, null, 2);
|
|
542
|
-
writeDeployRedirectIfMissing(ctx.outputDir);
|
|
543
|
-
return outputs;
|
|
544
|
-
}
|
|
545
|
-
};
|
|
546
|
-
function agentVarName$1(name) {
|
|
547
|
-
return "handler_" + name.replace(/[^a-zA-Z0-9]/g, "_");
|
|
548
|
-
}
|
|
549
|
-
/**
|
|
550
|
-
* Convert agent name to a PascalCase DO class name.
|
|
551
|
-
* "hello" → "Hello", "with-cloudflare" → "WithCloudflare"
|
|
552
|
-
*
|
|
553
|
-
* routeAgentRequest() converts binding names to kebab-case for URL matching,
|
|
554
|
-
* so "WithCloudflare" → "with-cloudflare" → URL /agents/with-cloudflare/:id
|
|
555
|
-
*/
|
|
556
|
-
function agentClassName(name) {
|
|
557
|
-
return name.split(/[-_]/).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
//#endregion
|
|
561
|
-
//#region src/build-plugin-node.ts
|
|
562
|
-
var NodePlugin = class {
|
|
563
|
-
name = "node";
|
|
564
|
-
generateEntryPoint(ctx) {
|
|
565
|
-
const { agents, roles } = ctx;
|
|
566
|
-
const rolesJson = JSON.stringify(roles);
|
|
567
|
-
const webhookAgents = agents.filter((a) => a.triggers.webhook);
|
|
568
|
-
return `
|
|
569
|
-
// Auto-generated by @flue/sdk build (node)
|
|
570
|
-
import { Hono } from 'hono';
|
|
571
|
-
import { streamSSE } from 'hono/streaming';
|
|
572
|
-
import { serve } from '@hono/node-server';
|
|
573
|
-
import { Bash, InMemoryFs, MountableFs, ReadWriteFs } from 'just-bash';
|
|
574
|
-
import { getModel } from '@mariozechner/pi-ai';
|
|
575
|
-
import { createFlueContext, InMemorySessionStore, bashToSessionEnv } from '@flue/sdk/internal';
|
|
576
|
-
import { randomUUID } from 'node:crypto';
|
|
577
|
-
|
|
578
|
-
${agents.map((a) => {
|
|
579
|
-
return `import ${agentVarName(a.name)} from '${a.filePath.replace(/\\/g, "/")}';`;
|
|
580
|
-
}).join("\n")}
|
|
581
|
-
|
|
582
|
-
// ─── Config ─────────────────────────────────────────────────────────────────
|
|
583
|
-
|
|
584
|
-
const skills = {};
|
|
585
|
-
const roles = ${rolesJson};
|
|
586
|
-
const systemPrompt = '';
|
|
587
|
-
|
|
588
|
-
const handlers = {
|
|
589
|
-
${agents.map((a) => ` ${JSON.stringify(a.name)}: ${agentVarName(a.name)},`).join("\n")}
|
|
590
|
-
};
|
|
591
|
-
|
|
592
|
-
const webhookAgents = new Set(${JSON.stringify(webhookAgents.map((a) => a.name))});
|
|
593
|
-
|
|
594
|
-
// When the CLI starts this server via \`flue run\`, it sets FLUE_MODE=local.
|
|
595
|
-
// In local mode the HTTP route accepts any registered agent (including
|
|
596
|
-
// trigger-less CI-only agents). In any other mode the route is restricted to
|
|
597
|
-
// agents with \`webhook: true\`, preventing accidental public exposure of
|
|
598
|
-
// agents that the user only intended to invoke from their CI pipeline.
|
|
599
|
-
const isLocalMode = process.env.FLUE_MODE === 'local';
|
|
600
|
-
|
|
601
|
-
const manifest = ${JSON.stringify({ agents: agents.map((a) => ({
|
|
602
|
-
name: a.name,
|
|
603
|
-
triggers: a.triggers
|
|
604
|
-
})) }, null, 2)};
|
|
605
|
-
|
|
606
|
-
// ─── Infrastructure ─────────────────────────────────────────────────────────
|
|
607
|
-
|
|
608
|
-
// No build-time model default. The user sets model at runtime via
|
|
609
|
-
// \`init({ model: "provider/model-id" })\` for a session default, or via
|
|
610
|
-
// \`{ model: "provider/model-id" }\` on any individual prompt/skill/task call.
|
|
611
|
-
const model = undefined;
|
|
612
|
-
|
|
613
|
-
function resolveModel(modelString) {
|
|
614
|
-
const slash = modelString.indexOf('/');
|
|
615
|
-
if (slash === -1) {
|
|
616
|
-
throw new Error(
|
|
617
|
-
'[flue] Invalid model "' + modelString + '". ' +
|
|
618
|
-
'Use the "provider/model-id" format (e.g. "anthropic/claude-haiku-4-5").'
|
|
619
|
-
);
|
|
620
|
-
}
|
|
621
|
-
const provider = modelString.slice(0, slash);
|
|
622
|
-
const modelId = modelString.slice(slash + 1);
|
|
623
|
-
const resolved = getModel(provider, modelId);
|
|
624
|
-
if (!resolved) {
|
|
625
|
-
throw new Error(
|
|
626
|
-
'[flue] Unknown model "' + modelString + '". ' +
|
|
627
|
-
'Provider "' + provider + '" / model id "' + modelId + '" ' +
|
|
628
|
-
'is not registered with @mariozechner/pi-ai.'
|
|
629
|
-
);
|
|
630
|
-
}
|
|
631
|
-
return resolved;
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
// ─── Sandbox Environments ───────────────────────────────────────────────────
|
|
635
|
-
|
|
636
|
-
/**
|
|
637
|
-
* Create an empty in-memory sandbox (default).
|
|
638
|
-
* Uses InMemoryFs (no real filesystem access) with sensible defaults:
|
|
639
|
-
* cwd = /home/user, /tmp exists, /bin and /usr/bin exist.
|
|
640
|
-
*/
|
|
641
|
-
async function createDefaultEnv() {
|
|
642
|
-
const bash = new Bash({
|
|
643
|
-
fs: new InMemoryFs(),
|
|
644
|
-
network: { dangerouslyAllowFullInternetAccess: true },
|
|
645
|
-
});
|
|
646
|
-
return bashToSessionEnv(bash);
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
/**
|
|
650
|
-
* Create a local sandbox backed by the host filesystem.
|
|
651
|
-
* Mounts process.cwd() at /workspace via ReadWriteFs + MountableFs.
|
|
652
|
-
*/
|
|
653
|
-
async function createLocalEnv() {
|
|
654
|
-
const rwfs = new ReadWriteFs({ root: process.cwd() });
|
|
655
|
-
const fs = new MountableFs({ base: new InMemoryFs() });
|
|
656
|
-
fs.mount('/workspace', rwfs);
|
|
657
|
-
const bash = new Bash({
|
|
658
|
-
fs,
|
|
659
|
-
cwd: '/workspace',
|
|
660
|
-
network: { dangerouslyAllowFullInternetAccess: true },
|
|
661
|
-
});
|
|
662
|
-
return bashToSessionEnv(bash);
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
// Default persistence store for Node — in-memory, process lifetime.
|
|
666
|
-
const defaultStore = new InMemorySessionStore();
|
|
667
|
-
|
|
668
|
-
function createContextForRequest(sessionId, payload) {
|
|
669
|
-
return createFlueContext({
|
|
670
|
-
sessionId,
|
|
671
|
-
payload,
|
|
672
|
-
env: process.env,
|
|
673
|
-
agentConfig: {
|
|
674
|
-
systemPrompt, skills, roles, model, resolveModel,
|
|
675
|
-
},
|
|
676
|
-
createDefaultEnv,
|
|
677
|
-
createLocalEnv,
|
|
678
|
-
defaultStore,
|
|
679
|
-
});
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
// ─── Server ─────────────────────────────────────────────────────────────────
|
|
683
|
-
|
|
684
|
-
const app = new Hono();
|
|
685
|
-
|
|
686
|
-
app.get('/health', (c) => c.json({ status: 'ok' }));
|
|
687
|
-
app.get('/agents', (c) => c.json(manifest));
|
|
688
|
-
|
|
689
|
-
// Session ID is required in the URL
|
|
690
|
-
app.post('/agents/:name', (c) => {
|
|
691
|
-
return c.json({
|
|
692
|
-
error: 'Session ID is required. Use /agents/:name/:sessionId',
|
|
693
|
-
}, 400);
|
|
694
|
-
});
|
|
695
|
-
|
|
696
|
-
app.post('/agents/:name/:sessionId', async (c) => {
|
|
697
|
-
const name = c.req.param('name');
|
|
698
|
-
const sessionId = c.req.param('sessionId');
|
|
699
|
-
|
|
700
|
-
if (!handlers[name]) {
|
|
701
|
-
return c.json({ error: 'Agent not found' }, 404);
|
|
702
|
-
}
|
|
703
|
-
if (!webhookAgents.has(name) && !isLocalMode) {
|
|
704
|
-
return c.json({ error: 'Agent "' + name + '" is not web-accessible (no webhook trigger)' }, 404);
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
const handler = handlers[name];
|
|
708
|
-
let payload;
|
|
709
|
-
try {
|
|
710
|
-
payload = await c.req.json();
|
|
711
|
-
} catch {
|
|
712
|
-
payload = {};
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
const accept = c.req.header('accept') || '';
|
|
716
|
-
const isWebhook = c.req.header('x-webhook') === 'true';
|
|
717
|
-
const isSSE = accept.includes('text/event-stream') && !isWebhook;
|
|
718
|
-
|
|
719
|
-
// Fire-and-forget (webhook mode)
|
|
720
|
-
if (isWebhook) {
|
|
721
|
-
const requestId = randomUUID();
|
|
722
|
-
const ctx = createContextForRequest(sessionId, payload);
|
|
723
|
-
handler(ctx).then(
|
|
724
|
-
(result) => {
|
|
725
|
-
ctx.setEventCallback(undefined);
|
|
726
|
-
console.log('[flue] Webhook handler complete:', name, result !== undefined ? JSON.stringify(result) : '(no return)');
|
|
727
|
-
},
|
|
728
|
-
(err) => {
|
|
729
|
-
ctx.setEventCallback(undefined);
|
|
730
|
-
console.error('[flue] Webhook handler error:', name, err);
|
|
731
|
-
},
|
|
732
|
-
);
|
|
733
|
-
return c.json({ status: 'accepted', requestId }, 202);
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
// SSE streaming mode
|
|
737
|
-
if (isSSE) {
|
|
738
|
-
return streamSSE(c, async (stream) => {
|
|
739
|
-
let eventId = 0;
|
|
740
|
-
const ctx = createContextForRequest(sessionId, payload);
|
|
741
|
-
ctx.setEventCallback((event) => {
|
|
742
|
-
stream.writeSSE({ data: JSON.stringify(event), event: event.type, id: String(eventId++) }).catch(() => {});
|
|
743
|
-
});
|
|
744
|
-
|
|
745
|
-
try {
|
|
746
|
-
const result = await handler(ctx);
|
|
747
|
-
await stream.writeSSE({
|
|
748
|
-
data: JSON.stringify({ type: 'result', data: result !== undefined ? result : null }),
|
|
749
|
-
event: 'result',
|
|
750
|
-
id: String(eventId++),
|
|
751
|
-
});
|
|
752
|
-
} catch (err) {
|
|
753
|
-
await stream.writeSSE({
|
|
754
|
-
data: JSON.stringify({ type: 'error', error: String(err) }),
|
|
755
|
-
event: 'error',
|
|
756
|
-
id: String(eventId++),
|
|
757
|
-
});
|
|
758
|
-
} finally {
|
|
759
|
-
ctx.setEventCallback(undefined);
|
|
760
|
-
}
|
|
761
|
-
});
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
// Sync mode (default)
|
|
765
|
-
try {
|
|
766
|
-
const ctx = createContextForRequest(sessionId, payload);
|
|
767
|
-
const result = await handler(ctx);
|
|
768
|
-
ctx.setEventCallback(undefined);
|
|
769
|
-
return c.json({ result: result !== undefined ? result : null });
|
|
770
|
-
} catch (err) {
|
|
771
|
-
console.error('[flue] Agent error:', name, err);
|
|
772
|
-
return c.json({ error: String(err) }, 500);
|
|
773
|
-
}
|
|
774
|
-
});
|
|
775
|
-
|
|
776
|
-
// ─── Start ──────────────────────────────────────────────────────────────────
|
|
777
|
-
|
|
778
|
-
const port = parseInt(process.env.PORT || '3000', 10);
|
|
779
|
-
|
|
780
|
-
const server = serve({ fetch: app.fetch, port });
|
|
781
|
-
console.log('[flue] Server listening on http://localhost:' + port);
|
|
782
|
-
if (isLocalMode) {
|
|
783
|
-
console.log('[flue] Mode: local (all agents invokable, including trigger-less)');
|
|
784
|
-
console.log('[flue] Available agents: ' + ${JSON.stringify(agents.map((a) => a.name).join(", "))});
|
|
785
|
-
} else {
|
|
786
|
-
console.log('[flue] Available agents: ' + ${JSON.stringify(webhookAgents.map((a) => a.name).join(", "))});
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
process.on('SIGINT', () => { server.close(); process.exit(0); });
|
|
790
|
-
process.on('SIGTERM', () => { server.close(); process.exit(0); });
|
|
791
|
-
`;
|
|
792
|
-
}
|
|
793
|
-
esbuildOptions(_ctx) {
|
|
794
|
-
return {
|
|
795
|
-
platform: "node",
|
|
796
|
-
target: "node22",
|
|
797
|
-
external: ["node-liblzma", "@mongodb-js/zstd"]
|
|
798
|
-
};
|
|
799
|
-
}
|
|
800
|
-
};
|
|
801
|
-
function agentVarName(name) {
|
|
802
|
-
return "handler_" + name.replace(/[^a-zA-Z0-9]/g, "_");
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
//#endregion
|
|
806
|
-
//#region src/build.ts
|
|
807
|
-
/**
|
|
808
|
-
* Build a workspace into a deployable artifact.
|
|
809
|
-
*
|
|
810
|
-
* `options.workspaceDir` is treated as an explicit workspace root — the directory
|
|
811
|
-
* directly containing agents/ and roles/. No .flue/ waterfall is performed here;
|
|
812
|
-
* callers that want waterfall behavior (e.g. the CLI when --workspace is omitted)
|
|
813
|
-
* should use `resolveWorkspaceFromCwd` first.
|
|
814
|
-
*
|
|
815
|
-
* AGENTS.md and .agents/skills/ are NOT bundled — discovered at runtime from session cwd.
|
|
816
|
-
*/
|
|
817
|
-
async function build(options) {
|
|
818
|
-
const workspaceDir = path.resolve(options.workspaceDir);
|
|
819
|
-
const outputDir = path.resolve(options.outputDir);
|
|
820
|
-
const plugin = resolvePlugin(options);
|
|
821
|
-
console.log(`[flue] Building workspace: ${workspaceDir}`);
|
|
822
|
-
console.log(`[flue] Output: ${outputDir}/dist`);
|
|
823
|
-
console.log(`[flue] Target: ${plugin.name}`);
|
|
824
|
-
const roles = discoverRoles(workspaceDir);
|
|
825
|
-
const agents = discoverAgents(workspaceDir);
|
|
826
|
-
if (agents.length === 0) throw new Error(`[flue] No agent files found.\n\nExpected at: ${path.join(workspaceDir, "agents")}/\nAdd at least one agent file (e.g. hello.ts).`);
|
|
827
|
-
const webhookAgents = agents.filter((a) => a.triggers.webhook);
|
|
828
|
-
const cronAgents = agents.filter((a) => a.triggers.cron);
|
|
829
|
-
const triggerlessAgents = agents.filter((a) => !a.triggers.webhook && !a.triggers.cron);
|
|
830
|
-
console.log(`[flue] Found ${Object.keys(roles).length} role(s): ${Object.keys(roles).join(", ") || "(none)"}`);
|
|
831
|
-
console.log(`[flue] Found ${agents.length} agent(s): ${agents.map((a) => a.name).join(", ")}`);
|
|
832
|
-
if (webhookAgents.length > 0) console.log(`[flue] Webhook agents: ${webhookAgents.map((a) => a.name).join(", ")}`);
|
|
833
|
-
if (cronAgents.length > 0) console.log(`[flue] Cron agents (manifest only): ${cronAgents.map((a) => `${a.name} (${a.triggers.cron})`).join(", ")}`);
|
|
834
|
-
if (triggerlessAgents.length > 0) console.log(`[flue] CLI-only agents (no HTTP route in deployed build): ${triggerlessAgents.map((a) => a.name).join(", ")}`);
|
|
835
|
-
console.log(`[flue] AGENTS.md and .agents/skills/ will be discovered at runtime from session cwd`);
|
|
836
|
-
const distDir = path.join(outputDir, "dist");
|
|
837
|
-
fs.mkdirSync(distDir, { recursive: true });
|
|
838
|
-
const manifest = { agents: agents.map((a) => ({
|
|
839
|
-
name: a.name,
|
|
840
|
-
triggers: a.triggers
|
|
841
|
-
})) };
|
|
842
|
-
const manifestPath = path.join(distDir, "manifest.json");
|
|
843
|
-
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf-8");
|
|
844
|
-
console.log(`[flue] Generated: ${manifestPath}`);
|
|
845
|
-
const ctx = {
|
|
846
|
-
agents,
|
|
847
|
-
roles,
|
|
848
|
-
workspaceDir,
|
|
849
|
-
outputDir,
|
|
850
|
-
options
|
|
851
|
-
};
|
|
852
|
-
const serverCode = plugin.generateEntryPoint(ctx);
|
|
853
|
-
const entryPath = path.join(distDir, "_entry_server.ts");
|
|
854
|
-
const outPath = path.join(distDir, "server.mjs");
|
|
855
|
-
fs.writeFileSync(entryPath, serverCode, "utf-8");
|
|
856
|
-
try {
|
|
857
|
-
const nodePathsSet = collectNodePaths(workspaceDir);
|
|
858
|
-
const { external: pluginExternal = [], ...pluginEsbuildOpts } = plugin.esbuildOptions(ctx);
|
|
859
|
-
const userExternals = getUserExternals(workspaceDir);
|
|
860
|
-
await esbuild.build({
|
|
861
|
-
entryPoints: [entryPath],
|
|
862
|
-
bundle: true,
|
|
863
|
-
outfile: outPath,
|
|
864
|
-
format: "esm",
|
|
865
|
-
external: [...pluginExternal, ...userExternals],
|
|
866
|
-
nodePaths: [...nodePathsSet],
|
|
867
|
-
logLevel: "warning",
|
|
868
|
-
loader: {
|
|
869
|
-
".ts": "ts",
|
|
870
|
-
".node": "empty"
|
|
871
|
-
},
|
|
872
|
-
treeShaking: true,
|
|
873
|
-
sourcemap: true,
|
|
874
|
-
...pluginEsbuildOpts
|
|
875
|
-
});
|
|
876
|
-
console.log(`[flue] Built: ${outPath}`);
|
|
877
|
-
} finally {
|
|
878
|
-
try {
|
|
879
|
-
fs.unlinkSync(entryPath);
|
|
880
|
-
} catch {}
|
|
881
|
-
}
|
|
882
|
-
if (plugin.additionalOutputs) {
|
|
883
|
-
const outputs = plugin.additionalOutputs(ctx);
|
|
884
|
-
for (const [filename, content] of Object.entries(outputs)) {
|
|
885
|
-
const filePath = path.join(distDir, filename);
|
|
886
|
-
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
887
|
-
fs.writeFileSync(filePath, content, "utf-8");
|
|
888
|
-
console.log(`[flue] Generated: ${filePath}`);
|
|
889
|
-
}
|
|
890
|
-
}
|
|
891
|
-
console.log(`[flue] Build complete. Output: ${distDir}`);
|
|
892
|
-
}
|
|
893
|
-
function resolvePlugin(options) {
|
|
894
|
-
if (options.plugin) return options.plugin;
|
|
895
|
-
if (!options.target) throw new Error("[flue] No build target specified. Use --target to choose a target:\n flue build --target node\n flue build --target cloudflare");
|
|
896
|
-
switch (options.target) {
|
|
897
|
-
case "node": return new NodePlugin();
|
|
898
|
-
case "cloudflare": return new CloudflarePlugin();
|
|
899
|
-
default: throw new Error(`[flue] Unknown target: "${options.target}". Supported targets: node, cloudflare`);
|
|
900
|
-
}
|
|
901
|
-
}
|
|
902
|
-
/**
|
|
903
|
-
* Resolve a Flue workspace directory from the current working directory,
|
|
904
|
-
* using the two-layout convention. Intended for the CLI when `--workspace` is
|
|
905
|
-
* not provided — callers that pass an explicit workspace path should skip this
|
|
906
|
-
* and pass the path straight to `build()`.
|
|
907
|
-
*
|
|
908
|
-
* Two supported layouts, checked in order:
|
|
909
|
-
* 1. `<cwd>/.flue/` — use this when Flue is embedded in an existing project.
|
|
910
|
-
* 2. `<cwd>/` — use this when the project itself is the Flue workspace.
|
|
911
|
-
*
|
|
912
|
-
* If `.flue/` exists, it wins unconditionally — no mixing with the bare layout.
|
|
913
|
-
* Returns null if neither is present so the caller can produce a helpful error.
|
|
914
|
-
*/
|
|
915
|
-
function resolveWorkspaceFromCwd(cwd) {
|
|
916
|
-
const dotFlue = path.join(cwd, ".flue");
|
|
917
|
-
if (fs.existsSync(dotFlue)) return dotFlue;
|
|
918
|
-
if (fs.existsSync(path.join(cwd, "agents"))) return cwd;
|
|
919
|
-
return null;
|
|
920
|
-
}
|
|
921
|
-
function discoverRoles(workspaceRoot) {
|
|
922
|
-
const rolesDir = path.join(workspaceRoot, "roles");
|
|
923
|
-
if (!fs.existsSync(rolesDir)) return {};
|
|
924
|
-
const roles = {};
|
|
925
|
-
for (const entry of fs.readdirSync(rolesDir)) {
|
|
926
|
-
if (!/\.(md|markdown)$/i.test(entry)) continue;
|
|
927
|
-
const filePath = path.join(rolesDir, entry);
|
|
928
|
-
const content = fs.readFileSync(filePath, "utf-8");
|
|
929
|
-
const name = entry.replace(/\.(md|markdown)$/i, "");
|
|
930
|
-
const parsed = parseFrontmatterFile(content, name);
|
|
931
|
-
roles[name] = {
|
|
932
|
-
name,
|
|
933
|
-
description: parsed.description,
|
|
934
|
-
instructions: parsed.body,
|
|
935
|
-
model: parsed.frontmatter.model
|
|
936
|
-
};
|
|
937
|
-
}
|
|
938
|
-
return roles;
|
|
939
|
-
}
|
|
940
|
-
function discoverAgents(workspaceRoot) {
|
|
941
|
-
const agentsDir = path.join(workspaceRoot, "agents");
|
|
942
|
-
if (!fs.existsSync(agentsDir)) return [];
|
|
943
|
-
return fs.readdirSync(agentsDir).filter((f) => /\.(ts|js|mts|mjs)$/.test(f)).map((f) => {
|
|
944
|
-
const filePath = path.join(agentsDir, f);
|
|
945
|
-
const triggers = parseTriggers(filePath);
|
|
946
|
-
return {
|
|
947
|
-
name: f.replace(/\.(ts|js|mts|mjs)$/, ""),
|
|
948
|
-
filePath,
|
|
949
|
-
triggers
|
|
950
|
-
};
|
|
951
|
-
});
|
|
952
|
-
}
|
|
953
|
-
/** Extract trigger config via regex. Only triggers are parsed at build time (needed for routing). */
|
|
954
|
-
function parseTriggers(filePath) {
|
|
955
|
-
const source = fs.readFileSync(filePath, "utf-8");
|
|
956
|
-
const result = {};
|
|
957
|
-
const triggersExportMatch = source.match(/export\s+const\s+triggers\s*=\s*\{([^}]*)\}/);
|
|
958
|
-
if (!triggersExportMatch) return result;
|
|
959
|
-
const triggersBlock = triggersExportMatch[1] ?? "";
|
|
960
|
-
if (/webhook\s*:\s*true/.test(triggersBlock)) result.webhook = true;
|
|
961
|
-
const cronMatch = triggersBlock.match(/cron\s*:\s*['"]([^'"]+)['"]/);
|
|
962
|
-
if (cronMatch?.[1]) result.cron = cronMatch[1];
|
|
963
|
-
return result;
|
|
964
|
-
}
|
|
965
|
-
/** Externalize user's direct deps (bare name + subpath wildcard). */
|
|
966
|
-
function getUserExternals(workspaceDir) {
|
|
967
|
-
const pkgPath = packageUpSync({ cwd: workspaceDir });
|
|
968
|
-
if (!pkgPath) return [];
|
|
969
|
-
try {
|
|
970
|
-
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
971
|
-
return Object.keys({
|
|
972
|
-
...pkg.dependencies,
|
|
973
|
-
...pkg.devDependencies,
|
|
974
|
-
...pkg.peerDependencies
|
|
975
|
-
}).flatMap((name) => [name, `${name}/*`]);
|
|
976
|
-
} catch {
|
|
977
|
-
return [];
|
|
978
|
-
}
|
|
979
|
-
}
|
|
980
|
-
function collectNodePaths(workspaceDir) {
|
|
981
|
-
const nodePathsSet = /* @__PURE__ */ new Set();
|
|
982
|
-
for (const startDir of [workspaceDir, getSDKDir()]) {
|
|
983
|
-
let dir = startDir;
|
|
984
|
-
while (dir !== path.dirname(dir)) {
|
|
985
|
-
const nm = path.join(dir, "node_modules");
|
|
986
|
-
if (fs.existsSync(nm)) nodePathsSet.add(nm);
|
|
987
|
-
dir = path.dirname(dir);
|
|
988
|
-
}
|
|
989
|
-
}
|
|
990
|
-
return nodePathsSet;
|
|
991
|
-
}
|
|
992
|
-
function getSDKDir() {
|
|
993
|
-
try {
|
|
994
|
-
return path.dirname(new URL(import.meta.url).pathname);
|
|
995
|
-
} catch {
|
|
996
|
-
return __dirname;
|
|
997
|
-
}
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
//#endregion
|
|
1001
|
-
export { BUILTIN_TOOL_NAMES, build, createTools, resolveWorkspaceFromCwd };
|