@niadra/sdk 0.1.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/CHANGELOG.md +28 -0
- package/LICENSE +201 -0
- package/README.md +361 -0
- package/dist/index.cjs +2389 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1441 -0
- package/dist/index.d.ts +1441 -0
- package/dist/index.js +2355 -0
- package/dist/index.js.map +1 -0
- package/package.json +74 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,2389 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/cache.ts
|
|
4
|
+
var ContextCache = class {
|
|
5
|
+
constructor(options, now = Date.now) {
|
|
6
|
+
this.options = options;
|
|
7
|
+
this.now = now;
|
|
8
|
+
}
|
|
9
|
+
options;
|
|
10
|
+
now;
|
|
11
|
+
entries = /* @__PURE__ */ new Map();
|
|
12
|
+
inflight = /* @__PURE__ */ new Map();
|
|
13
|
+
epoch = 0;
|
|
14
|
+
get size() {
|
|
15
|
+
return this.entries.size;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Bumped by `clear()`. A request started before a purge carries the old epoch, and its
|
|
19
|
+
* answer is not stored: a revoked key must not repopulate the cache on its way out.
|
|
20
|
+
*/
|
|
21
|
+
get generation() {
|
|
22
|
+
return this.epoch;
|
|
23
|
+
}
|
|
24
|
+
lookup(key) {
|
|
25
|
+
const entry = this.entries.get(key);
|
|
26
|
+
if (!entry) return null;
|
|
27
|
+
const age = this.now() - entry.confirmedAt;
|
|
28
|
+
if (age > this.options.maxStaleMs) {
|
|
29
|
+
this.entries.delete(key);
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
this.entries.delete(key);
|
|
33
|
+
this.entries.set(key, entry);
|
|
34
|
+
let freshness = "expired";
|
|
35
|
+
if (age <= this.options.ttlMs) freshness = "fresh";
|
|
36
|
+
else if (age <= this.options.ttlMs + this.options.staleWhileRevalidateMs) freshness = "stale";
|
|
37
|
+
return { response: entry.response, freshness };
|
|
38
|
+
}
|
|
39
|
+
store(key, scope, response, generation = this.epoch) {
|
|
40
|
+
if (generation !== this.epoch) return;
|
|
41
|
+
const previous = this.entries.get(key);
|
|
42
|
+
const pending = previous?.response.etag === response.etag ? [...previous.pending] : [];
|
|
43
|
+
if (response.delta) pending.push(response.delta);
|
|
44
|
+
this.entries.delete(key);
|
|
45
|
+
this.entries.set(key, { response: { ...response, delta: null }, confirmedAt: this.now(), scope, pending });
|
|
46
|
+
while (this.entries.size > this.options.maxEntries) {
|
|
47
|
+
const oldest = this.entries.keys().next();
|
|
48
|
+
if (oldest.done) break;
|
|
49
|
+
this.entries.delete(oldest.value);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The deltas pending for `key`, joined, which then count as delivered: `null` when there are
|
|
54
|
+
* none, `undefined` when the key is not cached at all.
|
|
55
|
+
*/
|
|
56
|
+
take(key) {
|
|
57
|
+
const entry = this.entries.get(key);
|
|
58
|
+
if (!entry) return void 0;
|
|
59
|
+
const delta = entry.pending.join("\n\n") || null;
|
|
60
|
+
entry.pending = [];
|
|
61
|
+
return delta;
|
|
62
|
+
}
|
|
63
|
+
delete(key) {
|
|
64
|
+
this.entries.delete(key);
|
|
65
|
+
}
|
|
66
|
+
/** Drops every pack of one conversation or task. */
|
|
67
|
+
deleteScope(scope) {
|
|
68
|
+
for (const [key, entry] of this.entries) {
|
|
69
|
+
if (entry.scope === scope) this.entries.delete(key);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
clear() {
|
|
73
|
+
this.entries.clear();
|
|
74
|
+
this.epoch++;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Runs `load` for a key unless a request for the same key is already in flight, in which
|
|
78
|
+
* case every caller shares that one request. This is what keeps a burst of turns in one
|
|
79
|
+
* conversation, or a stale entry read by many callers, down to a single refresh.
|
|
80
|
+
*/
|
|
81
|
+
dedupe(key, load) {
|
|
82
|
+
const running = this.inflight.get(key);
|
|
83
|
+
if (running) return running;
|
|
84
|
+
const started = load().finally(() => {
|
|
85
|
+
this.inflight.delete(key);
|
|
86
|
+
});
|
|
87
|
+
this.inflight.set(key, started);
|
|
88
|
+
return started;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// src/errors.ts
|
|
93
|
+
var NiadraError = class extends Error {
|
|
94
|
+
name = "NiadraError";
|
|
95
|
+
};
|
|
96
|
+
var NiadraConfigError = class extends NiadraError {
|
|
97
|
+
name = "NiadraConfigError";
|
|
98
|
+
};
|
|
99
|
+
var NiadraValidationError = class extends NiadraError {
|
|
100
|
+
name = "NiadraValidationError";
|
|
101
|
+
};
|
|
102
|
+
var NiadraTimeoutError = class extends NiadraError {
|
|
103
|
+
constructor(timeoutMs) {
|
|
104
|
+
super(`request timed out after ${timeoutMs} ms`);
|
|
105
|
+
this.timeoutMs = timeoutMs;
|
|
106
|
+
}
|
|
107
|
+
timeoutMs;
|
|
108
|
+
name = "NiadraTimeoutError";
|
|
109
|
+
};
|
|
110
|
+
var NiadraConnectionError = class extends NiadraError {
|
|
111
|
+
name = "NiadraConnectionError";
|
|
112
|
+
};
|
|
113
|
+
var NiadraAbortError = class extends NiadraError {
|
|
114
|
+
name = "NiadraAbortError";
|
|
115
|
+
};
|
|
116
|
+
var NiadraAPIError = class extends NiadraError {
|
|
117
|
+
name = "NiadraAPIError";
|
|
118
|
+
status;
|
|
119
|
+
code;
|
|
120
|
+
requestId;
|
|
121
|
+
problem;
|
|
122
|
+
/** How long the server asked callers to wait, from `Retry-After`, when it said so. */
|
|
123
|
+
retryAfterMs;
|
|
124
|
+
constructor(status, problem, requestId, retryAfterMs = null) {
|
|
125
|
+
const code = problem?.code ?? `http_${status}`;
|
|
126
|
+
const detail = problem?.detail ? `: ${problem.detail}` : "";
|
|
127
|
+
super(`${status} ${code}${detail}`);
|
|
128
|
+
this.status = status;
|
|
129
|
+
this.code = code;
|
|
130
|
+
this.problem = problem;
|
|
131
|
+
this.requestId = requestId ?? problem?.request_id ?? null;
|
|
132
|
+
this.retryAfterMs = retryAfterMs;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
var NiadraAuthenticationError = class extends NiadraAPIError {
|
|
136
|
+
name = "NiadraAuthenticationError";
|
|
137
|
+
};
|
|
138
|
+
var NiadraPermissionError = class extends NiadraAPIError {
|
|
139
|
+
name = "NiadraPermissionError";
|
|
140
|
+
};
|
|
141
|
+
var NiadraRateLimitError = class extends NiadraAPIError {
|
|
142
|
+
name = "NiadraRateLimitError";
|
|
143
|
+
};
|
|
144
|
+
function apiErrorFor(status, problem, requestId, retryAfterMs) {
|
|
145
|
+
if (status === 401) return new NiadraAuthenticationError(status, problem, requestId, retryAfterMs);
|
|
146
|
+
if (status === 403) return new NiadraPermissionError(status, problem, requestId, retryAfterMs);
|
|
147
|
+
if (status === 429) return new NiadraRateLimitError(status, problem, requestId, retryAfterMs);
|
|
148
|
+
return new NiadraAPIError(status, problem, requestId, retryAfterMs);
|
|
149
|
+
}
|
|
150
|
+
function toNiadraError(error) {
|
|
151
|
+
if (error instanceof NiadraError) return error;
|
|
152
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
153
|
+
return new NiadraError(message, { cause: error });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// src/handles.ts
|
|
157
|
+
function build(type, value, scope, options) {
|
|
158
|
+
const handle = { type, value };
|
|
159
|
+
if (scope !== void 0) handle.scope = scope;
|
|
160
|
+
if (options?.subjectKind !== void 0) handle.subject_kind = options.subjectKind;
|
|
161
|
+
return handle;
|
|
162
|
+
}
|
|
163
|
+
var handles = {
|
|
164
|
+
/** A phone number in E.164 form, such as `+5511987654321`. */
|
|
165
|
+
phone: (e164, options) => build("phone_e164", e164, void 0, options),
|
|
166
|
+
email: (address, options) => build("email", address, void 0, options),
|
|
167
|
+
/** The WhatsApp id (`wa_id`) the Cloud API reports for a contact. */
|
|
168
|
+
waId: (value, options) => build("wa_id", value, void 0, options),
|
|
169
|
+
waJid: (value, options) => build("wa_jid", value, void 0, options),
|
|
170
|
+
waLid: (value, options) => build("wa_lid", value, void 0, options),
|
|
171
|
+
/** A business-scoped WhatsApp user id; `businessAccount` is the WhatsApp Business account it belongs to. */
|
|
172
|
+
waBsuid: (value, businessAccount, options) => build("wa_bsuid", value, businessAccount, options),
|
|
173
|
+
/** The user id in your own app or site. */
|
|
174
|
+
appUserId: (value, options) => build("app_user_id", value, void 0, options),
|
|
175
|
+
/** The id of a person or organization in a system of record; `system` names that system, such as `crm`. */
|
|
176
|
+
systemId: (value, system, options) => build("system_id", value, system, options),
|
|
177
|
+
/** An HMAC of a national document number; `country` is its ISO 3166-1 alpha-2 code. */
|
|
178
|
+
govIdHmac: (value, country, options) => build("gov_id_hmac", value, country, options),
|
|
179
|
+
/** An HMAC of a company registry number. Identifies an organization. */
|
|
180
|
+
orgRegistryHmac: (value, country) => build("org_registry_hmac", value, country, { subjectKind: "account" }),
|
|
181
|
+
/** An e-mail domain, such as `acme.com`. Identifies an organization. */
|
|
182
|
+
emailDomain: (domain) => build("email_domain", domain, void 0, { subjectKind: "account" }),
|
|
183
|
+
/** An anonymous visitor or device id, before the person is known. */
|
|
184
|
+
anonId: (value, options) => build("anon_id", value, void 0, options)
|
|
185
|
+
};
|
|
186
|
+
function toObjectRef(object) {
|
|
187
|
+
if (typeof object !== "string") return object;
|
|
188
|
+
const first = object.indexOf(":");
|
|
189
|
+
const second = first < 0 ? -1 : object.indexOf(":", first + 1);
|
|
190
|
+
if (first <= 0 || second <= first + 1 || second === object.length - 1) {
|
|
191
|
+
throw new NiadraValidationError("object must look like `type:namespace:id`");
|
|
192
|
+
}
|
|
193
|
+
return { type: object.slice(0, first), namespace: object.slice(first + 1, second), id: object.slice(second + 1) };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// src/context.ts
|
|
197
|
+
var VIEW = /^(voice|chat|brief|full|custom|account|partner|task:[a-z0-9_]{1,40})$/;
|
|
198
|
+
var MAX_QUERY = 2e3;
|
|
199
|
+
function buildContextRequest(params) {
|
|
200
|
+
if (params.subject === void 0 === (params.object === void 0)) {
|
|
201
|
+
throw new NiadraValidationError("pass exactly one of `subject` or `object`");
|
|
202
|
+
}
|
|
203
|
+
if (params.conversation_id && params.task_id) {
|
|
204
|
+
throw new NiadraValidationError("pass `conversation_id` or `task_id`, not both");
|
|
205
|
+
}
|
|
206
|
+
if (params.view !== void 0 && !VIEW.test(params.view)) {
|
|
207
|
+
throw new NiadraValidationError("unknown view; task views look like `task:billing`");
|
|
208
|
+
}
|
|
209
|
+
if (params.query !== void 0 && params.query.length > MAX_QUERY) {
|
|
210
|
+
throw new NiadraValidationError(`query is longer than ${MAX_QUERY} characters`);
|
|
211
|
+
}
|
|
212
|
+
const request = { view: params.view ?? "chat" };
|
|
213
|
+
if (params.subject) request.subject = params.subject;
|
|
214
|
+
if (params.object !== void 0) request.object = toObjectRef(params.object);
|
|
215
|
+
if (params.about) request.about = params.about;
|
|
216
|
+
if (params.verification) request.verification = params.verification;
|
|
217
|
+
if (params.conversation_id) request.conversation_id = params.conversation_id;
|
|
218
|
+
if (params.task_id) request.task_id = params.task_id;
|
|
219
|
+
if (params.query) request.query = params.query;
|
|
220
|
+
if (params.delta) request.delta = true;
|
|
221
|
+
if (params.target) request.target = params.target;
|
|
222
|
+
return request;
|
|
223
|
+
}
|
|
224
|
+
function cacheScope(request) {
|
|
225
|
+
if (request.conversation_id) return `conversation:${request.conversation_id}`;
|
|
226
|
+
if (request.task_id) return `task:${request.task_id}`;
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
function cacheKey(request) {
|
|
230
|
+
return JSON.stringify([
|
|
231
|
+
request.subject ?? null,
|
|
232
|
+
request.object ?? null,
|
|
233
|
+
request.about ?? null,
|
|
234
|
+
request.view ?? "chat",
|
|
235
|
+
request.verification ?? "V0",
|
|
236
|
+
request.conversation_id ?? null,
|
|
237
|
+
request.task_id ?? null,
|
|
238
|
+
request.query ?? null,
|
|
239
|
+
request.target ?? null
|
|
240
|
+
]);
|
|
241
|
+
}
|
|
242
|
+
function mergeNotModified(cached, fresh) {
|
|
243
|
+
return {
|
|
244
|
+
...fresh,
|
|
245
|
+
not_modified: false,
|
|
246
|
+
text: cached.text ?? null,
|
|
247
|
+
variables: cached.variables,
|
|
248
|
+
version: cached.version,
|
|
249
|
+
etag: cached.etag,
|
|
250
|
+
manifest_hash: cached.manifest_hash ?? null,
|
|
251
|
+
withheld: cached.withheld,
|
|
252
|
+
cache: cached.cache ?? null
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
function resultFrom(response, source, error = null) {
|
|
256
|
+
const text = response.path === "holdout" ? "" : response.text ?? "";
|
|
257
|
+
return { text, suffix: renderSuffix(response), variables: response.variables, source, response, error };
|
|
258
|
+
}
|
|
259
|
+
function emptyResult(error) {
|
|
260
|
+
return { text: "", suffix: "", variables: {}, source: "none", response: null, error };
|
|
261
|
+
}
|
|
262
|
+
function renderSuffix(response) {
|
|
263
|
+
if (response.path === "holdout") return "";
|
|
264
|
+
return [response.delta ?? "", renderLive(response)].filter(Boolean).join("\n\n");
|
|
265
|
+
}
|
|
266
|
+
function renderLive(response) {
|
|
267
|
+
if (response.path === "holdout" || response.live.length === 0) return "";
|
|
268
|
+
const complete = response.live_complete ? "" : ' complete="false"';
|
|
269
|
+
const lines = response.live.map(renderLiveTurn).join("\n");
|
|
270
|
+
return `<live_turns source="niadra"${complete}>
|
|
271
|
+
${lines}
|
|
272
|
+
</live_turns>`;
|
|
273
|
+
}
|
|
274
|
+
function renderLiveTurn(turn) {
|
|
275
|
+
return `[${utcSeconds(turn.at)}] ${turn.channel} \xB7 ${turn.speaker}: ${turn.text}`;
|
|
276
|
+
}
|
|
277
|
+
function utcSeconds(at) {
|
|
278
|
+
const ms = Date.parse(at);
|
|
279
|
+
return Number.isNaN(ms) ? at : `${new Date(ms).toISOString().slice(0, 19)}Z`;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// src/ids.ts
|
|
283
|
+
var lastMs = -1;
|
|
284
|
+
var counter = 0;
|
|
285
|
+
function randomBytes(length) {
|
|
286
|
+
const bytes = new Uint8Array(length);
|
|
287
|
+
const webCrypto = globalThis.crypto;
|
|
288
|
+
if (webCrypto?.getRandomValues) {
|
|
289
|
+
webCrypto.getRandomValues(bytes);
|
|
290
|
+
} else {
|
|
291
|
+
for (let i = 0; i < length; i++) bytes[i] = Math.floor(Math.random() * 256);
|
|
292
|
+
}
|
|
293
|
+
return bytes;
|
|
294
|
+
}
|
|
295
|
+
function uuidv7(now = Date.now()) {
|
|
296
|
+
let ms = now;
|
|
297
|
+
if (ms <= lastMs) {
|
|
298
|
+
ms = lastMs;
|
|
299
|
+
counter = counter + 1 & 4095;
|
|
300
|
+
if (counter === 0) ms += 1;
|
|
301
|
+
} else {
|
|
302
|
+
counter = (randomBytes(2)[0] ?? 0) & 127;
|
|
303
|
+
}
|
|
304
|
+
lastMs = ms;
|
|
305
|
+
const bytes = randomBytes(16);
|
|
306
|
+
const high = Math.floor(ms / 2 ** 16);
|
|
307
|
+
const low = ms % 2 ** 16;
|
|
308
|
+
bytes[0] = high >>> 24 & 255;
|
|
309
|
+
bytes[1] = high >>> 16 & 255;
|
|
310
|
+
bytes[2] = high >>> 8 & 255;
|
|
311
|
+
bytes[3] = high & 255;
|
|
312
|
+
bytes[4] = low >>> 8 & 255;
|
|
313
|
+
bytes[5] = low & 255;
|
|
314
|
+
bytes[6] = 112 | counter >>> 8 & 15;
|
|
315
|
+
bytes[7] = counter & 255;
|
|
316
|
+
bytes[8] = 128 | (bytes[8] ?? 0) & 63;
|
|
317
|
+
let hex = "";
|
|
318
|
+
for (const byte of bytes) hex += byte.toString(16).padStart(2, "0");
|
|
319
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// src/types/events.ts
|
|
323
|
+
var MAX_EVENT_TEXT = 2e5;
|
|
324
|
+
var MAX_BATCH_ITEMS = 500;
|
|
325
|
+
var MAX_MEDIA_BYTES = 500 * 1024 * 1024;
|
|
326
|
+
|
|
327
|
+
// src/items.ts
|
|
328
|
+
var FEEDBACK_ACTIONS = [
|
|
329
|
+
"retract_fact",
|
|
330
|
+
"correct_fact",
|
|
331
|
+
"resolve_open_item",
|
|
332
|
+
"conversation_outcome"
|
|
333
|
+
];
|
|
334
|
+
var SHORT = 256;
|
|
335
|
+
function fail(message) {
|
|
336
|
+
throw new NiadraValidationError(message);
|
|
337
|
+
}
|
|
338
|
+
function iso(value) {
|
|
339
|
+
if (value === void 0) return (/* @__PURE__ */ new Date()).toISOString();
|
|
340
|
+
if (value instanceof Date) {
|
|
341
|
+
if (Number.isNaN(value.getTime())) fail("invalid Date");
|
|
342
|
+
return value.toISOString();
|
|
343
|
+
}
|
|
344
|
+
if (Number.isNaN(Date.parse(value))) fail("timestamps must be ISO 8601");
|
|
345
|
+
return value;
|
|
346
|
+
}
|
|
347
|
+
function short(value, field) {
|
|
348
|
+
if (value === void 0 || value === null) return;
|
|
349
|
+
if (value.length === 0 || value.length > SHORT) fail(`${field} must be 1 to ${SHORT} characters`);
|
|
350
|
+
}
|
|
351
|
+
function limit(list, max, field) {
|
|
352
|
+
if (list && list.length > max) fail(`${field} takes at most ${max} entries`);
|
|
353
|
+
}
|
|
354
|
+
function speakerRef(speaker) {
|
|
355
|
+
return typeof speaker === "string" ? { role: speaker } : speaker;
|
|
356
|
+
}
|
|
357
|
+
function checkCloses(closes) {
|
|
358
|
+
if (!closes) return;
|
|
359
|
+
const byId = Boolean(closes.item_id);
|
|
360
|
+
const byObject = Boolean(closes.object && closes.operation);
|
|
361
|
+
if (byId === byObject) fail("closes takes either item_id, or object and operation");
|
|
362
|
+
}
|
|
363
|
+
function checkContent(content) {
|
|
364
|
+
if (!content) return;
|
|
365
|
+
if ((content.text?.length ?? 0) > MAX_EVENT_TEXT) fail(`content.text is longer than ${MAX_EVENT_TEXT}`);
|
|
366
|
+
if ((content.transcript?.length ?? 0) > MAX_EVENT_TEXT) {
|
|
367
|
+
fail(`content.transcript is longer than ${MAX_EVENT_TEXT}`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
function buildEvent(input) {
|
|
371
|
+
if (input.text !== void 0 && input.content) fail("pass `text` or `content`, not both");
|
|
372
|
+
const kind = input.kind ?? "message";
|
|
373
|
+
const speaker = speakerRef(input.speaker);
|
|
374
|
+
const content = input.text !== void 0 ? { type: "text", text: input.text } : input.content ?? null;
|
|
375
|
+
short(input.channel, "channel");
|
|
376
|
+
checkContent(content);
|
|
377
|
+
limit(input.handles, 16, "handles");
|
|
378
|
+
limit(input.subjects, 8, "subjects");
|
|
379
|
+
limit(input.object_refs, 16, "object_refs");
|
|
380
|
+
limit(input.conversation_aliases, 8, "conversation_aliases");
|
|
381
|
+
if (kind === "action" && !input.action) fail("an action event needs the `action` block");
|
|
382
|
+
if (kind !== "action" && input.action) fail("`action` is only valid when kind is `action`");
|
|
383
|
+
if (kind === "system_event" && !input.canonical_type) fail("a system event needs `canonical_type`");
|
|
384
|
+
if (kind === "message" && !(content?.text || content?.media_ref || content?.transcript)) {
|
|
385
|
+
fail("a message needs text, a transcript or a media reference");
|
|
386
|
+
}
|
|
387
|
+
if (!hasTarget(input)) fail("an event needs at least one handle, subject or object");
|
|
388
|
+
if (input.action) {
|
|
389
|
+
short(input.action.operation, "action.operation");
|
|
390
|
+
if ((input.action.result?.length ?? 0) > 2e3) fail("action.result is longer than 2000");
|
|
391
|
+
checkCloses(input.action.closes);
|
|
392
|
+
}
|
|
393
|
+
const event = {
|
|
394
|
+
type: "event",
|
|
395
|
+
kind,
|
|
396
|
+
idempotency_key: input.idempotency_key ?? uuidv7(),
|
|
397
|
+
channel: input.channel,
|
|
398
|
+
speaker,
|
|
399
|
+
occurred_at: iso(input.occurred_at)
|
|
400
|
+
};
|
|
401
|
+
if (content) event.content = content;
|
|
402
|
+
const direction = input.direction ?? (kind === "message" ? defaultDirection(speaker.role) : void 0);
|
|
403
|
+
if (direction) event.direction = direction;
|
|
404
|
+
if (input.object_refs) event.object_refs = input.object_refs.map(toObjectRef);
|
|
405
|
+
if (input.canonical_type) event.canonical_type = input.canonical_type;
|
|
406
|
+
if (input.fields) event.fields = input.fields;
|
|
407
|
+
if (input.action) event.action = input.action;
|
|
408
|
+
copyOptional(event, input);
|
|
409
|
+
assertSerializable(event);
|
|
410
|
+
return event;
|
|
411
|
+
}
|
|
412
|
+
function buildAction(input) {
|
|
413
|
+
const { operation, result, purpose, closes, corrects_action_id, speaker, ...base } = input;
|
|
414
|
+
const action = { operation };
|
|
415
|
+
if (result !== void 0) action.result = result;
|
|
416
|
+
if (purpose !== void 0) action.purpose = purpose;
|
|
417
|
+
if (closes !== void 0) action.closes = closes;
|
|
418
|
+
if (corrects_action_id !== void 0) action.corrects_action_id = corrects_action_id;
|
|
419
|
+
return buildEvent({ ...base, kind: "action", speaker: speaker ?? "ai_agent", action });
|
|
420
|
+
}
|
|
421
|
+
function buildIdentify(input) {
|
|
422
|
+
if (input.handles.length < 2 || input.handles.length > 16) fail("identify takes 2 to 16 handles");
|
|
423
|
+
const item = {
|
|
424
|
+
type: "identify",
|
|
425
|
+
idempotency_key: input.idempotency_key ?? uuidv7(),
|
|
426
|
+
handles: input.handles,
|
|
427
|
+
method: input.method ?? "explicit_identify",
|
|
428
|
+
subject_kind: input.subject_kind ?? "person",
|
|
429
|
+
occurred_at: iso(input.occurred_at)
|
|
430
|
+
};
|
|
431
|
+
if (input.conversation_id) item.conversation_id = input.conversation_id;
|
|
432
|
+
return item;
|
|
433
|
+
}
|
|
434
|
+
function buildVerify(input) {
|
|
435
|
+
if (!input.handle) fail("verify needs the handle whose possession was proven");
|
|
436
|
+
if (input.conversation_id && input.task_id) fail("pass `conversation_id` or `task_id`, not both");
|
|
437
|
+
const item = {
|
|
438
|
+
type: "verify",
|
|
439
|
+
idempotency_key: input.idempotency_key ?? uuidv7(),
|
|
440
|
+
method: input.method,
|
|
441
|
+
level: input.level,
|
|
442
|
+
handle: input.handle,
|
|
443
|
+
occurred_at: iso(input.occurred_at)
|
|
444
|
+
};
|
|
445
|
+
if (input.conversation_id) item.conversation_id = input.conversation_id;
|
|
446
|
+
if (input.task_id) item.task_id = input.task_id;
|
|
447
|
+
if (input.valid_until) item.valid_until = iso(input.valid_until);
|
|
448
|
+
return item;
|
|
449
|
+
}
|
|
450
|
+
function buildHandoff(input) {
|
|
451
|
+
short(input.conversation_id, "conversation_id");
|
|
452
|
+
const item = {
|
|
453
|
+
type: "handoff",
|
|
454
|
+
idempotency_key: input.idempotency_key ?? uuidv7(),
|
|
455
|
+
conversation_id: input.conversation_id,
|
|
456
|
+
target: input.target,
|
|
457
|
+
mode: input.mode ?? "warm",
|
|
458
|
+
occurred_at: iso(input.occurred_at)
|
|
459
|
+
};
|
|
460
|
+
if (input.target_source) item.target_source = input.target_source;
|
|
461
|
+
if (input.reason) item.reason = input.reason;
|
|
462
|
+
return item;
|
|
463
|
+
}
|
|
464
|
+
function buildFeedback(input) {
|
|
465
|
+
if (!FEEDBACK_ACTIONS.includes(input.action)) fail(`action must be one of ${FEEDBACK_ACTIONS.join(", ")}`);
|
|
466
|
+
if ((input.value?.length ?? 0) > 2e3) fail("value is longer than 2000");
|
|
467
|
+
if ((input.reason?.length ?? 0) > 500) fail("reason is longer than 500");
|
|
468
|
+
const request = {
|
|
469
|
+
idempotency_key: input.idempotency_key ?? uuidv7(),
|
|
470
|
+
subject: input.subject,
|
|
471
|
+
action: input.action
|
|
472
|
+
};
|
|
473
|
+
if (input.fact_id) request.fact_id = input.fact_id;
|
|
474
|
+
if (input.open_item_id) request.open_item_id = input.open_item_id;
|
|
475
|
+
if (input.conversation_id) request.conversation_id = input.conversation_id;
|
|
476
|
+
if (input.value) request.value = input.value;
|
|
477
|
+
if (input.reason) request.reason = input.reason;
|
|
478
|
+
return request;
|
|
479
|
+
}
|
|
480
|
+
function buildConversationEnded(conversationId) {
|
|
481
|
+
return {
|
|
482
|
+
type: "conversation.ended",
|
|
483
|
+
idempotency_key: uuidv7(),
|
|
484
|
+
conversation_id: conversationId,
|
|
485
|
+
occurred_at: iso(void 0)
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
function buildTaskEnded(taskId) {
|
|
489
|
+
return { type: "task.ended", idempotency_key: uuidv7(), task_id: taskId, occurred_at: iso(void 0) };
|
|
490
|
+
}
|
|
491
|
+
function hasTarget(event) {
|
|
492
|
+
return [event.handles, event.subjects, event.object_refs].some((list) => (list?.length ?? 0) > 0);
|
|
493
|
+
}
|
|
494
|
+
function defaultDirection(role) {
|
|
495
|
+
if (role === "customer") return "inbound";
|
|
496
|
+
if (role === "ai_agent" || role === "human_agent") return "outbound";
|
|
497
|
+
return void 0;
|
|
498
|
+
}
|
|
499
|
+
function copyOptional(event, input) {
|
|
500
|
+
if (input.conversation_id) event.conversation_id = input.conversation_id;
|
|
501
|
+
if (input.conversation_aliases?.length) event.conversation_aliases = input.conversation_aliases;
|
|
502
|
+
if (input.task_id) event.task_id = input.task_id;
|
|
503
|
+
if (input.handles?.length) event.handles = input.handles;
|
|
504
|
+
if (input.subjects?.length) event.subjects = input.subjects;
|
|
505
|
+
if (input.visibility) event.visibility = input.visibility;
|
|
506
|
+
if (input.verification_hint) event.verification_hint = input.verification_hint;
|
|
507
|
+
if (input.corrects_event_id) event.corrects_event_id = input.corrects_event_id;
|
|
508
|
+
if (input.voice) event.voice = input.voice;
|
|
509
|
+
if (input.context_stamp) {
|
|
510
|
+
const { etag, injected_at } = input.context_stamp;
|
|
511
|
+
event.context_stamp = etag ? { etag, injected_at: iso(injected_at) } : { injected_at: iso(injected_at) };
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
function assertSerializable(event) {
|
|
515
|
+
try {
|
|
516
|
+
JSON.stringify(event);
|
|
517
|
+
} catch {
|
|
518
|
+
fail("event is not JSON-serializable (check `fields` for BigInt or circular values)");
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
var SessionState = class {
|
|
522
|
+
etag = null;
|
|
523
|
+
deltas = [];
|
|
524
|
+
stamp = null;
|
|
525
|
+
firstInjection = null;
|
|
526
|
+
firstAgentTurn = null;
|
|
527
|
+
last = null;
|
|
528
|
+
/** After the first pack, every read also asks what changed since. */
|
|
529
|
+
get wantsDelta() {
|
|
530
|
+
return this.etag !== null;
|
|
531
|
+
}
|
|
532
|
+
get lastContext() {
|
|
533
|
+
return this.last;
|
|
534
|
+
}
|
|
535
|
+
get contextStamp() {
|
|
536
|
+
return this.stamp;
|
|
537
|
+
}
|
|
538
|
+
get timings() {
|
|
539
|
+
return { contextInjectedAt: this.firstInjection, firstAgentTurnAt: this.firstAgentTurn };
|
|
540
|
+
}
|
|
541
|
+
/** Folds one `context()` result into the session and returns it with every delta since the pin. */
|
|
542
|
+
absorb(result) {
|
|
543
|
+
const response = result.response;
|
|
544
|
+
if (result.source === "none" || !response) {
|
|
545
|
+
this.reset();
|
|
546
|
+
} else if (response.etag !== this.etag) {
|
|
547
|
+
this.etag = response.etag;
|
|
548
|
+
this.deltas = [];
|
|
549
|
+
} else if (response.delta && !this.deltas.includes(response.delta)) {
|
|
550
|
+
this.deltas = [...this.deltas, response.delta].slice(-20);
|
|
551
|
+
}
|
|
552
|
+
const absorbed = response ? withDeltas(result, response, this.deltas) : result;
|
|
553
|
+
this.last = absorbed;
|
|
554
|
+
return absorbed;
|
|
555
|
+
}
|
|
556
|
+
/** Starts over from the next pack the server pins, as after a raised verification level. */
|
|
557
|
+
reset() {
|
|
558
|
+
this.etag = null;
|
|
559
|
+
this.deltas = [];
|
|
560
|
+
}
|
|
561
|
+
markInjected(context, at) {
|
|
562
|
+
const etag = (context ?? this.last)?.response?.etag;
|
|
563
|
+
this.firstInjection ??= at;
|
|
564
|
+
this.stamp = etag ? { etag, injected_at: at.toISOString() } : { injected_at: at.toISOString() };
|
|
565
|
+
}
|
|
566
|
+
/** Notes an agent turn and returns the stamp it carries, if context went in before it. */
|
|
567
|
+
agentTurn() {
|
|
568
|
+
this.firstAgentTurn ??= /* @__PURE__ */ new Date();
|
|
569
|
+
return this.stamp;
|
|
570
|
+
}
|
|
571
|
+
/** The stamp for an action, which only the AI agent took on the injected context. */
|
|
572
|
+
actionStamp(speaker) {
|
|
573
|
+
const role = typeof speaker === "string" ? speaker : speaker?.role ?? "ai_agent";
|
|
574
|
+
return role === "ai_agent" ? this.stamp : null;
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
function withDeltas(result, response, deltas) {
|
|
578
|
+
const merged = { ...response, delta: deltas.join("\n\n") || null };
|
|
579
|
+
return { ...result, response: merged, suffix: renderSuffix(merged) };
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// src/conversation.ts
|
|
583
|
+
var Conversation = class {
|
|
584
|
+
constructor(client, params, hooks) {
|
|
585
|
+
this.client = client;
|
|
586
|
+
this.params = params;
|
|
587
|
+
this.hooks = hooks;
|
|
588
|
+
this.id = params.conversation_id ?? uuidv7();
|
|
589
|
+
this.channel = params.channel;
|
|
590
|
+
this.subject = params.subject;
|
|
591
|
+
this.level = params.verification ?? "V0";
|
|
592
|
+
this.view = params.view ?? (params.channel === "voice" ? "voice" : "chat");
|
|
593
|
+
}
|
|
594
|
+
client;
|
|
595
|
+
params;
|
|
596
|
+
hooks;
|
|
597
|
+
id;
|
|
598
|
+
channel;
|
|
599
|
+
subject;
|
|
600
|
+
level;
|
|
601
|
+
view;
|
|
602
|
+
state = new SessionState();
|
|
603
|
+
ending = null;
|
|
604
|
+
/** The level in force for this conversation, raised by a successful `verify()`. */
|
|
605
|
+
get verification() {
|
|
606
|
+
return this.level;
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* When context first went into the prompt, and when the agent first spoke. Context injected
|
|
610
|
+
* after the agent's first turn is the "late context" signal the usage measurement reports.
|
|
611
|
+
*/
|
|
612
|
+
get timings() {
|
|
613
|
+
return this.state.timings;
|
|
614
|
+
}
|
|
615
|
+
/** What the agent's next turn and action carry: the last `markInjected()`, or `null`. */
|
|
616
|
+
get contextStamp() {
|
|
617
|
+
return this.state.contextStamp;
|
|
618
|
+
}
|
|
619
|
+
/** The last result `context()` returned for this conversation. */
|
|
620
|
+
get lastContext() {
|
|
621
|
+
return this.state.lastContext;
|
|
622
|
+
}
|
|
623
|
+
/** Where `wrap()` reports what it swallowed: the client's logger. */
|
|
624
|
+
get logger() {
|
|
625
|
+
return this.client.logger;
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* The pack for this turn: the pinned `text`, and a `suffix` with every delta since the pin
|
|
629
|
+
* and the current live turns. A read with `query` is compiled for that query and never
|
|
630
|
+
* pinned, so it leaves the conversation's deltas alone.
|
|
631
|
+
*/
|
|
632
|
+
async context(options = {}) {
|
|
633
|
+
const { query, ...requestOptions } = options;
|
|
634
|
+
const params = {
|
|
635
|
+
subject: this.subject,
|
|
636
|
+
view: this.view,
|
|
637
|
+
verification: this.level,
|
|
638
|
+
conversation_id: this.id,
|
|
639
|
+
...this.params.about ? { about: this.params.about } : {},
|
|
640
|
+
...this.params.target ? { target: this.params.target } : {}
|
|
641
|
+
};
|
|
642
|
+
if (query) return this.client.context({ ...params, query }, requestOptions);
|
|
643
|
+
if (this.state.wantsDelta) params.delta = true;
|
|
644
|
+
return this.state.absorb(await this.client.context(params, requestOptions));
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Records that `context` (by default the last one this conversation returned) went into the
|
|
648
|
+
* prompt. Call it each time you build the prompt; `timings.contextInjectedAt` keeps the first.
|
|
649
|
+
*/
|
|
650
|
+
markInjected(context, at = /* @__PURE__ */ new Date()) {
|
|
651
|
+
this.state.markInjected(context, at);
|
|
652
|
+
}
|
|
653
|
+
/** Captures what the customer said. */
|
|
654
|
+
customer(text, options = {}) {
|
|
655
|
+
return this.turn("customer", text, options);
|
|
656
|
+
}
|
|
657
|
+
/** Captures what the AI agent said, stamped with the context its prompt carried. */
|
|
658
|
+
agent(text, options = {}) {
|
|
659
|
+
const stamp = this.state.agentTurn();
|
|
660
|
+
return this.turn("ai_agent", text, stamp ? { context_stamp: stamp, ...options } : options);
|
|
661
|
+
}
|
|
662
|
+
/** Captures what a human attendant said, for example after a handoff. */
|
|
663
|
+
human(text, options = {}) {
|
|
664
|
+
return this.turn("human_agent", text, options);
|
|
665
|
+
}
|
|
666
|
+
/** Records any event in this conversation. The customer's handle is attached unless you pass your own. */
|
|
667
|
+
track(event) {
|
|
668
|
+
return this.client.track({ ...this.bind(event), ...event, channel: event.channel ?? this.channel });
|
|
669
|
+
}
|
|
670
|
+
/** Records an action the agent took during this conversation, stamped like its turns. */
|
|
671
|
+
action(event) {
|
|
672
|
+
const stamp = this.state.actionStamp(event.speaker);
|
|
673
|
+
return this.client.action({
|
|
674
|
+
...this.bind(event),
|
|
675
|
+
...stamp ? { context_stamp: stamp } : {},
|
|
676
|
+
...event,
|
|
677
|
+
channel: event.channel ?? this.channel
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Records that the customer proved who they are, and raises the level for later reads.
|
|
682
|
+
* `handle` defaults to the conversation's subject.
|
|
683
|
+
*/
|
|
684
|
+
async verify(params) {
|
|
685
|
+
const result = await this.client.verify({
|
|
686
|
+
method: params.method,
|
|
687
|
+
level: params.level,
|
|
688
|
+
handle: params.handle ?? this.subject,
|
|
689
|
+
conversation_id: this.id
|
|
690
|
+
});
|
|
691
|
+
if (result.ok) {
|
|
692
|
+
this.level = params.level;
|
|
693
|
+
this.state.reset();
|
|
694
|
+
}
|
|
695
|
+
return result;
|
|
696
|
+
}
|
|
697
|
+
/** Records a transfer to a human or another agent. */
|
|
698
|
+
handoff(params) {
|
|
699
|
+
return this.client.handoff({ ...params, conversation_id: this.id });
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* The navigation kit bound to this customer and conversation. The verification level is
|
|
703
|
+
* read at each call, so tools created before a `verify()` pick up the new level.
|
|
704
|
+
*/
|
|
705
|
+
tools() {
|
|
706
|
+
const read = () => this.level;
|
|
707
|
+
const binding = {
|
|
708
|
+
conversation_id: this.id,
|
|
709
|
+
voice: this.view === "voice",
|
|
710
|
+
get verification() {
|
|
711
|
+
return read();
|
|
712
|
+
}
|
|
713
|
+
};
|
|
714
|
+
if (this.params.about) binding.about = this.params.about;
|
|
715
|
+
return this.client.tools(this.subject, binding);
|
|
716
|
+
}
|
|
717
|
+
/** Emits `conversation.ended` and drops the conversation's cached packs. Safe to call twice. */
|
|
718
|
+
end() {
|
|
719
|
+
this.ending ??= this.hooks.endConversation(this.id);
|
|
720
|
+
return this.ending;
|
|
721
|
+
}
|
|
722
|
+
bind(event) {
|
|
723
|
+
const own = hasTarget(event);
|
|
724
|
+
return own ? { conversation_id: this.id } : { conversation_id: this.id, handles: [this.subject] };
|
|
725
|
+
}
|
|
726
|
+
turn(role, text, options) {
|
|
727
|
+
const transcript = options.stt_confidence !== void 0;
|
|
728
|
+
const content = transcript ? { type: "audio", transcript: text, stt_confidence: options.stt_confidence ?? null } : { type: "text", text };
|
|
729
|
+
const event = {
|
|
730
|
+
channel: this.channel,
|
|
731
|
+
conversation_id: this.id,
|
|
732
|
+
handles: [this.subject],
|
|
733
|
+
speaker: options.speaker_id ? { role, id: options.speaker_id } : { role },
|
|
734
|
+
content
|
|
735
|
+
};
|
|
736
|
+
if (options.idempotency_key) event.idempotency_key = options.idempotency_key;
|
|
737
|
+
if (options.occurred_at) event.occurred_at = options.occurred_at;
|
|
738
|
+
if (options.visibility) event.visibility = options.visibility;
|
|
739
|
+
if (options.voice) event.voice = options.voice;
|
|
740
|
+
if (options.context_stamp) event.context_stamp = options.context_stamp;
|
|
741
|
+
return this.client.track(event);
|
|
742
|
+
}
|
|
743
|
+
};
|
|
744
|
+
|
|
745
|
+
// src/exit.ts
|
|
746
|
+
var registered = /* @__PURE__ */ new Set();
|
|
747
|
+
var installedOn = null;
|
|
748
|
+
function nodeProcess() {
|
|
749
|
+
const candidate = globalThis.process;
|
|
750
|
+
if (!candidate?.versions?.node) return null;
|
|
751
|
+
if (typeof candidate.on !== "function" || typeof candidate.removeListener !== "function") return null;
|
|
752
|
+
return candidate;
|
|
753
|
+
}
|
|
754
|
+
function onBeforeExit() {
|
|
755
|
+
for (const ref of registered) {
|
|
756
|
+
const client = ref.deref();
|
|
757
|
+
if (client) {
|
|
758
|
+
client.flush().catch(() => void 0);
|
|
759
|
+
} else {
|
|
760
|
+
registered.delete(ref);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
function registerExitFlush(client) {
|
|
765
|
+
const proc = nodeProcess();
|
|
766
|
+
if (!proc || typeof WeakRef === "undefined") return () => void 0;
|
|
767
|
+
const ref = new WeakRef(client);
|
|
768
|
+
registered.add(ref);
|
|
769
|
+
if (!installedOn) {
|
|
770
|
+
proc.on("beforeExit", onBeforeExit);
|
|
771
|
+
installedOn = proc;
|
|
772
|
+
}
|
|
773
|
+
return () => {
|
|
774
|
+
registered.delete(ref);
|
|
775
|
+
if (registered.size === 0 && installedOn) {
|
|
776
|
+
installedOn.removeListener("beforeExit", onBeforeExit);
|
|
777
|
+
installedOn = null;
|
|
778
|
+
}
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// src/media.ts
|
|
783
|
+
async function prepareUpload(params) {
|
|
784
|
+
if (!params.content_type || params.content_type.length > 256) {
|
|
785
|
+
throw new NiadraValidationError("content_type must be 1 to 256 characters");
|
|
786
|
+
}
|
|
787
|
+
const bytes = await readBytes(params.data);
|
|
788
|
+
if (bytes.byteLength === 0 || bytes.byteLength > MAX_MEDIA_BYTES) {
|
|
789
|
+
throw new NiadraValidationError(`media must be 1 byte to ${MAX_MEDIA_BYTES} bytes`);
|
|
790
|
+
}
|
|
791
|
+
const request = {
|
|
792
|
+
content_type: params.content_type,
|
|
793
|
+
size_bytes: bytes.byteLength,
|
|
794
|
+
sha256: await sha256Hex(bytes)
|
|
795
|
+
};
|
|
796
|
+
if (params.subject) request.subject = params.subject;
|
|
797
|
+
return { bytes, request };
|
|
798
|
+
}
|
|
799
|
+
function checkUploadURL(url, baseURL) {
|
|
800
|
+
const protocol = new URL(url).protocol;
|
|
801
|
+
if (protocol === "https:" || protocol === "http:" && new URL(baseURL).protocol === "http:") return;
|
|
802
|
+
throw new NiadraValidationError("the upload URL is not HTTPS");
|
|
803
|
+
}
|
|
804
|
+
async function readBytes(data) {
|
|
805
|
+
if (data instanceof ArrayBuffer) return new Uint8Array(data);
|
|
806
|
+
if (data instanceof Uint8Array) {
|
|
807
|
+
return isPlain(data) ? data : new Uint8Array(data);
|
|
808
|
+
}
|
|
809
|
+
return new Uint8Array(await data.arrayBuffer());
|
|
810
|
+
}
|
|
811
|
+
function isPlain(view) {
|
|
812
|
+
return view.buffer instanceof ArrayBuffer;
|
|
813
|
+
}
|
|
814
|
+
async function sha256Hex(bytes) {
|
|
815
|
+
const subtle = globalThis.crypto?.subtle;
|
|
816
|
+
if (!subtle) throw new NiadraValidationError("uploadMedia() needs Web Crypto, which this runtime lacks");
|
|
817
|
+
const digest = new Uint8Array(await subtle.digest("SHA-256", bytes));
|
|
818
|
+
let hex = "";
|
|
819
|
+
for (const byte of digest) hex += byte.toString(16).padStart(2, "0");
|
|
820
|
+
return hex;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// src/objects.ts
|
|
824
|
+
function objectPath(object) {
|
|
825
|
+
const ref = toObjectRef(object);
|
|
826
|
+
const segments = [ref.type, ref.namespace, ref.id];
|
|
827
|
+
if (segments.some((segment) => !segment || segment.includes("/"))) {
|
|
828
|
+
throw new NiadraValidationError("object type, namespace and id must be non-empty and cannot contain a slash");
|
|
829
|
+
}
|
|
830
|
+
return `/v1/objects/${segments.map(encodeURIComponent).join("/")}`;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// src/key.ts
|
|
834
|
+
var PREFIX = "nia_sk_";
|
|
835
|
+
var DNS_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
836
|
+
var KEY_ID = /^[A-Za-z0-9]+$/;
|
|
837
|
+
function parseApiKey(apiKey) {
|
|
838
|
+
if (!apiKey.startsWith(PREFIX)) return null;
|
|
839
|
+
const parts = apiKey.slice(PREFIX.length).split("_");
|
|
840
|
+
if (parts.length < 5) return null;
|
|
841
|
+
const [mode, region, space, keyId] = parts;
|
|
842
|
+
const secret = parts.slice(4).join("_");
|
|
843
|
+
if (mode !== "live" && mode !== "test") return null;
|
|
844
|
+
if (!DNS_LABEL.test(region) || !DNS_LABEL.test(space)) return null;
|
|
845
|
+
if (!KEY_ID.test(keyId) || secret.length === 0) return null;
|
|
846
|
+
return { mode, region, space, keyId };
|
|
847
|
+
}
|
|
848
|
+
function baseURLFromKey(key) {
|
|
849
|
+
return `https://${key.space}.${key.region}.api.niadra.com`;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// src/logger.ts
|
|
853
|
+
var noop = () => void 0;
|
|
854
|
+
var consoleLogger = {
|
|
855
|
+
debug: noop,
|
|
856
|
+
warn: (message, ...details) => {
|
|
857
|
+
console.warn(`[niadra] ${message}`, ...details);
|
|
858
|
+
},
|
|
859
|
+
error: (message, ...details) => {
|
|
860
|
+
console.error(`[niadra] ${message}`, ...details);
|
|
861
|
+
}
|
|
862
|
+
};
|
|
863
|
+
var silentLogger = { debug: noop, warn: noop, error: noop };
|
|
864
|
+
|
|
865
|
+
// src/options.ts
|
|
866
|
+
var DEFAULT_TIMEOUTS = {
|
|
867
|
+
context: 300,
|
|
868
|
+
contextVoice: 150,
|
|
869
|
+
navigation: 600,
|
|
870
|
+
navigationVoice: 300,
|
|
871
|
+
write: 5e3,
|
|
872
|
+
token: 2e3,
|
|
873
|
+
upload: 6e4
|
|
874
|
+
};
|
|
875
|
+
var DEFAULT_CACHE = {
|
|
876
|
+
ttlMs: 1e4,
|
|
877
|
+
staleWhileRevalidateMs: 10 * 6e4,
|
|
878
|
+
maxStaleMs: 30 * 6e4,
|
|
879
|
+
maxEntries: 1e3
|
|
880
|
+
};
|
|
881
|
+
var DEFAULT_QUEUE = {
|
|
882
|
+
flushAt: 15,
|
|
883
|
+
flushIntervalMs: 1e3,
|
|
884
|
+
maxBatchSize: 100,
|
|
885
|
+
maxQueueSize: 1e4,
|
|
886
|
+
maxAttempts: 3,
|
|
887
|
+
retryDelayMs: 250,
|
|
888
|
+
maxRetryDelayMs: 5e3
|
|
889
|
+
};
|
|
890
|
+
|
|
891
|
+
// src/queue.ts
|
|
892
|
+
var HEARTBEAT_WINDOW_MS = 6e4;
|
|
893
|
+
var EventQueue = class {
|
|
894
|
+
constructor(send, options, logger, now = Date.now) {
|
|
895
|
+
this.send = send;
|
|
896
|
+
this.options = options;
|
|
897
|
+
this.logger = logger;
|
|
898
|
+
this.now = now;
|
|
899
|
+
this.windowStart = now();
|
|
900
|
+
}
|
|
901
|
+
send;
|
|
902
|
+
options;
|
|
903
|
+
logger;
|
|
904
|
+
now;
|
|
905
|
+
items = [];
|
|
906
|
+
timer = null;
|
|
907
|
+
tail = Promise.resolve();
|
|
908
|
+
closed = false;
|
|
909
|
+
droppedSinceWarning = 0;
|
|
910
|
+
windowStart;
|
|
911
|
+
sentInWindow = 0;
|
|
912
|
+
/** Items waiting to be sent, not counting a batch already in flight. */
|
|
913
|
+
get size() {
|
|
914
|
+
return this.items.length;
|
|
915
|
+
}
|
|
916
|
+
/** Queues one item. Returns `false`, and settles it with an error, when the queue is full or closed. */
|
|
917
|
+
push(item, settle) {
|
|
918
|
+
if (this.closed || this.items.length >= this.options.maxQueueSize) {
|
|
919
|
+
this.droppedSinceWarning++;
|
|
920
|
+
if (this.droppedSinceWarning === 1) {
|
|
921
|
+
this.logger.warn(this.closed ? "client is shut down; dropping events" : "event queue is full; dropping events");
|
|
922
|
+
}
|
|
923
|
+
settle?.(new NiadraError(this.closed ? "client is shut down" : "event queue is full"));
|
|
924
|
+
return false;
|
|
925
|
+
}
|
|
926
|
+
this.droppedSinceWarning = 0;
|
|
927
|
+
this.items.push({ item, settle });
|
|
928
|
+
if (this.items.length >= this.options.flushAt) {
|
|
929
|
+
this.flushInBackground();
|
|
930
|
+
} else {
|
|
931
|
+
this.schedule();
|
|
932
|
+
}
|
|
933
|
+
return true;
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Sends everything queued so far and resolves when it is done. Concurrent calls are
|
|
937
|
+
* serialized behind the batch already in flight rather than sending in parallel.
|
|
938
|
+
*/
|
|
939
|
+
flush() {
|
|
940
|
+
this.cancelTimer();
|
|
941
|
+
const run = this.tail.then(() => this.drain());
|
|
942
|
+
this.tail = run.catch(() => void 0);
|
|
943
|
+
return run;
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* Starts a flush without waiting for it. Item failures are logged and settled inside the
|
|
947
|
+
* drain, so only an unexpected error reaches this catch.
|
|
948
|
+
*/
|
|
949
|
+
flushInBackground() {
|
|
950
|
+
this.flush().catch((error) => {
|
|
951
|
+
this.logger.error(`event flush failed: ${toNiadraError(error).message}`);
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
/** Flushes, then refuses new items. */
|
|
955
|
+
async close() {
|
|
956
|
+
const report = await this.flush();
|
|
957
|
+
this.closed = true;
|
|
958
|
+
this.cancelTimer();
|
|
959
|
+
return report;
|
|
960
|
+
}
|
|
961
|
+
async drain() {
|
|
962
|
+
const report = { sent: 0, failed: 0, errors: [] };
|
|
963
|
+
while (this.items.length > 0) {
|
|
964
|
+
const batch = this.items.splice(0, this.options.maxBatchSize);
|
|
965
|
+
const heartbeat = this.heartbeat();
|
|
966
|
+
const wire = batch.map((pending) => pending.item);
|
|
967
|
+
if (heartbeat) wire.push(heartbeat);
|
|
968
|
+
await this.sendBatch(batch, wire, report);
|
|
969
|
+
}
|
|
970
|
+
return report;
|
|
971
|
+
}
|
|
972
|
+
async sendBatch(batch, wire, report) {
|
|
973
|
+
let response;
|
|
974
|
+
try {
|
|
975
|
+
response = await this.send(wire);
|
|
976
|
+
} catch (error) {
|
|
977
|
+
const failure = toNiadraError(error);
|
|
978
|
+
report.failed += batch.length;
|
|
979
|
+
report.errors.push(failure);
|
|
980
|
+
this.logger.warn(`dropped ${batch.length} events after retries: ${failure.message}`);
|
|
981
|
+
for (const pending of batch) pending.settle?.(failure);
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
const rejected = /* @__PURE__ */ new Map();
|
|
985
|
+
for (const itemError of response.errors) {
|
|
986
|
+
const detail = itemError.detail ? `: ${itemError.detail}` : "";
|
|
987
|
+
rejected.set(itemError.index, new NiadraValidationError(`${itemError.code}${detail}`));
|
|
988
|
+
}
|
|
989
|
+
if (rejected.size > 0) {
|
|
990
|
+
const codes = [...new Set(response.errors.map((e) => e.code))].join(", ");
|
|
991
|
+
this.logger.warn(`server rejected ${rejected.size} of ${wire.length} items (${codes})`);
|
|
992
|
+
}
|
|
993
|
+
batch.forEach((pending, index) => {
|
|
994
|
+
const error = rejected.get(index) ?? null;
|
|
995
|
+
if (error) {
|
|
996
|
+
report.failed++;
|
|
997
|
+
report.errors.push(error);
|
|
998
|
+
} else {
|
|
999
|
+
report.sent++;
|
|
1000
|
+
this.sentInWindow++;
|
|
1001
|
+
}
|
|
1002
|
+
pending.settle?.(error);
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* Once a minute, the next batch carries the number of items sent in the previous window.
|
|
1007
|
+
* The server uses it to tell a source that went quiet from one whose events are being lost.
|
|
1008
|
+
*/
|
|
1009
|
+
heartbeat() {
|
|
1010
|
+
const now = this.now();
|
|
1011
|
+
if (now - this.windowStart < HEARTBEAT_WINDOW_MS) return null;
|
|
1012
|
+
const item = {
|
|
1013
|
+
type: "heartbeat",
|
|
1014
|
+
window_start: new Date(this.windowStart).toISOString(),
|
|
1015
|
+
sent: this.sentInWindow
|
|
1016
|
+
};
|
|
1017
|
+
this.windowStart = now;
|
|
1018
|
+
this.sentInWindow = 0;
|
|
1019
|
+
return item;
|
|
1020
|
+
}
|
|
1021
|
+
schedule() {
|
|
1022
|
+
if (this.timer !== null) return;
|
|
1023
|
+
this.timer = setTimeout(() => {
|
|
1024
|
+
this.timer = null;
|
|
1025
|
+
this.flushInBackground();
|
|
1026
|
+
}, this.options.flushIntervalMs);
|
|
1027
|
+
unref(this.timer);
|
|
1028
|
+
}
|
|
1029
|
+
cancelTimer() {
|
|
1030
|
+
if (this.timer === null) return;
|
|
1031
|
+
clearTimeout(this.timer);
|
|
1032
|
+
this.timer = null;
|
|
1033
|
+
}
|
|
1034
|
+
};
|
|
1035
|
+
function unref(timer) {
|
|
1036
|
+
if (typeof timer === "object" && timer !== null && "unref" in timer && typeof timer.unref === "function") {
|
|
1037
|
+
timer.unref();
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// src/task.ts
|
|
1042
|
+
var Task = class {
|
|
1043
|
+
constructor(client, params, hooks) {
|
|
1044
|
+
this.client = client;
|
|
1045
|
+
this.params = params;
|
|
1046
|
+
this.hooks = hooks;
|
|
1047
|
+
this.id = params.task_id ?? uuidv7();
|
|
1048
|
+
this.object = params.object ?? null;
|
|
1049
|
+
this.level = params.verification;
|
|
1050
|
+
}
|
|
1051
|
+
client;
|
|
1052
|
+
params;
|
|
1053
|
+
hooks;
|
|
1054
|
+
id;
|
|
1055
|
+
object;
|
|
1056
|
+
level;
|
|
1057
|
+
state = new SessionState();
|
|
1058
|
+
ending = null;
|
|
1059
|
+
/** When context first went into the prompt, and when the agent first acted. */
|
|
1060
|
+
get timings() {
|
|
1061
|
+
return this.state.timings;
|
|
1062
|
+
}
|
|
1063
|
+
/** What the agent's next turn and action carry: the last `markInjected()`, or `null`. */
|
|
1064
|
+
get contextStamp() {
|
|
1065
|
+
return this.state.contextStamp;
|
|
1066
|
+
}
|
|
1067
|
+
/** The last result `context()` returned for this task. */
|
|
1068
|
+
get lastContext() {
|
|
1069
|
+
return this.state.lastContext;
|
|
1070
|
+
}
|
|
1071
|
+
/** Where `wrap()` reports what it swallowed: the client's logger. */
|
|
1072
|
+
get logger() {
|
|
1073
|
+
return this.client.logger;
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* Context for the task, centered on its object when it has one, otherwise on its subject.
|
|
1077
|
+
* Resolves with an empty result, never rejects, unless the client is strict.
|
|
1078
|
+
*/
|
|
1079
|
+
async context(options = {}) {
|
|
1080
|
+
const { query, ...requestOptions } = options;
|
|
1081
|
+
const target = this.object ? { object: this.object } : this.params.subject ? { subject: this.params.subject } : {};
|
|
1082
|
+
const params = {
|
|
1083
|
+
...target,
|
|
1084
|
+
view: this.params.view ?? "brief",
|
|
1085
|
+
task_id: this.id,
|
|
1086
|
+
...this.params.about ? { about: this.params.about } : {},
|
|
1087
|
+
...this.level ? { verification: this.level } : {},
|
|
1088
|
+
...this.params.target ? { target: this.params.target } : {}
|
|
1089
|
+
};
|
|
1090
|
+
if (query) return this.client.context({ ...params, query }, requestOptions);
|
|
1091
|
+
if (this.state.wantsDelta) params.delta = true;
|
|
1092
|
+
return this.state.absorb(await this.client.context(params, requestOptions));
|
|
1093
|
+
}
|
|
1094
|
+
/** Records that `context` (by default the last one this task returned) went into the prompt. */
|
|
1095
|
+
markInjected(context, at = /* @__PURE__ */ new Date()) {
|
|
1096
|
+
this.state.markInjected(context, at);
|
|
1097
|
+
}
|
|
1098
|
+
/** Captures what the agent answered, stamped with the context its prompt carried. */
|
|
1099
|
+
agent(text, options = {}) {
|
|
1100
|
+
const stamp = this.state.agentTurn();
|
|
1101
|
+
const event = {
|
|
1102
|
+
...this.bind({}),
|
|
1103
|
+
speaker: options.speaker_id ? { role: "ai_agent", id: options.speaker_id } : "ai_agent",
|
|
1104
|
+
text
|
|
1105
|
+
};
|
|
1106
|
+
const context_stamp = options.context_stamp ?? stamp;
|
|
1107
|
+
if (context_stamp) event.context_stamp = context_stamp;
|
|
1108
|
+
if (options.idempotency_key) event.idempotency_key = options.idempotency_key;
|
|
1109
|
+
if (options.occurred_at) event.occurred_at = options.occurred_at;
|
|
1110
|
+
if (options.visibility) event.visibility = options.visibility;
|
|
1111
|
+
return this.track(event);
|
|
1112
|
+
}
|
|
1113
|
+
/**
|
|
1114
|
+
* Records that the person the task is about proved who they are, and reads at the new level
|
|
1115
|
+
* from then on. `handle` defaults to the task's subject.
|
|
1116
|
+
*/
|
|
1117
|
+
async verify(params) {
|
|
1118
|
+
const handle = params.handle ?? this.params.subject;
|
|
1119
|
+
const result = await this.hooks.verifyTask({ method: params.method, level: params.level, handle, task_id: this.id });
|
|
1120
|
+
if (result.ok) {
|
|
1121
|
+
this.level = params.level;
|
|
1122
|
+
this.state.reset();
|
|
1123
|
+
}
|
|
1124
|
+
return result;
|
|
1125
|
+
}
|
|
1126
|
+
/** Records an event in this task, attaching the task's subject and object unless you pass your own. */
|
|
1127
|
+
track(event) {
|
|
1128
|
+
return this.client.track({ ...this.bind(event), ...event, channel: event.channel ?? this.params.channel });
|
|
1129
|
+
}
|
|
1130
|
+
/** Records an action taken in a system of record, such as `credit` on an invoice, stamped like its turns. */
|
|
1131
|
+
action(event) {
|
|
1132
|
+
const stamp = this.state.actionStamp(event.speaker);
|
|
1133
|
+
return this.client.action({
|
|
1134
|
+
...this.bind(event),
|
|
1135
|
+
...stamp ? { context_stamp: stamp } : {},
|
|
1136
|
+
...event,
|
|
1137
|
+
channel: event.channel ?? this.params.channel
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
/**
|
|
1141
|
+
* The navigation kit bound to the task's subject, or `null` for a task about an object only.
|
|
1142
|
+
* The verification level is read at each call, so tools created before a `verify()` pick up
|
|
1143
|
+
* the new level.
|
|
1144
|
+
*/
|
|
1145
|
+
tools() {
|
|
1146
|
+
const subject = this.params.subject;
|
|
1147
|
+
if (!subject) return null;
|
|
1148
|
+
const read = () => this.level ?? "V0";
|
|
1149
|
+
const binding = {
|
|
1150
|
+
task_id: this.id,
|
|
1151
|
+
voice: this.params.view === "voice",
|
|
1152
|
+
get verification() {
|
|
1153
|
+
return read();
|
|
1154
|
+
}
|
|
1155
|
+
};
|
|
1156
|
+
if (this.params.about) binding.about = this.params.about;
|
|
1157
|
+
return this.client.tools(subject, binding);
|
|
1158
|
+
}
|
|
1159
|
+
/** Emits `task.ended` and drops the task's cached packs. Safe to call twice. */
|
|
1160
|
+
end() {
|
|
1161
|
+
this.ending ??= this.hooks.endTask(this.id);
|
|
1162
|
+
return this.ending;
|
|
1163
|
+
}
|
|
1164
|
+
bind(event) {
|
|
1165
|
+
const own = hasTarget(event);
|
|
1166
|
+
const bound = {
|
|
1167
|
+
task_id: this.id
|
|
1168
|
+
};
|
|
1169
|
+
if (own) return bound;
|
|
1170
|
+
if (this.params.subject) bound.handles = [this.params.subject];
|
|
1171
|
+
if (this.object) bound.object_refs = [this.object];
|
|
1172
|
+
return bound;
|
|
1173
|
+
}
|
|
1174
|
+
};
|
|
1175
|
+
|
|
1176
|
+
// src/tools.ts
|
|
1177
|
+
var TOOL_NAMES = {
|
|
1178
|
+
search: "search_customer_history",
|
|
1179
|
+
timeline: "get_customer_timeline",
|
|
1180
|
+
open: "open_history_item"
|
|
1181
|
+
};
|
|
1182
|
+
var ITEM_KINDS = ["episode", "fact", "open_item", "action", "system_event", "object", "trait"];
|
|
1183
|
+
var period = {
|
|
1184
|
+
since: { type: "string", format: "date-time", description: "Only items at or after this ISO 8601 time." },
|
|
1185
|
+
until: { type: "string", format: "date-time", description: "Only items before this ISO 8601 time." },
|
|
1186
|
+
channels: {
|
|
1187
|
+
type: "array",
|
|
1188
|
+
items: { type: "string" },
|
|
1189
|
+
description: "Only these channels, such as whatsapp, voice, email."
|
|
1190
|
+
},
|
|
1191
|
+
item_kinds: {
|
|
1192
|
+
type: "array",
|
|
1193
|
+
items: { type: "string", enum: ITEM_KINDS },
|
|
1194
|
+
description: "Only these kinds of history items."
|
|
1195
|
+
}
|
|
1196
|
+
};
|
|
1197
|
+
var TOOL_DEFINITIONS = [
|
|
1198
|
+
{
|
|
1199
|
+
type: "function",
|
|
1200
|
+
function: {
|
|
1201
|
+
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.",
|
|
1203
|
+
parameters: {
|
|
1204
|
+
type: "object",
|
|
1205
|
+
properties: {
|
|
1206
|
+
query: { type: "string", description: "What to look for, in the customer's own terms." },
|
|
1207
|
+
...period,
|
|
1208
|
+
categories: { type: "array", items: { type: "string" }, description: "Only these topics." },
|
|
1209
|
+
outcome: { type: "string", description: "Only items with this outcome, such as resolved." }
|
|
1210
|
+
},
|
|
1211
|
+
required: ["query"],
|
|
1212
|
+
additionalProperties: false
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
},
|
|
1216
|
+
{
|
|
1217
|
+
type: "function",
|
|
1218
|
+
function: {
|
|
1219
|
+
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.",
|
|
1221
|
+
parameters: {
|
|
1222
|
+
type: "object",
|
|
1223
|
+
properties: {
|
|
1224
|
+
...period,
|
|
1225
|
+
limit: { type: "integer", minimum: 1, maximum: 100, description: "How many items. Defaults to 20." },
|
|
1226
|
+
cursor: { type: "string", description: "The next_cursor of a previous page." }
|
|
1227
|
+
},
|
|
1228
|
+
additionalProperties: false
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
},
|
|
1232
|
+
{
|
|
1233
|
+
type: "function",
|
|
1234
|
+
function: {
|
|
1235
|
+
name: TOOL_NAMES.open,
|
|
1236
|
+
description: "Open one history item returned by search_customer_history or get_customer_timeline: its summary, what was requested, commitments made by either side, the outcome and the resolution. Only use ids returned by those tools.",
|
|
1237
|
+
parameters: {
|
|
1238
|
+
type: "object",
|
|
1239
|
+
properties: { id: { type: "string", description: "The item id." } },
|
|
1240
|
+
required: ["id"],
|
|
1241
|
+
additionalProperties: false
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
];
|
|
1246
|
+
function parseArgs(args) {
|
|
1247
|
+
if (typeof args !== "string") return args;
|
|
1248
|
+
if (args.trim() === "") return {};
|
|
1249
|
+
let parsed;
|
|
1250
|
+
try {
|
|
1251
|
+
parsed = JSON.parse(args);
|
|
1252
|
+
} catch {
|
|
1253
|
+
throw new NiadraValidationError("tool arguments are not valid JSON");
|
|
1254
|
+
}
|
|
1255
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
1256
|
+
throw new NiadraValidationError("tool arguments must be a JSON object");
|
|
1257
|
+
}
|
|
1258
|
+
return parsed;
|
|
1259
|
+
}
|
|
1260
|
+
function str(args, name) {
|
|
1261
|
+
const value = args[name];
|
|
1262
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
1263
|
+
}
|
|
1264
|
+
function strings(args, name) {
|
|
1265
|
+
const value = args[name];
|
|
1266
|
+
if (!Array.isArray(value)) return void 0;
|
|
1267
|
+
const list = value.filter((entry) => typeof entry === "string");
|
|
1268
|
+
return list.length > 0 ? list : void 0;
|
|
1269
|
+
}
|
|
1270
|
+
function filtersFrom(args) {
|
|
1271
|
+
const filters = {};
|
|
1272
|
+
const since = str(args, "since");
|
|
1273
|
+
const until = str(args, "until");
|
|
1274
|
+
const channels = strings(args, "channels");
|
|
1275
|
+
const categories = strings(args, "categories");
|
|
1276
|
+
const kinds = strings(args, "item_kinds")?.filter(
|
|
1277
|
+
(kind) => ITEM_KINDS.includes(kind)
|
|
1278
|
+
);
|
|
1279
|
+
const outcome = str(args, "outcome");
|
|
1280
|
+
if (since) filters.since = since;
|
|
1281
|
+
if (until) filters.until = until;
|
|
1282
|
+
if (channels) filters.channels = channels;
|
|
1283
|
+
if (categories) filters.categories = categories;
|
|
1284
|
+
if (kinds?.length) filters.item_kinds = kinds;
|
|
1285
|
+
if (outcome) filters.outcome = outcome;
|
|
1286
|
+
return filters;
|
|
1287
|
+
}
|
|
1288
|
+
function bindTools(subject, binding, navigator, strict) {
|
|
1289
|
+
const voice = binding.voice ?? false;
|
|
1290
|
+
const names = new Set(Object.values(TOOL_NAMES));
|
|
1291
|
+
async function run(name, args) {
|
|
1292
|
+
switch (name) {
|
|
1293
|
+
case TOOL_NAMES.search: {
|
|
1294
|
+
const query = str(args, "query");
|
|
1295
|
+
if (!query) throw new NiadraValidationError("search_customer_history needs a query");
|
|
1296
|
+
const request = { subject, query, filters: filtersFrom(args) };
|
|
1297
|
+
applyBinding(request, binding);
|
|
1298
|
+
if (binding.task_id) request.task_id = binding.task_id;
|
|
1299
|
+
const result = await navigator.search(request, voice);
|
|
1300
|
+
return result.error ? result : { data: compactSearch(result.data), error: null };
|
|
1301
|
+
}
|
|
1302
|
+
case TOOL_NAMES.timeline: {
|
|
1303
|
+
const request = { subject, filters: filtersFrom(args) };
|
|
1304
|
+
applyBinding(request, binding);
|
|
1305
|
+
const cursor = str(args, "cursor");
|
|
1306
|
+
if (cursor) request.cursor = cursor;
|
|
1307
|
+
const limit2 = args.limit;
|
|
1308
|
+
if (typeof limit2 === "number" && Number.isInteger(limit2)) request.limit = Math.min(100, Math.max(1, limit2));
|
|
1309
|
+
return navigator.timeline(request, voice);
|
|
1310
|
+
}
|
|
1311
|
+
case TOOL_NAMES.open: {
|
|
1312
|
+
const id = str(args, "id");
|
|
1313
|
+
if (!id) throw new NiadraValidationError("open_history_item needs an id");
|
|
1314
|
+
return navigator.open(id, binding, voice);
|
|
1315
|
+
}
|
|
1316
|
+
default:
|
|
1317
|
+
throw new NiadraValidationError(`unknown tool: ${name}`);
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
return {
|
|
1321
|
+
definitions: TOOL_DEFINITIONS.map((definition) => cloneDefinition(definition)),
|
|
1322
|
+
has: (name) => names.has(name),
|
|
1323
|
+
async call(name, rawArgs) {
|
|
1324
|
+
let result;
|
|
1325
|
+
try {
|
|
1326
|
+
result = await run(name, parseArgs(rawArgs));
|
|
1327
|
+
} catch (error) {
|
|
1328
|
+
if (strict) throw error;
|
|
1329
|
+
return JSON.stringify({ error: "invalid_call", detail: error.message });
|
|
1330
|
+
}
|
|
1331
|
+
if (result.error) {
|
|
1332
|
+
if (strict) throw result.error;
|
|
1333
|
+
return JSON.stringify({ error: "unavailable", detail: "customer history is unavailable right now" });
|
|
1334
|
+
}
|
|
1335
|
+
return JSON.stringify(result.data);
|
|
1336
|
+
}
|
|
1337
|
+
};
|
|
1338
|
+
}
|
|
1339
|
+
function applyBinding(request, binding) {
|
|
1340
|
+
if (binding.about) request.about = binding.about;
|
|
1341
|
+
if (binding.verification) request.verification = binding.verification;
|
|
1342
|
+
if (binding.conversation_id) request.conversation_id = binding.conversation_id;
|
|
1343
|
+
}
|
|
1344
|
+
function compactSearch(response) {
|
|
1345
|
+
const { tokens_used: _, ...rest } = response;
|
|
1346
|
+
return rest;
|
|
1347
|
+
}
|
|
1348
|
+
function cloneDefinition(definition) {
|
|
1349
|
+
return JSON.parse(JSON.stringify(definition));
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// src/version.ts
|
|
1353
|
+
var VERSION = "0.1.0";
|
|
1354
|
+
|
|
1355
|
+
// src/transport.ts
|
|
1356
|
+
var RETRYABLE_WRITE_STATUS = /* @__PURE__ */ new Set([408, 421, 429, 500, 502, 503, 504]);
|
|
1357
|
+
var READ_421_ATTEMPTS = 3;
|
|
1358
|
+
var READ_POLICY = { kind: "read", maxAttempts: READ_421_ATTEMPTS };
|
|
1359
|
+
var Transport = class {
|
|
1360
|
+
constructor(config) {
|
|
1361
|
+
this.config = config;
|
|
1362
|
+
this.baseURL = config.baseURL.replace(/\/+$/, "");
|
|
1363
|
+
}
|
|
1364
|
+
config;
|
|
1365
|
+
baseURL;
|
|
1366
|
+
async request(spec) {
|
|
1367
|
+
return spec.retry.kind === "read" ? this.read(spec, spec.retry) : this.write(spec, spec.retry);
|
|
1368
|
+
}
|
|
1369
|
+
async read(spec, policy) {
|
|
1370
|
+
const deadline = new Deadline(spec.timeoutMs, spec.signal);
|
|
1371
|
+
try {
|
|
1372
|
+
for (let attempt = 1; ; attempt++) {
|
|
1373
|
+
try {
|
|
1374
|
+
return await this.send(spec, deadline);
|
|
1375
|
+
} catch (error) {
|
|
1376
|
+
if (isStatus(error, 421) && attempt < policy.maxAttempts && !deadline.expired) continue;
|
|
1377
|
+
throw error;
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
} finally {
|
|
1381
|
+
deadline.clear();
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
async write(spec, policy) {
|
|
1385
|
+
return this.retrying(spec.timeoutMs, policy, spec.signal, (deadline) => this.send(spec, deadline));
|
|
1386
|
+
}
|
|
1387
|
+
/**
|
|
1388
|
+
* Sends media bytes to the signed URL `POST /v1/media/uploads` returned, with the write retry
|
|
1389
|
+
* rules. Only the headers the reservation named go along: the signature covers them (content
|
|
1390
|
+
* type, and the digest the store checks the body against), and the key must never reach a
|
|
1391
|
+
* host other than the API.
|
|
1392
|
+
*/
|
|
1393
|
+
async upload(spec) {
|
|
1394
|
+
await this.retrying(
|
|
1395
|
+
spec.timeoutMs,
|
|
1396
|
+
spec.retry,
|
|
1397
|
+
spec.signal,
|
|
1398
|
+
(deadline) => this.exchange(
|
|
1399
|
+
spec.url,
|
|
1400
|
+
{ method: "PUT", headers: spec.headers, body: spec.body, signal: deadline.signal },
|
|
1401
|
+
deadline
|
|
1402
|
+
)
|
|
1403
|
+
);
|
|
1404
|
+
}
|
|
1405
|
+
async retrying(timeoutMs, policy, signal, attempt) {
|
|
1406
|
+
for (let count = 1; ; count++) {
|
|
1407
|
+
const deadline = new Deadline(timeoutMs, signal);
|
|
1408
|
+
try {
|
|
1409
|
+
return await attempt(deadline);
|
|
1410
|
+
} catch (error) {
|
|
1411
|
+
if (count >= policy.maxAttempts || !isTransient(error)) throw error;
|
|
1412
|
+
const delay = retryDelay(error, count, policy.baseDelayMs, policy.maxDelayMs);
|
|
1413
|
+
await sleep(delay, signal);
|
|
1414
|
+
} finally {
|
|
1415
|
+
deadline.clear();
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
async send(spec, deadline) {
|
|
1420
|
+
const headers = {
|
|
1421
|
+
accept: "application/json, application/problem+json",
|
|
1422
|
+
authorization: `Bearer ${this.config.apiKey}`,
|
|
1423
|
+
"x-niadra-sdk": `js/${VERSION}`,
|
|
1424
|
+
...this.config.defaultHeaders,
|
|
1425
|
+
...spec.headers
|
|
1426
|
+
};
|
|
1427
|
+
const init = { method: spec.method, headers, signal: deadline.signal };
|
|
1428
|
+
if (spec.body !== void 0) {
|
|
1429
|
+
headers["content-type"] = "application/json";
|
|
1430
|
+
init.body = JSON.stringify(spec.body);
|
|
1431
|
+
}
|
|
1432
|
+
return this.exchange(this.url(spec.path, spec.query), init, deadline);
|
|
1433
|
+
}
|
|
1434
|
+
async exchange(url, init, deadline) {
|
|
1435
|
+
let response;
|
|
1436
|
+
try {
|
|
1437
|
+
response = await this.config.fetch(url, init);
|
|
1438
|
+
} catch (error) {
|
|
1439
|
+
throw deadline.explain(error);
|
|
1440
|
+
}
|
|
1441
|
+
const requestId = response.headers.get("x-request-id");
|
|
1442
|
+
let payload;
|
|
1443
|
+
try {
|
|
1444
|
+
payload = await readBody(response);
|
|
1445
|
+
} catch (error) {
|
|
1446
|
+
throw deadline.explain(error);
|
|
1447
|
+
}
|
|
1448
|
+
if (!response.ok) {
|
|
1449
|
+
throw apiErrorFor(
|
|
1450
|
+
response.status,
|
|
1451
|
+
asProblem(payload),
|
|
1452
|
+
requestId,
|
|
1453
|
+
parseRetryAfter(response.headers.get("retry-after"))
|
|
1454
|
+
);
|
|
1455
|
+
}
|
|
1456
|
+
return { status: response.status, data: payload, requestId };
|
|
1457
|
+
}
|
|
1458
|
+
url(path, query) {
|
|
1459
|
+
const url = `${this.baseURL}${path}`;
|
|
1460
|
+
if (!query) return url;
|
|
1461
|
+
const params = new URLSearchParams();
|
|
1462
|
+
for (const [name, value] of Object.entries(query)) {
|
|
1463
|
+
if (value !== void 0) params.set(name, value);
|
|
1464
|
+
}
|
|
1465
|
+
const encoded = params.toString();
|
|
1466
|
+
return encoded ? `${url}?${encoded}` : url;
|
|
1467
|
+
}
|
|
1468
|
+
};
|
|
1469
|
+
var Deadline = class {
|
|
1470
|
+
constructor(timeoutMs, outer) {
|
|
1471
|
+
this.timeoutMs = timeoutMs;
|
|
1472
|
+
this.outer = outer;
|
|
1473
|
+
this.signal = this.controller.signal;
|
|
1474
|
+
this.timer = setTimeout(() => {
|
|
1475
|
+
this.timedOut = true;
|
|
1476
|
+
this.controller.abort();
|
|
1477
|
+
}, timeoutMs);
|
|
1478
|
+
const onAbort = () => {
|
|
1479
|
+
this.controller.abort();
|
|
1480
|
+
};
|
|
1481
|
+
if (outer?.aborted) this.controller.abort();
|
|
1482
|
+
outer?.addEventListener("abort", onAbort, { once: true });
|
|
1483
|
+
this.detach = () => {
|
|
1484
|
+
outer?.removeEventListener("abort", onAbort);
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
timeoutMs;
|
|
1488
|
+
outer;
|
|
1489
|
+
signal;
|
|
1490
|
+
controller = new AbortController();
|
|
1491
|
+
timer;
|
|
1492
|
+
detach;
|
|
1493
|
+
timedOut = false;
|
|
1494
|
+
get expired() {
|
|
1495
|
+
return this.signal.aborted;
|
|
1496
|
+
}
|
|
1497
|
+
clear() {
|
|
1498
|
+
clearTimeout(this.timer);
|
|
1499
|
+
this.detach();
|
|
1500
|
+
}
|
|
1501
|
+
explain(error) {
|
|
1502
|
+
if (this.timedOut) return new NiadraTimeoutError(this.timeoutMs);
|
|
1503
|
+
if (this.outer?.aborted) return new NiadraAbortError("request aborted by the caller", { cause: error });
|
|
1504
|
+
if (error instanceof NiadraError) return error;
|
|
1505
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1506
|
+
return new NiadraConnectionError(`connection failed: ${message}`, { cause: error });
|
|
1507
|
+
}
|
|
1508
|
+
};
|
|
1509
|
+
async function readBody(response) {
|
|
1510
|
+
const text = await response.text();
|
|
1511
|
+
if (text.length === 0) return null;
|
|
1512
|
+
const type = response.headers.get("content-type") ?? "";
|
|
1513
|
+
if (!type.includes("json")) return text;
|
|
1514
|
+
try {
|
|
1515
|
+
return JSON.parse(text);
|
|
1516
|
+
} catch {
|
|
1517
|
+
return text;
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
function asProblem(payload) {
|
|
1521
|
+
if (typeof payload !== "object" || payload === null) return null;
|
|
1522
|
+
const candidate = payload;
|
|
1523
|
+
if (typeof candidate.code !== "string" || typeof candidate.status !== "number") return null;
|
|
1524
|
+
return candidate;
|
|
1525
|
+
}
|
|
1526
|
+
function isStatus(error, status) {
|
|
1527
|
+
return error instanceof NiadraAPIError && error.status === status;
|
|
1528
|
+
}
|
|
1529
|
+
function isTransient(error) {
|
|
1530
|
+
if (error instanceof NiadraTimeoutError || error instanceof NiadraConnectionError) return true;
|
|
1531
|
+
return error instanceof NiadraAPIError && RETRYABLE_WRITE_STATUS.has(error.status);
|
|
1532
|
+
}
|
|
1533
|
+
function retryDelay(error, attempt, baseMs, maxMs) {
|
|
1534
|
+
if (isStatus(error, 421)) return 0;
|
|
1535
|
+
if (error instanceof NiadraAPIError && error.retryAfterMs !== null) return Math.min(error.retryAfterMs, maxMs);
|
|
1536
|
+
const ceiling = Math.min(maxMs, baseMs * 2 ** (attempt - 1));
|
|
1537
|
+
return Math.round(ceiling / 2 + Math.random() * ceiling / 2);
|
|
1538
|
+
}
|
|
1539
|
+
function parseRetryAfter(header) {
|
|
1540
|
+
if (!header) return null;
|
|
1541
|
+
const seconds = Number(header);
|
|
1542
|
+
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1e3);
|
|
1543
|
+
const date = Date.parse(header);
|
|
1544
|
+
return Number.isNaN(date) ? null : Math.max(0, date - Date.now());
|
|
1545
|
+
}
|
|
1546
|
+
function sleep(ms, signal) {
|
|
1547
|
+
if (ms <= 0) return Promise.resolve();
|
|
1548
|
+
return new Promise((resolve, reject) => {
|
|
1549
|
+
const timer = setTimeout(() => {
|
|
1550
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1551
|
+
resolve();
|
|
1552
|
+
}, ms);
|
|
1553
|
+
const onAbort = () => {
|
|
1554
|
+
clearTimeout(timer);
|
|
1555
|
+
reject(new NiadraAbortError("request aborted by the caller"));
|
|
1556
|
+
};
|
|
1557
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
// src/client.ts
|
|
1562
|
+
function readEnv(name) {
|
|
1563
|
+
const env = globalThis.process?.env;
|
|
1564
|
+
const value = env?.[name];
|
|
1565
|
+
return value === "" ? void 0 : value;
|
|
1566
|
+
}
|
|
1567
|
+
function describe(error) {
|
|
1568
|
+
if (error instanceof NiadraAPIError) {
|
|
1569
|
+
return error.requestId ? `${error.message} (request ${error.requestId})` : error.message;
|
|
1570
|
+
}
|
|
1571
|
+
return `${error.name}: ${error.message}`;
|
|
1572
|
+
}
|
|
1573
|
+
var Niadra = class {
|
|
1574
|
+
/** `false` when the client was built without a usable key and sends nothing. */
|
|
1575
|
+
enabled;
|
|
1576
|
+
core;
|
|
1577
|
+
timeouts;
|
|
1578
|
+
strict;
|
|
1579
|
+
/** Where the client reports what it swallows in fail-open mode. */
|
|
1580
|
+
logger;
|
|
1581
|
+
disabledReason;
|
|
1582
|
+
unregisterExit = () => void 0;
|
|
1583
|
+
constructor(options = {}) {
|
|
1584
|
+
this.strict = options.strict ?? false;
|
|
1585
|
+
this.logger = options.logger ?? consoleLogger;
|
|
1586
|
+
this.timeouts = { ...DEFAULT_TIMEOUTS, ...options.timeouts };
|
|
1587
|
+
const setup = this.setup(options);
|
|
1588
|
+
if (setup instanceof NiadraConfigError) {
|
|
1589
|
+
if (this.strict) throw setup;
|
|
1590
|
+
this.logger.warn(`${setup.message}; the client is disabled and will send nothing`);
|
|
1591
|
+
this.core = null;
|
|
1592
|
+
this.enabled = false;
|
|
1593
|
+
this.disabledReason = setup;
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1596
|
+
this.core = setup;
|
|
1597
|
+
this.enabled = true;
|
|
1598
|
+
this.disabledReason = null;
|
|
1599
|
+
if (options.flushOnExit ?? true) this.unregisterExit = registerExitFlush(this);
|
|
1600
|
+
}
|
|
1601
|
+
setup(options) {
|
|
1602
|
+
const apiKey = options.apiKey ?? readEnv("NIADRA_API_KEY");
|
|
1603
|
+
if (!apiKey) return new NiadraConfigError("no API key: pass `apiKey` or set NIADRA_API_KEY");
|
|
1604
|
+
let baseURL = options.baseURL ?? readEnv("NIADRA_BASE_URL");
|
|
1605
|
+
if (!baseURL) {
|
|
1606
|
+
const parsed = parseApiKey(apiKey);
|
|
1607
|
+
if (!parsed) {
|
|
1608
|
+
return new NiadraConfigError(
|
|
1609
|
+
"the API key does not look like nia_sk_<live|test>_<region>_<space>_<key_id>_<secret>; pass `baseURL` to use it anyway"
|
|
1610
|
+
);
|
|
1611
|
+
}
|
|
1612
|
+
baseURL = baseURLFromKey(parsed);
|
|
1613
|
+
}
|
|
1614
|
+
const fetchImpl = options.fetch ?? (typeof fetch === "function" ? fetch.bind(globalThis) : void 0);
|
|
1615
|
+
if (!fetchImpl) return new NiadraConfigError("no fetch implementation: pass `fetch` on this runtime");
|
|
1616
|
+
const transport = new Transport({
|
|
1617
|
+
baseURL,
|
|
1618
|
+
apiKey,
|
|
1619
|
+
fetch: fetchImpl,
|
|
1620
|
+
defaultHeaders: options.defaultHeaders ?? {}
|
|
1621
|
+
});
|
|
1622
|
+
const queueOptions = { ...DEFAULT_QUEUE, ...options.queue };
|
|
1623
|
+
queueOptions.maxBatchSize = Math.min(queueOptions.maxBatchSize, 499);
|
|
1624
|
+
const queue = new EventQueue(
|
|
1625
|
+
(items) => this.sendBatch(transport, items, queueOptions.maxAttempts, queueOptions),
|
|
1626
|
+
queueOptions,
|
|
1627
|
+
this.logger
|
|
1628
|
+
);
|
|
1629
|
+
const cache = options.cache === false ? null : new ContextCache({ ...DEFAULT_CACHE, ...options.cache });
|
|
1630
|
+
const writes = {
|
|
1631
|
+
kind: "write",
|
|
1632
|
+
maxAttempts: queueOptions.maxAttempts,
|
|
1633
|
+
baseDelayMs: queueOptions.retryDelayMs,
|
|
1634
|
+
maxDelayMs: queueOptions.maxRetryDelayMs
|
|
1635
|
+
};
|
|
1636
|
+
return { transport, queue, cache, baseURL, writes };
|
|
1637
|
+
}
|
|
1638
|
+
/**
|
|
1639
|
+
* The customer's context pack, to place in the system prompt before calling the model.
|
|
1640
|
+
*
|
|
1641
|
+
* Inside a conversation (`conversation_id` or `task_id`), packs are cached: a recent one is
|
|
1642
|
+
* returned without a request, an older one is returned at once while a single background
|
|
1643
|
+
* request refreshes it, and when a request fails the last good pack is returned instead.
|
|
1644
|
+
* A 401 or 403 is not an outage: it drops the cached packs, so revoking a key also stops
|
|
1645
|
+
* what the process had already cached from reaching the model.
|
|
1646
|
+
*
|
|
1647
|
+
* A plain read and a `delta` read of one conversation share the cached pack. The server
|
|
1648
|
+
* sends each delta once, and so does the cache, even one a background refresh brought in;
|
|
1649
|
+
* `conversation()` and `task()` keep them across turns.
|
|
1650
|
+
*
|
|
1651
|
+
* Never rejects unless `strict` is set. On failure `text` is empty and `error` is set.
|
|
1652
|
+
*/
|
|
1653
|
+
async context(params, options = {}) {
|
|
1654
|
+
if (!this.core) return emptyResult(this.disabledReason);
|
|
1655
|
+
let request;
|
|
1656
|
+
try {
|
|
1657
|
+
request = buildContextRequest(params);
|
|
1658
|
+
} catch (error) {
|
|
1659
|
+
return this.contextFailure(toNiadraError(error), null);
|
|
1660
|
+
}
|
|
1661
|
+
const timeout = options.timeout ?? (request.view === "voice" ? this.timeouts.contextVoice : this.timeouts.context);
|
|
1662
|
+
const cache = options.cache === false ? null : this.core.cache;
|
|
1663
|
+
const scope = cacheScope(request);
|
|
1664
|
+
if (!cache || !scope) {
|
|
1665
|
+
try {
|
|
1666
|
+
const response = await this.fetchContext(this.core, request, timeout, options.signal, options.headers);
|
|
1667
|
+
return resultFrom(response, "network");
|
|
1668
|
+
} catch (error) {
|
|
1669
|
+
const failure = toNiadraError(error);
|
|
1670
|
+
this.observeAuth(failure, null);
|
|
1671
|
+
return this.contextFailure(failure, null);
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
const key = cacheKey(request);
|
|
1675
|
+
const hit = cache.lookup(key);
|
|
1676
|
+
if (hit?.freshness === "fresh") return resultFrom(delivered(hit.response, cache.take(key)), "cache");
|
|
1677
|
+
if (hit?.freshness === "stale") {
|
|
1678
|
+
this.revalidate(this.core, cache, key, scope, request, timeout, options.headers).catch((error) => {
|
|
1679
|
+
this.logger.debug(`background context refresh failed: ${describe(toNiadraError(error))}`);
|
|
1680
|
+
});
|
|
1681
|
+
return resultFrom(delivered(hit.response, cache.take(key)), "stale");
|
|
1682
|
+
}
|
|
1683
|
+
try {
|
|
1684
|
+
const pending = this.revalidate(this.core, cache, key, scope, request, timeout, options.headers);
|
|
1685
|
+
const { response, source } = await abortable(pending, options.signal);
|
|
1686
|
+
return resultFrom(delivered(response, cache.take(key)), source);
|
|
1687
|
+
} catch (error) {
|
|
1688
|
+
const failure = toNiadraError(error);
|
|
1689
|
+
const fallback = cache.lookup(key);
|
|
1690
|
+
return this.contextFailure(failure, fallback ? delivered(fallback.response, cache.take(key)) : null);
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
/**
|
|
1694
|
+
* Searches the customer's history by keywords and meaning, with filters by period, channel,
|
|
1695
|
+
* topic and kind. The answer includes how often the same kind of issue came back.
|
|
1696
|
+
*/
|
|
1697
|
+
async search(params, options = {}) {
|
|
1698
|
+
return this.navigate(() => {
|
|
1699
|
+
if (!params.query || params.query.length > 2e3) {
|
|
1700
|
+
throw new NiadraValidationError("query must be 1 to 2000 characters");
|
|
1701
|
+
}
|
|
1702
|
+
return this.readSpec("POST", "/v1/history/search", params, this.timeouts.navigation, options);
|
|
1703
|
+
});
|
|
1704
|
+
}
|
|
1705
|
+
/** The customer's history in chronological order, one line per item, paginated by cursor. */
|
|
1706
|
+
async timeline(params, options = {}) {
|
|
1707
|
+
return this.navigate(
|
|
1708
|
+
() => this.readSpec("POST", "/v1/history/timeline", params, this.timeouts.navigation, options)
|
|
1709
|
+
);
|
|
1710
|
+
}
|
|
1711
|
+
/**
|
|
1712
|
+
* 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.
|
|
1715
|
+
*/
|
|
1716
|
+
async open(id, params = {}, options = {}) {
|
|
1717
|
+
return this.navigate(() => {
|
|
1718
|
+
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;
|
|
1727
|
+
});
|
|
1728
|
+
}
|
|
1729
|
+
/**
|
|
1730
|
+
* The derived state of a business object: what its systems of record reported last, `as_of`
|
|
1731
|
+
* when, and its open items, under this source's purpose.
|
|
1732
|
+
*
|
|
1733
|
+
* @example
|
|
1734
|
+
* const { data: invoice } = await niadra.objectState("invoice:erp:0823");
|
|
1735
|
+
*/
|
|
1736
|
+
async objectState(object, options = {}) {
|
|
1737
|
+
return this.navigate(() => this.readSpec("GET", objectPath(object), void 0, this.timeouts.navigation, options));
|
|
1738
|
+
}
|
|
1739
|
+
/**
|
|
1740
|
+
* System events and agent actions about one object, newest first, one line each and never
|
|
1741
|
+
* conversation content. Pass `next_cursor` back as `cursor` to go on.
|
|
1742
|
+
*/
|
|
1743
|
+
async objectTimeline(object, params = {}, options = {}) {
|
|
1744
|
+
return this.navigate(() => {
|
|
1745
|
+
const limit2 = params.limit ?? 20;
|
|
1746
|
+
if (!Number.isInteger(limit2) || limit2 < 1 || limit2 > 100) {
|
|
1747
|
+
throw new NiadraValidationError("limit must be between 1 and 100");
|
|
1748
|
+
}
|
|
1749
|
+
const path = `${objectPath(object)}/timeline`;
|
|
1750
|
+
const spec = this.readSpec("GET", path, void 0, this.timeouts.navigation, options);
|
|
1751
|
+
spec.query = { cursor: params.cursor, limit: String(limit2) };
|
|
1752
|
+
return spec;
|
|
1753
|
+
});
|
|
1754
|
+
}
|
|
1755
|
+
/**
|
|
1756
|
+
* The navigation kit as function-calling tools with the customer bound outside the model's
|
|
1757
|
+
* reach. Hand `definitions` to any model API and pass its tool calls to `call()`.
|
|
1758
|
+
*
|
|
1759
|
+
* @example
|
|
1760
|
+
* const kit = niadra.tools(handles.phone("+5511987654321"), { conversation_id: "wa-8812" });
|
|
1761
|
+
* const output = await kit.call(toolCall.function.name, toolCall.function.arguments);
|
|
1762
|
+
*/
|
|
1763
|
+
tools(subject, binding = {}) {
|
|
1764
|
+
const navigator = {
|
|
1765
|
+
search: (params, voice) => this.search(params, this.voiceBudget(voice)),
|
|
1766
|
+
timeline: (params, voice) => this.timeline(params, this.voiceBudget(voice)),
|
|
1767
|
+
open: (id, bound, voice) => {
|
|
1768
|
+
const scope = {};
|
|
1769
|
+
if (bound.verification) scope.verification = bound.verification;
|
|
1770
|
+
if (bound.conversation_id) scope.conversation_id = bound.conversation_id;
|
|
1771
|
+
if (bound.task_id) scope.task_id = bound.task_id;
|
|
1772
|
+
return this.open(id, scope, this.voiceBudget(voice));
|
|
1773
|
+
}
|
|
1774
|
+
};
|
|
1775
|
+
return bindTools(subject, binding, navigator, this.strict);
|
|
1776
|
+
}
|
|
1777
|
+
/**
|
|
1778
|
+
* Mints a signed, 15-minute token that binds one customer to a session. Your backend calls
|
|
1779
|
+
* this and hands the token to the MCP connection, so tools served over MCP can only ever
|
|
1780
|
+
* read that customer. Resolves with `data: null` on failure.
|
|
1781
|
+
*/
|
|
1782
|
+
async subjectToken(params, options = {}) {
|
|
1783
|
+
return this.navigate(() => this.readSpec("POST", "/v1/subject-tokens", params, this.timeouts.token, options));
|
|
1784
|
+
}
|
|
1785
|
+
/**
|
|
1786
|
+
* Records a message, a system event or an agent action. Returns at once with the event's
|
|
1787
|
+
* idempotency key, or `null` when the event was dropped: invalid, unserializable, the queue
|
|
1788
|
+
* full or the client disabled. Delivery happens in the background; `flush()` waits for it.
|
|
1789
|
+
*/
|
|
1790
|
+
track(event) {
|
|
1791
|
+
return this.enqueue(() => buildEvent(event));
|
|
1792
|
+
}
|
|
1793
|
+
/**
|
|
1794
|
+
* Records what an agent did in a system of record, such as a credit or a reschedule.
|
|
1795
|
+
* With `closes`, the action also resolves the open item it fulfils.
|
|
1796
|
+
*/
|
|
1797
|
+
action(event) {
|
|
1798
|
+
return this.enqueue(() => buildAction(event));
|
|
1799
|
+
}
|
|
1800
|
+
/**
|
|
1801
|
+
* States that several handles belong to the same subject. Sent right away rather than on the
|
|
1802
|
+
* next batch, so the next `context()` call already sees the merged profile.
|
|
1803
|
+
*/
|
|
1804
|
+
identify(params) {
|
|
1805
|
+
return this.sendNow(() => buildIdentify(params));
|
|
1806
|
+
}
|
|
1807
|
+
/**
|
|
1808
|
+
* Raises the verification level of one conversation or task after the customer proved who
|
|
1809
|
+
* they are. Sent right away, and drops the conversation's cached packs, because a pack
|
|
1810
|
+
* compiled for the old level may be missing what the new level allows.
|
|
1811
|
+
*/
|
|
1812
|
+
verify(params) {
|
|
1813
|
+
return this.verifyWith(params);
|
|
1814
|
+
}
|
|
1815
|
+
/**
|
|
1816
|
+
* Corrects what Niadra derived about a subject: retracts or corrects a fact, resolves an open
|
|
1817
|
+
* item, or records how a conversation ended. Sent right away; the server records it as an
|
|
1818
|
+
* event, so the correction is audited like any other. A rejected correction resolves with
|
|
1819
|
+
* `ok: false`.
|
|
1820
|
+
*/
|
|
1821
|
+
async feedback(params) {
|
|
1822
|
+
const core = this.core;
|
|
1823
|
+
if (!core) return { ok: false, idempotency_key: null, error: this.disabledError() };
|
|
1824
|
+
let key = null;
|
|
1825
|
+
try {
|
|
1826
|
+
const request = buildFeedback(params);
|
|
1827
|
+
key = request.idempotency_key;
|
|
1828
|
+
const response = await core.transport.request({
|
|
1829
|
+
method: "POST",
|
|
1830
|
+
path: "/v1/feedback",
|
|
1831
|
+
body: request,
|
|
1832
|
+
headers: { "idempotency-key": key },
|
|
1833
|
+
timeoutMs: this.timeouts.write,
|
|
1834
|
+
retry: core.writes
|
|
1835
|
+
});
|
|
1836
|
+
const [rejected] = response.data?.errors ?? [];
|
|
1837
|
+
if (rejected) {
|
|
1838
|
+
throw new NiadraValidationError(`${rejected.code}${rejected.detail ? `: ${rejected.detail}` : ""}`);
|
|
1839
|
+
}
|
|
1840
|
+
return { ok: true, idempotency_key: key, error: null };
|
|
1841
|
+
} catch (error) {
|
|
1842
|
+
return { ok: false, idempotency_key: key, error: this.swallow(error, "feedback") };
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
/**
|
|
1846
|
+
* Hands a file to Niadra, such as a call recording, and returns the reference its event
|
|
1847
|
+
* carries. Media never travels inside an event: this reserves an upload, sends the bytes
|
|
1848
|
+
* straight to storage over a short-lived signed URL, and resolves with `media_ref` and
|
|
1849
|
+
* `media_sha256` for the event's `content`.
|
|
1850
|
+
*
|
|
1851
|
+
* @example
|
|
1852
|
+
* const { data } = await niadra.uploadMedia({ data: recording, content_type: "audio/wav" });
|
|
1853
|
+
* if (data) convo.track({ speaker: "customer", content: { type: "audio", media_ref: data.media_ref, media_sha256: data.media_sha256 } });
|
|
1854
|
+
*/
|
|
1855
|
+
async uploadMedia(params, options = {}) {
|
|
1856
|
+
const core = this.core;
|
|
1857
|
+
if (!core) return { data: null, error: this.disabledError() };
|
|
1858
|
+
try {
|
|
1859
|
+
const { bytes, request } = await prepareUpload(params);
|
|
1860
|
+
const reserved = await core.transport.request({
|
|
1861
|
+
method: "POST",
|
|
1862
|
+
path: "/v1/media/uploads",
|
|
1863
|
+
body: request,
|
|
1864
|
+
timeoutMs: this.timeouts.write,
|
|
1865
|
+
retry: core.writes,
|
|
1866
|
+
signal: options.signal
|
|
1867
|
+
});
|
|
1868
|
+
const media_ref = reserved.data?.media_ref;
|
|
1869
|
+
if (typeof media_ref !== "string") throw new NiadraError("unexpected response from /v1/media/uploads");
|
|
1870
|
+
const url = reserved.data?.upload_url;
|
|
1871
|
+
if (url) {
|
|
1872
|
+
checkUploadURL(url, core.baseURL);
|
|
1873
|
+
await core.transport.upload({
|
|
1874
|
+
url,
|
|
1875
|
+
body: bytes,
|
|
1876
|
+
headers: uploadHeaders(reserved.data?.upload_headers, request.content_type),
|
|
1877
|
+
timeoutMs: this.timeouts.upload,
|
|
1878
|
+
retry: core.writes,
|
|
1879
|
+
signal: options.signal
|
|
1880
|
+
});
|
|
1881
|
+
}
|
|
1882
|
+
return {
|
|
1883
|
+
data: {
|
|
1884
|
+
media_ref,
|
|
1885
|
+
media_sha256: request.sha256,
|
|
1886
|
+
content_type: request.content_type,
|
|
1887
|
+
size_bytes: request.size_bytes,
|
|
1888
|
+
expires_at: reserved.data?.expires_at ?? null
|
|
1889
|
+
},
|
|
1890
|
+
error: null
|
|
1891
|
+
};
|
|
1892
|
+
} catch (error) {
|
|
1893
|
+
return { data: null, error: this.swallow(error, "media upload") };
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
/** Records a transfer to a human or another agent. Sent right away, so the receiver can read context at once. */
|
|
1897
|
+
handoff(params) {
|
|
1898
|
+
return this.sendNow(() => buildHandoff(params));
|
|
1899
|
+
}
|
|
1900
|
+
/**
|
|
1901
|
+
* A helper for one customer conversation: pins the pack across turns, captures turns and
|
|
1902
|
+
* emits `conversation.ended` when you call `end()`.
|
|
1903
|
+
*/
|
|
1904
|
+
conversation(params) {
|
|
1905
|
+
return new Conversation(this, params, {
|
|
1906
|
+
endConversation: (id) => this.endScope(buildConversationEnded(id), `conversation:${id}`)
|
|
1907
|
+
});
|
|
1908
|
+
}
|
|
1909
|
+
/** A helper for one internal-agent task: binds `task_id` to reads and writes and emits `task.ended`. */
|
|
1910
|
+
task(params) {
|
|
1911
|
+
return new Task(this, params, {
|
|
1912
|
+
endTask: (id) => this.endScope(buildTaskEnded(id), `task:${id}`),
|
|
1913
|
+
verifyTask: (verify) => this.verifyWith(verify)
|
|
1914
|
+
});
|
|
1915
|
+
}
|
|
1916
|
+
/**
|
|
1917
|
+
* Sends every queued event and resolves when done. Call it before a serverless function
|
|
1918
|
+
* returns, or pass it to `waitUntil()` on edge runtimes. Rejects only with `strict` set.
|
|
1919
|
+
*/
|
|
1920
|
+
async flush() {
|
|
1921
|
+
if (!this.core) return;
|
|
1922
|
+
const report = await this.core.queue.flush();
|
|
1923
|
+
const [first] = report.errors;
|
|
1924
|
+
if (this.strict && first) throw first;
|
|
1925
|
+
}
|
|
1926
|
+
/**
|
|
1927
|
+
* Flushes, stops the background timer and releases the exit hook. Events tracked after
|
|
1928
|
+
* this are dropped. Call it from your own SIGTERM handler in long-running services.
|
|
1929
|
+
*/
|
|
1930
|
+
async shutdown() {
|
|
1931
|
+
this.unregisterExit();
|
|
1932
|
+
if (!this.core) return;
|
|
1933
|
+
const report = await this.core.queue.close();
|
|
1934
|
+
this.core.cache?.clear();
|
|
1935
|
+
const [first] = report.errors;
|
|
1936
|
+
if (this.strict && first) throw first;
|
|
1937
|
+
}
|
|
1938
|
+
async verifyWith(params) {
|
|
1939
|
+
const result = await this.sendNow(() => buildVerify(params));
|
|
1940
|
+
if (result.ok) {
|
|
1941
|
+
if (params.conversation_id) this.forgetScope(`conversation:${params.conversation_id}`);
|
|
1942
|
+
if (params.task_id) this.forgetScope(`task:${params.task_id}`);
|
|
1943
|
+
}
|
|
1944
|
+
return result;
|
|
1945
|
+
}
|
|
1946
|
+
/** Throws under `strict`; otherwise logs what failed, never with content, and returns the error. */
|
|
1947
|
+
swallow(error, what) {
|
|
1948
|
+
const failure = toNiadraError(error);
|
|
1949
|
+
this.observeAuth(failure, null);
|
|
1950
|
+
if (this.strict) throw failure;
|
|
1951
|
+
this.logger.warn(`${what} failed: ${describe(failure)}`);
|
|
1952
|
+
return failure;
|
|
1953
|
+
}
|
|
1954
|
+
voiceBudget(voice) {
|
|
1955
|
+
return voice ? { timeout: this.timeouts.navigationVoice } : {};
|
|
1956
|
+
}
|
|
1957
|
+
readSpec(method, path, body, defaultTimeout, options) {
|
|
1958
|
+
const spec = {
|
|
1959
|
+
method,
|
|
1960
|
+
path,
|
|
1961
|
+
timeoutMs: options.timeout ?? defaultTimeout,
|
|
1962
|
+
retry: READ_POLICY,
|
|
1963
|
+
signal: options.signal,
|
|
1964
|
+
headers: options.headers
|
|
1965
|
+
};
|
|
1966
|
+
if (body !== void 0) spec.body = body;
|
|
1967
|
+
return spec;
|
|
1968
|
+
}
|
|
1969
|
+
async navigate(build2) {
|
|
1970
|
+
if (!this.core) return { data: null, error: this.disabledError() };
|
|
1971
|
+
try {
|
|
1972
|
+
const response = await this.core.transport.request(build2());
|
|
1973
|
+
return { data: response.data, error: null };
|
|
1974
|
+
} catch (error) {
|
|
1975
|
+
const failure = toNiadraError(error);
|
|
1976
|
+
this.observeAuth(failure, null);
|
|
1977
|
+
if (this.strict) throw failure;
|
|
1978
|
+
this.logger.warn(`read failed: ${describe(failure)}`);
|
|
1979
|
+
return { data: null, error: failure };
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
async fetchContext(core, request, timeout, signal, headers) {
|
|
1983
|
+
const response = await core.transport.request(
|
|
1984
|
+
this.readSpec("POST", "/v1/context", request, timeout, { signal, headers })
|
|
1985
|
+
);
|
|
1986
|
+
return normalizeContext(response.data);
|
|
1987
|
+
}
|
|
1988
|
+
/**
|
|
1989
|
+
* One request for a key, shared by every caller that needs it meanwhile. It sends the
|
|
1990
|
+
* cached ETag, so an unchanged pack costs a `not_modified` answer instead of the full text.
|
|
1991
|
+
* The caller's signal is deliberately not passed down: other callers may be waiting too.
|
|
1992
|
+
*/
|
|
1993
|
+
revalidate(core, cache, key, scope, request, timeout, headers) {
|
|
1994
|
+
return cache.dedupe(key, async () => {
|
|
1995
|
+
const cached = cache.lookup(key);
|
|
1996
|
+
const generation = cache.generation;
|
|
1997
|
+
const body = cached ? { ...request, known_etag: cached.response.etag } : request;
|
|
1998
|
+
try {
|
|
1999
|
+
const response = await this.fetchContext(core, body, timeout, void 0, headers);
|
|
2000
|
+
if (response.degraded && !response.not_modified && cached) {
|
|
2001
|
+
return { response: cached.response, source: "fallback" };
|
|
2002
|
+
}
|
|
2003
|
+
const merged = response.not_modified && cached ? mergeNotModified(cached.response, response) : response;
|
|
2004
|
+
cache.store(key, scope, merged, generation);
|
|
2005
|
+
return { response: merged, source: "network" };
|
|
2006
|
+
} catch (error) {
|
|
2007
|
+
this.observeAuth(toNiadraError(error), key);
|
|
2008
|
+
throw error;
|
|
2009
|
+
}
|
|
2010
|
+
});
|
|
2011
|
+
}
|
|
2012
|
+
contextFailure(error, fallback) {
|
|
2013
|
+
if (this.strict) throw error;
|
|
2014
|
+
if (fallback) {
|
|
2015
|
+
this.logger.warn(`context request failed, serving the last good pack: ${describe(error)}`);
|
|
2016
|
+
return resultFrom(fallback, "fallback", error);
|
|
2017
|
+
}
|
|
2018
|
+
this.logger.warn(`context unavailable: ${describe(error)}`);
|
|
2019
|
+
return emptyResult(error);
|
|
2020
|
+
}
|
|
2021
|
+
/**
|
|
2022
|
+
* 401 means the key itself is no longer valid, so every cached pack goes. 403 is specific to
|
|
2023
|
+
* what was asked, so only that pack goes.
|
|
2024
|
+
*/
|
|
2025
|
+
observeAuth(error, key) {
|
|
2026
|
+
const cache = this.core?.cache;
|
|
2027
|
+
if (!cache) return;
|
|
2028
|
+
if (error instanceof NiadraAuthenticationError) cache.clear();
|
|
2029
|
+
else if (error instanceof NiadraPermissionError && key) cache.delete(key);
|
|
2030
|
+
}
|
|
2031
|
+
forgetScope(scope) {
|
|
2032
|
+
this.core?.cache?.deleteScope(scope);
|
|
2033
|
+
}
|
|
2034
|
+
disabledError() {
|
|
2035
|
+
return this.disabledReason ?? new NiadraConfigError("client is disabled");
|
|
2036
|
+
}
|
|
2037
|
+
enqueue(build2) {
|
|
2038
|
+
if (!this.core) return null;
|
|
2039
|
+
let item;
|
|
2040
|
+
try {
|
|
2041
|
+
item = build2();
|
|
2042
|
+
} catch (error) {
|
|
2043
|
+
const failure = toNiadraError(error);
|
|
2044
|
+
if (this.strict) throw failure;
|
|
2045
|
+
this.logger.warn(`event dropped: ${failure.message}`);
|
|
2046
|
+
return null;
|
|
2047
|
+
}
|
|
2048
|
+
const accepted = this.core.queue.push(item);
|
|
2049
|
+
return accepted && "idempotency_key" in item ? item.idempotency_key : null;
|
|
2050
|
+
}
|
|
2051
|
+
sendNow(build2) {
|
|
2052
|
+
const core = this.core;
|
|
2053
|
+
if (!core) return Promise.resolve({ ok: false, idempotency_key: null, error: this.disabledError() });
|
|
2054
|
+
let item;
|
|
2055
|
+
try {
|
|
2056
|
+
item = build2();
|
|
2057
|
+
} catch (error) {
|
|
2058
|
+
const failure = toNiadraError(error);
|
|
2059
|
+
if (this.strict) return Promise.reject(failure);
|
|
2060
|
+
this.logger.warn(`write dropped: ${failure.message}`);
|
|
2061
|
+
return Promise.resolve({ ok: false, idempotency_key: null, error: failure });
|
|
2062
|
+
}
|
|
2063
|
+
return new Promise((resolve, reject) => {
|
|
2064
|
+
const key = item.idempotency_key;
|
|
2065
|
+
core.queue.push(item, (error) => {
|
|
2066
|
+
if (!error) resolve({ ok: true, idempotency_key: key, error: null });
|
|
2067
|
+
else if (this.strict) reject(error);
|
|
2068
|
+
else resolve({ ok: false, idempotency_key: key, error });
|
|
2069
|
+
});
|
|
2070
|
+
core.queue.flushInBackground();
|
|
2071
|
+
});
|
|
2072
|
+
}
|
|
2073
|
+
async endScope(item, scope) {
|
|
2074
|
+
const result = await this.sendNow(() => item);
|
|
2075
|
+
this.forgetScope(scope);
|
|
2076
|
+
return result;
|
|
2077
|
+
}
|
|
2078
|
+
async sendBatch(transport, items, maxAttempts, backoff) {
|
|
2079
|
+
try {
|
|
2080
|
+
const response = await transport.request({
|
|
2081
|
+
method: "POST",
|
|
2082
|
+
path: "/v1/batch",
|
|
2083
|
+
body: { items },
|
|
2084
|
+
timeoutMs: this.timeouts.write,
|
|
2085
|
+
retry: { kind: "write", maxAttempts, baseDelayMs: backoff.retryDelayMs, maxDelayMs: backoff.maxRetryDelayMs }
|
|
2086
|
+
});
|
|
2087
|
+
const data = response.data;
|
|
2088
|
+
return { accepted: data?.accepted ?? items.length, duplicates: data?.duplicates ?? 0, errors: data?.errors ?? [] };
|
|
2089
|
+
} catch (error) {
|
|
2090
|
+
const failure = toNiadraError(error);
|
|
2091
|
+
this.observeAuth(failure, null);
|
|
2092
|
+
throw failure;
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
};
|
|
2096
|
+
function normalizeContext(data) {
|
|
2097
|
+
if (typeof data !== "object" || data === null) throw new NiadraError("unexpected response from /v1/context");
|
|
2098
|
+
const body = data;
|
|
2099
|
+
if (typeof body.etag !== "string" || typeof body.path !== "string" || !body.verification) {
|
|
2100
|
+
throw new NiadraError("unexpected response from /v1/context");
|
|
2101
|
+
}
|
|
2102
|
+
return {
|
|
2103
|
+
...body,
|
|
2104
|
+
etag: body.etag,
|
|
2105
|
+
path: body.path,
|
|
2106
|
+
verification: body.verification,
|
|
2107
|
+
version: body.version ?? "",
|
|
2108
|
+
not_modified: body.not_modified ?? false,
|
|
2109
|
+
variables: body.variables ?? {},
|
|
2110
|
+
coverage: body.coverage ?? [],
|
|
2111
|
+
live: body.live ?? [],
|
|
2112
|
+
live_complete: body.live_complete ?? true,
|
|
2113
|
+
timing: body.timing ?? {},
|
|
2114
|
+
withheld: body.withheld ?? 0,
|
|
2115
|
+
degraded: body.degraded ?? false
|
|
2116
|
+
};
|
|
2117
|
+
}
|
|
2118
|
+
function uploadHeaders(named, contentType) {
|
|
2119
|
+
return named && Object.keys(named).length > 0 ? { ...named } : { "content-type": contentType };
|
|
2120
|
+
}
|
|
2121
|
+
function delivered(response, taken) {
|
|
2122
|
+
return taken === void 0 ? response : { ...response, delta: taken };
|
|
2123
|
+
}
|
|
2124
|
+
function abortable(promise, signal) {
|
|
2125
|
+
if (!signal) return promise;
|
|
2126
|
+
if (signal.aborted) return Promise.reject(new NiadraAbortError("request aborted by the caller"));
|
|
2127
|
+
return new Promise((resolve, reject) => {
|
|
2128
|
+
const onAbort = () => {
|
|
2129
|
+
reject(new NiadraAbortError("request aborted by the caller"));
|
|
2130
|
+
};
|
|
2131
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
2132
|
+
promise.then(
|
|
2133
|
+
(value) => {
|
|
2134
|
+
signal.removeEventListener("abort", onAbort);
|
|
2135
|
+
resolve(value);
|
|
2136
|
+
},
|
|
2137
|
+
(error) => {
|
|
2138
|
+
signal.removeEventListener("abort", onAbort);
|
|
2139
|
+
reject(error instanceof Error ? error : new NiadraError(String(error)));
|
|
2140
|
+
}
|
|
2141
|
+
);
|
|
2142
|
+
});
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
// src/wrap.ts
|
|
2146
|
+
var INSTRUCTION_ROLES = /* @__PURE__ */ new Set(["system", "developer"]);
|
|
2147
|
+
var INTERCEPTED = ["create", "parse"];
|
|
2148
|
+
function wrap(client, session) {
|
|
2149
|
+
const resolve = typeof session === "function" ? session : () => session;
|
|
2150
|
+
const chat = property(client, "chat");
|
|
2151
|
+
if (!isRecord(chat) || !isRecord(chat.completions)) {
|
|
2152
|
+
throw new TypeError("wrap() expects an OpenAI-compatible client with chat.completions");
|
|
2153
|
+
}
|
|
2154
|
+
const overrides = { chat: wrapChat(chat, resolve) };
|
|
2155
|
+
const beta = property(client, "beta");
|
|
2156
|
+
const betaChat = isRecord(beta) ? beta.chat : void 0;
|
|
2157
|
+
if (isRecord(beta) && isRecord(betaChat) && isRecord(betaChat.completions)) {
|
|
2158
|
+
overrides.beta = overlay(beta, { chat: wrapChat(betaChat, resolve) });
|
|
2159
|
+
}
|
|
2160
|
+
return overlay(client, overrides);
|
|
2161
|
+
}
|
|
2162
|
+
function injectContext(context, messages) {
|
|
2163
|
+
const result = [...messages];
|
|
2164
|
+
if (context.text) {
|
|
2165
|
+
let position = 0;
|
|
2166
|
+
while (position < result.length && INSTRUCTION_ROLES.has(roleOf(result[position]))) position++;
|
|
2167
|
+
result.splice(position, 0, { role: "system", content: context.text });
|
|
2168
|
+
}
|
|
2169
|
+
if (context.suffix) result.push({ role: "system", content: context.suffix });
|
|
2170
|
+
return result;
|
|
2171
|
+
}
|
|
2172
|
+
function wrapChat(chat, resolve) {
|
|
2173
|
+
const completions = chat.completions;
|
|
2174
|
+
const overrides = {};
|
|
2175
|
+
for (const name of INTERCEPTED) {
|
|
2176
|
+
const method = completions[name];
|
|
2177
|
+
if (typeof method === "function") overrides[name] = intercept(method.bind(completions), resolve);
|
|
2178
|
+
}
|
|
2179
|
+
return overlay(chat, { completions: overlay(completions, overrides) });
|
|
2180
|
+
}
|
|
2181
|
+
function intercept(original, resolve) {
|
|
2182
|
+
return (body, ...rest) => {
|
|
2183
|
+
let session;
|
|
2184
|
+
try {
|
|
2185
|
+
session = resolve();
|
|
2186
|
+
} catch {
|
|
2187
|
+
session = null;
|
|
2188
|
+
}
|
|
2189
|
+
if (!session || !isRecord(body)) return original(body, ...rest);
|
|
2190
|
+
const answer = new Answer(session, body.stream === true);
|
|
2191
|
+
const started = prepare(session, body).then((prepared) => ({ call: original(prepared, ...rest) }));
|
|
2192
|
+
return new PendingCall(started, answer);
|
|
2193
|
+
};
|
|
2194
|
+
}
|
|
2195
|
+
async function prepare(session, body) {
|
|
2196
|
+
try {
|
|
2197
|
+
const context = await session.context();
|
|
2198
|
+
if (!Array.isArray(body.messages)) return body;
|
|
2199
|
+
const messages = injectContext(context, body.messages);
|
|
2200
|
+
if (context.text || context.suffix || context.response?.path === "holdout") session.markInjected(context);
|
|
2201
|
+
return { ...body, messages };
|
|
2202
|
+
} catch (error) {
|
|
2203
|
+
session.logger.warn(`could not inject context (${errorName(error)})`);
|
|
2204
|
+
return body;
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
var PendingCall = class {
|
|
2208
|
+
constructor(started, answer) {
|
|
2209
|
+
this.started = started;
|
|
2210
|
+
this.answer = answer;
|
|
2211
|
+
started.catch(() => void 0);
|
|
2212
|
+
}
|
|
2213
|
+
started;
|
|
2214
|
+
answer;
|
|
2215
|
+
result = null;
|
|
2216
|
+
then(onfulfilled, onrejected) {
|
|
2217
|
+
return this.value().then(onfulfilled, onrejected);
|
|
2218
|
+
}
|
|
2219
|
+
catch(onrejected) {
|
|
2220
|
+
return this.value().catch(onrejected);
|
|
2221
|
+
}
|
|
2222
|
+
finally(onfinally) {
|
|
2223
|
+
return this.value().finally(onfinally);
|
|
2224
|
+
}
|
|
2225
|
+
async withResponse() {
|
|
2226
|
+
const { call } = await this.started;
|
|
2227
|
+
const withResponse = property(call, "withResponse");
|
|
2228
|
+
if (typeof withResponse !== "function") throw new TypeError("this client's calls have no withResponse()");
|
|
2229
|
+
const settled = await withResponse.call(call);
|
|
2230
|
+
if (!isRecord(settled)) return settled;
|
|
2231
|
+
return { ...settled, data: this.answer.parsed(settled.data) };
|
|
2232
|
+
}
|
|
2233
|
+
async asResponse() {
|
|
2234
|
+
const { call } = await this.started;
|
|
2235
|
+
const asResponse = property(call, "asResponse");
|
|
2236
|
+
if (typeof asResponse !== "function") throw new TypeError("this client's calls have no asResponse()");
|
|
2237
|
+
return asResponse.call(call);
|
|
2238
|
+
}
|
|
2239
|
+
value() {
|
|
2240
|
+
this.result ??= this.started.then(({ call }) => call).then((value) => this.answer.parsed(value));
|
|
2241
|
+
return this.result;
|
|
2242
|
+
}
|
|
2243
|
+
};
|
|
2244
|
+
var Answer = class {
|
|
2245
|
+
constructor(session, stream) {
|
|
2246
|
+
this.session = session;
|
|
2247
|
+
this.stream = stream;
|
|
2248
|
+
}
|
|
2249
|
+
session;
|
|
2250
|
+
stream;
|
|
2251
|
+
recorded = false;
|
|
2252
|
+
/** What to hand back in place of `value`. Never throws. */
|
|
2253
|
+
parsed(value) {
|
|
2254
|
+
try {
|
|
2255
|
+
if (!this.stream) {
|
|
2256
|
+
this.record(messageText(value));
|
|
2257
|
+
return value;
|
|
2258
|
+
}
|
|
2259
|
+
return isAsyncIterable(value) ? captureStream(value, (text) => {
|
|
2260
|
+
this.record(text);
|
|
2261
|
+
}) : value;
|
|
2262
|
+
} catch (error) {
|
|
2263
|
+
this.session.logger.warn(`could not capture the model's answer (${errorName(error)})`);
|
|
2264
|
+
return value;
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
record(text) {
|
|
2268
|
+
if (this.recorded || !text) return;
|
|
2269
|
+
this.recorded = true;
|
|
2270
|
+
try {
|
|
2271
|
+
this.session.agent(text);
|
|
2272
|
+
} catch (error) {
|
|
2273
|
+
this.session.logger.warn(`could not record the model's answer (${errorName(error)})`);
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
};
|
|
2277
|
+
function captureStream(stream, done) {
|
|
2278
|
+
const parts = [];
|
|
2279
|
+
let finished = false;
|
|
2280
|
+
const finish = () => {
|
|
2281
|
+
if (finished) return;
|
|
2282
|
+
finished = true;
|
|
2283
|
+
done(parts.join(""));
|
|
2284
|
+
};
|
|
2285
|
+
return new Proxy(stream, {
|
|
2286
|
+
get(target, prop) {
|
|
2287
|
+
if (prop === Symbol.asyncIterator) {
|
|
2288
|
+
return () => {
|
|
2289
|
+
const inner = target[Symbol.asyncIterator]();
|
|
2290
|
+
return {
|
|
2291
|
+
async next() {
|
|
2292
|
+
try {
|
|
2293
|
+
const step = await inner.next();
|
|
2294
|
+
if (step.done) finish();
|
|
2295
|
+
else parts.push(deltaText(step.value));
|
|
2296
|
+
return step;
|
|
2297
|
+
} catch (error) {
|
|
2298
|
+
finish();
|
|
2299
|
+
throw error;
|
|
2300
|
+
}
|
|
2301
|
+
},
|
|
2302
|
+
async return(value2) {
|
|
2303
|
+
finish();
|
|
2304
|
+
return inner.return ? inner.return(value2) : { done: true, value: value2 };
|
|
2305
|
+
}
|
|
2306
|
+
};
|
|
2307
|
+
};
|
|
2308
|
+
}
|
|
2309
|
+
const value = Reflect.get(target, prop, target);
|
|
2310
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
2311
|
+
}
|
|
2312
|
+
});
|
|
2313
|
+
}
|
|
2314
|
+
function firstChoice(value) {
|
|
2315
|
+
const choices = property(value, "choices");
|
|
2316
|
+
if (!Array.isArray(choices)) return void 0;
|
|
2317
|
+
return choices.find(
|
|
2318
|
+
(choice) => isRecord(choice) && (choice.index === void 0 || choice.index === 0)
|
|
2319
|
+
);
|
|
2320
|
+
}
|
|
2321
|
+
function messageText(response) {
|
|
2322
|
+
const content = property(firstChoice(response)?.message, "content");
|
|
2323
|
+
return typeof content === "string" && content ? content : null;
|
|
2324
|
+
}
|
|
2325
|
+
function deltaText(chunk) {
|
|
2326
|
+
const content = property(firstChoice(chunk)?.delta, "content");
|
|
2327
|
+
return typeof content === "string" ? content : "";
|
|
2328
|
+
}
|
|
2329
|
+
function overlay(target, overrides) {
|
|
2330
|
+
return new Proxy(target, {
|
|
2331
|
+
get(obj, prop) {
|
|
2332
|
+
if (Object.prototype.hasOwnProperty.call(overrides, prop)) return overrides[prop];
|
|
2333
|
+
const value = Reflect.get(obj, prop, obj);
|
|
2334
|
+
return typeof value === "function" ? value.bind(obj) : value;
|
|
2335
|
+
}
|
|
2336
|
+
});
|
|
2337
|
+
}
|
|
2338
|
+
function property(value, name) {
|
|
2339
|
+
return isRecord(value) ? value[name] : void 0;
|
|
2340
|
+
}
|
|
2341
|
+
function isRecord(value) {
|
|
2342
|
+
return (typeof value === "object" || typeof value === "function") && value !== null;
|
|
2343
|
+
}
|
|
2344
|
+
function isAsyncIterable(value) {
|
|
2345
|
+
return isRecord(value) && typeof value[Symbol.asyncIterator] === "function";
|
|
2346
|
+
}
|
|
2347
|
+
function roleOf(message) {
|
|
2348
|
+
const role = property(message, "role");
|
|
2349
|
+
return typeof role === "string" ? role : "";
|
|
2350
|
+
}
|
|
2351
|
+
function errorName(error) {
|
|
2352
|
+
return error instanceof Error ? error.name : typeof error;
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
exports.Conversation = Conversation;
|
|
2356
|
+
exports.DEFAULT_CACHE = DEFAULT_CACHE;
|
|
2357
|
+
exports.DEFAULT_QUEUE = DEFAULT_QUEUE;
|
|
2358
|
+
exports.DEFAULT_TIMEOUTS = DEFAULT_TIMEOUTS;
|
|
2359
|
+
exports.MAX_BATCH_ITEMS = MAX_BATCH_ITEMS;
|
|
2360
|
+
exports.MAX_EVENT_TEXT = MAX_EVENT_TEXT;
|
|
2361
|
+
exports.MAX_MEDIA_BYTES = MAX_MEDIA_BYTES;
|
|
2362
|
+
exports.Niadra = Niadra;
|
|
2363
|
+
exports.NiadraAPIError = NiadraAPIError;
|
|
2364
|
+
exports.NiadraAbortError = NiadraAbortError;
|
|
2365
|
+
exports.NiadraAuthenticationError = NiadraAuthenticationError;
|
|
2366
|
+
exports.NiadraConfigError = NiadraConfigError;
|
|
2367
|
+
exports.NiadraConnectionError = NiadraConnectionError;
|
|
2368
|
+
exports.NiadraError = NiadraError;
|
|
2369
|
+
exports.NiadraPermissionError = NiadraPermissionError;
|
|
2370
|
+
exports.NiadraRateLimitError = NiadraRateLimitError;
|
|
2371
|
+
exports.NiadraTimeoutError = NiadraTimeoutError;
|
|
2372
|
+
exports.NiadraValidationError = NiadraValidationError;
|
|
2373
|
+
exports.TOOL_DEFINITIONS = TOOL_DEFINITIONS;
|
|
2374
|
+
exports.TOOL_NAMES = TOOL_NAMES;
|
|
2375
|
+
exports.Task = Task;
|
|
2376
|
+
exports.VERSION = VERSION;
|
|
2377
|
+
exports.baseURLFromKey = baseURLFromKey;
|
|
2378
|
+
exports.consoleLogger = consoleLogger;
|
|
2379
|
+
exports.handles = handles;
|
|
2380
|
+
exports.injectContext = injectContext;
|
|
2381
|
+
exports.parseApiKey = parseApiKey;
|
|
2382
|
+
exports.renderLive = renderLive;
|
|
2383
|
+
exports.renderSuffix = renderSuffix;
|
|
2384
|
+
exports.silentLogger = silentLogger;
|
|
2385
|
+
exports.toObjectRef = toObjectRef;
|
|
2386
|
+
exports.uuidv7 = uuidv7;
|
|
2387
|
+
exports.wrap = wrap;
|
|
2388
|
+
//# sourceMappingURL=index.cjs.map
|
|
2389
|
+
//# sourceMappingURL=index.cjs.map
|