@xano-sdk/chatbot 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1128 @@
1
+ // src/options.ts
2
+ var DEFAULT_RATE_LIMIT_MAX = 20;
3
+ var DEFAULT_RATE_LIMIT_TTL = 60;
4
+ var DEFAULT_SYSTEM_PROMPT = "You are a helpful, concise assistant. Answer the user's questions directly. If you do not know something, say so rather than guessing. Format replies as light Markdown \u2014 emphasis, bullet lists, and fenced code blocks where they genuinely help. Keep it minimal: no headings or tables in a short answer, and never wrap an ordinary sentence in formatting. Do not emit raw HTML.";
5
+ var TOOLS_SYSTEM_PROMPT = " You have tools available. When a question needs information you do not have, call the appropriate tool rather than guessing or saying you do not know. Use what a tool returns to answer in your own words \u2014 never paste the raw tool response or its wrapper into your reply.";
6
+ var DEFAULT_HISTORY_LIMIT = 20;
7
+ var DEFAULT_LIST_LIMIT = 100;
8
+ var DEFAULT_TRANSCRIPT_LIMIT = 200;
9
+ var DEFAULT_ROUTE_PREFIX = "chat";
10
+ var DEFAULT_NAMES = {
11
+ conversation: "conversation",
12
+ message: "conversation_message",
13
+ replyFn: "chatbot/generate_reply",
14
+ agent: "chatbot_agent",
15
+ apiGroup: "Chatbot"
16
+ };
17
+ var CANONICAL_PATTERN = /^[A-Za-z0-9_-]+$/;
18
+ function isToolWrapper(entry) {
19
+ const ref4 = entry;
20
+ return ref4.tool !== void 0 || ref4.id !== void 0;
21
+ }
22
+ function applyDefaultToolAuth(tools, authTable) {
23
+ return tools.map((entry) => {
24
+ if (typeof entry === "object" && entry !== null && isToolWrapper(entry)) {
25
+ return "auth" in entry && entry.auth !== void 0 ? entry : { ...entry, auth: authTable };
26
+ }
27
+ return { tool: entry, auth: authTable };
28
+ });
29
+ }
30
+ var isPublicToolEntry = (entry) => typeof entry === "object" && entry !== null && isToolWrapper(entry) && entry.auth === false;
31
+ var show = (v) => typeof v === "number" ? String(v) : JSON.stringify(v);
32
+ function resolveOptions(opts = {}) {
33
+ const authenticated = opts.authenticated ?? true;
34
+ const guest = opts.guest ?? false;
35
+ if (!authenticated && !guest) {
36
+ throw new Error(
37
+ "createChatbot: both endpoint families are disabled ({ authenticated: false, guest: false }), which would register tables and an agent with no way to reach them. Enable at least one."
38
+ );
39
+ }
40
+ if (authenticated && opts.authTable === void 0) {
41
+ if ("authTable" in opts) {
42
+ throw new Error(
43
+ "createChatbot: `authTable` was passed but is undefined at the time registerChatbot/createChatbot ran. The table handle you passed has not been initialized yet \u2014 usually a circular import (the file defining the table imports, directly or indirectly, the file calling registerChatbot), or an import of a name the module does not export. Define the table in a module that does not import the chatbot wiring, or pass its name as a string."
44
+ );
45
+ }
46
+ throw new Error(
47
+ "createChatbot: `authTable` is required when the authenticated endpoint family is enabled. Pass the table conversations belong to \u2014 a table() handle (preferred) or its name: `registerChatbot(xano, { authTable: userTable })`. For an anonymous-only bot pass { authenticated: false, guest: true } instead, which needs no auth table."
48
+ );
49
+ }
50
+ const userIdType = opts.userIdType ?? "int";
51
+ if (userIdType !== "int" && userIdType !== "uuid") {
52
+ throw new Error(
53
+ `createChatbot: userIdType ${show(userIdType)} is not valid \u2014 pass "int" (the default) or "uuid". It must match the primary-key type of \`authTable\`; Xano allows no other primary-key type.`
54
+ );
55
+ }
56
+ const limits = {
57
+ historyLimit: {
58
+ value: opts.historyLimit ?? DEFAULT_HISTORY_LIMIT,
59
+ note: "It caps how many prior messages are replayed into the model on each send."
60
+ },
61
+ listLimit: {
62
+ value: opts.listLimit ?? DEFAULT_LIST_LIMIT,
63
+ note: "It caps how many conversations the list endpoint returns."
64
+ },
65
+ transcriptLimit: {
66
+ value: opts.transcriptLimit ?? DEFAULT_TRANSCRIPT_LIMIT,
67
+ note: "It caps how many messages a transcript endpoint returns."
68
+ }
69
+ };
70
+ for (const [name, { value, note }] of Object.entries(limits)) {
71
+ if (!Number.isInteger(value) || value <= 0) {
72
+ throw new Error(
73
+ `createChatbot: ${name} ${show(value)} is not valid \u2014 pass a positive integer. ${note}`
74
+ );
75
+ }
76
+ }
77
+ let rateLimit = false;
78
+ if (opts.rateLimit !== false) {
79
+ const rl = opts.rateLimit ?? {};
80
+ if (typeof rl !== "object" || rl === null) {
81
+ throw new Error(
82
+ `createChatbot: rateLimit ${show(rl)} is not valid \u2014 pass an object ({ max?, ttl?, error? }) or \`false\` to disable it.`
83
+ );
84
+ }
85
+ const max = rl.max ?? DEFAULT_RATE_LIMIT_MAX;
86
+ const ttl = rl.ttl ?? DEFAULT_RATE_LIMIT_TTL;
87
+ for (const [name, value] of Object.entries({ max, ttl })) {
88
+ if (!Number.isInteger(value) || value <= 0) {
89
+ throw new Error(
90
+ `createChatbot: rateLimit.${name} ${show(value)} is not valid \u2014 pass a positive integer. It bounds the model-invoking endpoints per caller; pass \`rateLimit: false\` to remove the limit.`
91
+ );
92
+ }
93
+ }
94
+ if (rl.error !== void 0 && (typeof rl.error !== "string" || rl.error.length === 0)) {
95
+ throw new Error(
96
+ `createChatbot: rateLimit.error ${show(rl.error)} is not valid \u2014 pass a non-empty string.`
97
+ );
98
+ }
99
+ rateLimit = { max, ttl, error: rl.error ?? "Too many messages. Please wait a moment and try again." };
100
+ }
101
+ const authoredTools = opts.tools ?? [];
102
+ if (!Array.isArray(authoredTools)) {
103
+ throw new Error(
104
+ `createChatbot: tools ${show(opts.tools)} is not valid \u2014 pass an array of tool() handles, tool names, or { tool, enabled?, auth? } entries.`
105
+ );
106
+ }
107
+ for (const [i, entry] of authoredTools.entries()) {
108
+ const ok = typeof entry === "string" && entry.length > 0 || typeof entry === "object" && entry !== null;
109
+ if (!ok) {
110
+ throw new Error(
111
+ `createChatbot: tools[${i}] ${show(entry)} is not valid \u2014 pass a tool() handle, a non-empty tool name, or a { tool, enabled?, auth? } entry.`
112
+ );
113
+ }
114
+ }
115
+ const scopedAgainst = authenticated ? opts.authTable : void 0;
116
+ const tools = scopedAgainst !== void 0 ? applyDefaultToolAuth(authoredTools, scopedAgainst) : authoredTools;
117
+ if (scopedAgainst !== void 0 && guest && tools.some((entry) => !isPublicToolEntry(entry))) {
118
+ console.warn(
119
+ "xanosdk: chatbot \u2014 both endpoint families are enabled and the agent's tools are scoped to the auth table, so a tool binds the caller on the authenticated endpoints and has no identity to bind on the PUBLIC guest ones. One agent serves both families and an entry carries one `auth`, so this cannot be resolved for you. Give a tool the model may call for a guest `{ tool, auth: false }` and keep `auth()` out of it, or register the guest bot as its own chatbot (own `routePrefix` and `names`) with its own tools."
120
+ );
121
+ }
122
+ if (opts.canonical !== void 0) {
123
+ if (typeof opts.canonical !== "string" || !CANONICAL_PATTERN.test(opts.canonical)) {
124
+ throw new Error(
125
+ `createChatbot: canonical ${show(opts.canonical)} is not a valid URL segment \u2014 it must be a non-empty string matching [A-Za-z0-9_-]+ (the alphabet Xano mints). It becomes the "<canonical>" in /api:<canonical>/chat/conversations.`
126
+ );
127
+ }
128
+ }
129
+ const routePrefix = opts.routePrefix ?? DEFAULT_ROUTE_PREFIX;
130
+ if (typeof routePrefix !== "string" || !/^[A-Za-z0-9_\-/]+$/.test(routePrefix) || routePrefix.startsWith("/") || routePrefix.endsWith("/")) {
131
+ throw new Error(
132
+ `createChatbot: routePrefix ${show(routePrefix)} is not valid \u2014 pass a non-empty path segment matching [A-Za-z0-9_-/]+ with no leading or trailing slash (e.g. "chat" or "support/chat"). It becomes the leading segment of every endpoint, as in <prefix>/conversations/{id}/send. A \`{param}\` marker is rejected: a prefix is not a place for a path param.`
133
+ );
134
+ }
135
+ const history = opts.history ?? false;
136
+ if (!(typeof history === "boolean" || history === "all" || typeof history === "number" && Number.isInteger(history) && history > 0)) {
137
+ throw new Error(
138
+ `createChatbot: history ${show(history)} is not a valid setting \u2014 pass \`false\` (off, the default), \`true\` (on at the engine's default capture depth), a positive integer (capture depth), or "all" (unlimited depth). The depth caps statements captured per record, not retention.`
139
+ );
140
+ }
141
+ const llmIn = opts.llm ?? {};
142
+ for (const field of ["prompt", "messages"]) {
143
+ if (field in llmIn && llmIn[field] !== void 0) {
144
+ throw new Error(
145
+ `createChatbot: llm.${field} is not configurable \u2014 this package owns the agent's run prompt, because that is how the conversation transcript reaches the model. Xano stores ONE prompt behind a prompt_type discriminator, so a supplied \`${field}\` would replace the transcript rather than add to it. Put your instructions in \`llm.systemPrompt\`, which is passed through.`
146
+ );
147
+ }
148
+ }
149
+ const basePrompt = llmIn.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
150
+ const systemPrompt = tools.length > 0 ? basePrompt + TOOLS_SYSTEM_PROMPT : basePrompt;
151
+ const llm = {
152
+ type: "xano-free",
153
+ ...llmIn,
154
+ systemPrompt,
155
+ // Set last: the transcript delivery mechanism, verified against a live
156
+ // instance. See `src/agent/chat-agent.ts` for why `messages` and not `prompt`.
157
+ messages: MESSAGES_TEMPLATE
158
+ };
159
+ return {
160
+ authTable: opts.authTable,
161
+ userIdType,
162
+ authenticated,
163
+ guest,
164
+ llm,
165
+ historyLimit: limits.historyLimit.value,
166
+ listLimit: limits.listLimit.value,
167
+ rateLimit,
168
+ tools,
169
+ transcriptLimit: limits.transcriptLimit.value,
170
+ canonical: opts.canonical,
171
+ history,
172
+ routePrefix,
173
+ names: { ...DEFAULT_NAMES, ...opts.names },
174
+ tags: opts.tags ?? ["xano:chatbot"]
175
+ };
176
+ }
177
+ var MESSAGES_TEMPLATE = "{{ $args.messages }}";
178
+
179
+ // src/tables/conversation.ts
180
+ import { table, f } from "@xano/sdk";
181
+ function conversationTable(opts) {
182
+ return table({
183
+ name: opts.names.conversation,
184
+ description: "A single chat thread. Owned by a user (authenticated) and/or reachable with a session token (guest).",
185
+ auth: false,
186
+ // Pinned rather than inherited: a table with no explicit `useXdo` takes the
187
+ // CONSUMER workspace's `use_xdo` at export, which would silently change this
188
+ // table's storage mode — and therefore its emitted bytes — depending on who
189
+ // installed it. `true` keeps the non-key columns in the internal `xdo` JSON
190
+ // column (and auto-prepends the engine's `gin(xdo)` index in canonical order).
191
+ useXdo: true,
192
+ tags: opts.tags,
193
+ schema: {
194
+ // Present only when the authenticated family is on. A guest-only install
195
+ // has no auth table to point at, and a nullable FK to nothing is worse
196
+ // than no column: it would still emit an `@` method carrying a guid that
197
+ // resolves to no registered table, which fails the export.
198
+ ...opts.authenticated ? {
199
+ user_id: f.tableRef(opts.authTable, {
200
+ type: opts.userIdType,
201
+ // Nullable exactly when guests are also enabled: a guest thread has
202
+ // no owner until it is claimed. With guests off, every conversation
203
+ // is created by an authenticated endpoint that always fills this in,
204
+ // so the tighter column is the honest one.
205
+ ...opts.guest ? { nullable: true } : {},
206
+ description: "The user this conversation belongs to. Null until a guest thread is claimed."
207
+ })
208
+ } : {},
209
+ // Present only when guests are enabled. This is a BEARER CAPABILITY: it is
210
+ // the whole of a guest's authorization, so it is minted by
211
+ // `security.create_uuid` (not derived from anything guessable) and its
212
+ // column is `internal` so a stray `db.get` without an explicit `output`
213
+ // list cannot leak another thread's token into a response.
214
+ ...opts.guest ? {
215
+ session_token: f.text({
216
+ access: "internal",
217
+ description: "Opaque bearer capability for guest access to this thread. Treat it like a password."
218
+ })
219
+ } : {},
220
+ title: f.text({
221
+ methods: ["trim"],
222
+ description: "Human-readable thread title. Seeded from the first user message."
223
+ }),
224
+ // Maintained by the reply function so a conversation list can sort by
225
+ // recency without joining the message table.
226
+ last_message_at: f.timestamp({
227
+ nullable: true,
228
+ description: "When the most recent message was appended. Null until the first send."
229
+ })
230
+ },
231
+ index: [
232
+ // The authenticated list endpoint's access path: every user's threads,
233
+ // newest first. Without it that endpoint is a full scan of every
234
+ // conversation in the workspace.
235
+ ...opts.authenticated ? [{ type: "btree", fields: [{ name: "user_id", op: "asc" }] }] : [],
236
+ // Unique, not merely indexed: a guest's entire authorization is "I hold
237
+ // this token", so two rows sharing one would make a single token address
238
+ // two threads. Uniqueness is what makes the lookup unambiguous.
239
+ ...opts.guest ? [{ type: "btree|unique", fields: [{ name: "session_token", op: "asc" }] }] : []
240
+ ]
241
+ });
242
+ }
243
+ var PUBLIC_CONVERSATION_FIELDS = [
244
+ "id",
245
+ "created_at",
246
+ "title",
247
+ "last_message_at"
248
+ ];
249
+
250
+ // src/tables/message.ts
251
+ import { table as table2, f as f2 } from "@xano/sdk";
252
+ var MESSAGE_ROLES = ["user", "assistant", "system"];
253
+ function messageTable(opts, conversation) {
254
+ return table2({
255
+ name: opts.names.message,
256
+ description: "One turn in a conversation. Replayed to the agent as an LLM message.",
257
+ auth: false,
258
+ // Pinned for the same reason as `conversation` — see that module.
259
+ useXdo: true,
260
+ tags: opts.tags,
261
+ schema: {
262
+ conversation_id: f2.tableRef(conversation, {
263
+ required: true,
264
+ description: "The thread this message belongs to."
265
+ }),
266
+ // An enum, NOT free text. An unrecognized role is a fatal agent error on
267
+ // the NEXT send, so the database is the right place to make it impossible.
268
+ role: f2.enum(MESSAGE_ROLES, {
269
+ required: true,
270
+ description: "Who produced this turn. The engine rejects any other value when replaying it."
271
+ }),
272
+ // `min:1` is the guard against the confabulation described in the header:
273
+ // an empty turn does not fail loudly, it produces a plausible fabrication.
274
+ content: f2.text({
275
+ required: true,
276
+ methods: ["min:1"],
277
+ description: "The message text. Must be non-empty \u2014 a blank turn makes the model confabulate."
278
+ })
279
+ },
280
+ index: [
281
+ // The access path for both the transcript endpoint and the reply
282
+ // function's history window: one thread's turns in order. A composite with
283
+ // `created_at` so the sort is served by the index rather than a filesort.
284
+ {
285
+ type: "btree",
286
+ fields: [
287
+ { name: "conversation_id", op: "asc" },
288
+ { name: "created_at", op: "asc" }
289
+ ]
290
+ }
291
+ ]
292
+ });
293
+ }
294
+ var PUBLIC_MESSAGE_FIELDS = [
295
+ "id",
296
+ "created_at",
297
+ "conversation_id",
298
+ "role",
299
+ "content"
300
+ ];
301
+
302
+ // src/agent/chat-agent.ts
303
+ import { agent } from "@xano/sdk";
304
+ function chatAgent(opts) {
305
+ return agent({
306
+ name: opts.names.agent,
307
+ description: "Conversational assistant. Receives the thread's recent turns as decoded LLM messages and returns the next one.",
308
+ tags: opts.tags,
309
+ tools: opts.tools,
310
+ llm: opts.llm
311
+ });
312
+ }
313
+
314
+ // src/functions/generate-reply.ts
315
+ import { defineFunction, input, s, c, inp, ref, expr, obj, col, withFilters, fl } from "@xano/sdk";
316
+ var TITLE_LENGTH = 60;
317
+ function generateReplyFn(opts, conversation, message, chatAgent2) {
318
+ return defineFunction({
319
+ name: opts.names.replyFn,
320
+ description: "Appends a user message, runs the chat agent over recent history, appends the reply, and returns it. Performs no authorization.",
321
+ tags: opts.tags,
322
+ input: {
323
+ conversation_id: input.int({
324
+ required: true,
325
+ description: "The thread to append to. NOT authorized here \u2014 the caller must have checked ownership."
326
+ }),
327
+ content: input.text({
328
+ required: true,
329
+ description: "The user's message text. Must be non-empty."
330
+ })
331
+ },
332
+ stack: [
333
+ // Defence in depth. Every endpoint in this package already rejects a blank
334
+ // message, so reaching this is a direct-caller mistake — but the failure it
335
+ // prevents is silent rather than loud, which is what earns it a second
336
+ // check here. An empty turn does not error at the provider; the model
337
+ // receives a blank message and fabricates a plausible prior conversation
338
+ // (verified — see AGENTS.md). A confabulated reply written into the
339
+ // transcript then poisons every subsequent turn's context.
340
+ // Compared TRIMMED: the endpoints trim at the input, but a direct
341
+ // `s.function.run` caller supplies `content` straight into this function
342
+ // and no input method runs for them. Comparing the raw value would let
343
+ // " " through on that path.
344
+ s.precondition({
345
+ expr: expr(withFilters(inp("content"), fl.trim()), "!=", c.text("")),
346
+ error_type: "badrequest",
347
+ error: c.text("Message content cannot be empty.")
348
+ }),
349
+ // 1. The user's turn. Written first so the history read below includes it —
350
+ // see the ordering note in the module header.
351
+ s.db.add({
352
+ table: message,
353
+ as: "user_message",
354
+ data: [
355
+ { name: "created_at", value: c.text("now") },
356
+ { name: "conversation_id", value: inp("conversation_id") },
357
+ { name: "role", value: c.text("user") },
358
+ { name: "content", value: inp("content") }
359
+ ]
360
+ }),
361
+ // 2. The context window: the most recent `historyLimit` turns of this
362
+ // thread. Sorted DESC and capped in the database so a long conversation
363
+ // costs a bounded read — then reversed below, because the model needs
364
+ // them oldest-first. `id` breaks a `created_at` tie: two messages written
365
+ // in the same second must still order deterministically, or a turn can
366
+ // appear to precede the one it answered.
367
+ s.db.query({
368
+ table: message,
369
+ where: expr(col("conversation_id"), "=", inp("conversation_id")),
370
+ output: ["role", "content"],
371
+ sort: [
372
+ { sortBy: "created_at", dir: "desc" },
373
+ { sortBy: "id", dir: "desc" }
374
+ ],
375
+ // `metadata: false` keeps this a bare array rather than the engine's
376
+ // paging envelope — `fl.reverse` and the map below both want the array.
377
+ paging: { per_page: opts.historyLimit, metadata: false },
378
+ as: "recent"
379
+ }),
380
+ // 3. Build exactly `[{ role, content }]`, oldest first.
381
+ //
382
+ // The projection is explicit rather than relying on the `output` list
383
+ // above. Extra keys are tolerated by the engine (verified), so this is
384
+ // not load-bearing today — it is load-bearing the moment someone adds a
385
+ // column to the message table, at which point the shape the provider
386
+ // sees stops depending on an `output` list edited in a different file.
387
+ s.array.map({
388
+ source: withFilters(ref("recent"), fl.reverse()),
389
+ transform: { role: ref("$this.role"), content: ref("$this.content") },
390
+ as: "turns"
391
+ }),
392
+ // 4. Render to JSON text. `messages` is a Twig-templated STRING, so the
393
+ // array has to arrive as its JSON serialization; the engine decodes it
394
+ // back into real message roles on the other side (verified — AGENTS.md).
395
+ s.set_var("messages_json", withFilters(ref("turns"), fl.json_encode())),
396
+ // 5. Run the agent. `args.messages` is what `{{ $args.messages }}` in the
397
+ // agent's `messages` template resolves to.
398
+ s.ai.agent.run({
399
+ agent: chatAgent2,
400
+ args: obj({ messages: ref("messages_json") }),
401
+ // Attaching tools to the agent is NOT enough — the RUN has to permit
402
+ // executing them. Without this the model is told the tools exist and can
403
+ // never call one, so it answers "I don't have access to that" and the
404
+ // whole feature silently does nothing (observed live before this line
405
+ // existed). Emitted only when tools are configured, so a tool-less
406
+ // install's bundle is unchanged.
407
+ ...opts.tools.length > 0 ? { allowToolExecution: c.bool(true) } : {},
408
+ as: "run"
409
+ }),
410
+ // 5b. The names of the tools this run actually executed — the only
411
+ // observable a client has that a tool ran at all. It exists because a
412
+ // live debugging session cost three deploy cycles when `tool_calls`
413
+ // came back null whether a tool had run, thrown, or never been called
414
+ // (see AGENTS.md).
415
+ //
416
+ // ⚠ WHERE the calls live was settled against a live instance, because
417
+ // the two obvious answers are both wrong. This package used to read
418
+ // `run.tool_calls`, a key the envelope does not carry. Core types the
419
+ // envelope with a top-level `toolCalls` — which IS present and is
420
+ // always `[]` on a live run, even one whose tool wrote a row. The
421
+ // calls are in
422
+ // `steps[].content[]`: one entry of `type: "tool-call"` carrying
423
+ // `toolName`, followed by a `tool-result` entry carrying the tool's
424
+ // whole return value.
425
+ //
426
+ // NAMES only, never the entries. A tool-call carries the arguments the
427
+ // model produced and a tool-result carries what the tool returned;
428
+ // both can hold data the caller never asked for. The name is the whole
429
+ // of what a client needs to know a tool ran.
430
+ //
431
+ // A loop rather than the one-line `fl.map` lambda that also works
432
+ // (verified). Core is explicit that a lambda is an escape hatch drawing
433
+ // on a workspace-wide worker pool, and every send in every install
434
+ // would pay it — for work four ordinary statements express.
435
+ //
436
+ // Emitted only when tools are configured, so a tool-less install's
437
+ // bundle is unchanged — the same rule as `allowToolExecution` above.
438
+ ...opts.tools.length > 0 ? [
439
+ // `fl.get` with a default rather than `ref(..., { safe: true })`:
440
+ // the safe form defaults to NULL, and looping over null is not a
441
+ // shape worth relying on. No steps means no names.
442
+ s.set_var("run_steps", withFilters(ref("run"), fl.get(c.text("steps"), c.array([])))),
443
+ s.set_var("tool_call_names", c.array([])),
444
+ s.foreach({
445
+ as: "step",
446
+ list: ref("run_steps"),
447
+ body: [
448
+ // `index_by` groups the step's parts by `type` and `get` takes
449
+ // one group — which is how the tool-CALLS are kept apart from
450
+ // the tool-RESULTS. Both carry `toolName`, so skipping this
451
+ // would report every call twice.
452
+ s.set_var(
453
+ "step_calls",
454
+ withFilters(
455
+ ref("step.content", { safe: true }),
456
+ fl.safe_array(),
457
+ fl.index_by(c.text("type")),
458
+ fl.get(c.text("tool-call"), c.array([]))
459
+ )
460
+ ),
461
+ s.array.map({
462
+ source: ref("step_calls"),
463
+ as: "step_names",
464
+ // The entry's key for the name is not a contract this package
465
+ // owns, so all three spellings are tried and the first
466
+ // non-null wins. Safe refs throughout: a missing key must
467
+ // resolve to null, not take the request down.
468
+ transform: withFilters(
469
+ ref("$this.toolName", { safe: true }),
470
+ fl.first_notnull(ref("$this.tool_name", { safe: true })),
471
+ fl.first_notnull(ref("$this.name", { safe: true }))
472
+ )
473
+ }),
474
+ // Append, so a tool called twice is reported twice and the order
475
+ // is the order the model called them in.
476
+ s.set_var(
477
+ "tool_call_names",
478
+ withFilters(ref("tool_call_names"), fl.array_merge(ref("step_names")))
479
+ )
480
+ ]
481
+ })
482
+ ] : [],
483
+ // 6. A blank completion would fail the message table's `min:1` check as an
484
+ // opaque column-validation error naming a column the caller never sent.
485
+ // Report it as what it is instead: the model returned nothing.
486
+ s.precondition({
487
+ expr: expr(ref("run.result"), "!=", c.text("")),
488
+ error_type: "standard",
489
+ error: c.text("The assistant returned an empty reply. Try again.")
490
+ }),
491
+ // 7. The assistant's turn.
492
+ s.db.add({
493
+ table: message,
494
+ as: "assistant_message",
495
+ data: [
496
+ { name: "created_at", value: c.text("now") },
497
+ { name: "conversation_id", value: inp("conversation_id") },
498
+ { name: "role", value: c.text("assistant") },
499
+ { name: "content", value: ref("run.result") }
500
+ ]
501
+ }),
502
+ // 8. Touch the thread so a conversation list can sort by recency without
503
+ // joining the message table. Read first, because the title is seeded
504
+ // from the first message only when it is still blank.
505
+ s.db.get({
506
+ table: conversation,
507
+ fieldName: "id",
508
+ fieldValue: inp("conversation_id"),
509
+ output: ["id", "title"],
510
+ as: "conversation"
511
+ }),
512
+ s.conditional({
513
+ when: expr(ref("conversation.title"), "=", c.text("")),
514
+ then: [
515
+ s.db.edit({
516
+ table: conversation,
517
+ fieldName: "id",
518
+ fieldValue: inp("conversation_id"),
519
+ data: [
520
+ { name: "last_message_at", value: c.text("now") },
521
+ // First message wins the title, truncated. `substr` rather than a
522
+ // model-generated summary on purpose: titling is not worth a second
523
+ // LLM call on the request path, and a deterministic prefix cannot
524
+ // fail, cost tokens, or return something unsafe to display.
525
+ {
526
+ name: "title",
527
+ value: withFilters(inp("content"), fl.substr(c.int(0), c.int(TITLE_LENGTH)))
528
+ }
529
+ ]
530
+ })
531
+ ],
532
+ else: [
533
+ s.db.edit({
534
+ table: conversation,
535
+ fieldName: "id",
536
+ fieldValue: inp("conversation_id"),
537
+ data: [{ name: "last_message_at", value: c.text("now") }]
538
+ })
539
+ ]
540
+ })
541
+ ],
542
+ response: {
543
+ conversation_id: inp("conversation_id"),
544
+ reply: ref("run.result"),
545
+ message_id: ref("assistant_message.id"),
546
+ // Always an ARRAY of tool names, never null — see step 5b. A tool-less
547
+ // install returns the empty array as a constant: no tool can have run, and
548
+ // a client's `tool_calls.length` should not have to branch on how the bot
549
+ // was configured. `filter_null` drops any record whose name could not be
550
+ // read, so the field never reports a call it cannot name.
551
+ tool_calls: opts.tools.length > 0 ? withFilters(ref("tool_call_names"), fl.filter_null()) : c.array([])
552
+ },
553
+ // Declared for the same reason the send endpoints declare it (see
554
+ // `api/types.ts`), and load-bearing for the build: a `function.run` of this
555
+ // def carries `InferResponse` of it, and the derived shape
556
+ // is too deep for the dts emit of either send endpoint (TS2589).
557
+ responseShape: {}
558
+ });
559
+ }
560
+
561
+ // src/api/group.ts
562
+ import { apiGroup } from "@xano/sdk";
563
+ function chatbotGroup(opts) {
564
+ return apiGroup({
565
+ name: opts.names.apiGroup,
566
+ description: "Conversational AI endpoints: create and list conversations, read a transcript, and send a message to the agent.",
567
+ tags: opts.tags,
568
+ // Only set when pinned — leaving it undefined is what defers identity to the
569
+ // consumer's `xano.lock` (or to a random segment assigned at import).
570
+ ...opts.canonical !== void 0 ? { canonical: opts.canonical } : {},
571
+ history: opts.history
572
+ });
573
+ }
574
+
575
+ // src/api/authenticated.ts
576
+ import { query, input as input2, s as s2, c as c2, inp as inp2, ref as ref2, expr as expr2, col as col2, auth, withFilters as withFilters2, fl as fl2, statements, setVar } from "@xano/sdk";
577
+ var ownershipGuard = (conversation) => statements(
578
+ s2.db.get({
579
+ table: conversation,
580
+ fieldName: "id",
581
+ fieldValue: inp2("conversation_id"),
582
+ output: ["id", "user_id"],
583
+ as: "conversation"
584
+ }),
585
+ s2.precondition({
586
+ expr: expr2(ref2("conversation.user_id", { safe: true }), "=", auth("id")),
587
+ error_type: "notfound",
588
+ error: c2.text("Conversation not found.")
589
+ })
590
+ );
591
+ function authenticatedQueries(opts, group, conversation, message, replyFn) {
592
+ const base = { apiGroup: group, auth: opts.authTable, tags: opts.tags };
593
+ const p = opts.routePrefix;
594
+ const mintSessionToken = s2.security.create_uuid({ as: "session_token" });
595
+ const addConversation = (withToken) => s2.db.add({
596
+ table: conversation,
597
+ as: "conversation",
598
+ output: PUBLIC_CONVERSATION_FIELDS,
599
+ data: [
600
+ { name: "created_at", value: c2.text("now") },
601
+ { name: "user_id", value: auth("id") },
602
+ // Truncated to the same length the reply function's auto-title uses, so
603
+ // a caller-supplied title and a generated one cannot differ in bound.
604
+ { name: "title", value: withFilters2(inp2("title"), fl2.substr(c2.int(0), c2.int(TITLE_LENGTH))) },
605
+ // Never returned: absent from PUBLIC_CONVERSATION_FIELDS, and the column
606
+ // is `internal`.
607
+ ...withToken ? [{ name: "session_token", value: ref2("session_token") }] : []
608
+ ]
609
+ });
610
+ const createConversation = query({
611
+ ...base,
612
+ name: `${p}/conversations/create`,
613
+ verb: "POST",
614
+ description: "Create a new conversation owned by the authenticated user",
615
+ input: {
616
+ title: input2.text({
617
+ methods: ["trim"],
618
+ description: "Optional thread title. Left blank, the first message's opening words become the title."
619
+ })
620
+ },
621
+ // Two `statements(...)` branches rather than one array with a
622
+ // `...(opts.guest ? [x] : [])` spread. That spread is not a fixed tuple, so
623
+ // it widens the stack and resolves this endpoint's `ref("conversation")` —
624
+ // and therefore its whole response type — to `unknown` in every consumer.
625
+ // Same trap as a `Statement[]` helper; see `ownershipGuard` above.
626
+ //
627
+ // The `data` array below keeps its conditional spread: it is a statement
628
+ // FIELD, not the stack, so no `as`/`ref()` inference depends on its tuple.
629
+ stack: opts.guest ? statements(mintSessionToken, addConversation(true)) : statements(addConversation(false)),
630
+ response: ref2("conversation")
631
+ });
632
+ const listConversations = query({
633
+ ...base,
634
+ name: `${p}/conversations`,
635
+ verb: "GET",
636
+ description: "List the authenticated user's conversations, most recently active first",
637
+ input: {},
638
+ stack: [
639
+ s2.db.query({
640
+ table: conversation,
641
+ where: expr2(col2("user_id"), "=", auth("id")),
642
+ output: PUBLIC_CONVERSATION_FIELDS,
643
+ // `last_message_at` is null until the first send, so a brand-new empty
644
+ // thread sorts last under `desc`. `id desc` is the tiebreak that keeps it
645
+ // visible at a stable position rather than shuffling between requests.
646
+ sort: [
647
+ { sortBy: "last_message_at", dir: "desc" },
648
+ { sortBy: "id", dir: "desc" }
649
+ ],
650
+ paging: { per_page: opts.listLimit, metadata: false },
651
+ as: "conversations"
652
+ })
653
+ ],
654
+ response: ref2("conversations")
655
+ });
656
+ const listMessages = query({
657
+ ...base,
658
+ name: `${p}/conversations/{conversation_id}/messages`,
659
+ verb: "GET",
660
+ description: "Read a conversation's transcript, oldest message first",
661
+ input: { conversation_id: input2.int({ required: true }) },
662
+ stack: [
663
+ ...ownershipGuard(conversation),
664
+ // Newest-N in the database, then reversed for display — the same shape the
665
+ // reply function uses, and for the same reason: a capped read. A thread
666
+ // longer than the cap loses its OLDEST messages from this response, which
667
+ // is the right end to drop for a chat UI that renders from the bottom.
668
+ s2.db.query({
669
+ table: message,
670
+ where: expr2(col2("conversation_id"), "=", inp2("conversation_id")),
671
+ output: PUBLIC_MESSAGE_FIELDS,
672
+ sort: [
673
+ { sortBy: "created_at", dir: "desc" },
674
+ { sortBy: "id", dir: "desc" }
675
+ ],
676
+ paging: { per_page: opts.transcriptLimit, metadata: false },
677
+ as: "recent"
678
+ }),
679
+ s2.set_var("messages", withFilters2(ref2("recent"), fl2.reverse()))
680
+ ],
681
+ response: ref2("messages"),
682
+ // Declared, not derived — the same exception `sendMessage` makes, for the
683
+ // same reason. The response is a `set_var` bound to an UNTYPED filter
684
+ // (`fl.reverse` is `typed: false`, result `<T>[]`), and core's static walk
685
+ // resolves both a `set_var` output and an untyped filter's result to
686
+ // `unknown`. Without this the transcript endpoint hands every consumer
687
+ // `unknown` and the frontend loses the projection entirely.
688
+ //
689
+ // The shape is not a guess: `output: PUBLIC_MESSAGE_FIELDS` is what the
690
+ // db.query above projects, and `PublicMessage` is derived from that same
691
+ // array — so editing the array still moves this type.
692
+ responseShape: []
693
+ });
694
+ const sendRateLimit = opts.rateLimit ? statements(
695
+ s2.redis.ratelimit({
696
+ key: withFilters2(c2.text(`${p}:send:`), fl2.concat(auth("id"))),
697
+ max: c2.int(opts.rateLimit.max),
698
+ ttl: c2.int(opts.rateLimit.ttl),
699
+ error: c2.text(opts.rateLimit.error)
700
+ })
701
+ ) : statements();
702
+ const sendMessage = query({
703
+ ...base,
704
+ name: `${p}/conversations/{conversation_id}/send`,
705
+ verb: "POST",
706
+ description: "Append a message to the conversation and return the agent's reply",
707
+ input: {
708
+ conversation_id: input2.int({ required: true }),
709
+ // `trim` is load-bearing, not cosmetic. Without it a whitespace-only
710
+ // message (" ") passes `required` (length 3) AND passes the reply
711
+ // function's `content != ""` guard, so a blank turn reaches the model,
712
+ // which confabulates — verified live: it replied "I can't respond to an
713
+ // empty message" and BOTH turns were written to the transcript, poisoning
714
+ // every later turn's context. Trimmed, " " becomes "" and the engine
715
+ // refuses it up front as a missing param, like any other empty content.
716
+ content: input2.text({
717
+ required: false,
718
+ methods: ["trim"],
719
+ description: "The user's message. Must be non-empty after trimming."
720
+ }),
721
+ message: input2.text({
722
+ required: false,
723
+ methods: ["trim"],
724
+ description: "Alias for `content`."
725
+ }),
726
+ prompt: input2.text({
727
+ required: false,
728
+ methods: ["trim"],
729
+ description: "Alias for `content`."
730
+ })
731
+ },
732
+ stack: [
733
+ ...sendRateLimit,
734
+ ...ownershipGuard(conversation),
735
+ setVar(
736
+ "resolved_turn_content",
737
+ withFilters2(inp2("content"), [
738
+ fl2.first_notempty(inp2("message")),
739
+ fl2.first_notempty(inp2("prompt"))
740
+ ])
741
+ ),
742
+ s2.precondition({
743
+ expr: expr2(ref2("resolved_turn_content"), "!=", c2.text("")),
744
+ error_type: "badrequest",
745
+ error: c2.text("send: non-empty message content must be provided via `content`, `message`, or `prompt`.")
746
+ }),
747
+ // Everything past the guard is shared with the guest family — see
748
+ // `functions/generate-reply.ts`.
749
+ s2.function.run({
750
+ fn: replyFn,
751
+ as: "reply",
752
+ input: { conversation_id: inp2("conversation_id"), content: ref2("resolved_turn_content") }
753
+ })
754
+ ],
755
+ response: ref2("reply"),
756
+ // Declared, not derived: `reply` is the agent run's `.result`, which core
757
+ // resolves to `unknown` because the agent carries no structured-output
758
+ // schema. See `api/types.ts`.
759
+ responseShape: {}
760
+ });
761
+ const deleteConversation = query({
762
+ ...base,
763
+ name: `${p}/conversations/{conversation_id}`,
764
+ verb: "DELETE",
765
+ description: "Delete a conversation and every message in it",
766
+ input: { conversation_id: input2.int({ required: true }) },
767
+ stack: [
768
+ ...ownershipGuard(conversation),
769
+ // Messages first. The other order would leave orphaned message rows behind
770
+ // if the second delete failed, and nothing would ever collect them — the FK
771
+ // is a reference, not a cascade.
772
+ s2.db.bulk.delete({
773
+ table: message,
774
+ where: expr2(col2("conversation_id"), "=", inp2("conversation_id"))
775
+ }),
776
+ s2.db.del({ table: conversation, fieldName: "id", fieldValue: inp2("conversation_id") })
777
+ ],
778
+ // No body. The thread is gone; there is nothing truthful to return about it.
779
+ response: c2.null()
780
+ });
781
+ const claimConversation = opts.guest ? query({
782
+ ...base,
783
+ name: `${p}/conversations/{conversation_id}/claim`,
784
+ verb: "POST",
785
+ description: "Attach an unclaimed guest conversation to the authenticated user",
786
+ input: {
787
+ conversation_id: input2.int({ required: true }),
788
+ session_token: input2.text({
789
+ required: true,
790
+ description: "The guest session token the thread was created with."
791
+ })
792
+ },
793
+ stack: [
794
+ s2.db.get({
795
+ table: conversation,
796
+ fieldName: "id",
797
+ fieldValue: inp2("conversation_id"),
798
+ // `session_token` is an `internal` column, so it must be named
799
+ // explicitly — an `output` list overrides column visibility. It stays
800
+ // inside this stack and is never returned.
801
+ output: ["id", "user_id", "session_token"],
802
+ as: "conversation"
803
+ }),
804
+ // The token must match. Same `notfound` as everywhere else, so a wrong
805
+ // token cannot be distinguished from a wrong id.
806
+ s2.precondition({
807
+ expr: expr2(ref2("conversation.session_token", { safe: true }), "=", inp2("session_token")),
808
+ error_type: "notfound",
809
+ error: c2.text("Conversation not found.")
810
+ }),
811
+ // And it must be UNCLAIMED. Without this, anyone who ever held the guest
812
+ // token could re-claim the thread away from its current owner — the
813
+ // token outlives the guest phase, so this is what ends its authority.
814
+ s2.precondition({
815
+ expr: expr2(ref2("conversation.user_id", { safe: true }), "=", c2.null()),
816
+ error_type: "accessdenied",
817
+ error: c2.text("This conversation has already been claimed.")
818
+ }),
819
+ s2.db.edit({
820
+ table: conversation,
821
+ fieldName: "id",
822
+ fieldValue: inp2("conversation_id"),
823
+ output: PUBLIC_CONVERSATION_FIELDS,
824
+ data: [{ name: "user_id", value: auth("id") }],
825
+ as: "claimed"
826
+ })
827
+ ],
828
+ response: ref2("claimed")
829
+ }) : void 0;
830
+ return {
831
+ createConversation,
832
+ listConversations,
833
+ listMessages,
834
+ sendMessage,
835
+ deleteConversation,
836
+ /** `undefined` unless the guest family is also enabled — see above. */
837
+ claimConversation,
838
+ /**
839
+ * Every def above that actually exists, ready to register. Built as one array
840
+ * literal rather than a `push`, so the element type is the union of the
841
+ * handles rather than the first one's — each `query()` handle carries its own
842
+ * literal `name` in its type, so a mutable array would fix that type to
843
+ * whichever def happened to be first.
844
+ */
845
+ all: [
846
+ createConversation,
847
+ listConversations,
848
+ listMessages,
849
+ sendMessage,
850
+ deleteConversation,
851
+ ...claimConversation ? [claimConversation] : []
852
+ ]
853
+ };
854
+ }
855
+
856
+ // src/api/guest.ts
857
+ import { query as query2, input as input3, s as s3, c as c3, inp as inp3, ref as ref3, expr as expr3, col as col3, withFilters as withFilters3, fl as fl3, statements as statements2, sys, setVar as setVar2 } from "@xano/sdk";
858
+ var capabilityGuard = (conversation, authenticated) => {
859
+ const nonEmptyToken = s3.precondition({
860
+ expr: expr3(inp3("session_token"), "!=", c3.text("")),
861
+ error_type: "notfound",
862
+ error: c3.text("Conversation not found.")
863
+ });
864
+ const load = s3.db.get({
865
+ table: conversation,
866
+ fieldName: "id",
867
+ fieldValue: inp3("conversation_id"),
868
+ // `session_token` is `internal`; an explicit `output` list is what makes it
869
+ // readable inside the stack. It is never placed in a response.
870
+ output: authenticated ? ["id", "user_id", "session_token"] : ["id", "session_token"],
871
+ as: "conversation"
872
+ });
873
+ const tokenMatches = s3.precondition({
874
+ expr: expr3(ref3("conversation.session_token", { safe: true }), "=", inp3("session_token")),
875
+ error_type: "notfound",
876
+ error: c3.text("Conversation not found.")
877
+ });
878
+ const stillUnclaimed = s3.precondition({
879
+ expr: expr3(ref3("conversation.user_id", { safe: true }), "=", c3.null()),
880
+ error_type: "accessdenied",
881
+ error: c3.text("This conversation now belongs to an account. Sign in to continue it.")
882
+ });
883
+ return authenticated ? statements2(nonEmptyToken, load, tokenMatches, stillUnclaimed) : statements2(nonEmptyToken, load, tokenMatches);
884
+ };
885
+ function guestQueries(opts, group, conversation, message, replyFn) {
886
+ const base = { apiGroup: group, tags: opts.tags };
887
+ const guard = capabilityGuard(conversation, opts.authenticated);
888
+ const p = opts.routePrefix;
889
+ const limit = (scope) => opts.rateLimit ? statements2(
890
+ s3.redis.ratelimit({
891
+ key: withFilters3(c3.text(`${p}:guest:${scope}:`), fl3.concat(sys.remoteIp())),
892
+ max: c3.int(opts.rateLimit.max),
893
+ ttl: c3.int(opts.rateLimit.ttl),
894
+ error: c3.text(opts.rateLimit.error)
895
+ })
896
+ ) : statements2();
897
+ const createConversation = query2({
898
+ ...base,
899
+ name: `${p}/guest/conversations/create`,
900
+ verb: "POST",
901
+ description: "Create an anonymous conversation and return its session token",
902
+ input: {
903
+ title: input3.text({
904
+ methods: ["trim"],
905
+ description: "Optional thread title. Left blank, the first message's opening words become the title."
906
+ })
907
+ },
908
+ stack: [
909
+ ...limit("create"),
910
+ s3.security.create_uuid({ as: "session_token" }),
911
+ s3.db.add({
912
+ table: conversation,
913
+ as: "conversation",
914
+ output: PUBLIC_CONVERSATION_FIELDS,
915
+ data: [
916
+ { name: "created_at", value: c3.text("now") },
917
+ { name: "session_token", value: ref3("session_token") },
918
+ { name: "title", value: withFilters3(inp3("title"), fl3.substr(c3.int(0), c3.int(TITLE_LENGTH))) }
919
+ // `user_id` is deliberately not written. It stays null, which is what
920
+ // marks the thread claimable.
921
+ ]
922
+ })
923
+ ],
924
+ // Spread the conversation projection, then add the token beside it. The token
925
+ // comes from `ref("session_token")` — the minted value — NOT from the written
926
+ // row, so the `internal` column never has to be read back to serve it.
927
+ response: {
928
+ id: ref3("conversation.id"),
929
+ created_at: ref3("conversation.created_at"),
930
+ title: ref3("conversation.title"),
931
+ last_message_at: ref3("conversation.last_message_at"),
932
+ session_token: ref3("session_token")
933
+ }
934
+ });
935
+ const listMessages = query2({
936
+ ...base,
937
+ name: `${p}/guest/conversations/{conversation_id}/messages`,
938
+ verb: "GET",
939
+ description: "Read a guest conversation's transcript, oldest message first",
940
+ input: {
941
+ conversation_id: input3.int({ required: true }),
942
+ session_token: input3.text({
943
+ required: true,
944
+ description: "The token returned when the conversation was created."
945
+ })
946
+ },
947
+ stack: [
948
+ ...guard,
949
+ s3.db.query({
950
+ table: message,
951
+ where: expr3(col3("conversation_id"), "=", inp3("conversation_id")),
952
+ output: PUBLIC_MESSAGE_FIELDS,
953
+ sort: [
954
+ { sortBy: "created_at", dir: "desc" },
955
+ { sortBy: "id", dir: "desc" }
956
+ ],
957
+ paging: { per_page: opts.transcriptLimit, metadata: false },
958
+ as: "recent"
959
+ }),
960
+ s3.set_var("messages", withFilters3(ref3("recent"), fl3.reverse()))
961
+ ],
962
+ response: ref3("messages"),
963
+ // Declared, not derived — the same exception `sendMessage` makes, for the
964
+ // same reason. The response is a `set_var` bound to an UNTYPED filter
965
+ // (`fl.reverse` is `typed: false`, result `<T>[]`), and core's static walk
966
+ // resolves both a `set_var` output and an untyped filter's result to
967
+ // `unknown`. Without this the transcript endpoint hands every consumer
968
+ // `unknown` and the frontend loses the projection entirely.
969
+ //
970
+ // The shape is not a guess: `output: PUBLIC_MESSAGE_FIELDS` is what the
971
+ // db.query above projects, and `PublicMessage` is derived from that same
972
+ // array — so editing the array still moves this type.
973
+ responseShape: []
974
+ });
975
+ const sendMessage = query2({
976
+ ...base,
977
+ name: `${p}/guest/conversations/{conversation_id}/send`,
978
+ verb: "POST",
979
+ description: "Append a message to a guest conversation and return the agent's reply",
980
+ input: {
981
+ conversation_id: input3.int({ required: true }),
982
+ session_token: input3.text({ required: true }),
983
+ // `trim` is load-bearing, not cosmetic. Without it a whitespace-only
984
+ // message (" ") passes `required` (length 3) AND passes the reply
985
+ // function's `content != ""` guard, so a blank turn reaches the model,
986
+ // which confabulates — verified live: it replied "I can't respond to an
987
+ // empty message" and BOTH turns were written to the transcript, poisoning
988
+ // every later turn's context. Trimmed, " " becomes "" and the engine
989
+ // refuses it up front as a missing param, like any other empty content.
990
+ content: input3.text({
991
+ required: false,
992
+ methods: ["trim"],
993
+ description: "The user's message. Must be non-empty after trimming."
994
+ }),
995
+ message: input3.text({
996
+ required: false,
997
+ methods: ["trim"],
998
+ description: "Alias for `content`."
999
+ }),
1000
+ prompt: input3.text({
1001
+ required: false,
1002
+ methods: ["trim"],
1003
+ description: "Alias for `content`."
1004
+ })
1005
+ },
1006
+ stack: [
1007
+ ...limit("send"),
1008
+ ...guard,
1009
+ setVar2(
1010
+ "resolved_turn_content",
1011
+ withFilters3(inp3("content"), [
1012
+ fl3.first_notempty(inp3("message")),
1013
+ fl3.first_notempty(inp3("prompt"))
1014
+ ])
1015
+ ),
1016
+ s3.precondition({
1017
+ expr: expr3(ref3("resolved_turn_content"), "!=", c3.text("")),
1018
+ error_type: "badrequest",
1019
+ error: c3.text("send: non-empty message content must be provided via `content`, `message`, or `prompt`.")
1020
+ }),
1021
+ s3.function.run({
1022
+ fn: replyFn,
1023
+ as: "reply",
1024
+ input: { conversation_id: inp3("conversation_id"), content: ref3("resolved_turn_content") }
1025
+ })
1026
+ ],
1027
+ response: ref3("reply"),
1028
+ // Declared, not derived: `reply` is the agent run's `.result`, which core
1029
+ // resolves to `unknown` because the agent carries no structured-output
1030
+ // schema. See `api/types.ts`.
1031
+ responseShape: {}
1032
+ });
1033
+ const deleteConversation = query2({
1034
+ ...base,
1035
+ // A POST, not a DELETE. The token is the credential and it would have to
1036
+ // travel as a query string on a DELETE — into access logs, proxies and
1037
+ // `Referer` headers. A body keeps it out of the URL. Deliberate deviation
1038
+ // from the authenticated family, whose DELETE carries no secret in the URL.
1039
+ name: `${p}/guest/conversations/{conversation_id}/delete`,
1040
+ verb: "POST",
1041
+ description: "Delete a guest conversation and every message in it",
1042
+ input: {
1043
+ conversation_id: input3.int({ required: true }),
1044
+ session_token: input3.text({ required: true })
1045
+ },
1046
+ stack: [
1047
+ ...guard,
1048
+ s3.db.bulk.delete({
1049
+ table: message,
1050
+ where: expr3(col3("conversation_id"), "=", inp3("conversation_id"))
1051
+ }),
1052
+ s3.db.del({ table: conversation, fieldName: "id", fieldValue: inp3("conversation_id") })
1053
+ ],
1054
+ response: c3.null()
1055
+ });
1056
+ return {
1057
+ createConversation,
1058
+ listMessages,
1059
+ sendMessage,
1060
+ deleteConversation,
1061
+ all: [createConversation, listMessages, sendMessage, deleteConversation]
1062
+ };
1063
+ }
1064
+
1065
+ // src/register.ts
1066
+ var installed = /* @__PURE__ */ new WeakSet();
1067
+ function createChatbot(opts = {}) {
1068
+ const options = resolveOptions(opts);
1069
+ const conversation = conversationTable(options);
1070
+ const message = messageTable(options, conversation);
1071
+ const agent2 = chatAgent(options);
1072
+ const replyFn = generateReplyFn(options, conversation, message, agent2);
1073
+ const group = chatbotGroup(options);
1074
+ const authenticated = options.authenticated ? authenticatedQueries(options, group, conversation, message, replyFn) : void 0;
1075
+ const guest = options.guest ? guestQueries(options, group, conversation, message, replyFn) : void 0;
1076
+ return {
1077
+ options,
1078
+ conversation,
1079
+ message,
1080
+ agent: agent2,
1081
+ replyFn,
1082
+ group,
1083
+ authenticated,
1084
+ guest,
1085
+ queries: [...authenticated?.all ?? [], ...guest?.all ?? []]
1086
+ // The conditional families are decided by `AuthenticatedOf`/`GuestOf` from
1087
+ // the OPTIONS type, while the values above are decided by the RESOLVED
1088
+ // options at runtime. The two agree by construction — `resolveOptions`
1089
+ // applies exactly the defaults those conditionals encode — but the compiler
1090
+ // cannot see that a resolved boolean came from a literal, so the bridge is
1091
+ // asserted once, here, rather than by every caller writing `!`.
1092
+ };
1093
+ }
1094
+ function registerChatbot(xano, opts = {}) {
1095
+ if (installed.has(xano)) {
1096
+ throw new Error(
1097
+ "registerChatbot: already called on this Xano instance. Register the chatbot set once \u2014 a second registration duplicates every def, which core cannot catch (the two sets are distinct objects sharing names) and which surfaces at export() as \"Duplicate object guid \u2026 shared by \\\"dbo/conversation\\\" and \\\"dbo/conversation\\\"\". To run TWO chatbots in one workspace, give the second one its own `routePrefix` AND `names` \u2014 the prefix is what keeps the ENDPOINT guids apart (a query's identity derives from its route name), and `names` keeps the tables, agent, function and group apart: registerChatbot(xano, { routePrefix: 'support', names: { conversation: 'support_conversation', message: 'support_message', agent: 'support_agent', apiGroup: 'Support', replyFn: 'support/reply' } })."
1098
+ );
1099
+ }
1100
+ const bot = createChatbot(opts);
1101
+ xano.registerTables([bot.conversation, bot.message]).registerAgents([bot.agent]).registerFunctions([bot.replyFn]).registerApiGroups([bot.group]).registerQueries(bot.queries);
1102
+ installed.add(xano);
1103
+ return { ...bot, xano };
1104
+ }
1105
+ export {
1106
+ DEFAULT_HISTORY_LIMIT,
1107
+ DEFAULT_LIST_LIMIT,
1108
+ DEFAULT_NAMES,
1109
+ DEFAULT_SYSTEM_PROMPT,
1110
+ DEFAULT_TRANSCRIPT_LIMIT,
1111
+ MESSAGES_TEMPLATE,
1112
+ MESSAGE_ROLES,
1113
+ PUBLIC_CONVERSATION_FIELDS,
1114
+ PUBLIC_MESSAGE_FIELDS,
1115
+ TITLE_LENGTH,
1116
+ TOOLS_SYSTEM_PROMPT,
1117
+ authenticatedQueries,
1118
+ chatAgent,
1119
+ chatbotGroup,
1120
+ conversationTable,
1121
+ createChatbot,
1122
+ generateReplyFn,
1123
+ guestQueries,
1124
+ messageTable,
1125
+ registerChatbot,
1126
+ resolveOptions
1127
+ };
1128
+ //# sourceMappingURL=index.js.map