@oberik/sdk 0.6.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
  }
@@ -930,8 +1031,25 @@ class AgentFramework {
930
1031
  const component = registry.get(r.name);
931
1032
  if (!component)
932
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
+ }
933
1051
  try {
934
- component.render(r.args ?? {});
1052
+ component.render(args);
935
1053
  }
936
1054
  catch (e) {
937
1055
  console.warn(`[oberik] UI component "${r.name}" threw while rendering:`, e);
@@ -2221,7 +2339,9 @@ class OberikProject {
2221
2339
  const text = await res.text();
2222
2340
  const data = text ? safeJson(text) : null;
2223
2341
  if (!res.ok) {
2224
- 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}`);
2225
2345
  }
2226
2346
  return data;
2227
2347
  }
@@ -2246,11 +2366,38 @@ class OberikProject {
2246
2366
  */
2247
2367
  forUser: (input) => async () => (await this.request("POST", "/token", input)).access_token,
2248
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
+ };
2249
2386
  /** The capability ceiling: the maximum any token minted here may hold. */
2250
2387
  capabilities = {
2251
2388
  get: () => this.request("GET", "/capabilities"),
2252
- /** Merges — send only what you want to change. */
2253
- 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
+ },
2254
2401
  };
2255
2402
  /** Documents owned by the project rather than by any one end-user: the corpus you
2256
2403
  * curate and your users only read. */
@@ -2267,7 +2414,11 @@ class OberikProject {
2267
2414
  * accepted nor needed — a recipe that worked by luck rather than by expression.
2268
2415
  */
2269
2416
  documents = {
2270
- 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
+ },
2271
2422
  get: (documentId) => this.request("GET", `/documents/${documentId}`),
2272
2423
  /** `file` may be a Blob/File, an ArrayBuffer or a Uint8Array — the same shapes the
2273
2424
  * end-user client takes. It was `Blob | File` only, so the quickstart's own
@@ -2364,9 +2515,19 @@ class OberikProject {
2364
2515
  /** Procedures you publish as Agent Plugins, and what your end-users have added. */
2365
2516
  skills = {
2366
2517
  list: () => this.request("GET", "/skills"),
2367
- 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 = {}) => {
2368
2529
  const form = new FormData();
2369
- form.append("file", zip, filename ?? zip.name ?? "skill.zip");
2530
+ form.append("file", asBlob(zip), opts.filename ?? zip.name ?? "skill.zip");
2370
2531
  return this.request("POST", "/skills", undefined, form);
2371
2532
  },
2372
2533
  delete: (pluginId) => this.request("DELETE", `/skills/${pluginId}`),
@@ -2386,9 +2547,16 @@ class OberikProject {
2386
2547
  tasks = {
2387
2548
  list: () => this.request("GET", "/tasks"),
2388
2549
  };
2389
- /** 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
+ */
2390
2558
  wiki = {
2391
- list: () => this.request("GET", "/wiki"),
2559
+ list: (opts = {}) => this.request("GET", `/wiki?kind=${opts.kind ?? "wiki"}`),
2392
2560
  delete: (itemId) => this.request("DELETE", `/wiki/${itemId}`),
2393
2561
  };
2394
2562
  /** Live sandboxes, and what to do about one. */