@flowy-agent-store/sdk 0.1.0-beta.10

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/dist/index.mjs ADDED
@@ -0,0 +1,467 @@
1
+ import { createRequire } from "node:module";
2
+ import { AppServerClient, WebSocketTransport, isLoopbackUrl } from "@flowy-agent-store/client";
3
+ import { existsSync } from "node:fs";
4
+ import { delimiter, dirname, join } from "node:path";
5
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
6
+ import { spawn } from "node:child_process";
7
+ import { tmpdir } from "node:os";
8
+ import { createInterface } from "node:readline";
9
+ import { APP_SERVER_PROTOCOL_VERSION } from "@flowy-agent-store/protocol";
10
+ //#region src/bin.ts
11
+ /**
12
+ * Locate the `flowy-agent-store` runtime binary.
13
+ *
14
+ * Order (docs/agent-store/12 §6):
15
+ * 1. explicit `bin` argument (skips every other lookup)
16
+ * 2. `AGENT_STORE_BIN` environment variable
17
+ * 3. platform runtime package (`@flowy-agent-store/runtime-<platform>-<arch>`,
18
+ * an optionalDependency of `@flowy-agent-store/sdk`) resolved via
19
+ * `createRequire(import.meta.url).resolve` — the binary ships inside the
20
+ * package (`vendor/flowy-agent-store[.exe]`)
21
+ * 4. `flowy-agent-store[.exe]` on `PATH`
22
+ *
23
+ * Anything else is a hard error — the SDK never downloads or guesses.
24
+ */
25
+ const VENDOR_DIR = "vendor";
26
+ const BIN_NAMES = ["flowy-agent-store", "flowy-agent-store.exe"];
27
+ function resolveAppServerBin(explicit) {
28
+ if (explicit && existsSync(explicit)) return explicit;
29
+ const candidates = [];
30
+ if (explicit) candidates.push(explicit);
31
+ const fromEnv = process.env["AGENT_STORE_BIN"];
32
+ if (fromEnv) candidates.push(fromEnv);
33
+ const fromRuntimePackage = findInRuntimePackage();
34
+ if (fromRuntimePackage) candidates.push(fromRuntimePackage);
35
+ for (const name of BIN_NAMES) {
36
+ const found = findOnPath(name);
37
+ if (found) candidates.push(found);
38
+ }
39
+ const hit = candidates.find((candidate) => existsSync(candidate));
40
+ if (!hit) throw new Error(`cannot find the flowy-agent-store runtime binary: pass \`bin\`, set AGENT_STORE_BIN, install @flowy-agent-store/runtime-${process.platform}-${process.arch} alongside @flowy-agent-store/sdk, or put flowy-agent-store[.exe] on PATH`);
41
+ return hit;
42
+ }
43
+ /** Probe the platform runtime package for a vendored binary (optional dep). */
44
+ function findInRuntimePackage() {
45
+ const pkg = `@flowy-agent-store/runtime-${process.platform}-${process.arch}`;
46
+ try {
47
+ const pkgRoot = createRequire(import.meta.url).resolve(`${pkg}/package.json`).slice(0, -12);
48
+ for (const name of BIN_NAMES) {
49
+ const candidate = join(pkgRoot, VENDOR_DIR, name);
50
+ if (existsSync(candidate)) return candidate;
51
+ }
52
+ return;
53
+ } catch {
54
+ return;
55
+ }
56
+ }
57
+ function findOnPath(name) {
58
+ const pathEnv = process.env["PATH"] ?? process.env["Path"] ?? "";
59
+ for (const dir of pathEnv.split(delimiter)) {
60
+ if (!dir) continue;
61
+ const full = join(dir, name);
62
+ try {
63
+ if (existsSync(full)) return full;
64
+ } catch {}
65
+ }
66
+ }
67
+ //#endregion
68
+ //#region src/export.ts
69
+ /**
70
+ * Export an expert (single agent) or an expert **team** together with its
71
+ * skills and its package-level assets, written out as a directory (docs `35`
72
+ * and `38`).
73
+ *
74
+ * The wire face (`agent/export` / `team/export`, docs/agent-store/32) carries
75
+ * skills **by reference** — `{name, id}`, never bytes — and, since
76
+ * `pack_format 2`, the plugin-level **assets** by reference too (`{path, kind,
77
+ * size, digest}`, doc 38). "Export the team" therefore means: walk the pack,
78
+ * fetch every referenced skill through `skill/files` + `skill/file`, fetch
79
+ * every asset through `import/assets` + `import/asset`, and write the whole
80
+ * thing to a directory the *caller* owns. Doc `32` §6.5 originally shipped
81
+ * this as an 11-line recipe in prose; doc `35` promoted it into these
82
+ * functions (and records why the earlier decision against an SDK helper was
83
+ * reversed); doc `38` extends the layout with the package's own files.
84
+ *
85
+ * Everything here runs in the caller's process. The host never writes a byte:
86
+ * there is no server-side directory-writing endpoint by design (doc `32` §2
87
+ * item 7), which is why this file is allowed to import `node:fs` — the same
88
+ * privilege `spawn.ts` already has — while `@flowy-agent-store/client` must
89
+ * never gain it.
90
+ */
91
+ /**
92
+ * Reject a caller-supplied relative path lexically, before touching the disk.
93
+ *
94
+ * The server already rejects traversal on both the skill and the asset faces
95
+ * (docs/agent-store/24 §4.4, 38 §2.3); this second gate keeps a hostile
96
+ * *inventory* from turning `join(dir, path)` into a write outside the target
97
+ * directory. Same rules as the server's lexical pass: no `..`, no absolute
98
+ * paths, no backslashes, no drive/UNC prefixes.
99
+ */
100
+ function rejectUnsafeRelative(path) {
101
+ if (path.length === 0 || path.includes("\\") || path.startsWith("/") || path.includes(":")) throw new Error(`unsafe asset path from the host: ${path}`);
102
+ if (path.split("/").some((segment) => segment === "..")) throw new Error(`unsafe asset path from the host: ${path}`);
103
+ }
104
+ /**
105
+ * Write an in-hand pack to `dir` as a real directory (docs `35` §5 and `38`
106
+ * §3.5). "Materialize" here means exactly that: the in-memory pack object plus
107
+ * the skill and asset bytes it references become files on disk; the pack data
108
+ * itself is unchanged.
109
+ *
110
+ * ```text
111
+ * <dir>/expert-pack.json # the wire pack, byte-for-byte (JSON.stringify(_, 2))
112
+ * <dir>/persona.md # kind === "agent" only: pack.persona.instructions
113
+ * <dir>/members/<id>/persona.md # kind === "team" only, one per member
114
+ * <dir>/skills/<name>/… # every referenced skill's files, deduplicated by id
115
+ * <dir>/avatars/… # pack.assets, kind === "avatar", at their snapshot paths
116
+ * <dir>/bin/… · scripts/… # pack.assets, kind === "script" (doc 38 D5)
117
+ * ```
118
+ *
119
+ * Error semantics (doc `35` §6): the pack is fetched **before** anything is
120
+ * written, so a failed wire export leaves no directory behind; a `skill/files`
121
+ * rejection lands in {@link ExportResult.danglingSkills} and the loop
122
+ * continues; a `skill/file` or `import/asset` rejection after a successful
123
+ * listing **throws** — that is a host I/O failure, and swallowing it would
124
+ * produce a directory that lists files it does not contain. No rollback on
125
+ * mid-write failure.
126
+ */
127
+ async function materializePack(deps, pack, dir) {
128
+ await mkdir(dir, { recursive: true });
129
+ await writeFile(join(dir, "expert-pack.json"), JSON.stringify(pack, null, 2));
130
+ if (pack.kind === "agent") await writeFile(join(dir, "persona.md"), pack.persona.instructions);
131
+ for (const member of pack.team?.members ?? []) {
132
+ const target = join(dir, "members", member.id, "persona.md");
133
+ await mkdir(dirname(target), { recursive: true });
134
+ await writeFile(target, member.persona.instructions);
135
+ }
136
+ const refs = [...pack.skills, ...(pack.team?.members ?? []).flatMap((m) => m.skills)];
137
+ const seen = /* @__PURE__ */ new Set();
138
+ const writtenSkills = [];
139
+ const danglingSkills = [];
140
+ for (const ref of refs) {
141
+ if (seen.has(ref.id)) continue;
142
+ seen.add(ref.id);
143
+ let files;
144
+ try {
145
+ files = (await deps.skills.files(ref.id)).files;
146
+ } catch (error) {
147
+ danglingSkills.push({
148
+ id: ref.id,
149
+ error: String(error)
150
+ });
151
+ continue;
152
+ }
153
+ for (const file of files) {
154
+ const target = join(dir, "skills", ref.name, file.path);
155
+ await mkdir(dirname(target), { recursive: true });
156
+ await writeFile(target, await deps.skills.readFile(ref.id, file.path));
157
+ }
158
+ writtenSkills.push(ref.name);
159
+ }
160
+ const writtenAssets = [];
161
+ for (const asset of pack.assets) {
162
+ rejectUnsafeRelative(asset.path);
163
+ const target = join(dir, asset.path);
164
+ await mkdir(dirname(target), { recursive: true });
165
+ await writeFile(target, await deps.readImportAsset(pack.provenance.snapshot_id, asset.path));
166
+ writtenAssets.push(asset.path);
167
+ }
168
+ return {
169
+ pack,
170
+ dir,
171
+ writtenSkills,
172
+ writtenAssets,
173
+ danglingSkills
174
+ };
175
+ }
176
+ /**
177
+ * Export one single-agent expert and write it out as a directory.
178
+ *
179
+ * Same preconditions as the wire method: the expert must be installed and
180
+ * enabled, or the host answers `agent_not_installed` / `agent_disabled` /
181
+ * `preset_disabled`; a host whose `[expert_export]` table refuses the id
182
+ * answers `policy_denied` (the capability bit only reports the seam). Check
183
+ * `capabilities.expert_export` for the seam, not for this id.
184
+ */
185
+ async function exportAgent(deps, agentId, dir) {
186
+ return materializePack(deps, await deps.agents.export(agentId), dir);
187
+ }
188
+ /**
189
+ * Export one expert team and write it out as a directory, leader first.
190
+ *
191
+ * All-or-nothing stays on the server: if any member is uninstalled, disabled,
192
+ * or refused by policy, the whole call fails and nothing is written. A pack
193
+ * over 1 MiB fails with `response_too_large` rather than being truncated.
194
+ * `teamVersion` is an optional pin; a mismatch is `version_mismatch`.
195
+ */
196
+ async function exportTeam(deps, teamId, dir, teamVersion) {
197
+ return materializePack(deps, await deps.teams.export(teamId, teamVersion), dir);
198
+ }
199
+ //#endregion
200
+ //#region src/readiness.ts
201
+ /** Parse one stdout line; `null` when it is not the readiness line. */
202
+ function parseReadinessLine(line) {
203
+ let parsed;
204
+ try {
205
+ parsed = JSON.parse(line);
206
+ } catch {
207
+ return null;
208
+ }
209
+ if (typeof parsed !== "object" || parsed === null) return null;
210
+ const record = parsed;
211
+ if (record["agent_store"] !== "listening") return null;
212
+ const { host, port, url, protocol_version, version, auth } = record;
213
+ if (typeof host !== "string" || typeof port !== "number" || typeof url !== "string" || typeof protocol_version !== "string" || typeof version !== "string" || typeof auth !== "string") return null;
214
+ return {
215
+ host,
216
+ port,
217
+ url,
218
+ protocol_version,
219
+ version,
220
+ auth
221
+ };
222
+ }
223
+ //#endregion
224
+ //#region src/spawn.ts
225
+ /**
226
+ * Spawn the `agent-store` runtime as a child process and wait for its
227
+ * readiness line (docs/agent-store/12 P0-1 / P1-3).
228
+ *
229
+ * The child is always loopback-only (`--host 127.0.0.1 --no-open`) with its
230
+ * own data directory (SDK独占, P0-3): a fresh temp dir by default, or an
231
+ * explicit one the caller owns. A server-side single-instance lock fails the
232
+ * spawn fast when the directory is already held — that error propagates.
233
+ */
234
+ const DEFAULT_READY_TIMEOUT_MS = 12e4;
235
+ const STDERR_TAIL_LINES = 50;
236
+ const KILL_GRACE_MS = 2e3;
237
+ /** Fail when the runtime speaks a different wire protocol (§7 acceptance). */
238
+ function assertProtocolCompatible(runtimeVersion) {
239
+ if (runtimeVersion !== APP_SERVER_PROTOCOL_VERSION) throw new Error(`protocol version mismatch: runtime speaks ${runtimeVersion}, SDK expects ${APP_SERVER_PROTOCOL_VERSION}`);
240
+ }
241
+ /**
242
+ * The host-policy environment the typed options above expand to.
243
+ *
244
+ * The spawned host cannot be handed backend CLI flags: `apps/agent-store` parses
245
+ * its own argv (`--host` / `--port` / `--data-dir` / `--no-open`) and has no
246
+ * `--agent-store-config`, so a flag would make clap exit before the readiness
247
+ * line. The parent environment is therefore the only channel a launcher has —
248
+ * the same conclusion `NOMI_LOG_LEVEL` already documents for the log filter.
249
+ */
250
+ function hostPolicyEnv(options) {
251
+ const env = {};
252
+ if (options.marketDownload !== void 0) env["AGENT_STORE_MARKET_DOWNLOAD"] = options.marketDownload;
253
+ if (options.configPath !== void 0) env["AGENT_STORE_CONFIG"] = options.configPath;
254
+ return env;
255
+ }
256
+ async function spawnAppServer(options = {}) {
257
+ const bin = resolveAppServerBin(options.bin);
258
+ const ownedDir = options.dataDir === void 0;
259
+ const dataDir = ownedDir ? await mkdtemp(join(tmpdir(), "agent-store-sdk-")) : options.dataDir;
260
+ const port = options.port ?? 0;
261
+ const policyEnv = {
262
+ ...hostPolicyEnv(options),
263
+ ...options.env
264
+ };
265
+ const { child, exited } = spawnRuntimeChild(bin, [
266
+ "--host",
267
+ "127.0.0.1",
268
+ "--port",
269
+ String(port),
270
+ "--data-dir",
271
+ dataDir,
272
+ "--no-open",
273
+ ...options.extraArgs ?? []
274
+ ], {
275
+ ...Object.keys(policyEnv).length > 0 ? { env: policyEnv } : {},
276
+ cwd: options.cwd,
277
+ onExit: options.onExit
278
+ });
279
+ const stderrTail = [];
280
+ child.stderr?.on("data", (chunk) => {
281
+ for (const line of String(chunk).split(/\r?\n/)) {
282
+ if (line.length === 0) continue;
283
+ stderrTail.push(line);
284
+ if (stderrTail.length > STDERR_TAIL_LINES) stderrTail.shift();
285
+ }
286
+ });
287
+ const stopChild = async () => {
288
+ if (child.exitCode !== null || child.signalCode !== null) return;
289
+ child.kill();
290
+ if (!await new Promise((resolve) => {
291
+ const timer = setTimeout(() => resolve(false), KILL_GRACE_MS);
292
+ child.once("exit", () => {
293
+ clearTimeout(timer);
294
+ resolve(true);
295
+ });
296
+ })) child.kill("SIGKILL");
297
+ };
298
+ const fail = async (message) => {
299
+ await stopChild();
300
+ if (ownedDir) await rm(dataDir, {
301
+ recursive: true,
302
+ force: true
303
+ }).catch(() => void 0);
304
+ const tail = stderrTail.length > 0 ? `\nstderr tail:\n${stderrTail.join("\n")}` : "";
305
+ throw new Error(`${message}${tail}`);
306
+ };
307
+ try {
308
+ const readiness = await waitForReadiness(child, options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS);
309
+ try {
310
+ assertProtocolCompatible(readiness.protocol_version);
311
+ } catch (error) {
312
+ await fail(error.message);
313
+ }
314
+ let closed = false;
315
+ return {
316
+ readiness,
317
+ dataDir,
318
+ exited,
319
+ close: async () => {
320
+ if (closed) return;
321
+ closed = true;
322
+ await stopChild();
323
+ if (ownedDir) await rm(dataDir, {
324
+ recursive: true,
325
+ force: true
326
+ }).catch(() => void 0);
327
+ }
328
+ };
329
+ } catch (error) {
330
+ await stopChild();
331
+ if (ownedDir) await rm(dataDir, {
332
+ recursive: true,
333
+ force: true
334
+ }).catch(() => void 0);
335
+ throw error;
336
+ }
337
+ }
338
+ /**
339
+ * Spawn the runtime process and start tracking its exit.
340
+ *
341
+ * Internal (not part of the package entry): exported so the lifecycle and
342
+ * `env` / `cwd` passthrough contract can be exercised with a synthetic child,
343
+ * which is what the managed CLI args normally make impossible to inject.
344
+ */
345
+ function spawnRuntimeChild(bin, args, options = {}) {
346
+ const child = spawn(bin, args, {
347
+ stdio: [
348
+ "ignore",
349
+ "pipe",
350
+ "pipe"
351
+ ],
352
+ windowsHide: true,
353
+ ...options.env ? { env: {
354
+ ...process.env,
355
+ ...options.env
356
+ } } : {},
357
+ ...options.cwd ? { cwd: options.cwd } : {}
358
+ });
359
+ return {
360
+ child,
361
+ exited: new Promise((resolve) => {
362
+ child.once("exit", (code, signal) => {
363
+ const info = {
364
+ code,
365
+ signal
366
+ };
367
+ resolve(info);
368
+ try {
369
+ options.onExit?.(info);
370
+ } catch {}
371
+ });
372
+ })
373
+ };
374
+ }
375
+ /** Internal: exported for the stdout-backpressure regression test only. */
376
+ function waitForReadiness(child, timeoutMs) {
377
+ return new Promise((resolve, reject) => {
378
+ if (!child.stdout) {
379
+ reject(/* @__PURE__ */ new Error("spawned runtime has no stdout pipe"));
380
+ return;
381
+ }
382
+ const stdout = child.stdout;
383
+ const lines = createInterface({ input: stdout });
384
+ let settled = false;
385
+ const fail = (error) => {
386
+ if (settled) return;
387
+ settled = true;
388
+ lines.close();
389
+ reject(error);
390
+ };
391
+ const timer = setTimeout(() => {
392
+ fail(/* @__PURE__ */ new Error(`timed out after ${timeoutMs}ms waiting for the runtime readiness line`));
393
+ }, timeoutMs);
394
+ lines.on("line", (line) => {
395
+ if (settled) return;
396
+ const readiness = parseReadinessLine(line);
397
+ if (!readiness) return;
398
+ settled = true;
399
+ clearTimeout(timer);
400
+ lines.close();
401
+ stdout.resume();
402
+ resolve(readiness);
403
+ });
404
+ child.once("error", (error) => {
405
+ fail(/* @__PURE__ */ new Error(`failed to spawn the runtime: ${error.message}`));
406
+ });
407
+ child.once("exit", (code) => {
408
+ fail(/* @__PURE__ */ new Error(`runtime exited before reporting readiness (code ${code})`));
409
+ });
410
+ });
411
+ }
412
+ //#endregion
413
+ //#region src/index.ts
414
+ /**
415
+ * `@flowy-agent-store/sdk` — spawn the runtime, connect over loopback, hand back
416
+ * a ready `AppServerClient`.
417
+ *
418
+ * The object `launchHarness` resolves to **is** that client (doc `31`): the
419
+ * `conversations` / `store` / `agents` surface sits on it directly, and the
420
+ * process it owns is reachable as `server` / `handshake` / `close()`. That is
421
+ * the shape both external references use — Codex's
422
+ * `with Codex(...) as codex: codex.thread_start(...)` and Kimi Code's
423
+ * `const harness = createKimiHarness(...); harness.createSession(...)` — the
424
+ * factory returns the main object, lifecycle included, with no `.client` hop.
425
+ */
426
+ /**
427
+ * Spawn a loopback runtime with its own data dir and return a connected,
428
+ * initialized client. Rejects non-loopback contact outright (P0-4): this
429
+ * entry point only ever dials the process it just spawned.
430
+ */
431
+ async function launchHarness(options) {
432
+ const server = await spawnAppServer(options);
433
+ const wsUrl = `ws://${server.readiness.host}:${server.readiness.port}/api/app-server/ws`;
434
+ if (!isLoopbackUrl(wsUrl)) {
435
+ await server.close();
436
+ throw new Error(`refusing to connect outside loopback: ${wsUrl}`);
437
+ }
438
+ const client = new AppServerClient({
439
+ transport: new WebSocketTransport(wsUrl, {
440
+ requestTimeoutMs: options.requestTimeoutMs,
441
+ token: options.token
442
+ }),
443
+ client: options.client,
444
+ capabilities: options.capabilities
445
+ });
446
+ const closeTransport = client.close.bind(client);
447
+ try {
448
+ const handshake = await client.connect();
449
+ let closed = false;
450
+ return Object.assign(client, {
451
+ server,
452
+ handshake,
453
+ close: async () => {
454
+ if (closed) return;
455
+ closed = true;
456
+ closeTransport();
457
+ await server.close();
458
+ }
459
+ });
460
+ } catch (error) {
461
+ client.close();
462
+ await server.close();
463
+ throw error;
464
+ }
465
+ }
466
+ //#endregion
467
+ export { assertProtocolCompatible, exportAgent, exportTeam, launchHarness, materializePack, parseReadinessLine, resolveAppServerBin, spawnAppServer };
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@flowy-agent-store/sdk",
3
+ "version": "0.1.0-beta.10",
4
+ "description": "Node.js host for the Agent Store App Server: spawn the runtime binary and connect over loopback (docs/agent-store/12 P1-3)",
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "engines": {
8
+ "node": ">=22"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/Michael-Lfx/allo.git",
13
+ "directory": "web/packages/sdk"
14
+ },
15
+ "sideEffects": false,
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.mts",
19
+ "import": "./dist/index.mjs",
20
+ "require": "./dist/index.cjs"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "files": [
25
+ "dist"
26
+ ],
27
+ "dependencies": {
28
+ "@flowy-agent-store/client": "0.1.0-beta.10",
29
+ "@flowy-agent-store/protocol": "0.1.0-beta.10"
30
+ },
31
+ "scripts": {
32
+ "build": "tsdown",
33
+ "prepack": "tsdown"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public",
37
+ "registry": "https://registry.npmjs.org/"
38
+ },
39
+ "main": "./dist/index.cjs",
40
+ "module": "./dist/index.mjs",
41
+ "types": "./dist/index.d.mts",
42
+ "optionalDependencies": {
43
+ "@flowy-agent-store/runtime-win32-x64": "0.1.0-beta.10",
44
+ "@flowy-agent-store/runtime-linux-x64": "0.1.0-beta.10",
45
+ "@flowy-agent-store/runtime-linux-arm64": "0.1.0-beta.10",
46
+ "@flowy-agent-store/runtime-darwin-x64": "0.1.0-beta.10",
47
+ "@flowy-agent-store/runtime-darwin-arm64": "0.1.0-beta.10"
48
+ }
49
+ }