@fusionkit/cli 0.1.5 → 0.1.7
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 +32 -8
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +1 -0
- package/dist/commands/doctor.js +7 -3
- package/dist/commands/ensemble-gateway.js +0 -2
- package/dist/commands/ensemble-records.d.ts +2 -1
- package/dist/commands/ensemble-records.js +3 -1
- package/dist/commands/ensemble.js +3 -4
- package/dist/commands/fusion.js +16 -13
- package/dist/commands/local.js +3 -3
- package/dist/cursor-acp.d.ts +3 -5
- package/dist/cursor-acp.js +12 -11
- package/dist/dashboard.d.ts +65 -0
- package/dist/dashboard.js +587 -0
- package/dist/fusion/env.d.ts +111 -0
- package/dist/fusion/env.js +98 -0
- package/dist/fusion/observability.d.ts +39 -0
- package/dist/fusion/observability.js +227 -0
- package/dist/fusion/preflight.d.ts +12 -0
- package/dist/fusion/preflight.js +42 -0
- package/dist/fusion/stack.d.ts +66 -0
- package/dist/fusion/stack.js +315 -0
- package/dist/fusion-config.d.ts +58 -7
- package/dist/fusion-config.js +152 -28
- package/dist/fusion-init.d.ts +1 -0
- package/dist/fusion-init.js +50 -15
- package/dist/fusion-quickstart.d.ts +11 -222
- package/dist/fusion-quickstart.js +58 -759
- package/dist/gateway.d.ts +0 -2
- package/dist/gateway.js +0 -2
- package/dist/local.d.ts +10 -17
- package/dist/local.js +50 -116
- package/dist/shared/options.d.ts +2 -1
- package/dist/shared/options.js +13 -19
- package/dist/shared/proc.d.ts +4 -70
- package/dist/shared/proc.js +3 -228
- package/dist/test/cli.test.js +11 -6
- package/dist/test/dashboard.test.d.ts +1 -0
- package/dist/test/dashboard.test.js +214 -0
- package/dist/test/fusion-config.test.js +64 -4
- package/dist/test/gateway-e2e.test.js +13 -10
- package/dist/test/local.test.js +4 -4
- package/dist/tools.d.ts +2 -0
- package/dist/tools.js +25 -0
- package/package.json +14 -9
- package/scope/.next/BUILD_ID +1 -1
- package/scope/.next/app-build-manifest.json +10 -10
- package/scope/.next/app-path-routes-manifest.json +2 -2
- package/scope/.next/build-manifest.json +2 -2
- package/scope/.next/prerender-manifest.json +13 -13
- package/scope/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/scope/.next/server/app/_not-found.html +1 -1
- package/scope/.next/server/app/_not-found.rsc +1 -1
- package/scope/.next/server/app/api/environments/route_client-reference-manifest.js +1 -1
- package/scope/.next/server/app/api/ingest/route_client-reference-manifest.js +1 -1
- package/scope/.next/server/app/api/models/route_client-reference-manifest.js +1 -1
- package/scope/.next/server/app/api/replay/route_client-reference-manifest.js +1 -1
- package/scope/.next/server/app/api/sessions/[traceId]/route_client-reference-manifest.js +1 -1
- package/scope/.next/server/app/api/sessions/route_client-reference-manifest.js +1 -1
- package/scope/.next/server/app/api/stream/route_client-reference-manifest.js +1 -1
- package/scope/.next/server/app/environments/page_client-reference-manifest.js +1 -1
- package/scope/.next/server/app/environments.html +1 -1
- package/scope/.next/server/app/environments.rsc +1 -1
- package/scope/.next/server/app/index.html +1 -1
- package/scope/.next/server/app/index.rsc +1 -1
- package/scope/.next/server/app/models/page_client-reference-manifest.js +1 -1
- package/scope/.next/server/app/models.html +1 -1
- package/scope/.next/server/app/models.rsc +1 -1
- package/scope/.next/server/app/page_client-reference-manifest.js +1 -1
- package/scope/.next/server/app/sessions/[traceId]/page_client-reference-manifest.js +1 -1
- package/scope/.next/server/app-paths-manifest.json +2 -2
- package/scope/.next/server/functions-config-manifest.json +2 -2
- package/scope/.next/server/pages/404.html +1 -1
- package/scope/.next/server/pages/500.html +1 -1
- package/scope/.next/server/server-reference-manifest.json +1 -1
- /package/scope/.next/static/{vxqImMqlOwssVTua5Facf → BrrtQvnEIgv-OVkaeanKI}/_buildManifest.js +0 -0
- /package/scope/.next/static/{vxqImMqlOwssVTua5Facf → BrrtQvnEIgv-OVkaeanKI}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fusion model stack: the single `fusionkit serve` router that fronts every
|
|
3
|
+
* panel model plus synthesis, and the in-process gateway that turns it into the
|
|
4
|
+
* judge-streamed-trajectory front door.
|
|
5
|
+
*/
|
|
6
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { MlxBackend, startGateway } from "@fusionkit/model-gateway";
|
|
10
|
+
import { startFusionStepGateway } from "../gateway.js";
|
|
11
|
+
import { createPortlessSession } from "../shared/portless.js";
|
|
12
|
+
import { freePort, spawnLogged, terminate, waitForHttp } from "../shared/proc.js";
|
|
13
|
+
import { PROMPT_CONFIG_KEY, PROMPT_IDS } from "../fusion-config.js";
|
|
14
|
+
import { defaultKeyEnv, fusionkitPyCommand, loadEnvFileInto } from "./env.js";
|
|
15
|
+
/**
|
|
16
|
+
* Heuristic: does the captured output indicate a permanent failure (bad key,
|
|
17
|
+
* inaccessible model) that a retry cannot fix? Used to fail fast with a clear
|
|
18
|
+
* message instead of burning the retry budget on a hopeless start.
|
|
19
|
+
*/
|
|
20
|
+
function looksPermanentFailure(log) {
|
|
21
|
+
return /401|403|invalid[ _-]?api[ _-]?key|unauthorized|forbidden|authentication|permission|model[^\n]*(not found|does not exist)|no such model|model_not_found/i.test(log);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Default provider base URL when a cloud spec carries no explicit `baseUrl`.
|
|
25
|
+
* fusionkit's `ModelEndpoint` requires `base_url`, so the router config must
|
|
26
|
+
* always set it (the `serve-endpoint` shim filled this in for us before). Mirrors
|
|
27
|
+
* `PROVIDER_DEFAULT_BASE_URL` in fusionkit's openai_endpoint.py.
|
|
28
|
+
*/
|
|
29
|
+
function providerDefaultBaseUrl(provider) {
|
|
30
|
+
switch (provider) {
|
|
31
|
+
case "openai":
|
|
32
|
+
return "https://api.openai.com";
|
|
33
|
+
case "anthropic":
|
|
34
|
+
return "https://api.anthropic.com";
|
|
35
|
+
case "google":
|
|
36
|
+
return "https://generativelanguage.googleapis.com";
|
|
37
|
+
case "openai-compatible":
|
|
38
|
+
return "http://127.0.0.1";
|
|
39
|
+
default: {
|
|
40
|
+
const exhaustive = provider;
|
|
41
|
+
throw new Error(`unknown provider ${String(exhaustive)}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Pick the panel spec that backs the judge (by model name), else the first. */
|
|
46
|
+
function judgeSpecFor(specs, judgeModel) {
|
|
47
|
+
const first = specs[0];
|
|
48
|
+
if (first === undefined)
|
|
49
|
+
throw new Error("at least one panel model is required");
|
|
50
|
+
if (judgeModel === undefined)
|
|
51
|
+
return first;
|
|
52
|
+
return specs.find((spec) => spec.model === judgeModel) ?? first;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Build the `fusionkit serve` config (YAML) for the consolidated router: one
|
|
56
|
+
* endpoint per panel model. Cloud models call their provider directly (keyed by
|
|
57
|
+
* `api_key_env`); MLX models are fronted as `openai-compatible` endpoints
|
|
58
|
+
* pointing at their in-process gateway loopback URL. The judge endpoint doubles
|
|
59
|
+
* as the synthesizer. Values are JSON-quoted (valid YAML flow scalars).
|
|
60
|
+
*/
|
|
61
|
+
function routerConfigYaml(input) {
|
|
62
|
+
const lines = ["endpoints:"];
|
|
63
|
+
for (const spec of input.specs) {
|
|
64
|
+
const provider = spec.provider ?? "mlx";
|
|
65
|
+
lines.push(` - id: ${JSON.stringify(spec.id)}`);
|
|
66
|
+
lines.push(` model: ${JSON.stringify(spec.model)}`);
|
|
67
|
+
if (provider === "mlx") {
|
|
68
|
+
lines.push(" provider: openai-compatible");
|
|
69
|
+
lines.push(` base_url: ${JSON.stringify(input.mlxUrls[spec.id] ?? "")}`);
|
|
70
|
+
lines.push(" api_key: not-needed");
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
// `base_url` is required by fusionkit's ModelEndpoint, so always emit one
|
|
74
|
+
// (the spec's, or the provider default).
|
|
75
|
+
const baseUrl = spec.baseUrl ?? providerDefaultBaseUrl(provider);
|
|
76
|
+
lines.push(` provider: ${provider}`);
|
|
77
|
+
lines.push(` base_url: ${JSON.stringify(baseUrl)}`);
|
|
78
|
+
const keyEnv = spec.keyEnv ?? defaultKeyEnv(provider);
|
|
79
|
+
if (keyEnv !== undefined)
|
|
80
|
+
lines.push(` api_key_env: ${JSON.stringify(keyEnv)}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
lines.push(`default_model: ${JSON.stringify(input.judgeId)}`);
|
|
84
|
+
lines.push(`judge_model: ${JSON.stringify(input.judgeId)}`);
|
|
85
|
+
lines.push(`synthesizer_model: ${JSON.stringify(input.judgeId)}`);
|
|
86
|
+
// Generous budget: reasoning models (gpt-5.x) spend tokens on reasoning before
|
|
87
|
+
// producing content, so a small cap can yield an empty answer.
|
|
88
|
+
lines.push("sampling: {temperature: 0.2, top_p: 0.9, max_tokens: 8192}");
|
|
89
|
+
// Committed `.fusionkit/prompts/*.md` overrides flow into the synthesizer's
|
|
90
|
+
// PromptOverrides here. JSON.stringify yields a valid YAML double-quoted
|
|
91
|
+
// scalar, so multi-line prompts are escaped safely.
|
|
92
|
+
const promptEntries = PROMPT_IDS.flatMap((id) => {
|
|
93
|
+
const value = input.prompts?.[id];
|
|
94
|
+
return value !== undefined ? [[PROMPT_CONFIG_KEY[id], value]] : [];
|
|
95
|
+
});
|
|
96
|
+
if (promptEntries.length > 0) {
|
|
97
|
+
lines.push("prompts:");
|
|
98
|
+
for (const [key, value] of promptEntries) {
|
|
99
|
+
lines.push(` ${key}: ${JSON.stringify(value)}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
lines.push("");
|
|
103
|
+
return lines.join("\n");
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Spawn the single `fusionkit serve` router fronting every panel model + the
|
|
107
|
+
* synthesizer. MLX specs first get an in-process OpenAI-compatible gateway
|
|
108
|
+
* (loopback) that the router proxies to; cloud specs call their provider
|
|
109
|
+
* directly. Returns the router URL, an id->routerUrl endpoint map, and a close
|
|
110
|
+
* that tears down the router process and any MLX gateways it fronts.
|
|
111
|
+
*/
|
|
112
|
+
export async function startRouter(options) {
|
|
113
|
+
const { specs, report } = options;
|
|
114
|
+
const judgeSpec = judgeSpecFor(specs, options.judgeModel);
|
|
115
|
+
const models = specs.map((spec) => ({ id: spec.id, model: spec.model }));
|
|
116
|
+
const identity = specs.map((spec) => spec.id).sort().join(",");
|
|
117
|
+
const announceStart = (label) => {
|
|
118
|
+
if (report)
|
|
119
|
+
report({ kind: "server.start", id: "router", label });
|
|
120
|
+
else
|
|
121
|
+
options.log(`fusion: starting ${label}...`);
|
|
122
|
+
};
|
|
123
|
+
const announceReady = (detail) => {
|
|
124
|
+
if (report)
|
|
125
|
+
report({ kind: "server.ready", id: "router", detail });
|
|
126
|
+
else
|
|
127
|
+
options.log(`fusion: router ready on ${detail}`);
|
|
128
|
+
};
|
|
129
|
+
announceStart(`router · ${specs.map((spec) => spec.id).join(", ")}`);
|
|
130
|
+
// The router inherits the parent env plus the FusionKit checkout's `.env` (so
|
|
131
|
+
// provider keys load seamlessly), without overriding anything already exported.
|
|
132
|
+
// It calls providers directly and MLX over loopback (never a portless HTTPS
|
|
133
|
+
// URL), so it needs no portless CA — and must keep its default certifi bundle
|
|
134
|
+
// intact to verify real provider certificates.
|
|
135
|
+
const env = { ...process.env };
|
|
136
|
+
if (options.fusionkitDir !== undefined) {
|
|
137
|
+
loadEnvFileInto(join(options.fusionkitDir, ".env"), env);
|
|
138
|
+
}
|
|
139
|
+
const backends = [];
|
|
140
|
+
const gateways = [];
|
|
141
|
+
const mlxUrls = {};
|
|
142
|
+
const closeBackends = async () => {
|
|
143
|
+
await Promise.allSettled(gateways.map((gateway) => gateway.close()));
|
|
144
|
+
await Promise.allSettled(backends.map((backend) => backend.stop()));
|
|
145
|
+
};
|
|
146
|
+
try {
|
|
147
|
+
// MLX backends are memory-heavy (each loads a model into RAM), so they start
|
|
148
|
+
// sequentially before the router that fronts them.
|
|
149
|
+
for (const spec of specs) {
|
|
150
|
+
if ((spec.provider ?? "mlx") !== "mlx")
|
|
151
|
+
continue;
|
|
152
|
+
const backend = new MlxBackend({ model: spec.model });
|
|
153
|
+
await backend.start();
|
|
154
|
+
const gateway = await startGateway({ backend });
|
|
155
|
+
backends.push(backend);
|
|
156
|
+
gateways.push(gateway);
|
|
157
|
+
mlxUrls[spec.id] = gateway.url();
|
|
158
|
+
}
|
|
159
|
+
const config = routerConfigYaml({
|
|
160
|
+
specs,
|
|
161
|
+
mlxUrls,
|
|
162
|
+
judgeId: judgeSpec.id,
|
|
163
|
+
...(options.prompts !== undefined ? { prompts: options.prompts } : {})
|
|
164
|
+
});
|
|
165
|
+
const configDir = mkdtempSync(join(tmpdir(), "fusion-router-"));
|
|
166
|
+
const configPath = join(configDir, "router.yaml");
|
|
167
|
+
writeFileSync(configPath, config);
|
|
168
|
+
const runner = fusionkitPyCommand(options.fusionkitDir);
|
|
169
|
+
const port = await freePort();
|
|
170
|
+
const proc = spawnLogged(runner.command, [...runner.prefix, "serve", "--config", configPath, "--host", "127.0.0.1", "--port", String(port)], {
|
|
171
|
+
...(runner.cwd !== undefined ? { cwd: runner.cwd } : {}),
|
|
172
|
+
...(options.logsDir !== undefined ? { logFile: join(options.logsDir, "router.log") } : {}),
|
|
173
|
+
env
|
|
174
|
+
});
|
|
175
|
+
proc.child.once("exit", () => rmSync(configDir, { recursive: true, force: true }));
|
|
176
|
+
const url = `http://127.0.0.1:${port}`;
|
|
177
|
+
try {
|
|
178
|
+
await waitForHttp(`${url}/v1/models`, proc, {
|
|
179
|
+
timeoutMs: 60_000,
|
|
180
|
+
label: "fusion router",
|
|
181
|
+
requireOk: true
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
terminate(proc.child);
|
|
186
|
+
// A provider-side rejection (bad key / model) will not be fixed by a
|
|
187
|
+
// retry, so surface the distilled cause with guidance.
|
|
188
|
+
const hint = looksPermanentFailure(proc.log()) ? " (check model names and provider API keys)" : "";
|
|
189
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}${hint}`);
|
|
190
|
+
}
|
|
191
|
+
announceReady(url);
|
|
192
|
+
const endpoints = Object.fromEntries(specs.map((spec) => [spec.id, url]));
|
|
193
|
+
return {
|
|
194
|
+
url,
|
|
195
|
+
port,
|
|
196
|
+
...(proc.child.pid !== undefined ? { pid: proc.child.pid } : {}),
|
|
197
|
+
endpoints,
|
|
198
|
+
models,
|
|
199
|
+
judgeModel: judgeSpec.id,
|
|
200
|
+
identity,
|
|
201
|
+
close: async () => {
|
|
202
|
+
terminate(proc.child);
|
|
203
|
+
await closeBackends();
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
await closeBackends();
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
export async function startFusionStack(options) {
|
|
213
|
+
const report = options.report;
|
|
214
|
+
const portless = options.portless ?? (await createPortlessSession({ enabled: false }));
|
|
215
|
+
// Full override (pre-running per-model endpoints + a pre-running synthesis
|
|
216
|
+
// URL, e.g. tests): use them verbatim and spawn no router.
|
|
217
|
+
const override = options.endpoints !== undefined && options.synthesisUrl !== undefined;
|
|
218
|
+
const judgeModelName = options.judgeModel ?? options.models[0]?.model ?? "";
|
|
219
|
+
const models = options.models.map((spec) => ({ id: spec.id, model: spec.model }));
|
|
220
|
+
let modelEndpoints;
|
|
221
|
+
let fusionBackendUrl;
|
|
222
|
+
let routerClose = () => { };
|
|
223
|
+
if (override) {
|
|
224
|
+
modelEndpoints = options.endpoints;
|
|
225
|
+
fusionBackendUrl = options.synthesisUrl;
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
// Discover-or-spawn the single router (models + synthesis), reusing a
|
|
229
|
+
// compatible running instance (same endpoint id set) across runs.
|
|
230
|
+
const expectedIdentity = options.models.map((spec) => spec.id).sort().join(",");
|
|
231
|
+
const resolved = await portless.discoverOrSpawn({
|
|
232
|
+
name: "router",
|
|
233
|
+
identity: expectedIdentity,
|
|
234
|
+
healthCheck: async (loopbackUrl) => {
|
|
235
|
+
try {
|
|
236
|
+
const response = await fetch(`${loopbackUrl}/v1/models`, { signal: AbortSignal.timeout(2000) });
|
|
237
|
+
if (!response.ok)
|
|
238
|
+
return undefined;
|
|
239
|
+
const body = (await response.json());
|
|
240
|
+
return (body.data ?? [])
|
|
241
|
+
.map((entry) => entry.id)
|
|
242
|
+
.filter((id) => typeof id === "string" && id !== "fusionkit/router")
|
|
243
|
+
.sort()
|
|
244
|
+
.join(",");
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
spawn: async () => {
|
|
251
|
+
if (report)
|
|
252
|
+
report({ kind: "server.start", id: "router", label: "router" });
|
|
253
|
+
const router = await startRouter({
|
|
254
|
+
specs: options.models,
|
|
255
|
+
...(options.judgeModel !== undefined ? { judgeModel: options.judgeModel } : {}),
|
|
256
|
+
...(options.fusionkitDir !== undefined ? { fusionkitDir: options.fusionkitDir } : {}),
|
|
257
|
+
...(options.prompts !== undefined ? { prompts: options.prompts } : {}),
|
|
258
|
+
...(options.logsDir !== undefined ? { logsDir: options.logsDir } : {}),
|
|
259
|
+
...(report !== undefined ? { report } : {}),
|
|
260
|
+
log: options.log
|
|
261
|
+
});
|
|
262
|
+
return {
|
|
263
|
+
port: router.port,
|
|
264
|
+
...(router.pid !== undefined ? { pid: router.pid } : {}),
|
|
265
|
+
close: router.close
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
// The harness + the in-process step call reach the router over loopback (the
|
|
270
|
+
// portless name is for humans); see the CA-at-startup note in portless.ts.
|
|
271
|
+
modelEndpoints = Object.fromEntries(options.models.map((spec) => [spec.id, resolved.loopbackUrl]));
|
|
272
|
+
fusionBackendUrl = resolved.loopbackUrl;
|
|
273
|
+
routerClose = resolved.close;
|
|
274
|
+
}
|
|
275
|
+
try {
|
|
276
|
+
if (report)
|
|
277
|
+
report({ kind: "gateway.start" });
|
|
278
|
+
// The judge-streamed-trajectory front door: each panel model produces a
|
|
279
|
+
// trajectory and the judge emits the trajectory the user's tool executes.
|
|
280
|
+
const gatewayConfig = {
|
|
281
|
+
fusionBackendUrl,
|
|
282
|
+
repo: options.repo,
|
|
283
|
+
outputRoot: options.outputRoot,
|
|
284
|
+
harnesses: ["agent"],
|
|
285
|
+
models,
|
|
286
|
+
judgeModel: judgeModelName,
|
|
287
|
+
modelEndpoints,
|
|
288
|
+
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {})
|
|
289
|
+
};
|
|
290
|
+
const gateway = await startFusionStepGateway({
|
|
291
|
+
config: gatewayConfig,
|
|
292
|
+
host: options.host ?? "127.0.0.1",
|
|
293
|
+
port: options.port ?? 0,
|
|
294
|
+
...(options.authToken !== undefined ? { authToken: options.authToken } : {})
|
|
295
|
+
});
|
|
296
|
+
// The gateway runs in-process (dies with this CLI), so it gets a per-run
|
|
297
|
+
// portless name rather than being a cross-run singleton.
|
|
298
|
+
const fusionUrl = portless.register("gateway", gateway.port());
|
|
299
|
+
if (report)
|
|
300
|
+
report({ kind: "gateway.ready", detail: fusionUrl });
|
|
301
|
+
return {
|
|
302
|
+
fusionUrl,
|
|
303
|
+
endpoints: modelEndpoints,
|
|
304
|
+
close: async () => {
|
|
305
|
+
await gateway.close();
|
|
306
|
+
portless.unregister("gateway");
|
|
307
|
+
await routerClose();
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
await routerClose();
|
|
313
|
+
throw error;
|
|
314
|
+
}
|
|
315
|
+
}
|
package/dist/fusion-config.d.ts
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
import type { FusionTool, PanelModelSpec } from "./fusion-quickstart.js";
|
|
2
|
+
export declare const FUSION_CONFIG_DIRNAME = ".fusionkit";
|
|
3
|
+
export declare const FUSION_CONFIG_BASENAME = "fusion.json";
|
|
4
|
+
export declare const FUSION_PROMPTS_DIRNAME = "prompts";
|
|
5
|
+
/** Legacy single-file config at the repo root (pre-`.fusionkit/`). */
|
|
2
6
|
export declare const FUSION_CONFIG_FILENAME = "fusionkit.json";
|
|
3
|
-
export declare const FUSION_CONFIG_VERSION = "fusionkit.fusion.
|
|
7
|
+
export declare const FUSION_CONFIG_VERSION = "fusionkit.fusion.v2";
|
|
8
|
+
/**
|
|
9
|
+
* The committable system-prompt override ids. Each maps to a
|
|
10
|
+
* `.fusionkit/prompts/<id>.md` file and to a `FusionConfig.prompts` key in the
|
|
11
|
+
* Python synthesizer (see {@link PROMPT_CONFIG_KEY}).
|
|
12
|
+
*/
|
|
13
|
+
export declare const PROMPT_IDS: readonly ["judge", "synthesizer", "trajectory-synthesizer", "trajectory-step", "verifier", "panel"];
|
|
14
|
+
export type PromptId = (typeof PROMPT_IDS)[number];
|
|
15
|
+
/** Map each prompt override id to the `prompts:` key fusionkit's config expects. */
|
|
16
|
+
export declare const PROMPT_CONFIG_KEY: Record<PromptId, string>;
|
|
17
|
+
export type PromptOverrides = Partial<Record<PromptId, string>>;
|
|
4
18
|
export type FusionConfig = {
|
|
5
19
|
version: typeof FUSION_CONFIG_VERSION;
|
|
6
20
|
tool?: FusionTool;
|
|
@@ -9,21 +23,58 @@ export type FusionConfig = {
|
|
|
9
23
|
local?: boolean;
|
|
10
24
|
observe?: boolean;
|
|
11
25
|
portless?: boolean;
|
|
12
|
-
cursorKitDir?: string | null;
|
|
13
26
|
port?: number | null;
|
|
27
|
+
/**
|
|
28
|
+
* System-prompt overrides, loaded from `.fusionkit/prompts/*.md`. Not stored
|
|
29
|
+
* inline in `config.json` — it is hydrated from the prompt files on load.
|
|
30
|
+
*/
|
|
31
|
+
prompts?: PromptOverrides;
|
|
14
32
|
};
|
|
15
33
|
export declare class FusionConfigError extends Error {
|
|
16
34
|
constructor(message: string);
|
|
17
35
|
}
|
|
36
|
+
/** The `.fusionkit/` directory at the repo root. */
|
|
37
|
+
export declare function fusionConfigDir(repoRoot: string): string;
|
|
38
|
+
/** The `.fusionkit/fusion.json` settings file. */
|
|
18
39
|
export declare function fusionConfigPath(repoRoot: string): string;
|
|
19
|
-
/**
|
|
40
|
+
/** The legacy `fusionkit.json` at the repo root (pre-`.fusionkit/`). */
|
|
41
|
+
export declare function legacyFusionConfigPath(repoRoot: string): string;
|
|
42
|
+
/** The `.fusionkit/prompts/` directory holding the override files. */
|
|
43
|
+
export declare function fusionPromptsDir(repoRoot: string): string;
|
|
44
|
+
/** The `.fusionkit/prompts/<id>.md` file for a single prompt override. */
|
|
45
|
+
export declare function fusionPromptPath(repoRoot: string, id: PromptId): string;
|
|
46
|
+
/**
|
|
47
|
+
* Validate a parsed settings object as a {@link FusionConfig}, throwing on any
|
|
48
|
+
* problem. Prompt overrides are loaded separately from `.fusionkit/prompts/`,
|
|
49
|
+
* not from this object. A `v1` version is accepted and upgraded to `v2`.
|
|
50
|
+
*/
|
|
20
51
|
export declare function parseFusionConfig(raw: unknown, source: string): FusionConfig;
|
|
21
52
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
53
|
+
* Read the committed prompt overrides from `.fusionkit/prompts/*.md`. Only files
|
|
54
|
+
* that exist and are non-empty (after trimming) become overrides.
|
|
55
|
+
*/
|
|
56
|
+
export declare function readFusionPrompts(repoRoot: string): PromptOverrides;
|
|
57
|
+
/**
|
|
58
|
+
* Load the per-repo config. Prefers `.fusionkit/config.json`; if it is absent
|
|
59
|
+
* but a legacy `fusionkit.json` exists, auto-migrates it into the folder (the
|
|
60
|
+
* original is left intact) and loads from there. Returns `undefined` when no
|
|
61
|
+
* config exists; throws {@link FusionConfigError} on malformed content.
|
|
62
|
+
*
|
|
63
|
+
* `onNotice` receives a one-line message when a migration happens.
|
|
64
|
+
*/
|
|
65
|
+
export declare function loadFusionConfig(repoRoot: string, onNotice?: (message: string) => void): FusionConfig | undefined;
|
|
66
|
+
/**
|
|
67
|
+
* Write `.fusionkit/config.json` (creating the folder), refusing to clobber
|
|
68
|
+
* unless `force`. Prompt overrides are stored as files, not inline, so any
|
|
69
|
+
* `prompts` on the config object is omitted here.
|
|
24
70
|
*/
|
|
25
|
-
export declare function loadFusionConfig(repoRoot: string): FusionConfig | undefined;
|
|
26
|
-
/** Write `fusionkit.json` at the repo root, refusing to clobber unless `force`. */
|
|
27
71
|
export declare function writeFusionConfig(repoRoot: string, config: FusionConfig, options?: {
|
|
28
72
|
force?: boolean;
|
|
29
73
|
}): string;
|
|
74
|
+
/**
|
|
75
|
+
* Write prompt override files into `.fusionkit/prompts/`. Existing files are
|
|
76
|
+
* left untouched unless `force`. Returns the paths actually written.
|
|
77
|
+
*/
|
|
78
|
+
export declare function writeFusionPrompts(repoRoot: string, prompts: PromptOverrides, options?: {
|
|
79
|
+
force?: boolean;
|
|
80
|
+
}): string[];
|
package/dist/fusion-config.js
CHANGED
|
@@ -1,29 +1,85 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Per-repo fusion configuration
|
|
2
|
+
* Per-repo fusion configuration, stored in a committed `.fusionkit/` folder at
|
|
3
|
+
* the repo root:
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* (`keyEnv`), never the secret values.
|
|
5
|
+
* .fusionkit/
|
|
6
|
+
* fusion.json - all settings (panel, judge, default tool, run defaults)
|
|
7
|
+
* prompts/<id>.md - optional system-prompt overrides (one file per prompt)
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* The folder is safe to commit: it stores only the env-var *names* that hold API
|
|
10
|
+
* keys (`keyEnv`), never the secret values. A prompt file that exists and is
|
|
11
|
+
* non-empty overrides the matching built-in synthesizer prompt; absent/empty
|
|
12
|
+
* falls back to the built-in default.
|
|
13
|
+
*
|
|
14
|
+
* Precedence at run time is: explicit CLI flags > .fusionkit > built-in
|
|
15
|
+
* defaults. CLI flags always win, so the folder is a default layer, not a lock.
|
|
16
|
+
*
|
|
17
|
+
* Legacy `fusionkit.json` files at the repo root are auto-migrated into
|
|
18
|
+
* `.fusionkit/fusion.json` on first load (the original is left intact as a
|
|
19
|
+
* back-compat fallback).
|
|
11
20
|
*/
|
|
12
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
22
|
import { join } from "node:path";
|
|
14
23
|
import { FUSION_TOOLS } from "./fusion-quickstart.js";
|
|
15
24
|
import { PANEL_PROVIDERS } from "./shared/options.js";
|
|
25
|
+
export const FUSION_CONFIG_DIRNAME = ".fusionkit";
|
|
26
|
+
// `fusion.json` (not `config.json`) so the fusion settings never collide with
|
|
27
|
+
// the plane home's `.fusionkit/config.json` (`warrant.config.v2`).
|
|
28
|
+
export const FUSION_CONFIG_BASENAME = "fusion.json";
|
|
29
|
+
export const FUSION_PROMPTS_DIRNAME = "prompts";
|
|
30
|
+
/** Legacy single-file config at the repo root (pre-`.fusionkit/`). */
|
|
16
31
|
export const FUSION_CONFIG_FILENAME = "fusionkit.json";
|
|
17
|
-
export const FUSION_CONFIG_VERSION = "fusionkit.fusion.
|
|
32
|
+
export const FUSION_CONFIG_VERSION = "fusionkit.fusion.v2";
|
|
33
|
+
/** Versions `parseFusionConfig` will load; `v1` is upgraded to `v2` in memory. */
|
|
34
|
+
const SUPPORTED_CONFIG_VERSIONS = ["fusionkit.fusion.v1", "fusionkit.fusion.v2"];
|
|
35
|
+
/**
|
|
36
|
+
* The committable system-prompt override ids. Each maps to a
|
|
37
|
+
* `.fusionkit/prompts/<id>.md` file and to a `FusionConfig.prompts` key in the
|
|
38
|
+
* Python synthesizer (see {@link PROMPT_CONFIG_KEY}).
|
|
39
|
+
*/
|
|
40
|
+
export const PROMPT_IDS = [
|
|
41
|
+
"judge",
|
|
42
|
+
"synthesizer",
|
|
43
|
+
"trajectory-synthesizer",
|
|
44
|
+
"trajectory-step",
|
|
45
|
+
"verifier",
|
|
46
|
+
"panel"
|
|
47
|
+
];
|
|
48
|
+
/** Map each prompt override id to the `prompts:` key fusionkit's config expects. */
|
|
49
|
+
export const PROMPT_CONFIG_KEY = {
|
|
50
|
+
judge: "judge_system",
|
|
51
|
+
synthesizer: "synthesizer_system",
|
|
52
|
+
"trajectory-synthesizer": "trajectory_synthesizer_system",
|
|
53
|
+
"trajectory-step": "trajectory_step_system",
|
|
54
|
+
verifier: "verifier_system",
|
|
55
|
+
panel: "panel_system"
|
|
56
|
+
};
|
|
18
57
|
export class FusionConfigError extends Error {
|
|
19
58
|
constructor(message) {
|
|
20
59
|
super(message);
|
|
21
60
|
this.name = "FusionConfigError";
|
|
22
61
|
}
|
|
23
62
|
}
|
|
63
|
+
/** The `.fusionkit/` directory at the repo root. */
|
|
64
|
+
export function fusionConfigDir(repoRoot) {
|
|
65
|
+
return join(repoRoot, FUSION_CONFIG_DIRNAME);
|
|
66
|
+
}
|
|
67
|
+
/** The `.fusionkit/fusion.json` settings file. */
|
|
24
68
|
export function fusionConfigPath(repoRoot) {
|
|
69
|
+
return join(fusionConfigDir(repoRoot), FUSION_CONFIG_BASENAME);
|
|
70
|
+
}
|
|
71
|
+
/** The legacy `fusionkit.json` at the repo root (pre-`.fusionkit/`). */
|
|
72
|
+
export function legacyFusionConfigPath(repoRoot) {
|
|
25
73
|
return join(repoRoot, FUSION_CONFIG_FILENAME);
|
|
26
74
|
}
|
|
75
|
+
/** The `.fusionkit/prompts/` directory holding the override files. */
|
|
76
|
+
export function fusionPromptsDir(repoRoot) {
|
|
77
|
+
return join(fusionConfigDir(repoRoot), FUSION_PROMPTS_DIRNAME);
|
|
78
|
+
}
|
|
79
|
+
/** The `.fusionkit/prompts/<id>.md` file for a single prompt override. */
|
|
80
|
+
export function fusionPromptPath(repoRoot, id) {
|
|
81
|
+
return join(fusionPromptsDir(repoRoot), `${id}.md`);
|
|
82
|
+
}
|
|
27
83
|
function isRecord(value) {
|
|
28
84
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
29
85
|
}
|
|
@@ -57,12 +113,16 @@ function validatePanelEntry(entry, index) {
|
|
|
57
113
|
}
|
|
58
114
|
return spec;
|
|
59
115
|
}
|
|
60
|
-
/**
|
|
116
|
+
/**
|
|
117
|
+
* Validate a parsed settings object as a {@link FusionConfig}, throwing on any
|
|
118
|
+
* problem. Prompt overrides are loaded separately from `.fusionkit/prompts/`,
|
|
119
|
+
* not from this object. A `v1` version is accepted and upgraded to `v2`.
|
|
120
|
+
*/
|
|
61
121
|
export function parseFusionConfig(raw, source) {
|
|
62
122
|
if (!isRecord(raw))
|
|
63
123
|
throw new FusionConfigError(`${source}: must be a JSON object`);
|
|
64
|
-
if (raw.version !==
|
|
65
|
-
throw new FusionConfigError(`${source}: unsupported version ${JSON.stringify(raw.version)} (expected
|
|
124
|
+
if (typeof raw.version !== "string" || !SUPPORTED_CONFIG_VERSIONS.includes(raw.version)) {
|
|
125
|
+
throw new FusionConfigError(`${source}: unsupported version ${JSON.stringify(raw.version)} (expected one of ${SUPPORTED_CONFIG_VERSIONS.join(", ")})`);
|
|
66
126
|
}
|
|
67
127
|
const config = { version: FUSION_CONFIG_VERSION };
|
|
68
128
|
if (raw.tool !== undefined) {
|
|
@@ -96,12 +156,6 @@ export function parseFusionConfig(raw, source) {
|
|
|
96
156
|
throw new FusionConfigError(`${source}: portless must be a boolean`);
|
|
97
157
|
config.portless = raw.portless;
|
|
98
158
|
}
|
|
99
|
-
if (raw.cursorKitDir !== undefined && raw.cursorKitDir !== null) {
|
|
100
|
-
if (typeof raw.cursorKitDir !== "string") {
|
|
101
|
-
throw new FusionConfigError(`${source}: cursorKitDir must be a string or null`);
|
|
102
|
-
}
|
|
103
|
-
config.cursorKitDir = raw.cursorKitDir;
|
|
104
|
-
}
|
|
105
159
|
if (raw.port !== undefined && raw.port !== null) {
|
|
106
160
|
if (typeof raw.port !== "number" || !Number.isInteger(raw.port) || raw.port < 0) {
|
|
107
161
|
throw new FusionConfigError(`${source}: port must be a non-negative integer or null`);
|
|
@@ -110,14 +164,7 @@ export function parseFusionConfig(raw, source) {
|
|
|
110
164
|
}
|
|
111
165
|
return config;
|
|
112
166
|
}
|
|
113
|
-
|
|
114
|
-
* Load `<repoRoot>/fusionkit.json` if present. Returns `undefined` when the file
|
|
115
|
-
* does not exist; throws {@link FusionConfigError} on malformed content.
|
|
116
|
-
*/
|
|
117
|
-
export function loadFusionConfig(repoRoot) {
|
|
118
|
-
const path = fusionConfigPath(repoRoot);
|
|
119
|
-
if (!existsSync(path))
|
|
120
|
-
return undefined;
|
|
167
|
+
function readAndParse(path) {
|
|
121
168
|
let raw;
|
|
122
169
|
try {
|
|
123
170
|
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
@@ -127,12 +174,89 @@ export function loadFusionConfig(repoRoot) {
|
|
|
127
174
|
}
|
|
128
175
|
return parseFusionConfig(raw, path);
|
|
129
176
|
}
|
|
130
|
-
/**
|
|
177
|
+
/**
|
|
178
|
+
* Read the committed prompt overrides from `.fusionkit/prompts/*.md`. Only files
|
|
179
|
+
* that exist and are non-empty (after trimming) become overrides.
|
|
180
|
+
*/
|
|
181
|
+
export function readFusionPrompts(repoRoot) {
|
|
182
|
+
const dir = fusionPromptsDir(repoRoot);
|
|
183
|
+
const prompts = {};
|
|
184
|
+
if (!existsSync(dir))
|
|
185
|
+
return prompts;
|
|
186
|
+
for (const id of PROMPT_IDS) {
|
|
187
|
+
const path = fusionPromptPath(repoRoot, id);
|
|
188
|
+
if (!existsSync(path))
|
|
189
|
+
continue;
|
|
190
|
+
const text = readFileSync(path, "utf8").trim();
|
|
191
|
+
if (text.length > 0)
|
|
192
|
+
prompts[id] = text;
|
|
193
|
+
}
|
|
194
|
+
return prompts;
|
|
195
|
+
}
|
|
196
|
+
function withPrompts(repoRoot, config) {
|
|
197
|
+
const prompts = readFusionPrompts(repoRoot);
|
|
198
|
+
if (Object.keys(prompts).length === 0)
|
|
199
|
+
return config;
|
|
200
|
+
return { ...config, prompts };
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Load the per-repo config. Prefers `.fusionkit/config.json`; if it is absent
|
|
204
|
+
* but a legacy `fusionkit.json` exists, auto-migrates it into the folder (the
|
|
205
|
+
* original is left intact) and loads from there. Returns `undefined` when no
|
|
206
|
+
* config exists; throws {@link FusionConfigError} on malformed content.
|
|
207
|
+
*
|
|
208
|
+
* `onNotice` receives a one-line message when a migration happens.
|
|
209
|
+
*/
|
|
210
|
+
export function loadFusionConfig(repoRoot, onNotice) {
|
|
211
|
+
const newPath = fusionConfigPath(repoRoot);
|
|
212
|
+
if (existsSync(newPath)) {
|
|
213
|
+
return withPrompts(repoRoot, readAndParse(newPath));
|
|
214
|
+
}
|
|
215
|
+
const legacyPath = legacyFusionConfigPath(repoRoot);
|
|
216
|
+
if (!existsSync(legacyPath))
|
|
217
|
+
return undefined;
|
|
218
|
+
const config = readAndParse(legacyPath);
|
|
219
|
+
try {
|
|
220
|
+
writeFusionConfig(repoRoot, config);
|
|
221
|
+
onNotice?.(`migrated ${legacyPath} into ${newPath}`);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
// Could not write the migrated copy (e.g. read-only FS); use the legacy
|
|
225
|
+
// file in place for this run rather than failing.
|
|
226
|
+
}
|
|
227
|
+
return withPrompts(repoRoot, config);
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Write `.fusionkit/config.json` (creating the folder), refusing to clobber
|
|
231
|
+
* unless `force`. Prompt overrides are stored as files, not inline, so any
|
|
232
|
+
* `prompts` on the config object is omitted here.
|
|
233
|
+
*/
|
|
131
234
|
export function writeFusionConfig(repoRoot, config, options = {}) {
|
|
132
235
|
const path = fusionConfigPath(repoRoot);
|
|
133
236
|
if (existsSync(path) && options.force !== true) {
|
|
134
237
|
throw new FusionConfigError(`${path} already exists (pass --force to overwrite)`);
|
|
135
238
|
}
|
|
136
|
-
|
|
239
|
+
mkdirSync(fusionConfigDir(repoRoot), { recursive: true });
|
|
240
|
+
const { prompts: _prompts, ...persisted } = config;
|
|
241
|
+
writeFileSync(path, JSON.stringify(persisted, null, 2) + "\n");
|
|
137
242
|
return path;
|
|
138
243
|
}
|
|
244
|
+
/**
|
|
245
|
+
* Write prompt override files into `.fusionkit/prompts/`. Existing files are
|
|
246
|
+
* left untouched unless `force`. Returns the paths actually written.
|
|
247
|
+
*/
|
|
248
|
+
export function writeFusionPrompts(repoRoot, prompts, options = {}) {
|
|
249
|
+
mkdirSync(fusionPromptsDir(repoRoot), { recursive: true });
|
|
250
|
+
const written = [];
|
|
251
|
+
for (const id of PROMPT_IDS) {
|
|
252
|
+
const text = prompts[id];
|
|
253
|
+
if (text === undefined)
|
|
254
|
+
continue;
|
|
255
|
+
const path = fusionPromptPath(repoRoot, id);
|
|
256
|
+
if (existsSync(path) && options.force !== true)
|
|
257
|
+
continue;
|
|
258
|
+
writeFileSync(path, text.endsWith("\n") ? text : `${text}\n`);
|
|
259
|
+
written.push(path);
|
|
260
|
+
}
|
|
261
|
+
return written;
|
|
262
|
+
}
|