esoul-sdk 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # esoul-sdk
2
+
3
+ Build **ExternalSoul apps**: native apps for an event-sourced workspace where people and AI
4
+ agents share one canvas. An app you write with this SDK is indistinguishable from the platform's
5
+ own once it ships — the same events, the same timeline, the same durability, the same tools that
6
+ chat, voice, agents and MCP call.
7
+
8
+ ```bash
9
+ npm install --save-dev esoul-sdk
10
+ ```
11
+
12
+ Inside ExternalSoul the package name resolves to the platform's real implementations. Outside it
13
+ (your editor, your tests) the package gives you the types, the manifest validator, and the test
14
+ helpers; the server functions throw "host only" if called, because they run in the platform.
15
+
16
+ ## Where to start
17
+
18
+ - **Build it in the Forge, not on your laptop.** Open a Forge board in your workspace and ask it
19
+ to open a workbench for your app. You get a cloud machine with the platform on it, a live
20
+ preview in your frame, your app's tools callable before it is installed, checks, and a submit
21
+ button. Read [docs/01-getting-started.md](docs/01-getting-started.md).
22
+ - **The contract, one page per part:**
23
+ 1. [Getting started](docs/01-getting-started.md) — the loop, the package layout
24
+ 2. [The manifest](docs/02-manifest.md) — `plugin.json`, every field
25
+ 3. [Events and state](docs/03-events-and-state.md) — the heart: dataCreator, processor, replay
26
+ 4. [Tools](docs/04-tools.md) — what agents call, on every surface
27
+ 5. [The UI](docs/05-ui.md) — React, hooks, theme, responsive rules
28
+ 6. [The server half](docs/06-server.md) — ops, webhooks, reading state, calling other apps
29
+ 7. [Background tasks](docs/07-background-tasks.md) — durable work, polling, the replay model
30
+ 8. [Connections and OAuth](docs/08-connections.md) — tokens the platform holds for you
31
+ 9. [Files](docs/09-files.md) — workspace files, Drive, your own provider
32
+ 10. [Testing](docs/10-testing.md) — the fold contract as tests
33
+ 11. [Shipping](docs/11-shipping.md) — submit, review, release, install; the import wall
34
+ 12. [Rules and failures](docs/12-rules.md) — every rule with the failure that earned it
35
+ - **For a coding model:** `llms.txt` (short) and `llms-full.txt` (the whole contract in one file).
36
+
37
+ ## The one rule that explains the others
38
+
39
+ **Events are the truth.** Your app's state is the fold of its events, re-run on every replay,
40
+ scrub and sync. So a reducer must be pure and idempotent, ids and timestamps are minted in the
41
+ `dataCreator` (never in a reducer), whole-replace events carry a collapse key, and the agent-facing
42
+ state description never claims something it could not read. Everything in the docs follows from
43
+ that.
44
+
45
+ ## What is in the package
46
+
47
+ | Entry | What it gives you |
48
+ |---|---|
49
+ | `esoul-sdk` | `ApplicationSchema`, `EventDefinition`, `EventTypes`, `ApplicationIdentifier`, `incompleteStateNotice`, `deterministicReducerId`, `stableStringify`, `timingSafeEqual`, `nanoid`, `callPluginOp`, the manifest schema |
50
+ | `esoul-sdk/react` | `usePluginEventDispatch`, `useAppCanEdit`, `usePluginCurrentChatId`, `useWorkspaceTools`, file hooks |
51
+ | `esoul-sdk/server` | `PluginServerModule`, `readAppState`, `callWorkspaceTool`, `emitPluginAppEvent`, `getPluginConnectionCredentials`, file provider types |
52
+ | `esoul-sdk/testing` | a mock OAuth server for connection tests |
53
+ | `esoul-app validate <dir>` | validates a package folder against the manifest schema |
54
+
55
+ ## Versions
56
+
57
+ - **0.3.0** — renamed from `@externalsoul/plugin-sdk` (still resolved as an alias inside the
58
+ platform). `readAppState`, `callWorkspaceTool`, `nanoid` on the index. The import wall: an app
59
+ reaches the platform only through this package. Docs rewritten for the Forge workbench loop.
60
+ - 0.2.0 — file sources and providers.
61
+ - 0.1.0 — the contract: manifest, schema, events, tools, tasks, webhooks, ops, connections.
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * esoul-plugin — repo-free plugin package checks.
4
+ *
5
+ * esoul-app validate <dir> exit 0 = the folder is a well-formed
6
+ * package; exit 1 = every problem listed.
7
+ *
8
+ * This is the AUTHOR-SIDE half of validation (manifest shape, entry
9
+ * existence, forbidden imports, secret-shaped keys). The host's
10
+ * plugins:sync re-checks everything plus host-only facts (type collisions
11
+ * with installed apps) at install time — passing here is necessary, not
12
+ * sufficient, and the CLI says so.
13
+ */
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import url from "node:url";
17
+
18
+ const here = path.dirname(url.fileURLToPath(import.meta.url));
19
+ let PluginManifestSchema;
20
+ try {
21
+ ({ PluginManifestSchema } = await import(
22
+ url.pathToFileURL(path.join(here, "..", "dist", "manifest.js")).href
23
+ ));
24
+ } catch {
25
+ console.error("esoul-plugin: package not built (dist/ missing) — reinstall esoul-sdk");
26
+ process.exit(2);
27
+ }
28
+
29
+ const [cmd, dirArg] = process.argv.slice(2);
30
+ if (cmd !== "validate" || !dirArg) {
31
+ console.error("usage: esoul-app validate <pluginDir>");
32
+ process.exit(2);
33
+ }
34
+ const dir = path.resolve(dirArg);
35
+ const problems = [];
36
+ const problem = (m) => problems.push(m);
37
+
38
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
39
+ console.error(`not a directory: ${dir}`);
40
+ process.exit(1);
41
+ }
42
+
43
+ /* manifest */
44
+ let manifest = null;
45
+ const manifestPath = path.join(dir, "plugin.json");
46
+ if (!fs.existsSync(manifestPath)) {
47
+ problem("plugin.json missing — not a plugin package");
48
+ } else {
49
+ try {
50
+ const raw = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
51
+ const parsed = PluginManifestSchema.safeParse(raw);
52
+ if (!parsed.success) {
53
+ for (const issue of parsed.error.issues) {
54
+ problem(`plugin.json ${issue.path.join(".") || "(root)"}: ${issue.message}`);
55
+ }
56
+ } else {
57
+ manifest = parsed.data;
58
+ }
59
+ } catch (e) {
60
+ problem(`plugin.json is not valid JSON: ${e.message}`);
61
+ }
62
+ }
63
+
64
+ /* entry + server module */
65
+ if (manifest) {
66
+ const entryOk = ["tsx", "ts"].some((ext) =>
67
+ fs.existsSync(path.join(dir, `${manifest.entry}.${ext}`)),
68
+ );
69
+ if (!entryOk) problem(`entry module "${manifest.entry}.(tsx|ts)" not found`);
70
+ const needsServer = manifest.webhooks.length > 0 || manifest.ops.length > 0;
71
+ const hasServer = ["ts", "tsx"].some((ext) =>
72
+ fs.existsSync(path.join(dir, `server.${ext}`)),
73
+ );
74
+ if (needsServer && !hasServer) {
75
+ problem("declares webhooks/ops but has no server.ts exporting pluginServer");
76
+ }
77
+ }
78
+
79
+ /* source lints (the init-cycle traps + secret smells) */
80
+ const walk = (d) =>
81
+ fs.readdirSync(d, { withFileTypes: true }).flatMap((e) => {
82
+ const p = path.join(d, e.name);
83
+ if (e.isDirectory()) {
84
+ return ["node_modules", ".git", "dist"].includes(e.name) ? [] : walk(p);
85
+ }
86
+ return /\.(ts|tsx)$/.test(e.name) ? [p] : [];
87
+ });
88
+ for (const f of walk(dir)) {
89
+ const src = fs.readFileSync(f, "utf8");
90
+ const rel = path.relative(dir, f);
91
+ if (/(from\s+|import\s*\(\s*|require\s*\(\s*)["'][^"']*client-registry["']/.test(src)) {
92
+ problem(`${rel}: imports client-registry — the init-cycle trap; plugins never import the registry`);
93
+ }
94
+ if (/from\s+["']@\/store\/venus-slice["']/.test(src)) {
95
+ problem(`${rel}: statically imports venus-slice — use the hooks from esoul-sdk/react`);
96
+ }
97
+ if (/(sk-[A-Za-z0-9]{20,}|-----BEGIN [A-Z ]*PRIVATE KEY-----)/.test(src)) {
98
+ problem(`${rel}: looks like a hard-coded secret — credentials belong in connections, never in code`);
99
+ }
100
+ }
101
+
102
+ if (problems.length) {
103
+ console.error(`✗ ${dir}`);
104
+ for (const p of problems) console.error(` - ${p}`);
105
+ process.exit(1);
106
+ }
107
+ console.log(`✓ ${manifest.name} (${manifest.id} / ${manifest.applicationType}) is a well-formed package.`);
108
+ console.log(
109
+ " Host-side checks (type collisions, gate tests) still run at install — this pass is necessary, not sufficient.",
110
+ );
@@ -0,0 +1,59 @@
1
+ /**
2
+ * File sources — the client-safe half of the files API
3
+ * (plugin-file-sources.md). PURE mirror of the host's
4
+ * src/lib/file-sources/types.ts; the sdk-drift gate keeps them in lockstep.
5
+ *
6
+ * A FileSource is a MOUNT: one place files come from (the workspace, the
7
+ * workspace's Google Drive, a local folder on the desktop build, or a mount
8
+ * contributed by a plugin's own connection). Identity = `sourceId` +
9
+ * provider-opaque `ref`; labels are display only. A source that cannot
10
+ * answer reports `source_unavailable` — never an empty listing.
11
+ */
12
+ export type FileSourceId = string;
13
+ export interface FileSource {
14
+ sourceId: FileSourceId;
15
+ providerKey: string;
16
+ label: string;
17
+ detail?: string;
18
+ caps: {
19
+ write: boolean;
20
+ watch: boolean;
21
+ durable: boolean;
22
+ };
23
+ online: boolean;
24
+ }
25
+ export interface FileRef {
26
+ sourceId: FileSourceId;
27
+ /** Provider-opaque id. Workspace source: the WorkspaceFile.id uuid. */
28
+ ref: string;
29
+ }
30
+ export interface FileEntry {
31
+ ref: FileRef;
32
+ name: string;
33
+ kind: "file" | "folder";
34
+ sizeBytes?: number;
35
+ mimeType?: string;
36
+ /** Epoch ms. */
37
+ modifiedAt?: number;
38
+ path?: string;
39
+ }
40
+ export type FileSourceErrorKind = "not_declared" | "source_unavailable" | "not_found" | "too_large" | "read_only" | "disabled" | "bad_ref";
41
+ export declare class FileSourceError extends Error {
42
+ readonly kind: FileSourceErrorKind;
43
+ constructor(kind: FileSourceErrorKind, message: string);
44
+ }
45
+ export declare const FILE_READ_CAP_BYTES: number;
46
+ export declare const FILE_READ_HARD_CAP_BYTES: number;
47
+ export type ParsedSourceId = {
48
+ kind: "workspace";
49
+ } | {
50
+ kind: "google-drive";
51
+ } | {
52
+ kind: "local";
53
+ mountId: string;
54
+ } | {
55
+ kind: "plugin";
56
+ providerKey: string;
57
+ connectionId: string;
58
+ };
59
+ export declare function parseSourceId(sourceId: string): ParsedSourceId;
package/dist/files.js ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * File sources — the client-safe half of the files API
3
+ * (plugin-file-sources.md). PURE mirror of the host's
4
+ * src/lib/file-sources/types.ts; the sdk-drift gate keeps them in lockstep.
5
+ *
6
+ * A FileSource is a MOUNT: one place files come from (the workspace, the
7
+ * workspace's Google Drive, a local folder on the desktop build, or a mount
8
+ * contributed by a plugin's own connection). Identity = `sourceId` +
9
+ * provider-opaque `ref`; labels are display only. A source that cannot
10
+ * answer reports `source_unavailable` — never an empty listing.
11
+ */
12
+ export class FileSourceError extends Error {
13
+ kind;
14
+ constructor(kind, message) {
15
+ super(message);
16
+ this.name = "FileSourceError";
17
+ this.kind = kind;
18
+ }
19
+ }
20
+ export const FILE_READ_CAP_BYTES = 25 * 1024 * 1024;
21
+ export const FILE_READ_HARD_CAP_BYTES = 100 * 1024 * 1024;
22
+ export function parseSourceId(sourceId) {
23
+ if (sourceId === "workspace")
24
+ return { kind: "workspace" };
25
+ if (sourceId === "google-drive")
26
+ return { kind: "google-drive" };
27
+ const colon = sourceId.indexOf(":");
28
+ if (colon <= 0 || colon === sourceId.length - 1) {
29
+ throw new FileSourceError("bad_ref", `unknown file source "${sourceId}"`);
30
+ }
31
+ const head = sourceId.slice(0, colon);
32
+ const tail = sourceId.slice(colon + 1);
33
+ if (head === "local")
34
+ return { kind: "local", mountId: tail };
35
+ if (head === "workspace" || head === "google-drive") {
36
+ throw new FileSourceError("bad_ref", `unknown file source "${sourceId}"`);
37
+ }
38
+ return { kind: "plugin", providerKey: head, connectionId: tail };
39
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Pure helpers — byte-for-byte vendored from the platform (a drift gate in
3
+ * the host asserts behavioural equality on every build).
4
+ */
5
+ export declare function stableStringify(v: unknown): string;
6
+ /** Refold-stable id from event payload — the ONLY way a processor may mint
7
+ * an id when the dataCreator didn't (never nanoid/Date.now in a reducer). */
8
+ export declare function deterministicReducerId(prefix: string, seed: unknown): string;
9
+ /** Fields whose ABSENCE means "did not load" (they are seeded at genesis). */
10
+ export interface RequiredStateShape {
11
+ /** Keys that must be arrays. */
12
+ lists?: Record<string, unknown>;
13
+ /** Keys that must be non-null objects. */
14
+ maps?: Record<string, unknown>;
15
+ }
16
+ export declare function missingStateKeys(shape: RequiredStateShape): string[];
17
+ /**
18
+ * `null` when the state is whole — the describer proceeds normally.
19
+ * Otherwise the block to return INSTEAD of a description. A missing
20
+ * collection is "state did not load", NEVER "empty" — flooring to [] tells
21
+ * the model something false and invites it to rebuild over real data.
22
+ */
23
+ export declare function incompleteStateNotice(args: {
24
+ title: string;
25
+ instanceName?: unknown;
26
+ shape: RequiredStateShape;
27
+ }): string | null;
28
+ /**
29
+ * Call one of your plugin's server ops from client-graph code (tools,
30
+ * tasks, UI). Server side authenticates with the internal secret; browser
31
+ * side rides the session. Throws the op's honest error — relay it, never
32
+ * swallow it.
33
+ */
34
+ export declare function callPluginOp<T = unknown>(pluginId: string, op: string, nodeId: string, args?: unknown): Promise<T>;
35
+ /**
36
+ * Constant-time string compare for webhook secrets/signatures. A plain
37
+ * `===` leaks length/prefix timing; use THIS in every webhook handler.
38
+ * Pure JS (no node:crypto) so it is safe in any runtime the SDK reaches.
39
+ */
40
+ export declare function timingSafeEqual(a: string, b: string): boolean;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Pure helpers — byte-for-byte vendored from the platform (a drift gate in
3
+ * the host asserts behavioural equality on every build).
4
+ */
5
+ /* ── deterministic ids (from event-spec.ts) ─────────────────────────── */
6
+ export function stableStringify(v) {
7
+ if (v === null || typeof v !== "object")
8
+ return JSON.stringify(v) ?? "null";
9
+ if (Array.isArray(v))
10
+ return `[${v.map(stableStringify).join(",")}]`;
11
+ const keys = Object.keys(v).sort();
12
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(v[k])}`).join(",")}}`;
13
+ }
14
+ /** Refold-stable id from event payload — the ONLY way a processor may mint
15
+ * an id when the dataCreator didn't (never nanoid/Date.now in a reducer). */
16
+ export function deterministicReducerId(prefix, seed) {
17
+ const s = stableStringify(seed);
18
+ let h = 5381;
19
+ for (let i = 0; i < s.length; i++)
20
+ h = ((h * 33) ^ s.charCodeAt(i)) >>> 0;
21
+ return `${prefix}_${h.toString(36)}${(s.length & 0xffff).toString(36)}`;
22
+ }
23
+ export function missingStateKeys(shape) {
24
+ const missing = [];
25
+ for (const [key, value] of Object.entries(shape.lists ?? {})) {
26
+ if (!Array.isArray(value))
27
+ missing.push(key);
28
+ }
29
+ for (const [key, value] of Object.entries(shape.maps ?? {})) {
30
+ if (value === null || value === undefined || typeof value !== "object") {
31
+ missing.push(key);
32
+ }
33
+ }
34
+ return missing;
35
+ }
36
+ /**
37
+ * `null` when the state is whole — the describer proceeds normally.
38
+ * Otherwise the block to return INSTEAD of a description. A missing
39
+ * collection is "state did not load", NEVER "empty" — flooring to [] tells
40
+ * the model something false and invites it to rebuild over real data.
41
+ */
42
+ export function incompleteStateNotice(args) {
43
+ const missing = missingStateKeys(args.shape);
44
+ if (missing.length === 0)
45
+ return null;
46
+ const name = typeof args.instanceName === "string" && args.instanceName
47
+ ? args.instanceName
48
+ : "(unnamed)";
49
+ return (`## ${args.title} — "${name}"\n` +
50
+ `- ⚠ STATE UNAVAILABLE: ${missing.join(", ")} did not load, so this app's contents are UNKNOWN.\n` +
51
+ `- This is NOT an empty app. Do not add, overwrite, re-import or re-run anything here on the assumption that it is empty — read it with this app's own read/list tools first, or tell the user it could not be read.\n`);
52
+ }
53
+ /* ── ops caller (from lib/plugins/call-op.ts) ───────────────────────── */
54
+ /**
55
+ * Call one of your plugin's server ops from client-graph code (tools,
56
+ * tasks, UI). Server side authenticates with the internal secret; browser
57
+ * side rides the session. Throws the op's honest error — relay it, never
58
+ * swallow it.
59
+ */
60
+ export async function callPluginOp(pluginId, op, nodeId, args) {
61
+ const serverBase = typeof window === "undefined"
62
+ ? process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"
63
+ : "";
64
+ const headers = { "Content-Type": "application/json" };
65
+ if (typeof window === "undefined" && process.env.INTERNAL_TOOL_SECRET) {
66
+ headers["x-esoul-internal-secret"] = process.env.INTERNAL_TOOL_SECRET;
67
+ }
68
+ const res = await fetch(`${serverBase}/api/plugins/${pluginId}/op/${op}`, {
69
+ method: "POST",
70
+ headers,
71
+ body: JSON.stringify({ nodeId, args }),
72
+ });
73
+ const body = (await res.json().catch(() => null));
74
+ if (!res.ok || !body?.ok) {
75
+ throw new Error(body?.error ?? `plugin op ${pluginId}/${op} failed (HTTP ${res.status})`);
76
+ }
77
+ return body.result;
78
+ }
79
+ /* ── webhook secret comparison ──────────────────────────────────────── */
80
+ /**
81
+ * Constant-time string compare for webhook secrets/signatures. A plain
82
+ * `===` leaks length/prefix timing; use THIS in every webhook handler.
83
+ * Pure JS (no node:crypto) so it is safe in any runtime the SDK reaches.
84
+ */
85
+ export function timingSafeEqual(a, b) {
86
+ const len = Math.max(a.length, b.length, 1);
87
+ let diff = a.length === b.length ? 0 : 1;
88
+ for (let i = 0; i < len; i++) {
89
+ diff |= (a.charCodeAt(i % Math.max(a.length, 1)) || 0) ^
90
+ (b.charCodeAt(i % Math.max(b.length, 1)) || 0);
91
+ }
92
+ return diff === 0;
93
+ }
@@ -0,0 +1,6 @@
1
+ export * from "./types.js";
2
+ export * from "./manifest.js";
3
+ export * from "./helpers.js";
4
+ export * from "./files.js";
5
+ /** Id minting for dataCreators; an app depends on the SDK, not on nanoid. */
6
+ export { nanoid } from "nanoid";
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export * from "./types.js";
2
+ export * from "./manifest.js";
3
+ export * from "./helpers.js";
4
+ export * from "./files.js";
5
+ /** Id minting for dataCreators; an app depends on the SDK, not on nanoid. */
6
+ export { nanoid } from "nanoid";