@oberik/sdk 0.5.0 → 0.7.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/dist/cjs/index.js CHANGED
@@ -34,6 +34,8 @@
34
34
  */
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.OberikProject = exports.DEFAULT_CONTROL_PLANE_URL = exports.AgentFramework = exports.AgentStreamError = exports.AgentApiError = exports.AgentCancelledError = exports.DEFAULT_BASE_URL = void 0;
37
+ exports.coerceUiArgs = coerceUiArgs;
38
+ exports.uiSchemaViolations = uiSchemaViolations;
37
39
  exports.createProjectClient = createProjectClient;
38
40
  exports.createClient = createClient;
39
41
  // ============================================================================
@@ -68,6 +70,103 @@ exports.AgentCancelledError = AgentCancelledError;
68
70
  // Errors
69
71
  // ============================================================================
70
72
  /** Does this look like a page rather than an answer? */
73
+ // ---- UI component arguments ----
74
+ //
75
+ // A UI component declares `parameters` exactly as a client tool does, and `render` draws
76
+ // into the customer's own product. Two schema violations of the *same* declared schema
77
+ // reached `render` unchecked: `bars` as a JSON string rather than an array, and array
78
+ // items missing a required `percent` while carrying two fields the schema never mentioned.
79
+ //
80
+ // For a client *tool* you can validate in the handler and answer the model. A UI component
81
+ // paints, so the check has to happen before it draws.
82
+ /** JSON Schema `properties`, whichever of the two shapes the app declared. */
83
+ function uiProps(schema) {
84
+ const s = schema;
85
+ return (s?.properties ?? s?.parameters?.properties ?? {});
86
+ }
87
+ function parseIfJson(value, want) {
88
+ if (typeof value !== "string")
89
+ return value;
90
+ const text = value.trim();
91
+ if (!text)
92
+ return value;
93
+ const opens = want === "array" ? "[" : "{";
94
+ if (!text.startsWith(opens))
95
+ return value;
96
+ try {
97
+ return JSON.parse(text);
98
+ }
99
+ catch {
100
+ // Passed through untouched. A cosmetic mismatch must not become a thrown render.
101
+ return value;
102
+ }
103
+ }
104
+ /**
105
+ * Make the arguments match the declared types, as far as that is safe.
106
+ *
107
+ * Narrow on purpose — this is the app's own schema and the values go straight back to it.
108
+ * A container declared as `array`/`object` that arrived as its JSON text is parsed, and
109
+ * that parsing recurses into array items, which is where the server's own coercion stops.
110
+ */
111
+ function coerceUiArgs(schema, args) {
112
+ const props = uiProps(schema);
113
+ if (!Object.keys(props).length)
114
+ return args;
115
+ const out = { ...args };
116
+ for (const [key, spec] of Object.entries(props)) {
117
+ if (!(key in out))
118
+ continue;
119
+ const want = String(spec?.type ?? "");
120
+ if (want === "array" || want === "object")
121
+ out[key] = parseIfJson(out[key], want);
122
+ // Items of an array of objects: the same JSON-text-instead-of-a-container mistake,
123
+ // one level down, which is the level a chart's data actually lives at.
124
+ if (want === "array" && Array.isArray(out[key]) && spec?.items?.type === "object") {
125
+ out[key] = out[key].map((item) => parseIfJson(item, "object"));
126
+ }
127
+ }
128
+ return out;
129
+ }
130
+ /** How `args` fails `schema`, as sentences — empty when it does not. */
131
+ function uiSchemaViolations(schema, args) {
132
+ const props = uiProps(schema);
133
+ if (!Object.keys(props).length)
134
+ return [];
135
+ const required = (schema?.required ?? []);
136
+ const out = [];
137
+ for (const key of required) {
138
+ if (args[key] === undefined || args[key] === null)
139
+ out.push(`missing required "${key}"`);
140
+ }
141
+ for (const [key, spec] of Object.entries(props)) {
142
+ const value = args[key];
143
+ if (value === undefined || value === null)
144
+ continue;
145
+ const want = String(spec?.type ?? "");
146
+ if (want === "array" && !Array.isArray(value)) {
147
+ out.push(`"${key}" should be an array, got ${typeof value}`);
148
+ continue;
149
+ }
150
+ if (want === "array" && Array.isArray(value) && spec?.items) {
151
+ const itemRequired = (spec.items.required ?? []);
152
+ value.forEach((item, i) => {
153
+ if (typeof item !== "object" || item === null)
154
+ return;
155
+ for (const f of itemRequired) {
156
+ if (item[f] === undefined)
157
+ out.push(`"${key}[${i}]" is missing required "${f}"`);
158
+ }
159
+ });
160
+ }
161
+ if (want === "number" && typeof value !== "number") {
162
+ out.push(`"${key}" should be a number, got ${typeof value}`);
163
+ }
164
+ if (want === "string" && typeof value !== "string") {
165
+ out.push(`"${key}" should be a string, got ${typeof value}`);
166
+ }
167
+ }
168
+ return out;
169
+ }
71
170
  function looksLikeHtml(text) {
72
171
  return /^\s*(<!doctype html|<html|<head|<body)/i.test(text) || /<\/html>\s*$/i.test(text);
73
172
  }
@@ -88,9 +187,11 @@ function looksLikeHtml(text) {
88
187
  function describeDetail(status, detail) {
89
188
  if (typeof detail === "string") {
90
189
  if (looksLikeHtml(detail)) {
190
+ // Report what arrived, not a guess about what produced it. The API answers with
191
+ // JSON, so an HTML body is worth saying plainly — but naming whatever returned it
192
+ // would be speculation about someone else's infrastructure.
91
193
  const title = /<title[^>]*>([^<]{1,160})<\/title>/i.exec(detail)?.[1]?.trim();
92
- const where = status >= 502 ? " this came from a proxy in front of the API, not from the API" : "";
93
- return `HTTP ${status}${title ? `: ${title}` : ""}${where}`;
194
+ return `HTTP ${status}${title ? `: ${title}` : ""} (the response body was HTML, not JSON)`;
94
195
  }
95
196
  return detail.slice(0, 2000) || `HTTP ${status}`;
96
197
  }
@@ -288,6 +389,37 @@ async function collectDecisions(pending, handler) {
288
389
  * A handler that throws is treated as the user declining rather than as a failure:
289
390
  * the alternative is leaving the turn paused forever on a question nobody can now
290
391
  * answer, and "they'd rather talk about it" is both true and recoverable. */
392
+ /** One signal that aborts when any of its inputs does.
393
+ *
394
+ * `AbortSignal.any` exists on Node 20+ and modern browsers; the manual path is for
395
+ * anything older, because a client that cannot compose signals should lose the timeout,
396
+ * not the request. */
397
+ function combineSignals(...signals) {
398
+ const live = signals.filter(Boolean);
399
+ if (live.length <= 1)
400
+ return live[0];
401
+ const anyOf = AbortSignal.any;
402
+ if (typeof anyOf === "function")
403
+ return anyOf(live);
404
+ const ac = new AbortController();
405
+ for (const s of live) {
406
+ if (s.aborted) {
407
+ ac.abort(s.reason);
408
+ break;
409
+ }
410
+ s.addEventListener("abort", () => ac.abort(s.reason), { once: true });
411
+ }
412
+ return ac.signal;
413
+ }
414
+ /** Anything `UploadInput` allows, as a Blob for `FormData`. */
415
+ function asBlob(input) {
416
+ if (typeof Blob !== "undefined" && input instanceof Blob)
417
+ return input;
418
+ const bytes = input instanceof Uint8Array ? input : new Uint8Array(input);
419
+ // Copied into a fresh buffer: a Node Buffer is a view onto a pooled allocation, and
420
+ // handing that straight to Blob can carry bytes belonging to something else.
421
+ return new Blob([bytes.slice()]);
422
+ }
291
423
  async function collectAnswers(pending, handler) {
292
424
  const out = [];
293
425
  for (const batch of pending) {
@@ -899,8 +1031,25 @@ class AgentFramework {
899
1031
  const component = registry.get(r.name);
900
1032
  if (!component)
901
1033
  continue;
1034
+ // Coerced and checked against the schema the app declared, because `render` paints
1035
+ // straight into somebody's product and the frame handed to it is the one thing you
1036
+ // would expect to have been checked against the schema you just supplied.
1037
+ //
1038
+ // The server coerces too, but only the top-level properties, and it checks nothing
1039
+ // required — so a declared `bars: array of {label, percent}` arrived as a JSON
1040
+ // *string* in a browser and as `[{label, value, target}]` in Node, and both were
1041
+ // dispatched. The page drew "? 0.0%" four times.
1042
+ const args = coerceUiArgs(component.parameters, r.args ?? {});
1043
+ const problems = uiSchemaViolations(component.parameters, args);
1044
+ if (problems.length) {
1045
+ // Warn, then draw anyway. The agent has already told the user it showed them
1046
+ // something, so silently skipping leaves them looking for a chart that never
1047
+ // appears — worse than a chart with a gap in it. Loud enough to find in a console.
1048
+ console.warn(`[oberik] UI component "${r.name}" was called with arguments that do not match ` +
1049
+ `its declared schema: ${problems.join("; ")}. Rendering anyway.`);
1050
+ }
902
1051
  try {
903
- component.render(r.args ?? {});
1052
+ component.render(args);
904
1053
  }
905
1054
  catch (e) {
906
1055
  console.warn(`[oberik] UI component "${r.name}" threw while rendering:`, e);
@@ -938,7 +1087,16 @@ class AgentFramework {
938
1087
  headers["Content-Type"] = "application/json";
939
1088
  body = JSON.stringify(init.body);
940
1089
  }
941
- return this._fetch(url.toString(), { method, headers, body, signal: init.signal });
1090
+ // A timeout the caller can actually set. Node's built-in fetch gives up waiting
1091
+ // for response headers after 300s with `UND_ERR_HEADERS_TIMEOUT`, and there was
1092
+ // no way to raise it short of hand-building an undici dispatcher — so a blocking
1093
+ // turn that ran a long sandbox job simply died as `TypeError: fetch failed` with
1094
+ // nothing naming this SDK. Composed with the caller's own signal rather than
1095
+ // replacing it, so `opts.signal` still cancels.
1096
+ const ms = this.opts.timeoutMs ?? 600_000;
1097
+ const timer = ms > 0 ? AbortSignal.timeout(ms) : undefined;
1098
+ const signal = combineSignals(init.signal, timer);
1099
+ return this._fetch(url.toString(), { method, headers, body, signal });
942
1100
  };
943
1101
  let res = await send();
944
1102
  // Token expired mid-session: get a fresh one and replay the request once, so the
@@ -1040,8 +1198,15 @@ class AgentFramework {
1040
1198
  },
1041
1199
  /** Blocking chat that AUTO-EXECUTES registered client tools: it sends the
1042
1200
  * message, and whenever the agent asks for client tools it runs their
1043
- * handlers, submits the results, and repeats until the agent is done. */
1044
- run: (opts = {}) => this.runWithTools(opts),
1201
+ * handlers, submits the results, and repeats until the agent is done.
1202
+ *
1203
+ * Takes ONE argument, with the handlers inside it — unlike `chat.stream(req,
1204
+ * handlers)`, which takes two. Both forms are accepted here because the asymmetry
1205
+ * is a trap: passing handlers the way `stream` takes them was silently ignored, so
1206
+ * `onApproval` never fired and the turn came back with `approvals` set, which reads
1207
+ * exactly like approvals being broken. TypeScript catches it; the docs' own browser
1208
+ * examples are plain JS, where it does not. */
1209
+ run: (opts = {}, handlers) => this.runWithTools(handlers ? { ...opts, ...handlers } : opts),
1045
1210
  /** Streaming chat. When client tools are registered/passed, tool calls are
1046
1211
  * auto-executed and the stream resumes, so `done` only resolves when the
1047
1212
  * agent finishes (never with `requires_action`). */
@@ -2148,6 +2313,20 @@ class OberikProject {
2148
2313
  throw new Error("No fetch available; pass options.fetch");
2149
2314
  this._fetch = raw.bind(globalThis);
2150
2315
  }
2316
+ /**
2317
+ * Any Project API route, with this project's key — the escape hatch.
2318
+ *
2319
+ * The end-user client has always had `raw()`; this one had a private `request` and
2320
+ * nothing else, so the documented routes with no method here (the webhook secret,
2321
+ * webhook-tool rotation, `webhook-tools/deliveries`) meant hand-rolling `fetch` with
2322
+ * an `X-API-Key` header and re-deriving the base URL. A published SDK should not make
2323
+ * you leave it to use the API it wraps.
2324
+ *
2325
+ * `path` is relative to `/api/projects/<id>` — `raw("GET", "/webhook-secret")`.
2326
+ */
2327
+ async raw(method, path, body, form) {
2328
+ return this.request(method, path, body, form);
2329
+ }
2151
2330
  async request(method, path, body, form) {
2152
2331
  const res = await this._fetch(`${this.baseUrl}/api/projects/${this.projectId}${path}`, {
2153
2332
  method,
@@ -2160,7 +2339,9 @@ class OberikProject {
2160
2339
  const text = await res.text();
2161
2340
  const data = text ? safeJson(text) : null;
2162
2341
  if (!res.ok) {
2163
- throw new AgentApiError(res.status, (data && (data.error || data.message)) || `HTTP ${res.status}`);
2342
+ // `detail` on both planes now, so an integrator writes one accessor rather than
2343
+ // discovering that the control plane spelled it differently.
2344
+ throw new AgentApiError(res.status, (data && (data.detail || data.message)) || `HTTP ${res.status}`);
2164
2345
  }
2165
2346
  return data;
2166
2347
  }
@@ -2185,11 +2366,38 @@ class OberikProject {
2185
2366
  */
2186
2367
  forUser: (input) => async () => (await this.request("POST", "/token", input)).access_token,
2187
2368
  };
2369
+ /**
2370
+ * The project itself — its name, description, and the settings that are properties of
2371
+ * the project rather than permissions on a token.
2372
+ *
2373
+ * These had no SDK path at all. `doneWebhookUrl` is documented in the capability table
2374
+ * but is NOT a capability, so the obvious `capabilities.set({ doneWebhookUrl })` stored
2375
+ * nothing; the only symptom was `turn.stopped` never firing. Renaming a project, or
2376
+ * turning citation markers off, meant dropping to raw HTTP through `raw()`.
2377
+ */
2378
+ project = {
2379
+ get: () => this.request("GET", ""),
2380
+ /** Merges — send only what you want to change. */
2381
+ set: (fields) => this.request("PATCH", "", fields),
2382
+ /** Whether `[2]` stays in the visible answer. Its own route, not a PATCH field.
2383
+ * `stripped` leaves `claims[].start/end` as the only way to place a footnote. */
2384
+ citations: (markers) => this.request("PUT", "/citations", { markers }),
2385
+ };
2188
2386
  /** The capability ceiling: the maximum any token minted here may hold. */
2189
2387
  capabilities = {
2190
2388
  get: () => this.request("GET", "/capabilities"),
2191
- /** Merges — send only what you want to change. */
2192
- set: (caps) => this.request("PATCH", "", { capabilities: caps }),
2389
+ /**
2390
+ * Merges send only what you want to change.
2391
+ *
2392
+ * Read-only fields that `get()` returns are dropped rather than rejected, so
2393
+ * `set(await get())` — the obvious way to flip one flag — works. Anything else
2394
+ * unrecognised still errors: a capability nobody enforces must not be accepted in
2395
+ * silence, which is the failure this whole check exists for.
2396
+ */
2397
+ set: (caps) => {
2398
+ const { supportedModalities: _readOnly, ...settable } = caps;
2399
+ return this.request("PATCH", "", { capabilities: settable });
2400
+ },
2193
2401
  };
2194
2402
  /** Documents owned by the project rather than by any one end-user: the corpus you
2195
2403
  * curate and your users only read. */
@@ -2206,11 +2414,20 @@ class OberikProject {
2206
2414
  * accepted nor needed — a recipe that worked by luck rather than by expression.
2207
2415
  */
2208
2416
  documents = {
2209
- list: () => this.request("GET", "/documents"),
2417
+ /** Same filters as the end-user client's `documents.list`, so the two agree. */
2418
+ list: (query = {}) => {
2419
+ const qs = new URLSearchParams(Object.entries(query).filter(([, v]) => v != null)).toString();
2420
+ return this.request("GET", `/documents${qs ? `?${qs}` : ""}`);
2421
+ },
2210
2422
  get: (documentId) => this.request("GET", `/documents/${documentId}`),
2423
+ /** `file` may be a Blob/File, an ArrayBuffer or a Uint8Array — the same shapes the
2424
+ * end-user client takes. It was `Blob | File` only, so the quickstart's own
2425
+ * `await readFile("q3-report.pdf")` (a Buffer) failed deep inside undici as
2426
+ * `parameter 2 is not of type 'Blob'`, with nothing naming Oberik in the stack. */
2211
2427
  upload: (file, opts = {}) => {
2212
2428
  const form = new FormData();
2213
- form.append("file", file, opts.filename ?? file.name ?? "upload.bin");
2429
+ const name = opts.filename ?? file.name ?? "upload.bin";
2430
+ form.append("file", asBlob(file), name);
2214
2431
  form.append("tags", (opts.tags ?? []).join(","));
2215
2432
  return this.request("POST", "/documents", undefined, form);
2216
2433
  },
@@ -2298,9 +2515,19 @@ class OberikProject {
2298
2515
  /** Procedures you publish as Agent Plugins, and what your end-users have added. */
2299
2516
  skills = {
2300
2517
  list: () => this.request("GET", "/skills"),
2301
- upload: (zip, filename) => {
2518
+ /**
2519
+ * `zip` takes the same shapes as every other upload here — a Blob/File, an
2520
+ * ArrayBuffer or a Uint8Array — and `filename` goes in the options object.
2521
+ *
2522
+ * It was `Blob | File` with a positional filename, alone among four upload methods.
2523
+ * `await readFile("skill.zip")` (a Buffer) died as `parameter 2 is not of type 'Blob'`
2524
+ * inside undici with nothing naming Oberik in the stack — the identical failure that
2525
+ * was fixed for `documents.upload` and left here — and passing `{ filename }` in the
2526
+ * second slot, as the siblings take it, silently named the file `[object Object]`.
2527
+ */
2528
+ upload: (zip, opts = {}) => {
2302
2529
  const form = new FormData();
2303
- form.append("file", zip, filename ?? zip.name ?? "skill.zip");
2530
+ form.append("file", asBlob(zip), opts.filename ?? zip.name ?? "skill.zip");
2304
2531
  return this.request("POST", "/skills", undefined, form);
2305
2532
  },
2306
2533
  delete: (pluginId) => this.request("DELETE", `/skills/${pluginId}`),
@@ -2320,9 +2547,16 @@ class OberikProject {
2320
2547
  tasks = {
2321
2548
  list: () => this.request("GET", "/tasks"),
2322
2549
  };
2323
- /** What the agent has written down: remembered facts and wiki pages. */
2550
+ /**
2551
+ * What the agent has written down: remembered facts and wiki pages.
2552
+ *
2553
+ * `kind` defaults to `"wiki"` — the route's default, and the reason this looked like
2554
+ * it "returns pages only": it does, and there was no argument here to ask for the
2555
+ * memories. Same shape and same type as the end-user client's `knowledge.list`, so the
2556
+ * two do not disagree about what a stored item is.
2557
+ */
2324
2558
  wiki = {
2325
- list: () => this.request("GET", "/wiki"),
2559
+ list: (opts = {}) => this.request("GET", `/wiki?kind=${opts.kind ?? "wiki"}`),
2326
2560
  delete: (itemId) => this.request("DELETE", `/wiki/${itemId}`),
2327
2561
  };
2328
2562
  /** Live sandboxes, and what to do about one. */
@@ -2336,14 +2570,33 @@ class OberikProject {
2336
2570
  };
2337
2571
  /** Browser origins allowed to call the data plane with this project's tokens. */
2338
2572
  origins = {
2573
+ /** What is allowed right now — the first thing you want when a browser request is
2574
+ * being refused, and previously unanswerable without the dashboard. */
2575
+ get: () => this.request("GET", "/origins"),
2339
2576
  set: (origins) => this.request("PUT", "/origins", { origins }),
2340
2577
  };
2341
2578
  /** Tools the agent calls by URL. The signing secret comes back once, on create. */
2342
2579
  webhookTools = {
2343
2580
  list: () => this.request("GET", "/webhook-tools"),
2344
2581
  create: (tool) => this.request("POST", "/webhook-tools", tool),
2582
+ /** Change a tool WITHOUT rotating its secret.
2583
+ *
2584
+ * There was no update at all, so moving a URL after a tunnel restart meant
2585
+ * delete-and-recreate — which issues a new signing secret, shown once, that your
2586
+ * handler then has to be redeployed with. A URL change is not a credential
2587
+ * rotation. `name` is what the model calls and is not editable. */
2588
+ update: (toolId, patch) => this.request("PATCH", `/webhook-tools/${toolId}`, patch),
2589
+ /** A new signing secret, with the old one still verifying for 24 hours. */
2590
+ rotate: (toolId) => this.request("POST", `/webhook-tools/${toolId}/rotate`, {}),
2591
+ /** Recent calls to your handlers: when, which tool, what came back, how long. */
2592
+ deliveries: () => this.request("GET", "/webhook-tools/deliveries"),
2345
2593
  delete: (toolId) => this.request("DELETE", `/webhook-tools/${toolId}`),
2346
2594
  };
2595
+ /** The key that signs `turn.stopped` and scheduled-task deliveries to you. */
2596
+ webhookSecret = {
2597
+ get: () => this.request("GET", "/webhook-secret"),
2598
+ rotate: () => this.request("POST", "/webhook-secret/rotate", {}),
2599
+ };
2347
2600
  /** Server-side keys. A created one is returned once and never again. */
2348
2601
  /**
2349
2602
  * Checks on what goes into the model and what comes back.