libfx 0.0.6 → 0.0.7-dev.607.ga90fc3585568

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 CHANGED
@@ -1,357 +1,162 @@
1
1
  # libfx
2
2
 
3
- `libfx` embeds fx agents and interactive terminals in JavaScript
4
- applications. It supports Node.js hosts and browser environments with
5
- JavaScript Promise Integration (JSPI).
6
-
7
- ## Installation
3
+ `libfx` is the small fx agent kernel for JavaScript hosts. One agent is one
4
+ in-memory conversation with three operations: `prompt`, `checkpoint`, and
5
+ `close`.
8
6
 
9
7
  ```sh
10
8
  npm install libfx
11
9
  ```
12
10
 
13
- Requirements:
14
-
15
- - Node.js 20 or later
16
- - Chrome or Edge 137 or later for browser WebAssembly
17
- - JSPI when using the WebAssembly backend
18
- - A Vercel AI Gateway credential or a host-provided authenticated `fetch`
19
-
20
- The package includes:
21
-
22
- - Native Node addons for Linux and macOS on x64 and arm64
23
- - `fx-core.wasm` for headless agents
24
- - `fx-term.wasm` for interactive terminals
25
- - A dependency-free JavaScript host layer
26
-
27
- ## Exports
28
-
29
- | Import | Environment | Description |
30
- | --- | --- | --- |
31
- | `libfx` | Node.js or browser | Environment-aware default |
32
- | `libfx/node` | Node.js | Native-first Node entry point |
33
- | `libfx/browser` | Browser | WebAssembly browser entry point |
34
- | `libfx/wasm` | Browser or Node.js | Direct WebAssembly host layer |
11
+ Node.js uses the native addon when available and falls back to WebAssembly.
12
+ Browsers use WebAssembly with JSPI. The default package has no runtime
13
+ dependencies and performs no MCP connection, skill scan, process spawn, or
14
+ filesystem read when imported.
35
15
 
36
- Public exports:
37
-
38
- - `createFxAgent()` creates a headless ACP agent.
39
- - `createFxTerminal()` runs the interactive fx terminal.
40
- - `supportsJspi()` detects WebAssembly JSPI support.
41
- - `xtermAdapter()` connects fx to an xterm.js terminal.
42
- - `encodeXtermKeyEvent()` translates browser keyboard events into terminal input.
43
-
44
- ## Headless agent
45
-
46
- The default Node entry point prefers the native addon and falls back to
47
- WebAssembly when necessary.
16
+ ## Agent
48
17
 
49
18
  ```js
50
19
  import { createFxAgent } from "libfx";
51
20
 
52
21
  const agent = await createFxAgent({
53
- env: {
54
- AI_GATEWAY_API_KEY: process.env.AI_GATEWAY_API_KEY,
55
- },
56
- onEvent(event) {
57
- console.log(event.type);
58
- },
59
- async onPermission(request) {
60
- // Return one of request.options[*].optionId to approve it.
61
- // Returning null or undefined cancels the request.
62
- return null;
63
- },
22
+ env: { AI_GATEWAY_API_KEY: process.env.AI_GATEWAY_API_KEY },
64
23
  });
65
24
 
66
- const session = await agent.createSession();
67
- const turn = session.prompt("Explain the files in this project.");
25
+ const turn = agent.prompt("Explain this project.");
68
26
 
69
- for await (const update of turn) {
70
- console.log(update);
27
+ for await (const event of turn) {
28
+ if (event.type === "text_delta") process.stdout.write(event.delta);
71
29
  }
72
30
 
73
- console.log("Stopped:", await turn.stopReason);
74
-
75
- await session.close();
31
+ console.log(await turn.result); // { stopReason, usage }
32
+ const checkpoint = await agent.checkpoint();
76
33
  await agent.close();
77
34
  ```
78
35
 
79
- A prompt may be a string or an array of text and resource blocks:
36
+ `prompt(input, { signal? })` accepts a string or text/resource blocks. It
37
+ returns an async iterable of normalized events:
80
38
 
81
- ```js
82
- const turn = session.prompt([
83
- { type: "text", text: "Summarize this file." },
84
- {
85
- type: "resource",
86
- resource: {
87
- uri: "file:///workspace/README.md",
88
- text: readmeContents,
89
- },
90
- },
91
- ]);
92
- ```
93
-
94
- Image prompt blocks are not currently supported.
95
-
96
- ### Agent lifecycle
97
-
98
- The object returned by `createFxAgent()` provides:
39
+ - `text_delta`
40
+ - `reasoning_delta` when supplied by the provider
41
+ - `tool_start`
42
+ - `tool_end`
99
43
 
100
- | Member | Description |
101
- | --- | --- |
102
- | `createSession()` | Creates a new active session |
103
- | `openSession(id)` | Loads a stored session |
104
- | `listSessions()` | Lists stored sessions |
105
- | `close()` | Closes the active session and shuts down cleanly |
106
- | `abort()` | Immediately aborts the runtime |
107
- | `exited` | Promise that resolves with the process exit code |
108
-
109
- A session provides:
110
-
111
- | Member | Description |
112
- | --- | --- |
113
- | `prompt(input, options?)` | Starts an async iterable turn |
114
- | `setModel(model)` | Changes the active model |
115
- | `setMode(mode)` | Changes the active mode |
116
- | `setConfig(config)` | Applies multiple configuration values |
117
- | `close()` | Closes the active session |
118
- | `remove()` | Removes the stored session |
119
- | `history` | Previously loaded session updates |
120
- | `configOptions` | Current configurable values |
121
-
122
- Each session allows one active prompt at a time. Cancel a turn directly or
123
- with an `AbortSignal`:
44
+ Only one prompt may run at a time. `checkpoint()` is idle-only and returns
45
+ opaque, bounded, versioned bytes. Restore them only when creating a fresh
46
+ agent:
124
47
 
125
48
  ```js
126
- const controller = new AbortController();
127
- const turn = session.prompt("Wait for more instructions.", {
128
- signal: controller.signal,
129
- });
130
-
131
- controller.abort();
132
- console.log(await turn.stopReason); // "cancelled"
49
+ const restored = await createFxAgent({ checkpoint, env });
133
50
  ```
134
51
 
135
- ## Browser agent
52
+ The checkpoint contains conversation history and usage only. The host owns
53
+ durable storage and must resupply models, credentials, instructions, tools,
54
+ MCP clients, and skill records.
136
55
 
137
- Browser hosts always use WebAssembly.
56
+ ## JavaScript tools and instructions
138
57
 
139
58
  ```js
140
- import {
141
- createFxAgent,
142
- supportsJspi,
143
- } from "libfx/browser";
144
-
145
- if (!supportsJspi()) {
146
- throw new Error("This browser does not support WebAssembly JSPI.");
147
- }
148
-
149
59
  const agent = await createFxAgent({
150
- env: {
151
- AI_GATEWAY_API_KEY: "<short-lived credential>",
152
- },
60
+ instructions: "Keep answers concise.",
61
+ tools: [{
62
+ name: "lookup",
63
+ description: "Look up a value.",
64
+ inputSchema: {
65
+ type: "object",
66
+ properties: { key: { type: "string" } },
67
+ required: ["key"],
68
+ },
69
+ async execute(input, { signal }) {
70
+ return database.get(input.key, { signal });
71
+ },
72
+ }],
73
+ env,
153
74
  });
154
-
155
- const session = await agent.createSession();
156
- const turn = session.prompt("Describe this workspace.");
157
-
158
- for await (const update of turn) {
159
- console.log(update);
160
- }
161
75
  ```
162
76
 
163
- The browser entry point resolves `fx-core.wasm` and `fx-term.wasm` relative to
164
- the installed package. Pass `wasm` explicitly to provide a URL, `Response`,
165
- `ArrayBuffer`, typed array, or precompiled `WebAssembly.Module`.
166
-
167
- Do not embed a long-lived API key in public browser code. Use a short-lived
168
- credential or an authenticated server-side proxy.
169
-
170
- ## Interactive terminal
77
+ The JavaScript host is the authority for tool effects. The same descriptors,
78
+ schemas, cancellation, results, and events are used by N-API and WebAssembly.
79
+ Instructions are limited to 64 KiB of UTF-8 text, including text assembled by
80
+ the MCP and skills adapters.
171
81
 
172
- Install xterm.js in the host application:
82
+ ## MCP
173
83
 
174
- ```sh
175
- npm install @xterm/xterm @xterm/addon-fit
176
- ```
177
-
178
- Create the terminal and connect it to fx:
84
+ `libfx/mcp` accepts a host-owned MCP client. Transport, authentication,
85
+ elicitation, and cleanup remain outside the kernel.
179
86
 
180
87
  ```js
181
- import { Terminal } from "@xterm/xterm";
182
- import { FitAddon } from "@xterm/addon-fit";
183
- import "@xterm/xterm/css/xterm.css";
184
- import {
185
- createFxTerminal,
186
- supportsJspi,
187
- xtermAdapter,
188
- } from "libfx/browser";
189
-
190
- if (!supportsJspi()) {
191
- throw new Error("This browser does not support WebAssembly JSPI.");
192
- }
88
+ import { createMcpAdapter } from "libfx/mcp";
193
89
 
194
- const terminal = new Terminal({
195
- cursorBlink: true,
196
- scrollback: 10_000,
90
+ const mcp = await createMcpAdapter(client, {
91
+ prefix: "github_",
92
+ resources: ["repo://instructions"],
93
+ prompts: ["review"],
197
94
  });
198
95
 
199
- const fit = new FitAddon();
200
- terminal.loadAddon(fit);
201
- terminal.open(document.querySelector("#terminal"));
202
- fit.fit();
203
-
204
- const runtime = await createFxTerminal({
205
- terminal: xtermAdapter(terminal),
206
- env: {
207
- AI_GATEWAY_API_KEY: "<short-lived credential>",
208
- },
209
- });
210
-
211
- await runtime.interactive;
212
-
213
- window.addEventListener("resize", () => {
214
- fit.fit();
215
- runtime.resize();
216
- });
217
- ```
218
-
219
- The terminal runtime provides:
220
-
221
- | Member | Description |
222
- | --- | --- |
223
- | `interactive` | Resolves after the terminal is ready for input |
224
- | `exited` | Resolves with the terminal exit code |
225
- | `write(data)` | Writes input directly to fx |
226
- | `resize()` | Notifies fx of terminal geometry changes |
227
- | `abort()` | Stops the terminal and releases subscriptions |
228
-
229
- Try the hosted terminal at [fx.sh/try](https://fx.sh/try).
230
-
231
- ## Backend selection
232
-
233
- Node hosts may select a backend explicitly:
234
-
235
- ```js
236
96
  const agent = await createFxAgent({
237
- backend: "native",
97
+ tools: mcp.tools,
98
+ instructions: mcp.instructions,
99
+ env,
238
100
  });
239
- ```
240
-
241
- | Backend | Behavior |
242
- | --- | --- |
243
- | `auto` | Prefer a compatible native addon and fall back to WebAssembly |
244
- | `native` | Require the native backend and fail if it cannot load |
245
- | `wasm` | Require WebAssembly and JSPI |
246
-
247
- The native loader checks `libfx.node` followed by the platform-specific addon:
248
101
 
249
- ```text
250
- libfx.<platform>-<arch>.node
102
+ // ...
103
+ await agent.close();
104
+ await mcp.close();
251
105
  ```
252
106
 
253
- Supported packaged targets:
254
-
255
- - `linux-x64`
256
- - `linux-arm64`
257
- - `darwin-x64`
258
- - `darwin-arm64`
107
+ ## Skills
259
108
 
260
- If no compatible native backend is available and JSPI cannot run, startup
261
- rejects with:
109
+ Use `libfx/skills` for already-loaded records or `libfx/skills/node` to load a
110
+ `SKILL.md` explicitly in Node or Bun.
262
111
 
263
112
  ```js
264
- error.code === "LIBFX_JSPI_REQUIRED"
265
- ```
266
-
267
- On Node versions where JSPI remains behind a flag, start the process with:
113
+ import { loadSkillFile } from "libfx/skills/node";
114
+ import { createSkillsAdapter } from "libfx/skills";
268
115
 
269
- ```sh
270
- node --experimental-wasm-jspi app.mjs
116
+ const record = await loadSkillFile("./skills/review/SKILL.md");
117
+ const skills = createSkillsAdapter([record]);
118
+ const agent = await createFxAgent({ ...skills, env });
271
119
  ```
272
120
 
273
- ## Host integrations
274
-
275
- Hosts may provide adapters for runtime state and external effects:
276
-
277
- | Option | Purpose |
278
- | --- | --- |
279
- | `fetch` | Routes Gateway requests through the host |
280
- | `env` | Supplies runtime configuration without changing process globals |
281
- | `onEvent` | Receives runtime, ACP, terminal, and lifecycle events |
282
- | `onPermission` | Resolves agent permission requests |
283
- | `configStore` | Persists accepted configuration values |
284
- | `sessionStore` | Persists agent or terminal sessions |
285
- | `oauthSessionStore` | Persists browser device-login sessions |
286
- | `promptHistoryStore` | Stores terminal prompt history |
287
- | `openUrl` | Opens authentication and verification URLs |
288
- | `workspace` | Provides the constrained browser workspace adapter |
289
-
290
- ## Security boundaries
291
-
292
- `nativeAddon` and `env.FX_GATEWAY_CHAT_URL` are trusted host configuration. Do
293
- not populate them from request, tenant, or other untrusted input.
294
-
295
- The native backend sends production credentials only to the canonical Vercel
296
- AI Gateway endpoint. Custom Gateway endpoints are limited to explicit loopback
297
- HTTP URLs for local development.
298
-
299
- The WebAssembly runtime intentionally does not provide:
300
-
301
- - Native processes
302
- - OS sandboxing
303
- - Native MCP servers
304
- - Subagents or skills
305
- - Automatic upgrades
306
- - Clipboard integration
307
- - Arbitrary WASI filesystem access
308
- - Web search
309
-
310
- The optional browser workspace exposes foreground terminal execution through
311
- the typed contract:
121
+ ## Backends
312
122
 
313
123
  ```js
314
- { action: "exec", command }
124
+ await createFxAgent({ backend: "auto" }); // native, then Wasm fallback
125
+ await createFxAgent({ backend: "native" }); // require N-API
126
+ await createFxAgent({ backend: "wasm" }); // require Wasm + JSPI
315
127
  ```
316
128
 
317
- The host remains responsible for admitting commands, enforcing limits, and
318
- returning bounded output.
319
-
320
- ## Local development
321
-
322
- From the fx repository root, build the native addon and both WebAssembly
323
- surfaces:
324
-
325
- ```sh
326
- zig build -Dnapi-surface=core -Doptimize=ReleaseSafe
327
- zig build -Dwasm-surface=core -Doptimize=ReleaseSmall
328
- zig build -Dwasm-surface=term -Doptimize=ReleaseSmall
329
- ```
129
+ Within one JavaScript realm, libfx compiles each stable Wasm source once and
130
+ creates a separate WebAssembly instance for every Agent. Agent memory, history,
131
+ tools, cancellation, and shutdown remain isolated. Workers and separate
132
+ processes maintain their own module caches.
330
133
 
331
- Run the SDK test suites:
134
+ Node.js 20+ is supported. Browser WebAssembly requires a JSPI-capable browser.
135
+ Some Node versions require `--experimental-wasm-jspi`.
332
136
 
333
- ```sh
334
- npm ci --prefix sdk/node
335
- npm run --prefix sdk test:node-napi
336
- npm run --prefix sdk test:node-wasm
337
- ```
137
+ ## Interactive terminal
338
138
 
339
- Serve the repository:
139
+ `createFxTerminal()` remains a separate terminal harness API. In browsers,
140
+ connect it to xterm.js with `xtermAdapter()`:
340
141
 
341
- ```sh
342
- python3 -m http.server 8080
343
- ```
142
+ ```js
143
+ import { createFxTerminal, xtermAdapter } from "libfx/browser";
344
144
 
345
- After starting the server, open these local URLs:
145
+ const runtime = await createFxTerminal({
146
+ terminal: xtermAdapter(term),
147
+ env: { AI_GATEWAY_API_KEY: "<short-lived credential>" },
148
+ });
346
149
 
347
- ```text
348
- Core debugger: http://localhost:8080/sdk/index.html
349
- Interactive terminal: http://localhost:8080/sdk/term-demo.html
150
+ await runtime.interactive;
350
151
  ```
351
152
 
352
- These are local development pages and are not publicly hosted links.
153
+ The terminal runtime exposes `interactive`, `exited`, `write`, `resize`, and
154
+ `abort`. Terminal session, config, OAuth, prompt-history, URL, and workspace
155
+ stores remain terminal-only host integrations.
353
156
 
354
- Maintainer references:
157
+ ## Security
355
158
 
356
- - [SDK contributor guide](https://github.com/vercel-labs/fx/blob/main/sdk/AGENTS.md)
357
- - [Native Node-API design and security model](https://github.com/vercel-labs/fx/blob/main/sdk/NAPI.md)
159
+ Treat `nativeAddon` and `env.FX_GATEWAY_CHAT_URL` as trusted host
160
+ configuration. Do not embed long-lived credentials in public browser code.
161
+ Host tool functions, MCP clients, and skill loaders retain their own authority;
162
+ libfx validates and sequences them but does not grant operating-system access.
package/fx-core.wasm CHANGED
Binary file
package/fx-sdk.js CHANGED
@@ -4,6 +4,7 @@ const strictDecoder = new TextDecoder("utf-8", { fatal: true });
4
4
  const workspaceInfoLimit = 4 * 1024;
5
5
  const workspaceCommandLimit = 64 * 1024;
6
6
  const workspaceOutputLimit = 64 * 1024;
7
+ const maxInstructionsBytes = 64 * 1024;
7
8
  const streamReadsPerTaskYield = 32;
8
9
 
9
10
  function validWorkspacePath(path) {
@@ -50,7 +51,7 @@ function utf8Prefix(value, limit) {
50
51
  return value.subarray(0, end);
51
52
  }
52
53
 
53
- export const fxSdkApiVersion = 1;
54
+ export const fxSdkApiVersion = 2;
54
55
 
55
56
  export function supportsJspi() {
56
57
  return typeof WebAssembly.Suspending === "function" &&
@@ -92,35 +93,6 @@ export function xtermAdapter(term) {
92
93
  };
93
94
  }
94
95
 
95
- function createMemorySessionStore() {
96
- const records = new Map();
97
- let nextRevision = 1;
98
- return {
99
- async load(id) {
100
- const record = records.get(id);
101
- return record ? { bytes: record.bytes.slice(), revision: record.revision } : null;
102
- },
103
- async commit(id, bytes, expectedRevision) {
104
- const current = records.get(id);
105
- if ((current?.revision) !== expectedRevision) throw revisionConflict();
106
- const revision = String(nextRevision++);
107
- records.set(id, { bytes: bytes.slice(), revision, updatedAtMs: Date.now() });
108
- return { revision };
109
- },
110
- async list() {
111
- return [...records.entries()].map(([id, record]) => ({ id, updatedAtMs: record.updatedAtMs }))
112
- .sort((a, b) => b.updatedAtMs - a.updatedAtMs);
113
- },
114
- async remove(id) { records.delete(id); },
115
- };
116
- }
117
-
118
- function revisionConflict() {
119
- const error = new Error("session revision conflict");
120
- error.code = "FX_SESSION_REVISION_CONFLICT";
121
- return error;
122
- }
123
-
124
96
  class ByteQueue {
125
97
  chunks = [];
126
98
  waiters = [];
@@ -176,7 +148,10 @@ class ByteQueue {
176
148
  }
177
149
  }
178
150
 
179
- async function loadModule(input) {
151
+ const modulePromisesBySource = new Map();
152
+ const modulePromisesByObject = new WeakMap();
153
+
154
+ async function compileModule(input) {
180
155
  if (input instanceof WebAssembly.Module) return input;
181
156
  if (typeof input === "string") input = fetch(input);
182
157
  if (input instanceof Promise) input = await input;
@@ -195,6 +170,21 @@ async function loadModule(input) {
195
170
  throw new TypeError("wasm must be a URL, Response, ArrayBuffer, typed array, or WebAssembly.Module");
196
171
  }
197
172
 
173
+ function loadModule(input) {
174
+ if (input instanceof WebAssembly.Module) return Promise.resolve(input);
175
+ const isString = typeof input === "string";
176
+ if (!isString && (typeof input !== "object" || input === null)) return compileModule(input);
177
+ const cache = isString ? modulePromisesBySource : modulePromisesByObject;
178
+ const cached = cache.get(input);
179
+ if (cached) return cached;
180
+ const pending = compileModule(input);
181
+ cache.set(input, pending);
182
+ pending.catch(() => {
183
+ if (cache.get(input) === pending) cache.delete(input);
184
+ });
185
+ return pending;
186
+ }
187
+
198
188
  function raceWithTimeout(promise, timeoutMs, timeoutValue) {
199
189
  let timer;
200
190
  return new Promise((resolve, reject) => {
@@ -455,6 +445,22 @@ function createRuntime(options) {
455
445
  }).catch(() => -1);
456
446
  }
457
447
 
448
+ function hostToolCall(namePtr, nameLen, argumentsPtr, argumentsLen, outputPtr, outputCap, statusPtr) {
449
+ if (typeof options.hostToolExecutor !== "function") return -1;
450
+ if (options.traceWasi) console.error("fx host tool call start");
451
+ let input;
452
+ try { input = JSON.parse(text(argumentsPtr, argumentsLen)); } catch { return -1; }
453
+ return Promise.resolve(options.hostToolExecutor(text(namePtr, nameLen), input)).then((result) => {
454
+ if (options.traceWasi) console.error("fx host tool call settled", result.cancelled, result.isError);
455
+ if (result.cancelled) return -2;
456
+ const output = encoder.encode(result.content);
457
+ if (output.length > outputCap) return -3;
458
+ bytes(outputPtr, output.length).set(output);
459
+ bytes(statusPtr, 1)[0] = result.isError ? 1 : 0;
460
+ return output.length;
461
+ }).catch(() => -1);
462
+ }
463
+
458
464
  function openUrl(urlPtr, urlLen) {
459
465
  if (typeof options.openUrl !== "function") return 0;
460
466
  return Promise.resolve().then(() => options.openUrl(text(urlPtr, urlLen))).then((accepted) =>
@@ -774,6 +780,7 @@ function createRuntime(options) {
774
780
  fx_http_stream_next: new WebAssembly.Suspending(streamNext),
775
781
  fx_http_stream_close(handle) { const state = streams.get(handle); state?.controller.abort(); streams.delete(handle); },
776
782
  fx_http_request: new WebAssembly.Suspending(httpRequest),
783
+ fx_host_tool_call: new WebAssembly.Suspending(hostToolCall),
777
784
  fx_open_url: new WebAssembly.Suspending(openUrl),
778
785
  fx_oauth_session_load: new WebAssembly.Suspending(oauthSessionLoad),
779
786
  fx_oauth_session_commit: new WebAssembly.Suspending(oauthSessionCommit),
@@ -930,20 +937,117 @@ function normalizePromptInput(input) {
930
937
  });
931
938
  }
932
939
 
933
- export async function createFxAgent(options) {
934
- options = { ...options, sessionStore: options.sessionStore || createMemorySessionStore() };
940
+ function normalizeHostTools(value) {
941
+ if (value === undefined) return { descriptors: [], executors: new Map() };
942
+ if (!Array.isArray(value)) throw new TypeError("tools must be an array");
943
+ if (value.length > 64) throw new RangeError("tools cannot contain more than 64 entries");
944
+ const descriptors = [];
945
+ const executors = new Map();
946
+ for (const [index, tool] of value.entries()) {
947
+ if (!tool || typeof tool !== "object") throw new TypeError(`tool ${index} must be an object`);
948
+ const { name, description, inputSchema, execute } = tool;
949
+ if (typeof name !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(name)) {
950
+ throw new TypeError(`tool ${index} has an invalid name`);
951
+ }
952
+ if (executors.has(name)) throw new TypeError(`duplicate tool name: ${name}`);
953
+ if (typeof description !== "string") throw new TypeError(`tool ${name} requires a description`);
954
+ if (typeof execute !== "function") throw new TypeError(`tool ${name} requires execute()`);
955
+ if (!inputSchema || typeof inputSchema !== "object" || Array.isArray(inputSchema)) {
956
+ throw new TypeError(`tool ${name} requires an object inputSchema`);
957
+ }
958
+ let schema;
959
+ try { schema = JSON.parse(JSON.stringify(inputSchema)); } catch {
960
+ throw new TypeError(`tool ${name} inputSchema must be JSON-serializable`);
961
+ }
962
+ descriptors.push({ name, description, inputSchema: schema });
963
+ executors.set(name, execute);
964
+ }
965
+ return { descriptors, executors };
966
+ }
967
+
968
+ function normalizeInstructions(value) {
969
+ let instructions;
970
+ if (value === undefined) instructions = "";
971
+ else if (typeof value === "string") instructions = value;
972
+ if (Array.isArray(value) && value.every((entry) => typeof entry === "string")) {
973
+ instructions = value.filter(Boolean).join("\n\n");
974
+ }
975
+ if (instructions === undefined) {
976
+ throw new TypeError("instructions must be a string or an array of strings");
977
+ }
978
+ if (encoder.encode(instructions).length > maxInstructionsBytes) {
979
+ throw new RangeError(`instructions exceed the ${maxInstructionsBytes} byte libfx limit`);
980
+ }
981
+ return instructions;
982
+ }
983
+
984
+ function hostToolContent(value) {
985
+ if (typeof value === "string") return value;
986
+ if (value === undefined) return "null";
987
+ const encoded = JSON.stringify(value);
988
+ return encoded === undefined ? "null" : encoded;
989
+ }
990
+
991
+ function checkpointBytes(value) {
992
+ if (value === undefined) return null;
993
+ if (value instanceof Uint8Array) return value.slice();
994
+ if (value instanceof ArrayBuffer) return new Uint8Array(value.slice(0));
995
+ if (ArrayBuffer.isView(value)) {
996
+ return new Uint8Array(value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength));
997
+ }
998
+ throw new TypeError("checkpoint must be an ArrayBuffer or typed array");
999
+ }
1000
+
1001
+ function bytesToBase64(value) {
1002
+ let binary = "";
1003
+ for (let offset = 0; offset < value.length; offset += 0x8000) {
1004
+ binary += String.fromCharCode(...value.subarray(offset, offset + 0x8000));
1005
+ }
1006
+ return btoa(binary);
1007
+ }
1008
+
1009
+ function base64ToBytes(value) {
1010
+ const binary = atob(value);
1011
+ const bytes = new Uint8Array(binary.length);
1012
+ for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
1013
+ return bytes;
1014
+ }
1015
+
1016
+ export async function createFxAgent(options = {}) {
1017
+ options = { ...options };
1018
+ const hostTools = normalizeHostTools(options.tools);
1019
+ const instructions = normalizeInstructions(options.instructions);
1020
+ const initialCheckpoint = checkpointBytes(options.checkpoint);
935
1021
  const pending = new Map();
936
- const turns = new Map();
937
1022
  let nextId = 1;
938
- let activeSession = null;
939
- let loadingSessionId = null;
940
- let loadingUpdates = [];
1023
+ let sessionId = null;
1024
+ let activeTurn = null;
941
1025
  let closing = false;
942
1026
  const emit = (type, detail = {}) => {
943
1027
  try { options.onEvent?.({ type, timestamp: performance.now(), ...detail }); } catch {}
944
1028
  };
1029
+ const executeHostTool = async (name, input, requestedSessionId) => {
1030
+ const execute = hostTools.executors.get(name);
1031
+ const turn = requestedSessionId === undefined || requestedSessionId === sessionId
1032
+ ? activeTurn
1033
+ : null;
1034
+ const controller = new AbortController();
1035
+ turn?.toolControllers.add(controller);
1036
+ let content;
1037
+ let isError = false;
1038
+ try {
1039
+ if (!execute) throw new Error(`unknown host tool: ${String(name)}`);
1040
+ content = hostToolContent(await execute(input, { signal: controller.signal }));
1041
+ } catch (error) {
1042
+ isError = true;
1043
+ content = error instanceof Error ? error.message : String(error);
1044
+ } finally {
1045
+ turn?.toolControllers.delete(controller);
1046
+ }
1047
+ return { content, isError, cancelled: controller.signal.aborted };
1048
+ };
945
1049
  emit("runtime.start");
946
- const runtimeOptions = { ...options, args: ["acp"] };
1050
+ const runtimeOptions = { ...options, args: ["acp"], hostToolExecutor: executeHostTool };
947
1051
  const runtime = options.runtimeFactory
948
1052
  ? await options.runtimeFactory(runtimeOptions)
949
1053
  : await instantiate(runtimeOptions);
@@ -967,9 +1071,7 @@ export async function createFxAgent(options) {
967
1071
  runtime.setLineHandler(async (message) => {
968
1072
  emit("acp.receive", { message });
969
1073
  if (message.method === "session/update") {
970
- const turn = turns.get(message.params.sessionId);
971
- if (turn) turn.push(message.params.update);
972
- else if (loadingSessionId === message.params.sessionId) loadingUpdates.push(message.params.update);
1074
+ if (message.params.sessionId === sessionId) activeTurn?.push(message.params.update);
973
1075
  return;
974
1076
  }
975
1077
  if (message.method === "session/request_permission") {
@@ -980,150 +1082,169 @@ export async function createFxAgent(options) {
980
1082
  send({ jsonrpc: "2.0", id: message.id, result: optionId ? { outcome: { outcome: "selected", optionId } } : { outcome: { outcome: "cancelled" } } });
981
1083
  return;
982
1084
  }
1085
+ if (message.method === "libfx/tool_call") {
1086
+ const { content, isError, cancelled } = await executeHostTool(
1087
+ message.params?.name,
1088
+ message.params?.input,
1089
+ message.params?.sessionId,
1090
+ );
1091
+ if (cancelled || closing) return;
1092
+ send({ jsonrpc: "2.0", id: message.id, result: { content, isError } });
1093
+ return;
1094
+ }
983
1095
  const waiter = pending.get(message.id); if (!waiter) return; pending.delete(message.id);
984
1096
  if (message.error) waiter.reject(new Error(message.error.message)); else waiter.resolve(message.result);
985
1097
  });
986
- await request("initialize", { protocolVersion: 1, clientCapabilities: {} });
1098
+ try {
1099
+ await request("initialize", {
1100
+ protocolVersion: 1,
1101
+ clientCapabilities: {
1102
+ ...(hostTools.descriptors.length || instructions
1103
+ ? { libfx: { tools: hostTools.descriptors, instructions } }
1104
+ : {}),
1105
+ },
1106
+ });
1107
+
1108
+ const sessionResult = await request("libfx/new");
1109
+ sessionId = sessionResult.sessionId;
1110
+ if (initialCheckpoint) {
1111
+ await request("libfx/restore", {
1112
+ sessionId,
1113
+ checkpoint: bytesToBase64(initialCheckpoint),
1114
+ });
1115
+ }
1116
+ } catch (error) {
1117
+ closing = true;
1118
+ try { runtime.abortHostEffects(); } catch {}
1119
+ try { runtime.closeStdin(); } catch {}
1120
+ try { await runtime.exited; } catch {}
1121
+ throw error;
1122
+ }
987
1123
 
988
1124
  const agent = {
989
- exited: runtime.exited,
990
- abort() { closing = true; runtime.abort(); },
1125
+ prompt(input, promptOptions = {}) {
1126
+ if (closing) throw new Error("fx agent is closed");
1127
+ if (activeTurn) throw new Error("a prompt is already in progress for this session");
1128
+ return normalizeTurn(startTurn(input, promptOptions));
1129
+ },
1130
+ async checkpoint() {
1131
+ if (closing) throw new Error("fx agent is closed");
1132
+ if (activeTurn) throw new Error("cannot checkpoint while a prompt is active");
1133
+ const response = await request("libfx/checkpoint", { sessionId });
1134
+ if (typeof response?.checkpoint !== "string") throw new Error("fx returned an invalid checkpoint");
1135
+ return base64ToBytes(response.checkpoint);
1136
+ },
991
1137
  async close() {
992
- if (closing) return runtime.exited;
993
- if (activeSession) await activeSession.close();
1138
+ if (closing) { await runtime.exited; return; }
1139
+ const turn = activeTurn;
1140
+ turn?.cancel();
1141
+ if (turn) await turn.result.catch(() => {});
994
1142
  closing = true;
995
1143
  runtime.closeStdin();
996
- return runtime.exited;
997
- },
998
- async createSession() {
999
- if (activeSession) await activeSession.close();
1000
- const result = await request("session/new");
1001
- activeSession = await makeSession(result);
1002
- return activeSession;
1003
- },
1004
- async listSessions() {
1005
- return (await request("session/list")).sessions || [];
1006
- },
1007
- async openSession(id) {
1008
- if (activeSession) await activeSession.close();
1009
- loadingSessionId = id;
1010
- loadingUpdates = [];
1011
- try {
1012
- const result = await request("session/load", { sessionId: id });
1013
- activeSession = await makeSession({ sessionId: id, history: loadingUpdates, ...result });
1014
- return activeSession;
1015
- } finally {
1016
- loadingSessionId = null;
1017
- loadingUpdates = [];
1018
- }
1144
+ await runtime.exited;
1019
1145
  },
1020
1146
  };
1021
1147
  return agent;
1022
1148
 
1023
- async function makeSession(result) {
1024
- let configOptions = result.configOptions || [];
1025
- let closed = false;
1026
- let activeTurn = null;
1027
- const assertOpen = () => {
1028
- if (closed) throw new Error("fx session is closed");
1029
- if (activeSession !== session) throw new Error("fx session is no longer active");
1030
- };
1031
- const updateConfig = (response) => {
1032
- configOptions = response.configOptions || configOptions;
1033
- return configOptions;
1149
+ function normalizeTurn(rawTurn) {
1150
+ const toolNames = new Map();
1151
+ const started = new Set();
1152
+ const eventFor = (update) => {
1153
+ if (update.sessionUpdate === "agent_message_chunk") {
1154
+ const delta = update.content?.text;
1155
+ if (!delta || delta.startsWith("[context]")) return null;
1156
+ return { type: "text_delta", delta };
1157
+ }
1158
+ if (update.sessionUpdate === "agent_thought_chunk") {
1159
+ const delta = update.content?.text;
1160
+ return delta ? { type: "reasoning_delta", delta } : null;
1161
+ }
1162
+ if (update.sessionUpdate === "tool_call") {
1163
+ toolNames.set(update.toolCallId, update.name || update.toolName || update.title || "tool");
1164
+ if (started.has(update.toolCallId)) return null;
1165
+ started.add(update.toolCallId);
1166
+ return {
1167
+ type: "tool_start",
1168
+ id: update.toolCallId,
1169
+ name: toolNames.get(update.toolCallId),
1170
+ };
1171
+ }
1172
+ if (update.sessionUpdate === "tool_call_update" &&
1173
+ (update.status === "completed" || update.status === "failed")) {
1174
+ const content = update.content?.find((entry) => entry.content?.type === "text")?.content?.text;
1175
+ return {
1176
+ type: "tool_end",
1177
+ id: update.toolCallId,
1178
+ name: toolNames.get(update.toolCallId) || "tool",
1179
+ ...(content === undefined ? {} : { content }),
1180
+ isError: update.status === "failed",
1181
+ };
1182
+ }
1183
+ return null;
1034
1184
  };
1035
- const session = {
1036
- id: result.sessionId,
1037
- modes: result.modes,
1038
- history: result.history || [],
1039
- get configOptions() { return configOptions; },
1040
- async setConfigOption(configId, value, source = "sdk") {
1041
- assertOpen();
1042
- const previousValue = configOptions.find((option) => option.id === configId)?.currentValue;
1043
- const updated = updateConfig(await request("session/set_config_option", { sessionId: result.sessionId, configId, value }));
1044
- const accepted = updated.find((option) => option.id === configId)?.currentValue;
1045
- if (configId === "mode" && accepted) this.modes.currentModeId = accepted;
1046
- if (accepted === value) {
1047
- if (options.configStore?.set) {
1048
- try { await options.configStore.set(configId, value); } catch (error) { emit("config.persist_error", { configId, error }); }
1049
- }
1050
- emit("config.changed", { configId, previousValue, value: accepted, source });
1185
+ return {
1186
+ cancel() { rawTurn.cancel(); },
1187
+ async *[Symbol.asyncIterator]() {
1188
+ for await (const update of rawTurn) {
1189
+ const event = eventFor(update);
1190
+ if (event) yield event;
1051
1191
  }
1052
- return updated;
1053
- },
1054
- setModel(value) { return this.setConfigOption("model", value); },
1055
- setMode(value) { return this.setConfigOption("mode", value); },
1056
- async setConfig(config) {
1057
- for (const [key, value] of Object.entries(config)) await this.setConfigOption(key, value);
1058
- return configOptions;
1059
- },
1060
- async close() {
1061
- if (closed) return;
1062
- activeTurn?.cancel();
1063
- if (activeTurn) await activeTurn.result.catch(() => {});
1064
- closed = true;
1065
- activeTurn = null;
1066
- if (activeSession === session) activeSession = null;
1067
- },
1068
- async remove() {
1069
- if (activeTurn) throw new Error("cannot remove a session while a prompt is active");
1070
- await request("session/remove", { sessionId: result.sessionId });
1071
- closed = true;
1072
- if (activeSession === session) activeSession = null;
1073
1192
  },
1074
- prompt(input, promptOptions = {}) {
1075
- assertOpen();
1076
- if (activeTurn) throw new Error("a prompt is already in progress for this session");
1077
- const prompt = normalizePromptInput(input);
1078
- const signal = promptOptions.signal;
1079
- if (signal !== undefined && (typeof signal?.addEventListener !== "function" || typeof signal?.removeEventListener !== "function")) throw new TypeError("prompt signal must be an AbortSignal");
1080
- const queue = [];
1081
- const waiters = [];
1082
- let finished = false;
1083
- let cancelled = false;
1084
- const turn = {
1085
- push(update) { const waiter = waiters.shift(); if (waiter) waiter({ value: update, done: false }); else queue.push(update); },
1086
- cancel() {
1087
- if (finished || cancelled) return;
1088
- cancelled = true;
1089
- send({ jsonrpc: "2.0", method: "session/cancel", params: { sessionId: result.sessionId } });
1090
- runtime.abortHostEffects();
1091
- },
1092
- [Symbol.asyncIterator]() { return { next() { if (queue.length) return Promise.resolve({ value: queue.shift(), done: false }); if (finished) return Promise.resolve({ done: true }); return new Promise((resolve) => waiters.push(resolve)); } }; },
1093
- };
1094
- turns.set(result.sessionId, turn);
1095
- activeTurn = turn;
1096
- const abort = () => turn.cancel();
1097
- signal?.addEventListener("abort", abort, { once: true });
1098
- turn.result = request("session/prompt", { sessionId: result.sessionId, prompt })
1099
- .then((response) => ({ stopReason: response.stopReason }))
1100
- .catch((error) => {
1101
- if (error.message === "Cancelled") return { stopReason: "cancelled" };
1102
- throw error;
1103
- })
1104
- .finally(() => {
1105
- finished = true;
1106
- signal?.removeEventListener("abort", abort);
1107
- turns.delete(result.sessionId);
1108
- if (activeTurn === turn) activeTurn = null;
1109
- waiters.splice(0).forEach((resolve) => resolve({ done: true }));
1110
- });
1111
- turn.stopReason = turn.result.then((turnResult) => turnResult.stopReason);
1112
- void turn.stopReason.catch(() => {});
1113
- if (signal?.aborted) turn.cancel();
1114
- return turn;
1193
+ result: rawTurn.result.then((result) => ({
1194
+ stopReason: result.stopReason,
1195
+ usage: normalizeTurnUsage(result.usage),
1196
+ })),
1197
+ };
1198
+ }
1199
+
1200
+ function normalizeTurnUsage(usage) {
1201
+ const result = {};
1202
+ if (Number.isSafeInteger(usage?.inputTokens)) result.inputTokens = usage.inputTokens;
1203
+ if (Number.isSafeInteger(usage?.outputTokens)) result.outputTokens = usage.outputTokens;
1204
+ if (Number.isSafeInteger(usage?.cacheReadTokens)) result.cacheReadTokens = usage.cacheReadTokens;
1205
+ if (Number.isSafeInteger(usage?.cacheWriteTokens)) result.cacheWriteTokens = usage.cacheWriteTokens;
1206
+ if (Number.isSafeInteger(usage?.reasoningTokens)) result.reasoningTokens = usage.reasoningTokens;
1207
+ return result;
1208
+ }
1209
+
1210
+ function startTurn(input, promptOptions) {
1211
+ const prompt = normalizePromptInput(input);
1212
+ const signal = promptOptions.signal;
1213
+ if (signal !== undefined && (typeof signal?.addEventListener !== "function" || typeof signal?.removeEventListener !== "function")) throw new TypeError("prompt signal must be an AbortSignal");
1214
+ const queue = [];
1215
+ const waiters = [];
1216
+ const toolControllers = new Set();
1217
+ let finished = false;
1218
+ let cancelled = false;
1219
+ const turn = {
1220
+ push(update) { const waiter = waiters.shift(); if (waiter) waiter({ value: update, done: false }); else queue.push(update); },
1221
+ toolControllers,
1222
+ cancel() {
1223
+ if (finished || cancelled) return;
1224
+ cancelled = true;
1225
+ send({ jsonrpc: "2.0", method: "session/cancel", params: { sessionId } });
1226
+ for (const controller of toolControllers) controller.abort();
1227
+ runtime.abortHostEffects();
1115
1228
  },
1229
+ [Symbol.asyncIterator]() { return { next() { if (queue.length) return Promise.resolve({ value: queue.shift(), done: false }); if (finished) return Promise.resolve({ done: true }); return new Promise((resolve) => waiters.push(resolve)); } }; },
1116
1230
  };
1117
- if (options.configStore?.get) {
1118
- activeSession = session;
1119
- for (const config of [...configOptions]) {
1120
- let value;
1121
- try { value = await options.configStore.get(config.id); } catch (error) { emit("config.restore_error", { configId: config.id, error }); continue; }
1122
- if (typeof value !== "string" || value === config.currentValue) continue;
1123
- try { await session.setConfigOption(config.id, value, "restore"); } catch (error) { emit("config.restore_error", { configId: config.id, error }); }
1124
- }
1125
- }
1126
- session.modes.currentModeId = configOptions.find((option) => option.id === "mode")?.currentValue || session.modes.currentModeId;
1127
- return session;
1231
+ activeTurn = turn;
1232
+ const abort = () => turn.cancel();
1233
+ signal?.addEventListener("abort", abort, { once: true });
1234
+ turn.result = request("session/prompt", { sessionId, prompt })
1235
+ .then((response) => ({ stopReason: response.stopReason, usage: response.usage }))
1236
+ .catch((error) => {
1237
+ if (error.message === "Cancelled") return { stopReason: "cancelled" };
1238
+ throw error;
1239
+ })
1240
+ .finally(() => {
1241
+ finished = true;
1242
+ signal?.removeEventListener("abort", abort);
1243
+ if (activeTurn === turn) activeTurn = null;
1244
+ toolControllers.clear();
1245
+ waiters.splice(0).forEach((resolve) => resolve({ done: true }));
1246
+ });
1247
+ if (signal?.aborted) turn.cancel();
1248
+ return turn;
1128
1249
  }
1129
1250
  }
package/fx-term.wasm CHANGED
Binary file
Binary file
Binary file
Binary file
Binary file
package/mcp.js ADDED
@@ -0,0 +1,89 @@
1
+ const maxTools = 64;
2
+ const maxInstructionsBytes = 64 * 1024;
3
+
4
+ function contentText(content) {
5
+ if (typeof content === "string") return content;
6
+ if (!Array.isArray(content)) return "";
7
+ return content.map((item) => {
8
+ if (item?.type === "text" && typeof item.text === "string") return item.text;
9
+ if (item?.type === "resource" && typeof item.resource?.text === "string") return item.resource.text;
10
+ if (typeof item?.text === "string") return item.text;
11
+ return "";
12
+ }).filter(Boolean).join("\n");
13
+ }
14
+
15
+ function resultText(result) {
16
+ const text = contentText(result?.content);
17
+ if (text) return text;
18
+ if (result?.structuredContent !== undefined) return JSON.stringify(result.structuredContent);
19
+ return "";
20
+ }
21
+
22
+ function appendInstruction(parts, label, text) {
23
+ if (!text) return;
24
+ parts.push(`<${label}>\n${text}\n</${label}>`);
25
+ }
26
+
27
+ export async function createMcpAdapter(client, options = {}) {
28
+ if (!client || typeof client.listTools !== "function" || typeof client.callTool !== "function") {
29
+ throw new TypeError("MCP client must provide listTools() and callTool()");
30
+ }
31
+ const prefix = options.prefix ?? "";
32
+ if (typeof prefix !== "string" || !/^[A-Za-z0-9_-]*$/.test(prefix)) {
33
+ throw new TypeError("MCP prefix must contain only letters, digits, underscore, or hyphen");
34
+ }
35
+ const listed = await client.listTools();
36
+ const catalog = Array.isArray(listed) ? listed : listed?.tools;
37
+ if (!Array.isArray(catalog) || catalog.length > maxTools) {
38
+ throw new TypeError("MCP listTools() returned an invalid tool catalog");
39
+ }
40
+ const tools = catalog.map((tool, index) => {
41
+ if (!tool || typeof tool.name !== "string" || typeof tool.description !== "string") {
42
+ throw new TypeError(`MCP tool ${index} is invalid`);
43
+ }
44
+ const name = `${prefix}${tool.name}`;
45
+ return {
46
+ name,
47
+ description: tool.description,
48
+ inputSchema: tool.inputSchema ?? { type: "object", properties: {} },
49
+ async execute(input, { signal }) {
50
+ const result = await client.callTool({ name: tool.name, arguments: input }, { signal });
51
+ const text = resultText(result);
52
+ if (result?.isError) throw new Error(text || `MCP tool ${tool.name} failed`);
53
+ return text;
54
+ },
55
+ };
56
+ });
57
+
58
+ const instructions = [];
59
+ for (const uri of options.resources ?? []) {
60
+ if (typeof client.readResource !== "function") throw new TypeError("MCP client does not provide readResource()");
61
+ const result = await client.readResource({ uri });
62
+ appendInstruction(instructions, "mcp_resource", contentText(result?.contents ?? result?.content));
63
+ }
64
+ for (const prompt of options.prompts ?? []) {
65
+ if (typeof client.getPrompt !== "function") throw new TypeError("MCP client does not provide getPrompt()");
66
+ const request = typeof prompt === "string" ? { name: prompt } : prompt;
67
+ const result = await client.getPrompt(request);
68
+ appendInstruction(
69
+ instructions,
70
+ "mcp_prompt",
71
+ (result?.messages ?? []).map((message) => contentText(message.content)).filter(Boolean).join("\n"),
72
+ );
73
+ }
74
+ const instructionText = instructions.join("\n\n");
75
+ if (new TextEncoder().encode(instructionText).length > maxInstructionsBytes) {
76
+ throw new RangeError(`MCP instructions exceed the ${maxInstructionsBytes} byte libfx limit`);
77
+ }
78
+
79
+ let closed = false;
80
+ return {
81
+ tools,
82
+ instructions: instructionText,
83
+ async close() {
84
+ if (closed) return;
85
+ closed = true;
86
+ await client.close?.();
87
+ },
88
+ };
89
+ }
package/node.js CHANGED
@@ -27,6 +27,7 @@ const defaultNativeCandidates = [
27
27
  `./libfx.${process.platform}-${process.arch}.node`,
28
28
  ];
29
29
  let nativeBackendPromise;
30
+ const wasmFilePromises = new Map();
30
31
 
31
32
  function jspiFallbackError(surface, nativeError) {
32
33
  const nativeDetail = nativeError ? ` Native loading failed: ${nativeError.message}.` : " No compatible native addon was found.";
@@ -104,10 +105,19 @@ async function resolveNativeBackend(nativeAddon) {
104
105
  return nativeBackendPromise;
105
106
  }
106
107
 
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;
108
+ function wasmBytes(input) {
109
+ let path;
110
+ if (input instanceof URL && input.protocol === "file:") path = fileURLToPath(input);
111
+ else if (typeof input === "string" && !URL.canParse(input)) path = resolve(input);
112
+ else return input;
113
+ const cached = wasmFilePromises.get(path);
114
+ if (cached) return cached;
115
+ const pending = readFile(path);
116
+ wasmFilePromises.set(path, pending);
117
+ pending.catch(() => {
118
+ if (wasmFilePromises.get(path) === pending) wasmFilePromises.delete(path);
119
+ });
120
+ return pending;
111
121
  }
112
122
 
113
123
  function validateGatewayChatUrl(value) {
@@ -218,7 +228,7 @@ function createNativeCoreRuntime(addon, options) {
218
228
  if (newline < 0) break;
219
229
  const line = lineBuffer.slice(0, newline);
220
230
  lineBuffer = lineBuffer.slice(newline + 1);
221
- if (line) lineHandler(JSON.parse(line));
231
+ if (line) void Promise.resolve(lineHandler(JSON.parse(line))).catch(() => finish(1));
222
232
  }
223
233
  }
224
234
  if (addon.coreExited(core)) finish(addon.coreExitCode(core));
@@ -254,11 +264,13 @@ async function createWithFallback(surface, nativeMethod, wasmFactory, defaultWas
254
264
  }
255
265
 
256
266
  let nativeError;
267
+ let nativeAttempted = false;
257
268
  if (backend !== "wasm") {
258
269
  const native = await resolveNativeBackend(nativeAddon);
259
270
  nativeError = native.error;
260
271
  if (typeof native.backend?.[nativeMethod] === "function" ||
261
272
  (surface === "agent" && typeof native.backend?.createCore === "function")) {
273
+ nativeAttempted = true;
262
274
  try {
263
275
  if (typeof native.backend?.[nativeMethod] === "function") {
264
276
  return await native.backend[nativeMethod](runtimeOptions);
@@ -276,7 +288,10 @@ async function createWithFallback(surface, nativeMethod, wasmFactory, defaultWas
276
288
  }
277
289
  }
278
290
 
279
- if (!supportsJspi()) throw jspiFallbackError(surface, nativeError);
291
+ if (!supportsJspi()) {
292
+ if (nativeAttempted) throw nativeError;
293
+ throw jspiFallbackError(surface, nativeError);
294
+ }
280
295
  return wasmFactory({
281
296
  ...runtimeOptions,
282
297
  wasm: await wasmBytes(runtimeOptions.wasm ?? defaultWasm),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libfx",
3
- "version": "0.0.6",
3
+ "version": "0.0.7-dev.607.ga90fc3585568",
4
4
  "description": "Embed fx agents and terminals in JavaScript hosts",
5
5
  "type": "module",
6
6
  "repository": {
@@ -23,7 +23,10 @@
23
23
  },
24
24
  "./node": "./node.js",
25
25
  "./browser": "./browser.js",
26
- "./wasm": "./fx-sdk.js"
26
+ "./wasm": "./fx-sdk.js",
27
+ "./mcp": "./mcp.js",
28
+ "./skills": "./skills.js",
29
+ "./skills/node": "./skills-node.js"
27
30
  },
28
31
  "engines": {
29
32
  "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
+ }