@niadra/sdk 0.1.0 → 0.1.1

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/CHANGELOG.md CHANGED
@@ -2,6 +2,55 @@
2
2
 
3
3
  All notable changes to this package are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the package follows [Semantic Versioning](https://semver.org/).
4
4
 
5
+ ## [0.1.1] - 2026-09-24
6
+
7
+ ### Added
8
+
9
+ - The agent's turn carries the usage the model provider reported for the call behind it, as
10
+ `usage` (`ModelUsage`: provider, model, prompt tokens with the cached ones included, cached
11
+ tokens, tokens written to the cache). `wrap()` reads it from every OpenAI-compatible response
12
+ and from the last chunk of a stream that asked for it (`stream_options: { include_usage: true }`),
13
+ and never changes the request. Niadra sums it per agent, vendor and model, and the Console shows
14
+ the prompt cache's hit rate and estimated savings.
15
+ - `agent(text, { usage })` on conversations and tasks takes the provider's response (OpenAI chat
16
+ completions or Responses, Anthropic messages) or a `ModelUsage`, for agents that do not use
17
+ `wrap()`; `modelUsage()` reads one yourself. A response without usage is left out; the turn is
18
+ recorded either way.
19
+ - CI runs the build on Deno (with no permissions), Bun, workerd (Cloudflare Workers) and the Vercel
20
+ Edge Runtime (`pnpm runtimes`), and the README names them.
21
+
22
+ ### Changed
23
+
24
+ - `engines` asks for Node 20 or later, the versions the CI tests. The README no longer claims Node 18.
25
+ - A conversation turn (a message with a `conversation_id`) leaves the queue at most 200 ms after it
26
+ was queued, taking whatever else is waiting along, instead of up to a second: it is what the other
27
+ agents read in `live`. `queue.turnFlushIntervalMs` sets it; other items still wait for
28
+ `flushIntervalMs` (1 s) or `flushAt` (15).
29
+ - The timeline tool says the history comes newest first, as the server returns it, instead of
30
+ "in chronological order".
31
+ - The search tool no longer offers the model a `system_event` item kind, and its description names
32
+ business objects instead of system events: a system event is never an item, it changes its object,
33
+ so the filter is `object`. The server still reads `system_event` from 0.1.0 as `object`.
34
+ - `open()` sends `POST /v1/history/open` with the item id, the level and the conversation id in the
35
+ body, instead of `GET /v1/history/items/{id}` with the conversation id in the query: a
36
+ conversation id may be a phone number or an e-mail, and a URL reaches access logs.
37
+ - `open()` takes `subject`, the customer the item must belong to; the server opens any other item
38
+ as 404. The tool kit passes its bound customer, so `open_history_item` opens only that
39
+ customer's items.
40
+ - `task_id` on `open()` is no longer sent: the server never read it on this route. The field stays
41
+ in `OpenParams` for code written against 0.1.0.
42
+ - The `excerpt` field of an opened item says the server no longer sends it.
43
+
44
+ ### Fixed
45
+
46
+ - Building a client on Deno without `--allow-env` no longer throws: a runtime that refuses to read
47
+ the environment now counts as one without `NIADRA_API_KEY` and `NIADRA_BASE_URL`.
48
+ - `feedback()` and the reservation in `uploadMedia()` end within `timeouts.write` (5 s) in total,
49
+ retries and backoff included, instead of 5 s per attempt.
50
+ - The transfer in `uploadMedia()` ends within `timeouts.upload` (60 s) in total instead of per attempt.
51
+ - `identify()`, `verify()` and `handoff()` resolve by `timeouts.write` with a `NiadraTimeoutError`
52
+ when the queue could not confirm them in time; the item stays queued and is still sent.
53
+
5
54
  ## [0.1.0] - 2026-09-23
6
55
 
7
56
  First public release, with the same surface as the Python SDK.
package/README.md CHANGED
@@ -5,10 +5,11 @@
5
5
 
6
6
  **Niadra is the shared customer memory for every AI agent in a company.** The WhatsApp agent, the
7
7
  voice agent, the billing agent and the human team read the same memory before they act and write
8
- back what they said and did. This package connects a TypeScript or JavaScript agent to it, on Node
9
- 18+ and on edge runtimes (Vercel, Cloudflare Workers, Deno).
8
+ back what they said and did. This package connects a TypeScript or JavaScript agent to it, on
9
+ Node 20+, Deno, Bun, Cloudflare Workers and the Vercel Edge Runtime: it needs only `fetch` and
10
+ Web Crypto, and CI runs the build on each of them.
10
11
 
11
- [Website](https://niadra.com/en) · [Documentation](https://niadra.com/en/docs) ·
12
+ [Website](https://niadra.com/en) · [Documentation](https://docs.niadra.com/en) ·
12
13
  [Talk to us](https://niadra.com/en/enterprise) · [Python SDK](https://github.com/ainiadra/niadra-sdk-python)
13
14
 
14
15
  ```sh
@@ -90,7 +91,8 @@ exported on request. The SDK never logs handles or message text.
90
91
 
91
92
  **Which models and frameworks does it work with?** Any. The context is text you place in your
92
93
  prompt, the tools follow the common function-calling format, and `wrap()` covers
93
- OpenAI-compatible clients.
94
+ OpenAI-compatible clients; `agent(text, { usage })` takes the usage of an OpenAI or Anthropic
95
+ response.
94
96
 
95
97
  ## Concepts
96
98
 
@@ -168,6 +170,22 @@ const completion = await openai.chat.completions.create({ model: "gpt-4.1", mess
168
170
 
169
171
  Every `chat.completions.create` and `chat.completions.parse` call through the wrapper, streaming or not, gets the pack after your leading system messages and the suffix at the end. The injection is stamped, and the model's answer (its first choice) is recorded as the agent's turn: at once, or when a stream ends or you stop reading it. `.withResponse()` keeps working and records too; `.asResponse()` returns the raw HTTP response, so nothing is recorded then. Pass a function instead of a conversation to pick one per call; when it returns `null`, the call passes through untouched. Nothing the wrapper does can fail your call: a context it cannot fetch is left out, and a failure to record the answer is logged, without content.
170
172
 
173
+ #### The provider's prompt cache
174
+
175
+ The agent's turn also carries the usage the provider reported for the call: every input token (`usage.prompt_tokens`), the ones read from its prompt cache (`usage.prompt_tokens_details.cached_tokens`) and, through gateways that pass Anthropic's fields along, the ones written to it. A stream reports usage only when you ask for it with `stream_options: { include_usage: true }`; the wrapper never changes your request to get it. Niadra sums the usage per agent, vendor and model, and the Console shows the cache's hit rate and the estimated savings next to the rest of the space's usage.
176
+
177
+ Without `wrap()`, pass the provider's response with the turn. An OpenAI response and an Anthropic one are both understood (Anthropic's `usage.input_tokens`, `cache_read_input_tokens` and `cache_creation_input_tokens`):
178
+
179
+ ```ts
180
+ const message = await anthropic.messages.create({ model: "claude-sonnet-4-5", max_tokens: 1024, system, messages });
181
+ convo.agent(textOf(message), { usage: message });
182
+
183
+ // or build it yourself
184
+ convo.agent(reply, { usage: { provider: "openai", model: "gpt-4.1", prompt_tokens: 3000, cached_tokens: 2048 } });
185
+ ```
186
+
187
+ `modelUsage(response)` reads one yourself. A response without usage is left out, and the turn is recorded either way.
188
+
171
189
  ### Tasks
172
190
 
173
191
  Internal agents (billing, collections, triage) work in tasks rather than conversations. A task centers its pack on its object, keeps deltas and stamps like a conversation, scopes the cache, groups events and ends with `task.ended`.
@@ -191,6 +209,8 @@ await task.end();
191
209
  | `handoff({ conversation_id, target })` | A transfer to a human or another agent | Sent at once |
192
210
  | `feedback({ subject, action, ... })` | A correction of what Niadra derived: `retract_fact`, `correct_fact`, `resolve_open_item`, `conversation_outcome` | Sent at once |
193
211
 
212
+ Queued items leave in batches when 15 are waiting or a second after the first one, whichever comes first. A message with a `conversation_id` is a turn the other agents read in `live`, so it leaves within 200 ms (`queue.turnFlushIntervalMs`), taking whatever else is waiting along.
213
+
194
214
  Every item carries an idempotency key: the provider's message id when you pass one, a UUIDv7 otherwise. Retrying the same event is harmless.
195
215
 
196
216
  `identify()`, `verify()`, `handoff()` and `feedback()` resolve to `{ ok, idempotency_key, error }` once the server has answered. A correction is recorded as an event, so it is audited like any other. A `context()` call made after `identify()` or `verify()` resolves already reflects it.
@@ -211,6 +231,8 @@ if (first) {
211
231
 
212
232
  `search()` also reports recurrence: how many times the same kind of issue came back, and how it was last resolved.
213
233
 
234
+ The handle, the search and the conversation id go in request bodies, never in a URL: a conversation id may be a phone number or an e-mail. `open()` sends `POST /v1/history/open`, and the tool kit adds the bound customer to it, so the server opens only that customer's items.
235
+
214
236
  ### Business objects
215
237
 
216
238
  ```ts
@@ -229,7 +251,7 @@ if (upload) {
229
251
  }
230
252
  ```
231
253
 
232
- Media never travels inside an event. `uploadMedia()` takes a `Uint8Array`, `ArrayBuffer` or `Blob`, reserves an upload, sends the bytes straight to storage over a signed URL (HTTPS only, with exactly the headers the signature covers and nothing else, so never your key or default headers; storage checks the body against the declared size and digest), and resolves with the reference and digest for the event. With `subject`, the file is stored under that person, so erasing them erases it even if no event ever references it. Hashing uses Web Crypto, which Node 18 only exposes behind a flag.
254
+ Media never travels inside an event. `uploadMedia()` takes a `Uint8Array`, `ArrayBuffer` or `Blob`, reserves an upload, sends the bytes straight to storage over a signed URL (HTTPS only, with exactly the headers the signature covers and nothing else, so never your key or default headers; storage checks the body against the declared size and digest), and resolves with the reference and digest for the event. With `subject`, the file is stored under that person, so erasing them erases it even if no event ever references it. Hashing uses Web Crypto.
233
255
 
234
256
  ### Tools for any model
235
257
 
@@ -273,17 +295,17 @@ Memory should make an agent better, never make it fail. By default:
273
295
  | Queue full (10,000 items) | New events are dropped and logged. |
274
296
  | Server rejects one item of a batch (207) | Only that item fails; the rest are stored. |
275
297
 
276
- Every read has its own time budget, independent of your platform's:
298
+ Every call you wait for has its own time budget for the whole call, retries and waits included, independent of your platform's:
277
299
 
278
300
  | Call | Default |
279
301
  | --- | --- |
280
302
  | `context()` | 300 ms, 150 ms with `view: "voice"` |
281
303
  | `search()`, `timeline()`, `open()`, `objectState()`, `objectTimeline()` | 600 ms, 300 ms through voice conversations and voice-bound tools |
282
304
  | `subjectToken()` | 2 s |
283
- | Each attempt of a batch, `feedback()` or an upload reservation | 5 s |
284
- | Each attempt of an `uploadMedia()` transfer | 60 s |
305
+ | `identify()`, `verify()`, `handoff()`, `feedback()` and the reservation in `uploadMedia()` | 5 s |
306
+ | The transfer in `uploadMedia()` | 60 s |
285
307
 
286
- Override them with `timeouts`, or per call with `{ timeout }`. Pass `{ signal }` to cancel a call.
308
+ An `identify()`, `verify()` or `handoff()` that runs out of time resolves with a `NiadraTimeoutError` and stays in the queue, which keeps sending it. `track()` never waits; each attempt of a background batch has 5 s. Override them with `timeouts`, or per call with `{ timeout }`. Pass `{ signal }` to cancel a call.
287
309
 
288
310
  ### The context cache
289
311
 
@@ -310,7 +332,6 @@ Configure it with `cache: { ttlMs, staleWhileRevalidateMs, maxStaleMs, maxEntrie
310
332
 
311
333
  - **Long-running Node services:** queued events are flushed when the event loop runs out of work (`beforeExit`). That event does not fire on signals or `process.exit()`, so call `await niadra.shutdown()` in your SIGTERM handler.
312
334
  - **Serverless functions:** `await niadra.flush()` before returning.
313
- - **Edge runtimes:** pass the flush to the platform, for example `ctx.waitUntil(niadra.flush())`.
314
335
 
315
336
  Create one client per process and share it: it owns the queue and the cache.
316
337
 
@@ -322,7 +343,7 @@ new Niadra({
322
343
  baseURL: "http://localhost:4010", // default: NIADRA_BASE_URL, then derived from the key
323
344
  timeouts: { context: 300, contextVoice: 150, navigation: 600, navigationVoice: 300, write: 5000, token: 2000, upload: 60_000 },
324
345
  cache: { ttlMs: 10_000, staleWhileRevalidateMs: 600_000, maxStaleMs: 1_800_000, maxEntries: 1000 },
325
- queue: { flushAt: 15, flushIntervalMs: 1000, maxBatchSize: 100, maxQueueSize: 10_000, maxAttempts: 3 },
346
+ queue: { flushAt: 15, flushIntervalMs: 1000, turnFlushIntervalMs: 200, maxBatchSize: 100, maxQueueSize: 10_000, maxAttempts: 3 },
326
347
  strict: false,
327
348
  flushOnExit: true,
328
349
  logger: console, // anything with debug, warn and error
@@ -347,14 +368,10 @@ pnpm check # typecheck, lint, tests
347
368
  pnpm build # ESM and CommonJS into dist/
348
369
  ```
349
370
 
350
- ## Em português
371
+ ## Documentation in Portuguese
351
372
 
352
- A Niadra é a memória de clientes compartilhada por todos os agentes de IA de uma empresa: o agente
353
- do WhatsApp, o de voz, o de cobrança e o time humano leem a mesma memória antes de agir e registram
354
- o que disseram e fizeram. Este pacote conecta um agente em TypeScript ou JavaScript a essa memória:
355
- `context()` antes de chamar o modelo, `track()` depois, e `action()` quando o agente faz algo num
356
- sistema. Documentação em [niadra.com/docs](https://niadra.com/docs) e contato em
357
- [niadra.com/enterprise](https://niadra.com/enterprise).
373
+ The documentation is also available in Portuguese at [docs.niadra.com](https://docs.niadra.com),
374
+ and the contact page in Portuguese at [niadra.com/enterprise](https://niadra.com/enterprise).
358
375
 
359
376
  ## License
360
377
 
package/dist/index.cjs CHANGED
@@ -360,6 +360,15 @@ function checkCloses(closes) {
360
360
  const byObject = Boolean(closes.object && closes.operation);
361
361
  if (byId === byObject) fail("closes takes either item_id, or object and operation");
362
362
  }
363
+ function checkUsage(usage) {
364
+ if (!/^[a-z0-9][a-z0-9_.-]{0,63}$/.test(usage.provider)) fail("usage.provider must be lowercase, like `openai`");
365
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.:/@-]{0,127}$/.test(usage.model)) fail("usage.model is not a model name");
366
+ const counts = [usage.prompt_tokens, usage.cached_tokens ?? 0, usage.cache_write_tokens ?? 0];
367
+ if (!counts.every((n) => Number.isInteger(n) && n >= 0)) fail("usage counts are whole numbers from 0");
368
+ if ((usage.cached_tokens ?? 0) + (usage.cache_write_tokens ?? 0) > usage.prompt_tokens) {
369
+ fail("cached and written tokens are part of prompt_tokens");
370
+ }
371
+ }
363
372
  function checkContent(content) {
364
373
  if (!content) return;
365
374
  if ((content.text?.length ?? 0) > MAX_EVENT_TEXT) fail(`content.text is longer than ${MAX_EVENT_TEXT}`);
@@ -390,6 +399,10 @@ function buildEvent(input) {
390
399
  if ((input.action.result?.length ?? 0) > 2e3) fail("action.result is longer than 2000");
391
400
  checkCloses(input.action.closes);
392
401
  }
402
+ if (input.usage) {
403
+ if (kind !== "message" || speaker.role !== "ai_agent") fail("`usage` is only valid on a message of the `ai_agent`");
404
+ checkUsage(input.usage);
405
+ }
393
406
  const event = {
394
407
  type: "event",
395
408
  kind,
@@ -405,6 +418,7 @@ function buildEvent(input) {
405
418
  if (input.canonical_type) event.canonical_type = input.canonical_type;
406
419
  if (input.fields) event.fields = input.fields;
407
420
  if (input.action) event.action = input.action;
421
+ if (input.usage) event.usage = input.usage;
408
422
  copyOptional(event, input);
409
423
  assertSerializable(event);
410
424
  return event;
@@ -579,6 +593,76 @@ function withDeltas(result, response, deltas) {
579
593
  return { ...result, response: merged, suffix: renderSuffix(merged) };
580
594
  }
581
595
 
596
+ // src/usage.ts
597
+ var PROVIDER = /^[a-z0-9][a-z0-9_.-]{0,63}$/;
598
+ var MODEL = /^[A-Za-z0-9][A-Za-z0-9_.:/@-]{0,127}$/;
599
+ function get(value, name) {
600
+ return (typeof value === "object" || typeof value === "function") && value !== null ? value[name] : void 0;
601
+ }
602
+ function count(value) {
603
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
604
+ }
605
+ function tokenCounts(usage) {
606
+ const read = count(get(usage, "cache_read_input_tokens")) ?? 0;
607
+ const written = count(get(usage, "cache_creation_input_tokens")) ?? 0;
608
+ let prompt = count(get(usage, "prompt_tokens"));
609
+ let cached;
610
+ if (prompt !== null) {
611
+ const reported = count(get(get(usage, "prompt_tokens_details"), "cached_tokens"));
612
+ cached = reported !== null && reported > 0 ? reported : read;
613
+ } else {
614
+ const inputs = count(get(usage, "input_tokens"));
615
+ if (inputs === null) return null;
616
+ const details = get(usage, "input_tokens_details");
617
+ if (details !== void 0 && details !== null) {
618
+ prompt = inputs;
619
+ cached = count(get(details, "cached_tokens")) ?? 0;
620
+ } else {
621
+ prompt = inputs + read + written;
622
+ cached = read;
623
+ }
624
+ }
625
+ return { prompt_tokens: Math.max(prompt, cached + written), cached_tokens: cached, cache_write_tokens: written };
626
+ }
627
+ function providerOf(model, usage) {
628
+ const slash = model.indexOf("/");
629
+ if (slash > 0) return model.slice(0, slash).trim().toLowerCase();
630
+ const name = model.trim().toLowerCase();
631
+ if (name.startsWith("claude") || `.${name}`.includes(".anthropic.")) return "anthropic";
632
+ if (name.startsWith("gemini")) return "google";
633
+ if (get(usage, "prompt_tokens") === void 0 && get(usage, "cache_read_input_tokens") !== void 0) {
634
+ return "anthropic";
635
+ }
636
+ return "openai";
637
+ }
638
+ function modelUsage(response, options = {}) {
639
+ try {
640
+ let usage = get(response, "usage");
641
+ if (usage === void 0 || usage === null || typeof usage !== "object") usage = response;
642
+ const counts = tokenCounts(usage);
643
+ const name = options.model ?? get(response, "model");
644
+ if (!counts || typeof name !== "string" || !MODEL.test(name)) return null;
645
+ const provider = (options.provider ?? providerOf(name, usage)).toLowerCase();
646
+ if (!PROVIDER.test(provider)) return null;
647
+ return { provider, model: name, ...counts };
648
+ } catch {
649
+ return null;
650
+ }
651
+ }
652
+ function asModelUsage(value) {
653
+ if (value === void 0 || value === null) return null;
654
+ if (isModelUsage(value)) return value;
655
+ return modelUsage(value);
656
+ }
657
+ function isModelUsage(value) {
658
+ const provider = get(value, "provider");
659
+ const model = get(value, "model");
660
+ const prompt = count(get(value, "prompt_tokens"));
661
+ const cached = get(value, "cached_tokens") === void 0 ? 0 : count(get(value, "cached_tokens"));
662
+ const written = get(value, "cache_write_tokens") === void 0 ? 0 : count(get(value, "cache_write_tokens"));
663
+ return typeof provider === "string" && PROVIDER.test(provider) && typeof model === "string" && MODEL.test(model) && prompt !== null && cached !== null && written !== null && cached + written <= prompt;
664
+ }
665
+
582
666
  // src/conversation.ts
583
667
  var Conversation = class {
584
668
  constructor(client, params, hooks) {
@@ -738,6 +822,8 @@ var Conversation = class {
738
822
  if (options.visibility) event.visibility = options.visibility;
739
823
  if (options.voice) event.voice = options.voice;
740
824
  if (options.context_stamp) event.context_stamp = options.context_stamp;
825
+ const usage = role === "ai_agent" ? asModelUsage(options.usage) : null;
826
+ if (usage) event.usage = usage;
741
827
  return this.client.track(event);
742
828
  }
743
829
  };
@@ -881,6 +967,7 @@ var DEFAULT_CACHE = {
881
967
  var DEFAULT_QUEUE = {
882
968
  flushAt: 15,
883
969
  flushIntervalMs: 1e3,
970
+ turnFlushIntervalMs: 200,
884
971
  maxBatchSize: 100,
885
972
  maxQueueSize: 1e4,
886
973
  maxAttempts: 3,
@@ -904,6 +991,7 @@ var EventQueue = class {
904
991
  now;
905
992
  items = [];
906
993
  timer = null;
994
+ timerDueAt = Number.POSITIVE_INFINITY;
907
995
  tail = Promise.resolve();
908
996
  closed = false;
909
997
  droppedSinceWarning = 0;
@@ -928,7 +1016,8 @@ var EventQueue = class {
928
1016
  if (this.items.length >= this.options.flushAt) {
929
1017
  this.flushInBackground();
930
1018
  } else {
931
- this.schedule();
1019
+ const { flushIntervalMs, turnFlushIntervalMs } = this.options;
1020
+ this.schedule(isTurn(item) ? Math.min(turnFlushIntervalMs, flushIntervalMs) : flushIntervalMs);
932
1021
  }
933
1022
  return true;
934
1023
  }
@@ -1018,20 +1107,29 @@ var EventQueue = class {
1018
1107
  this.sentInWindow = 0;
1019
1108
  return item;
1020
1109
  }
1021
- schedule() {
1022
- if (this.timer !== null) return;
1110
+ /** Sends what is waiting in `delayMs`, unless a send is already due sooner. */
1111
+ schedule(delayMs) {
1112
+ const dueAt = this.now() + delayMs;
1113
+ if (this.timer !== null && this.timerDueAt <= dueAt) return;
1114
+ this.cancelTimer();
1115
+ this.timerDueAt = dueAt;
1023
1116
  this.timer = setTimeout(() => {
1024
1117
  this.timer = null;
1118
+ this.timerDueAt = Number.POSITIVE_INFINITY;
1025
1119
  this.flushInBackground();
1026
- }, this.options.flushIntervalMs);
1120
+ }, delayMs);
1027
1121
  unref(this.timer);
1028
1122
  }
1029
1123
  cancelTimer() {
1030
1124
  if (this.timer === null) return;
1031
1125
  clearTimeout(this.timer);
1032
1126
  this.timer = null;
1127
+ this.timerDueAt = Number.POSITIVE_INFINITY;
1033
1128
  }
1034
1129
  };
1130
+ function isTurn(item) {
1131
+ return item.type === "event" && item.kind === "message" && Boolean(item.conversation_id);
1132
+ }
1035
1133
  function unref(timer) {
1036
1134
  if (typeof timer === "object" && timer !== null && "unref" in timer && typeof timer.unref === "function") {
1037
1135
  timer.unref();
@@ -1108,6 +1206,8 @@ var Task = class {
1108
1206
  if (options.idempotency_key) event.idempotency_key = options.idempotency_key;
1109
1207
  if (options.occurred_at) event.occurred_at = options.occurred_at;
1110
1208
  if (options.visibility) event.visibility = options.visibility;
1209
+ const usage = asModelUsage(options.usage);
1210
+ if (usage) event.usage = usage;
1111
1211
  return this.track(event);
1112
1212
  }
1113
1213
  /**
@@ -1179,7 +1279,7 @@ var TOOL_NAMES = {
1179
1279
  timeline: "get_customer_timeline",
1180
1280
  open: "open_history_item"
1181
1281
  };
1182
- var ITEM_KINDS = ["episode", "fact", "open_item", "action", "system_event", "object", "trait"];
1282
+ var ITEM_KINDS = ["episode", "fact", "open_item", "action", "object", "trait"];
1183
1283
  var period = {
1184
1284
  since: { type: "string", format: "date-time", description: "Only items at or after this ISO 8601 time." },
1185
1285
  until: { type: "string", format: "date-time", description: "Only items before this ISO 8601 time." },
@@ -1199,7 +1299,7 @@ var TOOL_DEFINITIONS = [
1199
1299
  type: "function",
1200
1300
  function: {
1201
1301
  name: TOOL_NAMES.search,
1202
- description: "Search this customer's past conversations, actions and system events by meaning and keywords. Use it when the customer refers to something that happened before and the details are not in the customer context you already have. Do not use it for facts already listed there. The result also says how often the same kind of issue came back. To read one result in full, call open_history_item.",
1302
+ description: "Search this customer's past conversations, actions and business objects by meaning and keywords. Use it when the customer refers to something that happened before and the details are not in the customer context you already have. Do not use it for facts already listed there. The result also says how often the same kind of issue came back. To read one result in full, call open_history_item.",
1203
1303
  parameters: {
1204
1304
  type: "object",
1205
1305
  properties: {
@@ -1217,7 +1317,7 @@ var TOOL_DEFINITIONS = [
1217
1317
  type: "function",
1218
1318
  function: {
1219
1319
  name: TOOL_NAMES.timeline,
1220
- description: "List this customer's history in chronological order, one line per item. Use it when you need the sequence of events, for example what happened since a given date. Prefer search_customer_history when you are looking for something specific.",
1320
+ description: "List this customer's history, newest first, one line per item. Use it when you need the sequence of events, for example what happened since a given date. Prefer search_customer_history when you are looking for something specific.",
1221
1321
  parameters: {
1222
1322
  type: "object",
1223
1323
  properties: {
@@ -1311,7 +1411,7 @@ function bindTools(subject, binding, navigator, strict) {
1311
1411
  case TOOL_NAMES.open: {
1312
1412
  const id = str(args, "id");
1313
1413
  if (!id) throw new NiadraValidationError("open_history_item needs an id");
1314
- return navigator.open(id, binding, voice);
1414
+ return navigator.open(id, subject, binding, voice);
1315
1415
  }
1316
1416
  default:
1317
1417
  throw new NiadraValidationError(`unknown tool: ${name}`);
@@ -1350,7 +1450,7 @@ function cloneDefinition(definition) {
1350
1450
  }
1351
1451
 
1352
1452
  // src/version.ts
1353
- var VERSION = "0.1.0";
1453
+ var VERSION = "0.1.1";
1354
1454
 
1355
1455
  // src/transport.ts
1356
1456
  var RETRYABLE_WRITE_STATUS = /* @__PURE__ */ new Set([408, 421, 429, 500, 502, 503, 504]);
@@ -1403,13 +1503,17 @@ var Transport = class {
1403
1503
  );
1404
1504
  }
1405
1505
  async retrying(timeoutMs, policy, signal, attempt) {
1406
- for (let count = 1; ; count++) {
1407
- const deadline = new Deadline(timeoutMs, signal);
1506
+ const end = policy.totalMs === void 0 ? Number.POSITIVE_INFINITY : Date.now() + policy.totalMs;
1507
+ for (let count2 = 1; ; count2++) {
1508
+ const left = end - Date.now();
1509
+ if (left <= 0) throw new NiadraTimeoutError(policy.totalMs ?? timeoutMs);
1510
+ const deadline = new Deadline(Math.min(timeoutMs, left), signal);
1408
1511
  try {
1409
1512
  return await attempt(deadline);
1410
1513
  } catch (error) {
1411
- if (count >= policy.maxAttempts || !isTransient(error)) throw error;
1412
- const delay = retryDelay(error, count, policy.baseDelayMs, policy.maxDelayMs);
1514
+ if (count2 >= policy.maxAttempts || !isTransient(error)) throw error;
1515
+ const delay = retryDelay(error, count2, policy.baseDelayMs, policy.maxDelayMs);
1516
+ if (Date.now() + delay >= end) throw error;
1413
1517
  await sleep(delay, signal);
1414
1518
  } finally {
1415
1519
  deadline.clear();
@@ -1560,9 +1664,13 @@ function sleep(ms, signal) {
1560
1664
 
1561
1665
  // src/client.ts
1562
1666
  function readEnv(name) {
1563
- const env = globalThis.process?.env;
1564
- const value = env?.[name];
1565
- return value === "" ? void 0 : value;
1667
+ try {
1668
+ const env = globalThis.process?.env;
1669
+ const value = env?.[name];
1670
+ return value === "" ? void 0 : value;
1671
+ } catch {
1672
+ return void 0;
1673
+ }
1566
1674
  }
1567
1675
  function describe(error) {
1568
1676
  if (error instanceof NiadraAPIError) {
@@ -1702,7 +1810,7 @@ var Niadra = class {
1702
1810
  return this.readSpec("POST", "/v1/history/search", params, this.timeouts.navigation, options);
1703
1811
  });
1704
1812
  }
1705
- /** The customer's history in chronological order, one line per item, paginated by cursor. */
1813
+ /** The customer's history, newest first, one line per item, paginated by cursor. */
1706
1814
  async timeline(params, options = {}) {
1707
1815
  return this.navigate(
1708
1816
  () => this.readSpec("POST", "/v1/history/timeline", params, this.timeouts.navigation, options)
@@ -1710,20 +1818,17 @@ var Niadra = class {
1710
1818
  }
1711
1819
  /**
1712
1820
  * Opens one history item from `search()` or `timeline()`: summary, request, commitments,
1713
- * outcome and resolution. The literal transcript excerpt only comes back to keys with an
1714
- * elevated scope.
1821
+ * outcome and resolution. Sent as `POST /v1/history/open`: the conversation id and `subject` go
1822
+ * in the body, never in a URL.
1715
1823
  */
1716
1824
  async open(id, params = {}, options = {}) {
1717
1825
  return this.navigate(() => {
1718
1826
  if (!id) throw new NiadraValidationError("open() needs an item id");
1719
- const path = `/v1/history/items/${encodeURIComponent(id)}`;
1720
- const spec = this.readSpec("GET", path, void 0, this.timeouts.navigation, options);
1721
- spec.query = {
1722
- verification: params.verification,
1723
- conversation_id: params.conversation_id,
1724
- task_id: params.task_id
1725
- };
1726
- return spec;
1827
+ const body = { item_id: id };
1828
+ if (params.subject) body.subject = params.subject;
1829
+ if (params.verification) body.verification = params.verification;
1830
+ if (params.conversation_id) body.conversation_id = params.conversation_id;
1831
+ return this.readSpec("POST", "/v1/history/open", body, this.timeouts.navigation, options);
1727
1832
  });
1728
1833
  }
1729
1834
  /**
@@ -1764,11 +1869,10 @@ var Niadra = class {
1764
1869
  const navigator = {
1765
1870
  search: (params, voice) => this.search(params, this.voiceBudget(voice)),
1766
1871
  timeline: (params, voice) => this.timeline(params, this.voiceBudget(voice)),
1767
- open: (id, bound, voice) => {
1768
- const scope = {};
1872
+ open: (id, customer, bound, voice) => {
1873
+ const scope = { subject: customer };
1769
1874
  if (bound.verification) scope.verification = bound.verification;
1770
1875
  if (bound.conversation_id) scope.conversation_id = bound.conversation_id;
1771
- if (bound.task_id) scope.task_id = bound.task_id;
1772
1876
  return this.open(id, scope, this.voiceBudget(voice));
1773
1877
  }
1774
1878
  };
@@ -1831,7 +1935,7 @@ var Niadra = class {
1831
1935
  body: request,
1832
1936
  headers: { "idempotency-key": key },
1833
1937
  timeoutMs: this.timeouts.write,
1834
- retry: core.writes
1938
+ retry: { ...core.writes, totalMs: this.timeouts.write }
1835
1939
  });
1836
1940
  const [rejected] = response.data?.errors ?? [];
1837
1941
  if (rejected) {
@@ -1862,7 +1966,7 @@ var Niadra = class {
1862
1966
  path: "/v1/media/uploads",
1863
1967
  body: request,
1864
1968
  timeoutMs: this.timeouts.write,
1865
- retry: core.writes,
1969
+ retry: { ...core.writes, totalMs: this.timeouts.write },
1866
1970
  signal: options.signal
1867
1971
  });
1868
1972
  const media_ref = reserved.data?.media_ref;
@@ -1875,7 +1979,7 @@ var Niadra = class {
1875
1979
  body: bytes,
1876
1980
  headers: uploadHeaders(reserved.data?.upload_headers, request.content_type),
1877
1981
  timeoutMs: this.timeouts.upload,
1878
- retry: core.writes,
1982
+ retry: { ...core.writes, totalMs: this.timeouts.upload },
1879
1983
  signal: options.signal
1880
1984
  });
1881
1985
  }
@@ -2062,11 +2166,20 @@ var Niadra = class {
2062
2166
  }
2063
2167
  return new Promise((resolve, reject) => {
2064
2168
  const key = item.idempotency_key;
2065
- core.queue.push(item, (error) => {
2169
+ let settled = false;
2170
+ const timer = setTimeout(() => {
2171
+ this.logger.warn(`write not confirmed within ${this.timeouts.write} ms; it stays queued`);
2172
+ settle(new NiadraTimeoutError(this.timeouts.write));
2173
+ }, this.timeouts.write);
2174
+ const settle = (error) => {
2175
+ if (settled) return;
2176
+ settled = true;
2177
+ clearTimeout(timer);
2066
2178
  if (!error) resolve({ ok: true, idempotency_key: key, error: null });
2067
2179
  else if (this.strict) reject(error);
2068
2180
  else resolve({ ok: false, idempotency_key: key, error });
2069
- });
2181
+ };
2182
+ core.queue.push(item, settle);
2070
2183
  core.queue.flushInBackground();
2071
2184
  });
2072
2185
  }
@@ -2253,22 +2366,22 @@ var Answer = class {
2253
2366
  parsed(value) {
2254
2367
  try {
2255
2368
  if (!this.stream) {
2256
- this.record(messageText(value));
2369
+ this.record(messageText(value), modelUsage(value));
2257
2370
  return value;
2258
2371
  }
2259
- return isAsyncIterable(value) ? captureStream(value, (text) => {
2260
- this.record(text);
2372
+ return isAsyncIterable(value) ? captureStream(value, (text, usage) => {
2373
+ this.record(text, usage);
2261
2374
  }) : value;
2262
2375
  } catch (error) {
2263
2376
  this.session.logger.warn(`could not capture the model's answer (${errorName(error)})`);
2264
2377
  return value;
2265
2378
  }
2266
2379
  }
2267
- record(text) {
2380
+ record(text, usage) {
2268
2381
  if (this.recorded || !text) return;
2269
2382
  this.recorded = true;
2270
2383
  try {
2271
- this.session.agent(text);
2384
+ this.session.agent(text, usage ? { usage } : {});
2272
2385
  } catch (error) {
2273
2386
  this.session.logger.warn(`could not record the model's answer (${errorName(error)})`);
2274
2387
  }
@@ -2276,11 +2389,19 @@ var Answer = class {
2276
2389
  };
2277
2390
  function captureStream(stream, done) {
2278
2391
  const parts = [];
2392
+ let usage = null;
2393
+ let model;
2279
2394
  let finished = false;
2395
+ const observe = (chunk) => {
2396
+ const reported = property(chunk, "usage");
2397
+ if (isRecord(reported)) usage = reported;
2398
+ const name = property(chunk, "model");
2399
+ if (typeof name === "string" && name) model = name;
2400
+ };
2280
2401
  const finish = () => {
2281
2402
  if (finished) return;
2282
2403
  finished = true;
2283
- done(parts.join(""));
2404
+ done(parts.join(""), usage ? modelUsage(usage, model ? { model } : {}) : null);
2284
2405
  };
2285
2406
  return new Proxy(stream, {
2286
2407
  get(target, prop) {
@@ -2292,7 +2413,10 @@ function captureStream(stream, done) {
2292
2413
  try {
2293
2414
  const step = await inner.next();
2294
2415
  if (step.done) finish();
2295
- else parts.push(deltaText(step.value));
2416
+ else {
2417
+ parts.push(deltaText(step.value));
2418
+ observe(step.value);
2419
+ }
2296
2420
  return step;
2297
2421
  } catch (error) {
2298
2422
  finish();
@@ -2378,11 +2502,14 @@ exports.baseURLFromKey = baseURLFromKey;
2378
2502
  exports.consoleLogger = consoleLogger;
2379
2503
  exports.handles = handles;
2380
2504
  exports.injectContext = injectContext;
2505
+ exports.modelUsage = modelUsage;
2381
2506
  exports.parseApiKey = parseApiKey;
2507
+ exports.providerOf = providerOf;
2382
2508
  exports.renderLive = renderLive;
2383
2509
  exports.renderSuffix = renderSuffix;
2384
2510
  exports.silentLogger = silentLogger;
2385
2511
  exports.toObjectRef = toObjectRef;
2512
+ exports.tokenCounts = tokenCounts;
2386
2513
  exports.uuidv7 = uuidv7;
2387
2514
  exports.wrap = wrap;
2388
2515
  //# sourceMappingURL=index.cjs.map