libfx 0.0.7 → 0.0.8-dev.820.g1d9d3b63d6ea

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/node.js CHANGED
@@ -1,32 +1,46 @@
1
1
  import { access, readFile } from "node:fs/promises";
2
+ import { closeSync } from "node:fs";
2
3
  import { createRequire } from "node:module";
4
+ import { Socket } from "node:net";
3
5
  import { homedir } from "node:os";
4
6
  import { isAbsolute, resolve } from "node:path";
5
7
  import { fileURLToPath, pathToFileURL } from "node:url";
8
+ import { CoreOutput } from "./core-output.js";
9
+ import { loadModule, withModuleFailure } from "./wasm-module.js";
6
10
  import {
7
11
  createFxAgent as createWasmAgent,
8
12
  createFxTerminal as createWasmTerminal,
9
13
  encodeXtermKeyEvent,
10
14
  fxSdkApiVersion,
15
+ listModels,
11
16
  supportsJspi,
12
17
  xtermAdapter,
13
18
  } from "./fx-sdk.js";
14
19
 
15
- export { encodeXtermKeyEvent, fxSdkApiVersion, supportsJspi, xtermAdapter };
20
+ export { encodeXtermKeyEvent, fxSdkApiVersion, listModels, supportsJspi, xtermAdapter };
16
21
  export const libfxApiVersion = 2;
22
+ const nativeCoreApiVersion = 3;
17
23
 
18
24
  const fetchOperationStale = 0;
19
25
  const fetchOperationApplied = 1;
20
26
  const fetchOperationBackpressure = 2;
21
27
 
22
- const require = createRequire(import.meta.url);
28
+ const nodeRequire = createRequire(import.meta.url);
23
29
  const defaultCoreWasm = new URL("./fx-core.wasm", import.meta.url);
24
30
  const defaultTermWasm = new URL("./fx-term.wasm", import.meta.url);
25
- const defaultNativeCandidates = [
26
- "./libfx.node",
27
- `./libfx.${process.platform}-${process.arch}.node`,
28
- ];
29
31
  let nativeBackendPromise;
32
+ const wasmFilePromises = new Map();
33
+
34
+ const backendReasonCodes = {
35
+ unsupportedPlatform: "LIBFX_UNSUPPORTED_PLATFORM",
36
+ missingArtifact: "LIBFX_NATIVE_ARTIFACT_MISSING",
37
+ nativeLoad: "LIBFX_NATIVE_LOAD_FAILED",
38
+ nativeApi: "LIBFX_NATIVE_API_MISMATCH",
39
+ missingSurface: "LIBFX_NATIVE_SURFACE_MISSING",
40
+ disabledNative: "LIBFX_NATIVE_DISABLED",
41
+ jspiUnavailable: "LIBFX_JSPI_UNAVAILABLE",
42
+ wasmLoad: "LIBFX_WASM_LOAD_FAILED",
43
+ };
30
44
 
31
45
  function jspiFallbackError(surface, nativeError) {
32
46
  const nativeDetail = nativeError ? ` Native loading failed: ${nativeError.message}.` : " No compatible native addon was found.";
@@ -44,7 +58,8 @@ async function loadNativeCandidate(candidate) {
44
58
  if (candidate == null) return null;
45
59
  if (candidate instanceof URL) {
46
60
  if (candidate.protocol === "file:" && candidate.pathname.endsWith(".node")) {
47
- return require(fileURLToPath(candidate));
61
+ // Bundlers trace the asset URL; Node must load the native file at runtime.
62
+ return Reflect.apply(nodeRequire, undefined, [fileURLToPath(candidate)]);
48
63
  }
49
64
  const imported = await import(candidate.href);
50
65
  return imported.default ?? imported;
@@ -53,83 +68,256 @@ async function loadNativeCandidate(candidate) {
53
68
  if (typeof candidate !== "string") {
54
69
  throw new TypeError("nativeAddon must be a module, path, URL, false, or undefined");
55
70
  }
56
- if (candidate.endsWith(".node")) return require(isAbsolute(candidate) ? candidate : resolve(candidate));
71
+ if (candidate.endsWith(".node")) {
72
+ return Reflect.apply(nodeRequire, undefined, [isAbsolute(candidate) ? candidate : resolve(candidate)]);
73
+ }
57
74
  const imported = await import(candidate.startsWith("file:") ? candidate : pathToFileURL(candidate).href);
58
75
  return imported.default ?? imported;
59
76
  }
60
77
 
78
+ function defaultNativeCandidate() {
79
+ if (process.platform === "linux" && process.arch === "x64") {
80
+ return new URL("./libfx.linux-x64.node", import.meta.url);
81
+ }
82
+ if (process.platform === "linux" && process.arch === "arm64") {
83
+ return new URL("./libfx.linux-arm64.node", import.meta.url);
84
+ }
85
+ if (process.platform === "darwin" && process.arch === "x64") {
86
+ return new URL("./libfx.darwin-x64.node", import.meta.url);
87
+ }
88
+ if (process.platform === "darwin" && process.arch === "arm64") {
89
+ return new URL("./libfx.darwin-arm64.node", import.meta.url);
90
+ }
91
+ return null;
92
+ }
93
+
61
94
  function validateNativeBackend(backend) {
62
95
  if (!backend) return null;
63
96
  const hasLowLevelCore = typeof backend.createCore === "function";
64
- if ((hasLowLevelCore && backend.libfxApiVersion !== libfxApiVersion) ||
65
- (!hasLowLevelCore && backend.libfxApiVersion !== undefined && backend.libfxApiVersion !== libfxApiVersion)) {
97
+ const expectedVersion = hasLowLevelCore ? nativeCoreApiVersion : libfxApiVersion;
98
+ if ((hasLowLevelCore || backend.libfxApiVersion !== undefined) && backend.libfxApiVersion !== expectedVersion) {
66
99
  const actualVersion = backend.libfxApiVersion ?? "missing";
67
- throw new Error(`native addon API version ${actualVersion} is incompatible with libfx API version ${libfxApiVersion}`);
100
+ throw new Error(`native addon API version ${actualVersion} is incompatible with expected API version ${expectedVersion}`);
68
101
  }
69
- if (typeof backend.createFxAgent !== "function" && typeof backend.createCore !== "function" &&
70
- typeof backend.createFxTerminal !== "function") {
71
- throw new Error("native addon must export createFxAgent(), createCore(), or createFxTerminal()");
102
+ if (typeof backend.createCore !== "function" && typeof backend.createFxTerminal !== "function") {
103
+ throw new Error("native addon must export createCore() or createFxTerminal()");
72
104
  }
73
105
  return backend;
74
106
  }
75
107
 
108
+ function missingArtifact(error) {
109
+ return error?.code === "ENOENT" || error?.code === "MODULE_NOT_FOUND" || error?.code === "ERR_MODULE_NOT_FOUND";
110
+ }
111
+
112
+ function nativeCandidateFilePath(candidate) {
113
+ if (candidate instanceof URL) return candidate.protocol === "file:" ? fileURLToPath(candidate) : null;
114
+ if (typeof candidate !== "string") return null;
115
+ if (candidate.startsWith("file:")) return fileURLToPath(new URL(candidate));
116
+ return URL.canParse(candidate) ? null : resolve(candidate);
117
+ }
118
+
119
+ async function nativeArtifactMissing(candidate) {
120
+ try {
121
+ const path = nativeCandidateFilePath(candidate);
122
+ if (path === null) return false;
123
+ await access(path);
124
+ return false;
125
+ } catch (error) {
126
+ return missingArtifact(error);
127
+ }
128
+ }
129
+
130
+ function validationFailure(error) {
131
+ return error?.message?.startsWith("native addon API version ") ? "api" : "surface";
132
+ }
133
+
134
+ async function loadAndValidateNativeCandidate(candidate, artifactMissing = false) {
135
+ let backend;
136
+ try {
137
+ backend = await loadNativeCandidate(candidate);
138
+ } catch (error) {
139
+ return { backend: null, error, failure: artifactMissing ? "missing" : "load" };
140
+ }
141
+ try {
142
+ return { backend: validateNativeBackend(backend), error: null, failure: null };
143
+ } catch (error) {
144
+ return { backend: null, error, failure: validationFailure(error) };
145
+ }
146
+ }
147
+
76
148
  async function discoverNativeBackend() {
77
- for (const relativePath of defaultNativeCandidates) {
78
- const url = new URL(relativePath, import.meta.url);
79
- try {
80
- await access(fileURLToPath(url));
81
- } catch (error) {
82
- if (error?.code === "ENOENT") continue;
83
- return { backend: null, error };
84
- }
85
- try {
86
- return { backend: validateNativeBackend(await loadNativeCandidate(url)), error: null };
87
- } catch (error) {
88
- return { backend: null, error };
149
+ const candidate = defaultNativeCandidate();
150
+ if (!candidate) {
151
+ return { backend: null, error: null, failure: "unsupported" };
152
+ }
153
+ try {
154
+ await access(fileURLToPath(candidate));
155
+ } catch (error) {
156
+ if (missingArtifact(error)) {
157
+ return { backend: null, error: null, probeError: error, failure: "missing" };
89
158
  }
159
+ return { backend: null, error, failure: "load" };
90
160
  }
91
- return { backend: null, error: null };
161
+ return loadAndValidateNativeCandidate(candidate);
92
162
  }
93
163
 
94
164
  async function resolveNativeBackend(nativeAddon) {
95
- if (nativeAddon === false) return { backend: null, error: null };
165
+ if (nativeAddon === false) return { backend: null, error: null, failure: "disabled" };
96
166
  if (nativeAddon !== undefined) {
97
- try {
98
- return { backend: validateNativeBackend(await loadNativeCandidate(nativeAddon)), error: null };
99
- } catch (error) {
100
- return { backend: null, error };
101
- }
167
+ return loadAndValidateNativeCandidate(nativeAddon, await nativeArtifactMissing(nativeAddon));
102
168
  }
103
169
  nativeBackendPromise ??= discoverNativeBackend();
104
170
  return nativeBackendPromise;
105
171
  }
106
172
 
107
- async function wasmBytes(input) {
108
- if (input instanceof URL && input.protocol === "file:") return readFile(input);
109
- if (typeof input === "string" && !URL.canParse(input)) return readFile(input);
110
- return input;
173
+ function wasmInput(input) {
174
+ const path = wasmFilePath(input);
175
+ if (path === null) {
176
+ if (input instanceof URL) return input.href;
177
+ return input;
178
+ }
179
+ const cached = wasmFilePromises.get(path);
180
+ if (cached) return cached;
181
+ const pendingRead = readFile(path);
182
+ let pending;
183
+ pending = withModuleFailure(pendingRead, () => {
184
+ if (wasmFilePromises.get(path) === pending) wasmFilePromises.delete(path);
185
+ });
186
+ wasmFilePromises.set(path, pending);
187
+ pendingRead.catch(() => {
188
+ if (wasmFilePromises.get(path) === pending) wasmFilePromises.delete(path);
189
+ });
190
+ return pending;
191
+ }
192
+
193
+ function wasmFilePath(input) {
194
+ if (input instanceof URL && input.protocol === "file:") return fileURLToPath(input);
195
+ if (typeof input === "string" && !URL.canParse(input)) return resolve(input);
196
+ return null;
197
+ }
198
+
199
+ function reason(code, message, error) {
200
+ const causeCode = error?.code;
201
+ return {
202
+ code,
203
+ message,
204
+ ...(typeof causeCode === "string" || typeof causeCode === "number" ? { causeCode } : {}),
205
+ };
206
+ }
207
+
208
+ function nativeFailureReason(result) {
209
+ const detailError = result.probeError ?? result.error;
210
+ switch (result.failure) {
211
+ case "unsupported":
212
+ return reason(
213
+ backendReasonCodes.unsupportedPlatform,
214
+ `native addon is not available for ${process.platform}-${process.arch}`,
215
+ );
216
+ case "missing":
217
+ return reason(
218
+ backendReasonCodes.missingArtifact,
219
+ `native addon artifact was not found${detailError?.message ? `: ${detailError.message}` : ""}`,
220
+ detailError,
221
+ );
222
+ case "load":
223
+ return reason(
224
+ backendReasonCodes.nativeLoad,
225
+ `native addon failed to load${result.error?.message ? `: ${result.error.message}` : ""}`,
226
+ result.error,
227
+ );
228
+ case "api":
229
+ return reason(backendReasonCodes.nativeApi, result.error.message, result.error);
230
+ case "surface":
231
+ return reason(backendReasonCodes.missingSurface, result.error.message, result.error);
232
+ case "disabled":
233
+ return reason(backendReasonCodes.disabledNative, "native addon loading is disabled");
234
+ default:
235
+ return reason(backendReasonCodes.nativeLoad, "native addon is unavailable", result.error);
236
+ }
111
237
  }
112
238
 
113
- function validateGatewayChatUrl(value) {
114
- if (value === undefined) return;
115
- if (typeof value !== "string") throw new TypeError("FX_GATEWAY_CHAT_URL must be a string");
116
- let url;
117
- try { url = new URL(value); } catch { throw new TypeError("FX_GATEWAY_CHAT_URL must be a valid URL"); }
118
- if (url.username || url.password || url.hash) {
119
- throw new TypeError("FX_GATEWAY_CHAT_URL must not contain credentials or a fragment");
120
- }
121
- if (url.href === "https://ai-gateway.vercel.sh/v3/ai/language-model") return;
122
- const loopback = url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "localhost";
123
- if (url.protocol !== "http:" || !loopback || !url.port) {
124
- throw new TypeError("FX_GATEWAY_CHAT_URL must use the canonical Gateway or explicit loopback HTTP");
239
+ function validateBackendInfoOptions(value) {
240
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
241
+ throw new TypeError("getBackendInfo() options must be an object");
242
+ }
243
+ const options = { ...value };
244
+ for (const key of Object.keys(options)) {
245
+ if (!new Set(["surface", "backend", "nativeAddon", "wasm"]).has(key)) {
246
+ throw new TypeError(`getBackendInfo() does not accept ${key}`);
247
+ }
248
+ }
249
+ const surface = options.surface ?? "agent";
250
+ if (!new Set(["agent", "terminal"]).has(surface)) {
251
+ throw new TypeError('surface must be "agent" or "terminal"');
252
+ }
253
+ const backend = options.backend ?? "auto";
254
+ if (!new Set(["auto", "native", "wasm"]).has(backend)) {
255
+ throw new TypeError('backend must be "auto", "native", or "wasm"');
256
+ }
257
+ if (Object.hasOwn(options, "nativeAddon") && options.nativeAddon !== undefined && options.nativeAddon !== false &&
258
+ typeof options.nativeAddon !== "string" && !(options.nativeAddon instanceof URL) &&
259
+ (typeof options.nativeAddon !== "object" || options.nativeAddon === null)) {
260
+ throw new TypeError("nativeAddon must be a module, path, URL, false, or undefined");
261
+ }
262
+ const validWasm = options.wasm === undefined || typeof options.wasm === "string" || options.wasm instanceof URL ||
263
+ options.wasm instanceof Promise || options.wasm instanceof WebAssembly.Module || options.wasm instanceof Response ||
264
+ options.wasm instanceof ArrayBuffer || ArrayBuffer.isView(options.wasm);
265
+ if (Object.hasOwn(options, "wasm") && !validWasm) {
266
+ throw new TypeError("wasm must be a URL, Response, ArrayBuffer, typed array, or WebAssembly.Module");
267
+ }
268
+ return { ...options, surface, backend };
269
+ }
270
+
271
+ export async function getBackendInfo(value = {}) {
272
+ const { surface, backend, nativeAddon, wasm } = validateBackendInfoOptions(value);
273
+ const attempts = [];
274
+ if (backend !== "wasm") {
275
+ const native = await resolveNativeBackend(nativeAddon);
276
+ const nativeMethod = surface === "agent" ? "createCore" : "createFxTerminal";
277
+ if (typeof native.backend?.[nativeMethod] === "function") {
278
+ attempts.push({ backend: "native", available: true, reason: null });
279
+ return { surface, backend: "native", attempts };
280
+ }
281
+ const failureReason = native.backend
282
+ ? reason(backendReasonCodes.missingSurface, `native addon does not provide ${nativeMethod}()`)
283
+ : nativeFailureReason(native);
284
+ attempts.push({ backend: "native", available: false, reason: failureReason });
285
+ if (backend === "native") return { surface, backend: "unavailable", attempts };
286
+ }
287
+
288
+ if (!supportsJspi()) {
289
+ attempts.push({
290
+ backend: "wasm-jspi",
291
+ available: false,
292
+ reason: reason(
293
+ backendReasonCodes.jspiUnavailable,
294
+ "WebAssembly backend requires JavaScript Promise Integration (JSPI)",
295
+ ),
296
+ });
297
+ return { surface, backend: "unavailable", attempts };
298
+ }
299
+ const defaultWasm = surface === "agent" ? defaultCoreWasm : defaultTermWasm;
300
+ const wasmSource = wasm ?? defaultWasm;
301
+ try {
302
+ await loadModule(wasmInput(wasmSource));
303
+ attempts.push({ backend: "wasm-jspi", available: true, reason: null });
304
+ return { surface, backend: "wasm-jspi", attempts };
305
+ } catch (error) {
306
+ attempts.push({
307
+ backend: "wasm-jspi",
308
+ available: false,
309
+ reason: reason(
310
+ backendReasonCodes.wasmLoad,
311
+ `WebAssembly asset failed to load or compile: ${error?.message ?? String(error)}`,
312
+ error,
313
+ ),
314
+ });
315
+ return { surface, backend: "unavailable", attempts };
125
316
  }
126
317
  }
127
318
 
128
319
  function createNativeCoreRuntime(addon, options) {
129
- const apiKey = options.env?.AI_GATEWAY_API_KEY;
130
- const model = options.env?.FX_MODEL;
131
- const gatewayChatUrl = options.env?.FX_GATEWAY_CHAT_URL;
132
- validateGatewayChatUrl(gatewayChatUrl);
320
+ const { apiKey, model, gatewayChatUrl } = options;
133
321
  const core = addon.createCore({
134
322
  apiKey,
135
323
  home: options.home ?? homedir(),
@@ -137,9 +325,24 @@ function createNativeCoreRuntime(addon, options) {
137
325
  ...(model === undefined ? {} : { model }),
138
326
  ...(gatewayChatUrl === undefined ? {} : { gatewayChatUrl }),
139
327
  });
328
+ let readyFd;
329
+ let readySocket;
330
+ try {
331
+ readyFd = addon.takeCoreReadyFd(core);
332
+ readySocket = new Socket({ fd: readyFd, readable: true, writable: false });
333
+ } catch (error) {
334
+ if (readyFd !== undefined) {
335
+ try { closeSync(readyFd); } catch {}
336
+ }
337
+ addon.destroyCore(core);
338
+ throw error;
339
+ }
340
+ const readyClosed = new Promise((resolve) => readySocket.once("close", resolve));
140
341
  let exitedResolve;
141
342
  let lineHandler = null;
142
- let lineBuffer = "";
343
+ const output = new CoreOutput((message, size) => lineHandler(message, size));
344
+ let draining = false;
345
+ let outputError;
143
346
  let settled = false;
144
347
  let fetchState = null;
145
348
  const exited = new Promise((resolve) => { exitedResolve = resolve; });
@@ -147,13 +350,15 @@ function createNativeCoreRuntime(addon, options) {
147
350
  fetchState?.controller.abort();
148
351
  try { addon.abortCoreFetch(core); } catch {}
149
352
  };
150
- const finish = (code) => {
353
+ const finish = (code, error) => {
151
354
  if (settled) return;
152
355
  settled = true;
153
- clearInterval(timer);
356
+ outputError = error;
357
+ output.close();
154
358
  abortHostEffects();
155
359
  try { addon.destroyCore(core); } catch {}
156
- exitedResolve(code);
360
+ readySocket.destroy();
361
+ void readyClosed.then(() => exitedResolve(code));
157
362
  };
158
363
  const pumpFetch = async (request) => {
159
364
  const controller = new AbortController();
@@ -197,10 +402,14 @@ function createNativeCoreRuntime(addon, options) {
197
402
  } catch {}
198
403
  }
199
404
  } finally {
200
- if (fetchState === state) fetchState = null;
405
+ if (fetchState === state) {
406
+ fetchState = null;
407
+ queueMicrotask(drainReady);
408
+ }
201
409
  }
202
410
  };
203
- const timer = setInterval(() => {
411
+ function drainReady() {
412
+ if (settled) return;
204
413
  try {
205
414
  if (fetchState) {
206
415
  if (!fetchState.controller.signal.aborted && !addon.coreFetchActive(core, fetchState.handle)) {
@@ -210,29 +419,55 @@ function createNativeCoreRuntime(addon, options) {
210
419
  const fetchRequest = addon.takeCoreFetch(core);
211
420
  if (fetchRequest) void pumpFetch(JSON.parse(fetchRequest.toString("utf8")));
212
421
  }
213
- const chunk = addon.drainCore(core);
214
- if (chunk.length && lineHandler) {
215
- lineBuffer += chunk.toString("utf8");
216
- for (;;) {
217
- const newline = lineBuffer.indexOf("\n");
218
- if (newline < 0) break;
219
- const line = lineBuffer.slice(0, newline);
220
- lineBuffer = lineBuffer.slice(newline + 1);
221
- if (line) lineHandler(JSON.parse(line));
222
- }
422
+ if (addon.coreExitCode(core) !== 0) {
423
+ finish(1, new Error("native output delivery failed"));
424
+ return;
425
+ }
426
+ void drainOutput();
427
+ } catch (error) {
428
+ finish(1, error);
429
+ }
430
+ }
431
+ async function drainOutput() {
432
+ if (draining || settled) return;
433
+ draining = true;
434
+ try {
435
+ while (!settled) {
436
+ const chunk = addon.drainCore(core);
437
+ if (!chunk.length) break;
438
+ const pending = output.write(chunk);
439
+ if (pending) await pending;
223
440
  }
224
- if (addon.coreExited(core)) finish(addon.coreExitCode(core));
225
- } catch {
226
- finish(1);
441
+ if (!settled && addon.coreExited(core)) {
442
+ output.finish();
443
+ finish(addon.coreExitCode(core));
444
+ }
445
+ } catch (error) {
446
+ finish(1, error);
447
+ } finally {
448
+ draining = false;
227
449
  }
228
- }, 2);
450
+ }
451
+
452
+ readySocket.on("data", drainReady);
453
+ readySocket.on("end", () => { drainReady(); if (!settled) finish(1); });
454
+ readySocket.on("error", () => finish(1));
455
+ readySocket.on("close", () => { if (!settled) finish(1); });
456
+ // Some runtimes defer descriptor adoption until connect().
457
+ if (readySocket.pending) {
458
+ try { readySocket.connect({ fd: readyFd }); } catch (error) { finish(1); throw error; }
459
+ }
229
460
 
230
461
  return {
231
462
  exited,
463
+ get error() { return outputError; },
232
464
  write(data) { addon.writeCore(core, Buffer.from(data)); },
233
465
  closeStdin() { addon.closeCore(core); },
234
466
  abortHostEffects,
235
- abort() { abortHostEffects(); addon.closeCore(core); },
467
+ abort(error) {
468
+ if (error) finish(1, error);
469
+ else { abortHostEffects(); addon.closeCore(core); }
470
+ },
236
471
  setLineHandler(handler) { lineHandler = handler; },
237
472
  };
238
473
  }
@@ -248,22 +483,20 @@ function createNativeAgent(addon, options) {
248
483
 
249
484
  async function createWithFallback(surface, nativeMethod, wasmFactory, defaultWasm, options) {
250
485
  const { nativeAddon, backend = "auto", ...runtimeOptions } = options ?? {};
251
- validateGatewayChatUrl(runtimeOptions.env?.FX_GATEWAY_CHAT_URL);
252
486
  if (!new Set(["auto", "native", "wasm"]).has(backend)) {
253
487
  throw new TypeError('backend must be "auto", "native", or "wasm"');
254
488
  }
255
489
 
256
490
  let nativeError;
491
+ let nativeAttempted = false;
257
492
  if (backend !== "wasm") {
258
493
  const native = await resolveNativeBackend(nativeAddon);
259
494
  nativeError = native.error;
260
- if (typeof native.backend?.[nativeMethod] === "function" ||
261
- (surface === "agent" && typeof native.backend?.createCore === "function")) {
495
+ if (typeof native.backend?.[nativeMethod] === "function") {
496
+ nativeAttempted = true;
262
497
  try {
263
- if (typeof native.backend?.[nativeMethod] === "function") {
264
- return await native.backend[nativeMethod](runtimeOptions);
265
- }
266
- return await createNativeAgent(native.backend, runtimeOptions);
498
+ if (surface === "agent") return await createNativeAgent(native.backend, runtimeOptions);
499
+ return await native.backend[nativeMethod](runtimeOptions);
267
500
  } catch (error) {
268
501
  nativeError = error;
269
502
  if (backend === "native") throw error;
@@ -276,15 +509,25 @@ async function createWithFallback(surface, nativeMethod, wasmFactory, defaultWas
276
509
  }
277
510
  }
278
511
 
279
- if (!supportsJspi()) throw jspiFallbackError(surface, nativeError);
280
- return wasmFactory({
281
- ...runtimeOptions,
282
- wasm: await wasmBytes(runtimeOptions.wasm ?? defaultWasm),
283
- });
512
+ if (!supportsJspi()) {
513
+ if (nativeAttempted) throw nativeError;
514
+ throw jspiFallbackError(surface, nativeError);
515
+ }
516
+ const wasmSource = runtimeOptions.wasm ?? defaultWasm;
517
+ return wasmFactory({ ...runtimeOptions, wasm: wasmInput(wasmSource) });
284
518
  }
285
519
 
286
- export function createFxAgent(options = {}) {
287
- return createWithFallback("agent", "createFxAgent", createWasmAgent, defaultCoreWasm, options);
520
+ export async function createFxAgent(options = {}) {
521
+ if (options != null && Object.hasOwn(Object(options), "env")) {
522
+ throw new TypeError("createFxAgent() does not accept env; pass apiKey and model directly");
523
+ }
524
+ return createWithFallback(
525
+ "agent",
526
+ "createCore",
527
+ createWasmAgent,
528
+ defaultCoreWasm,
529
+ options,
530
+ );
288
531
  }
289
532
 
290
533
  export function createFxTerminal(options = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libfx",
3
- "version": "0.0.7",
3
+ "version": "0.0.8-dev.820.g1d9d3b63d6ea",
4
4
  "description": "Embed fx agents and terminals in JavaScript hosts",
5
5
  "type": "module",
6
6
  "repository": {
@@ -17,13 +17,22 @@
17
17
  },
18
18
  "exports": {
19
19
  ".": {
20
- "node": "./node.js",
20
+ "node": {
21
+ "import": "./node.js",
22
+ "require": "./node.cjs"
23
+ },
21
24
  "browser": "./browser.js",
22
25
  "default": "./browser.js"
23
26
  },
24
- "./node": "./node.js",
27
+ "./node": {
28
+ "import": "./node.js",
29
+ "require": "./node.cjs"
30
+ },
25
31
  "./browser": "./browser.js",
26
- "./wasm": "./fx-sdk.js"
32
+ "./wasm": "./fx-sdk.js",
33
+ "./mcp": "./mcp.js",
34
+ "./skills": "./skills.js",
35
+ "./skills/node": "./skills-node.js"
27
36
  },
28
37
  "engines": {
29
38
  "node": ">=20"
package/skills-node.js ADDED
@@ -0,0 +1,29 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { basename } from "node:path";
3
+
4
+ function parseFrontmatter(source) {
5
+ if (!source.startsWith("---\n")) return { metadata: {}, body: source };
6
+ const end = source.indexOf("\n---\n", 4);
7
+ if (end < 0) throw new Error("SKILL.md has unterminated frontmatter");
8
+ const metadata = {};
9
+ for (const line of source.slice(4, end).split("\n")) {
10
+ const separator = line.indexOf(":");
11
+ if (separator < 0) continue;
12
+ metadata[line.slice(0, separator).trim()] = line.slice(separator + 1).trim();
13
+ }
14
+ return { metadata, body: source.slice(end + 5).trim() };
15
+ }
16
+
17
+ export async function loadSkillFile(path, options = {}) {
18
+ const source = await (options.readFile ?? readFile)(path, "utf8");
19
+ const { metadata, body } = parseFrontmatter(source);
20
+ return {
21
+ name: metadata.name || basename(path).replace(/\.md$/i, ""),
22
+ description: metadata.description || "",
23
+ instructions: body,
24
+ resources: options.resources ?? [],
25
+ tools: options.tools ?? [],
26
+ };
27
+ }
28
+
29
+ export { createSkillsAdapter } from "./skills.js";
package/skills.js ADDED
@@ -0,0 +1,44 @@
1
+ const maxSkills = 64;
2
+ const maxInstructionsBytes = 64 * 1024;
3
+
4
+ function escapeAttribute(value) {
5
+ return value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;");
6
+ }
7
+
8
+ export function createSkillsAdapter(records) {
9
+ if (!Array.isArray(records) || records.length > maxSkills) {
10
+ throw new TypeError("skills must be an array with at most 64 records");
11
+ }
12
+ const names = new Set();
13
+ const sections = [];
14
+ const tools = [];
15
+ for (const [index, record] of records.entries()) {
16
+ if (!record || typeof record.name !== "string" || typeof record.instructions !== "string") {
17
+ throw new TypeError(`skill ${index} requires name and instructions`);
18
+ }
19
+ if (names.has(record.name)) throw new TypeError(`duplicate skill name: ${record.name}`);
20
+ names.add(record.name);
21
+ const resources = (record.resources ?? []).map((resource) => {
22
+ if (typeof resource?.uri !== "string" || typeof resource?.text !== "string") {
23
+ throw new TypeError(`skill ${record.name} has an invalid resource`);
24
+ }
25
+ return `<resource uri="${escapeAttribute(resource.uri)}">\n${resource.text}\n</resource>`;
26
+ }).join("\n");
27
+ sections.push([
28
+ `<skill name="${escapeAttribute(record.name)}">`,
29
+ record.description ? `<description>${record.description}</description>` : "",
30
+ record.instructions,
31
+ resources,
32
+ "</skill>",
33
+ ].filter(Boolean).join("\n"));
34
+ if (record.tools !== undefined) {
35
+ if (!Array.isArray(record.tools)) throw new TypeError(`skill ${record.name} tools must be an array`);
36
+ tools.push(...record.tools);
37
+ }
38
+ }
39
+ const instructions = sections.join("\n\n");
40
+ if (new TextEncoder().encode(instructions).length > maxInstructionsBytes) {
41
+ throw new RangeError(`skill instructions exceed the ${maxInstructionsBytes} byte libfx limit`);
42
+ }
43
+ return { instructions, tools };
44
+ }