@objectstack/service-ai 5.2.0 → 6.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -112,7 +112,9 @@ var init_data_tools = __esm({
112
112
  properties: {
113
113
  field: { type: "string" },
114
114
  order: { type: "string", enum: ["asc", "desc"] }
115
- }
115
+ },
116
+ required: ["field", "order"],
117
+ additionalProperties: false
116
118
  },
117
119
  description: 'Sort order (e.g. [{ "field": "created_at", "order": "desc" }])'
118
120
  },
@@ -182,7 +184,8 @@ var init_data_tools = __esm({
182
184
  description: "Result column alias"
183
185
  }
184
186
  },
185
- required: ["function", "alias"]
187
+ required: ["function", "alias"],
188
+ additionalProperties: false
186
189
  },
187
190
  description: "Aggregation definitions"
188
191
  },
@@ -841,8 +844,112 @@ var MemoryLLMAdapter = class {
841
844
  async chat(messages, options) {
842
845
  const lastUserMessage = [...messages].reverse().find((m) => m.role === "user");
843
846
  const userContent = lastUserMessage?.content;
844
- const text = typeof userContent === "string" ? userContent : "(complex content)";
845
- const content = lastUserMessage ? `[memory] ${text}` : "[memory] (no user message)";
847
+ const userText = typeof userContent === "string" ? userContent : "(complex content)";
848
+ const tools = options?.tools;
849
+ const hasQueryDataTool = Array.isArray(tools) && tools.some((t) => t?.name === "query_data");
850
+ const alreadyCalledQueryData = messages.some(
851
+ (m) => m.role === "tool" && Array.isArray(m.content) && m.content.some((c) => c?.toolName === "query_data")
852
+ );
853
+ const alreadyCalledAction = messages.some(
854
+ (m) => m.role === "tool" && Array.isArray(m.content) && m.content.some(
855
+ (c) => typeof c?.toolName === "string" && c.toolName.startsWith("action_")
856
+ )
857
+ );
858
+ if (Array.isArray(tools) && !alreadyCalledAction && lastUserMessage) {
859
+ const actionTools = tools.filter((t) => typeof t?.name === "string" && t.name.startsWith("action_"));
860
+ const chosen = pickActionTool(userText, actionTools);
861
+ if (chosen) {
862
+ const recordId = extractRecordIdFromMessages(messages, userText);
863
+ if (recordId) {
864
+ const toolCallId = `memory_tc_${Date.now().toString(36)}`;
865
+ return {
866
+ content: "",
867
+ model: options?.model ?? "memory",
868
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
869
+ toolCalls: [
870
+ {
871
+ type: "tool-call",
872
+ toolCallId,
873
+ toolName: chosen.name,
874
+ input: { recordId }
875
+ }
876
+ ]
877
+ };
878
+ }
879
+ }
880
+ }
881
+ if (hasQueryDataTool && !alreadyCalledQueryData && lastUserMessage) {
882
+ const toolCallId = `memory_tc_${Date.now().toString(36)}`;
883
+ return {
884
+ content: "",
885
+ model: options?.model ?? "memory",
886
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
887
+ toolCalls: [
888
+ {
889
+ type: "tool-call",
890
+ toolCallId,
891
+ toolName: "query_data",
892
+ input: { request: userText }
893
+ }
894
+ ]
895
+ };
896
+ }
897
+ if (alreadyCalledAction) {
898
+ const lastTool = [...messages].reverse().find((m) => m.role === "tool");
899
+ const part = Array.isArray(lastTool?.content) ? lastTool.content.find((c) => typeof c?.toolName === "string" && c.toolName.startsWith("action_")) : void 0;
900
+ const raw = part?.output && typeof part.output === "object" && "value" in part.output ? part.output.value : part?.result;
901
+ let payload = {};
902
+ if (typeof raw === "string") {
903
+ try {
904
+ payload = JSON.parse(raw);
905
+ } catch {
906
+ }
907
+ } else if (raw && typeof raw === "object") {
908
+ payload = raw;
909
+ }
910
+ if (payload.error) {
911
+ return {
912
+ content: `[memory] action ${payload.action ?? ""} failed: ${payload.error}`,
913
+ model: options?.model ?? "memory",
914
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
915
+ };
916
+ }
917
+ return {
918
+ content: `[memory] ${payload.message ?? "Action executed."} (${payload.action ?? "action"})`,
919
+ model: options?.model ?? "memory",
920
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
921
+ };
922
+ }
923
+ if (alreadyCalledQueryData) {
924
+ const lastTool = [...messages].reverse().find((m) => m.role === "tool");
925
+ const part = Array.isArray(lastTool?.content) ? lastTool.content.find((c) => c?.toolName === "query_data") : void 0;
926
+ let payload = {};
927
+ const raw = part?.output && typeof part.output === "object" && "value" in part.output ? part.output.value : part?.result;
928
+ if (typeof raw === "string") {
929
+ try {
930
+ payload = JSON.parse(raw);
931
+ } catch {
932
+ payload = {};
933
+ }
934
+ } else if (raw && typeof raw === "object") {
935
+ payload = raw;
936
+ }
937
+ if (payload.error) {
938
+ return {
939
+ content: `[memory] query_data failed: ${payload.error}`,
940
+ model: options?.model ?? "memory",
941
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
942
+ };
943
+ }
944
+ const records = payload.records ?? [];
945
+ const count = payload.count ?? records.length;
946
+ return {
947
+ content: `[memory] Found ${count} record${count === 1 ? "" : "s"} for "${userText}".`,
948
+ model: options?.model ?? "memory",
949
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
950
+ };
951
+ }
952
+ const content = lastUserMessage ? `[memory] ${userText}` : "[memory] (no user message)";
846
953
  return {
847
954
  content,
848
955
  model: options?.model ?? "memory",
@@ -877,7 +984,171 @@ var MemoryLLMAdapter = class {
877
984
  async listModels() {
878
985
  return ["memory"];
879
986
  }
987
+ /**
988
+ * Heuristic structured-output for testing & demos — NOT a real LLM.
989
+ *
990
+ * Strategy:
991
+ * 1. Extract candidate object names from the system messages by matching
992
+ * schema-context headers (`### name — Label`) emitted by
993
+ * {@link SchemaRetriever.renderSnippet}.
994
+ * 2. Pick the candidate whose tokens overlap most with the last user
995
+ * message (falls back to the first candidate).
996
+ * 3. Try `schema.safeParse({ objectName, limit: 20 })` — this satisfies the
997
+ * `QueryPlanSchema` used by the built-in `query_data` tool.
998
+ * 4. If that fails, fall back to `schema.safeParse({})` for schemas that
999
+ * accept defaults.
1000
+ * 5. Otherwise throw with a clear message — the demo needs a real provider.
1001
+ */
1002
+ async generateObject(messages, schema, options) {
1003
+ const sys = messages.filter((m) => m.role === "system").map((m) => typeof m.content === "string" ? m.content : "").join("\n");
1004
+ const headerRe = /^###\s+([a-z0-9_]+)(?:\s+—\s+([^\n]+))?/gim;
1005
+ const candidates = [];
1006
+ for (const match of sys.matchAll(headerRe)) {
1007
+ const machineName = match[1];
1008
+ if (!machineName) continue;
1009
+ const aliasText = match[2] ?? "";
1010
+ const aliasTokens = /* @__PURE__ */ new Set();
1011
+ for (const t of machineName.split(/[^a-z0-9]+/)) {
1012
+ if (t) aliasTokens.add(t);
1013
+ }
1014
+ for (const t of aliasText.toLowerCase().split(/[^a-z0-9]+/)) {
1015
+ if (t) aliasTokens.add(t);
1016
+ }
1017
+ for (const t of [...aliasTokens]) {
1018
+ if (t.length > 3 && t.endsWith("s")) aliasTokens.add(t.slice(0, -1));
1019
+ }
1020
+ candidates.push({ name: machineName, aliasTokens });
1021
+ }
1022
+ const lastUser = [...messages].reverse().find((m) => m.role === "user");
1023
+ const userText = typeof lastUser?.content === "string" ? lastUser.content.toLowerCase() : "";
1024
+ const userTokens = new Set(
1025
+ userText.split(/[^a-z0-9_]+/).filter((t) => t.length > 1)
1026
+ );
1027
+ for (const t of [...userTokens]) {
1028
+ if (t.length > 3 && t.endsWith("s")) userTokens.add(t.slice(0, -1));
1029
+ }
1030
+ let chosen = candidates[0]?.name;
1031
+ let bestScore = -1;
1032
+ for (const cand of candidates) {
1033
+ let score = 0;
1034
+ for (const tok of cand.aliasTokens) {
1035
+ if (userTokens.has(tok)) score += 1;
1036
+ }
1037
+ if (score > bestScore) {
1038
+ bestScore = score;
1039
+ chosen = cand.name;
1040
+ }
1041
+ }
1042
+ const attempts = [];
1043
+ if (chosen) attempts.push({ objectName: chosen, limit: 20 });
1044
+ attempts.push({});
1045
+ for (const attempt of attempts) {
1046
+ const result = schema.safeParse(attempt);
1047
+ if (result.success) {
1048
+ return {
1049
+ object: result.data,
1050
+ model: options?.model ?? "memory",
1051
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
1052
+ };
1053
+ }
1054
+ }
1055
+ throw new Error(
1056
+ "MemoryLLMAdapter.generateObject: unable to synthesise a value for the requested schema. The memory adapter only handles QueryPlan-shaped schemas \u2014 wire a real LLM adapter (OpenAI / Anthropic / Google) for arbitrary structured output."
1057
+ );
1058
+ }
880
1059
  };
1060
+ function pickActionTool(userText, actionTools) {
1061
+ if (actionTools.length === 0 || !userText) return null;
1062
+ const userTokens = new Set(
1063
+ userText.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length > 2)
1064
+ );
1065
+ const ACTION_VERBS = /* @__PURE__ */ new Set([
1066
+ "complete",
1067
+ "finish",
1068
+ "done",
1069
+ "close",
1070
+ "start",
1071
+ "begin",
1072
+ "resume",
1073
+ "clone",
1074
+ "copy",
1075
+ "duplicate",
1076
+ "cancel",
1077
+ "abort",
1078
+ "archive",
1079
+ "restore",
1080
+ "approve",
1081
+ "reject",
1082
+ "assign",
1083
+ "unassign",
1084
+ "export",
1085
+ "import",
1086
+ "send",
1087
+ "notify",
1088
+ "publish",
1089
+ "unpublish",
1090
+ "mark"
1091
+ ]);
1092
+ const hasActionVerb = [...userTokens].some((t) => ACTION_VERBS.has(t));
1093
+ if (!hasActionVerb) return null;
1094
+ let best = null;
1095
+ let bestScore = 0;
1096
+ for (const tool of actionTools) {
1097
+ const nameTokens = tool.name.replace(/^action_/, "").toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length > 2);
1098
+ let score = 0;
1099
+ for (const tok of nameTokens) {
1100
+ if (!userTokens.has(tok)) continue;
1101
+ score += ACTION_VERBS.has(tok) ? 3 : 1;
1102
+ }
1103
+ if (score > bestScore) {
1104
+ bestScore = score;
1105
+ best = tool;
1106
+ }
1107
+ }
1108
+ return bestScore >= 3 ? best : null;
1109
+ }
1110
+ function extractRecordIdFromMessages(messages, userText) {
1111
+ const userTokens = userText.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length > 2);
1112
+ for (let i = messages.length - 1; i >= 0; i--) {
1113
+ const m = messages[i];
1114
+ if (m.role !== "tool" || !Array.isArray(m.content)) continue;
1115
+ const parts = m.content;
1116
+ for (const part of parts) {
1117
+ if (part?.toolName !== "query_data") continue;
1118
+ const raw = part.output && typeof part.output === "object" && "value" in part.output ? part.output.value : part.result;
1119
+ let payload = {};
1120
+ if (typeof raw === "string") {
1121
+ try {
1122
+ payload = JSON.parse(raw);
1123
+ } catch {
1124
+ }
1125
+ } else if (raw && typeof raw === "object") {
1126
+ payload = raw;
1127
+ }
1128
+ const records = payload.records ?? [];
1129
+ if (records.length === 0) continue;
1130
+ let bestId;
1131
+ let bestScore = -1;
1132
+ for (const rec of records) {
1133
+ if (!rec || typeof rec !== "object") continue;
1134
+ const id = rec.id;
1135
+ if (typeof id !== "string" && typeof id !== "number") continue;
1136
+ const hay = Object.values(rec).filter((v) => typeof v === "string").join(" ").toLowerCase();
1137
+ const hayTokens = hay.split(/[^a-z0-9]+/).filter(Boolean);
1138
+ let score = 0;
1139
+ for (const ut of userTokens) {
1140
+ if (hayTokens.includes(ut)) score += 1;
1141
+ }
1142
+ if (score > bestScore) {
1143
+ bestScore = score;
1144
+ bestId = String(id);
1145
+ }
1146
+ }
1147
+ return bestId ?? String(records[0].id);
1148
+ }
1149
+ }
1150
+ return void 0;
1151
+ }
881
1152
 
882
1153
  // src/tools/tool-registry.ts
883
1154
  var ToolRegistry = class {
@@ -1043,6 +1314,70 @@ var InMemoryConversationService = class {
1043
1314
  }
1044
1315
  };
1045
1316
 
1317
+ // src/trace-recorder.ts
1318
+ import { randomUUID } from "crypto";
1319
+ var TRACE_OBJECT = "ai_traces";
1320
+ var NullTraceRecorder = class {
1321
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
1322
+ record(_event) {
1323
+ }
1324
+ };
1325
+ var ObjectQLTraceRecorder = class {
1326
+ constructor(engine, options = {}) {
1327
+ this.engine = engine;
1328
+ this.logger = options.logger;
1329
+ }
1330
+ async record(event) {
1331
+ const row = {
1332
+ id: `trace_${randomUUID()}`,
1333
+ conversation_id: event.conversationId ?? null,
1334
+ agent_id: event.agentId ?? null,
1335
+ operation: event.operation,
1336
+ model: event.model ?? null,
1337
+ adapter: event.adapter,
1338
+ prompt_tokens: event.promptTokens,
1339
+ completion_tokens: event.completionTokens,
1340
+ total_tokens: event.totalTokens,
1341
+ input_cost: event.cost?.inputCost ?? null,
1342
+ output_cost: event.cost?.outputCost ?? null,
1343
+ total_cost: event.cost?.totalCost ?? null,
1344
+ currency: event.cost?.currency ?? null,
1345
+ latency_ms: event.latencyMs,
1346
+ status: event.status,
1347
+ error: event.error ?? null,
1348
+ metadata: event.metadata ? JSON.stringify(event.metadata) : null,
1349
+ created_at: (/* @__PURE__ */ new Date()).toISOString()
1350
+ };
1351
+ try {
1352
+ await this.engine.insert(TRACE_OBJECT, row);
1353
+ } catch (err) {
1354
+ this.logger?.warn(
1355
+ "[AI] Failed to record trace (non-fatal)",
1356
+ err instanceof Error ? { error: err.message } : { error: String(err) }
1357
+ );
1358
+ }
1359
+ }
1360
+ };
1361
+ function buildTraceEvent(input) {
1362
+ const usage = input.usage ?? { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
1363
+ const cost = input.model && input.registry ? input.registry.estimateCost(input.model, usage) : void 0;
1364
+ return {
1365
+ operation: input.operation,
1366
+ adapter: input.adapter,
1367
+ model: input.model,
1368
+ agentId: input.agentId,
1369
+ conversationId: input.conversationId,
1370
+ promptTokens: usage.promptTokens,
1371
+ completionTokens: usage.completionTokens,
1372
+ totalTokens: usage.totalTokens,
1373
+ latencyMs: input.latencyMs,
1374
+ status: input.status,
1375
+ error: input.error,
1376
+ cost,
1377
+ metadata: input.metadata
1378
+ };
1379
+ }
1380
+
1046
1381
  // src/ai-service.ts
1047
1382
  function textDeltaPart(id, text) {
1048
1383
  return { type: "text-delta", id, text };
@@ -1061,22 +1396,83 @@ var _AIService = class _AIService {
1061
1396
  this.logger = config.logger ?? createLogger({ level: "info", format: "pretty" });
1062
1397
  this.toolRegistry = config.toolRegistry ?? new ToolRegistry();
1063
1398
  this.conversationService = config.conversationService ?? new InMemoryConversationService();
1399
+ this.modelRegistry = config.modelRegistry;
1400
+ this.traceRecorder = config.traceRecorder ?? new NullTraceRecorder();
1064
1401
  this.logger.info(
1065
- `[AI] Service initialized with adapter="${this.adapter.name}", tools=${this.toolRegistry.size}`
1402
+ `[AI] Service initialized with adapter="${this.adapter.name}", tools=${this.toolRegistry.size}, models=${this.modelRegistry?.size ?? 0}`
1066
1403
  );
1067
1404
  }
1068
1405
  /** The name of the active LLM adapter. */
1069
1406
  get adapterName() {
1070
1407
  return this.adapter.name;
1071
1408
  }
1409
+ /**
1410
+ * Run an adapter call and emit a trace event.
1411
+ *
1412
+ * Records both success and failure. Tracing failures never escape — the
1413
+ * recorder is expected to be defensive.
1414
+ */
1415
+ async instrument(operation, options, fn) {
1416
+ const started = Date.now();
1417
+ try {
1418
+ const result = await fn();
1419
+ void this.traceRecorder.record(buildTraceEvent({
1420
+ operation,
1421
+ adapter: this.adapter.name,
1422
+ model: result.model ?? options?.model,
1423
+ usage: result.usage,
1424
+ latencyMs: Date.now() - started,
1425
+ status: "success",
1426
+ registry: this.modelRegistry
1427
+ }));
1428
+ return result;
1429
+ } catch (err) {
1430
+ void this.traceRecorder.record(buildTraceEvent({
1431
+ operation,
1432
+ adapter: this.adapter.name,
1433
+ model: options?.model,
1434
+ latencyMs: Date.now() - started,
1435
+ status: "error",
1436
+ error: err instanceof Error ? err.message : String(err),
1437
+ registry: this.modelRegistry
1438
+ }));
1439
+ throw err;
1440
+ }
1441
+ }
1072
1442
  // ── IAIService implementation ──────────────────────────────────
1073
1443
  async chat(messages, options) {
1074
1444
  this.logger.debug("[AI] chat", { messageCount: messages.length, model: options?.model });
1075
- return this.adapter.chat(messages, options);
1445
+ return this.instrument("chat", options, () => this.adapter.chat(messages, options));
1076
1446
  }
1077
1447
  async complete(prompt, options) {
1078
1448
  this.logger.debug("[AI] complete", { promptLength: prompt.length, model: options?.model });
1079
- return this.adapter.complete(prompt, options);
1449
+ return this.instrument("complete", options, () => this.adapter.complete(prompt, options));
1450
+ }
1451
+ /**
1452
+ * Generate a strongly-typed object validated against a Zod schema.
1453
+ *
1454
+ * Delegates to the adapter's `generateObject` when supported; throws a
1455
+ * descriptive error when the adapter does not implement structured output.
1456
+ *
1457
+ * @example
1458
+ * ```ts
1459
+ * import { z } from 'zod';
1460
+ * const Schema = z.object({ name: z.string(), priority: z.number().int() });
1461
+ * const { object } = await ai.generateObject(messages, Schema);
1462
+ * ```
1463
+ */
1464
+ async generateObject(messages, schema, options) {
1465
+ this.logger.debug("[AI] generateObject", { messageCount: messages.length, model: options?.model });
1466
+ if (!this.adapter.generateObject) {
1467
+ throw new Error(
1468
+ `[AI] Adapter "${this.adapter.name}" does not support generateObject. Use VercelLLMAdapter with a structured-output-capable model.`
1469
+ );
1470
+ }
1471
+ return this.instrument(
1472
+ "generate_object",
1473
+ options,
1474
+ () => this.adapter.generateObject(messages, schema, options)
1475
+ );
1080
1476
  }
1081
1477
  async *streamChat(messages, options) {
1082
1478
  this.logger.debug("[AI] streamChat", { messageCount: messages.length, model: options?.model });
@@ -1116,6 +1512,13 @@ var _AIService = class _AIService {
1116
1512
  * maximum number of iterations (`maxIterations`) is reached.
1117
1513
  */
1118
1514
  async chatWithTools(messages, options) {
1515
+ return this.instrument(
1516
+ "chat_with_tools",
1517
+ options,
1518
+ () => this.chatWithToolsImpl(messages, options)
1519
+ );
1520
+ }
1521
+ async chatWithToolsImpl(messages, options) {
1119
1522
  const { maxIterations: maxIter, onToolError, ...restOptions } = options ?? {};
1120
1523
  const maxIterations = maxIter ?? _AIService.DEFAULT_MAX_ITERATIONS;
1121
1524
  const registeredTools = this.toolRegistry.getAll();
@@ -2158,7 +2561,7 @@ function buildToolRoutes(aiService, logger) {
2158
2561
  }
2159
2562
 
2160
2563
  // src/conversation/objectql-conversation-service.ts
2161
- import { randomUUID } from "crypto";
2564
+ import { randomUUID as randomUUID2 } from "crypto";
2162
2565
  var CONVERSATIONS_OBJECT = "ai_conversations";
2163
2566
  var MESSAGES_OBJECT = "ai_messages";
2164
2567
  var CONVERSATION_ORDER = [
@@ -2175,7 +2578,7 @@ var ObjectQLConversationService = class {
2175
2578
  }
2176
2579
  async create(options = {}) {
2177
2580
  const now = (/* @__PURE__ */ new Date()).toISOString();
2178
- const id = `conv_${randomUUID()}`;
2581
+ const id = `conv_${randomUUID2()}`;
2179
2582
  const record = {
2180
2583
  id,
2181
2584
  title: options.title ?? null,
@@ -2248,7 +2651,7 @@ var ObjectQLConversationService = class {
2248
2651
  throw new Error(`Conversation "${conversationId}" not found`);
2249
2652
  }
2250
2653
  const now = (/* @__PURE__ */ new Date()).toISOString();
2251
- const msgId = `msg_${randomUUID()}`;
2654
+ const msgId = `msg_${randomUUID2()}`;
2252
2655
  let contentStr;
2253
2656
  let toolCallsJson = null;
2254
2657
  let toolCallId = null;
@@ -2493,10 +2896,677 @@ var AiMessageObject = ObjectSchema2.create({
2493
2896
  }
2494
2897
  });
2495
2898
 
2899
+ // src/objects/ai-trace.object.ts
2900
+ import { ObjectSchema as ObjectSchema3, Field as Field3 } from "@objectstack/spec/data";
2901
+ var AiTraceObject = ObjectSchema3.create({
2902
+ name: "ai_traces",
2903
+ label: "AI Trace",
2904
+ pluralLabel: "AI Traces",
2905
+ icon: "activity",
2906
+ isSystem: true,
2907
+ description: "Per-call LLM invocation trace with token usage and cost",
2908
+ fields: {
2909
+ id: Field3.text({
2910
+ label: "Trace ID",
2911
+ required: true,
2912
+ readonly: true
2913
+ }),
2914
+ conversation_id: Field3.lookup("ai_conversations", {
2915
+ label: "Conversation",
2916
+ required: false,
2917
+ description: "Parent conversation, if any"
2918
+ }),
2919
+ agent_id: Field3.text({
2920
+ label: "Agent",
2921
+ required: false,
2922
+ maxLength: 128,
2923
+ description: "Agent metadata name that originated the call"
2924
+ }),
2925
+ operation: Field3.select({
2926
+ label: "Operation",
2927
+ required: true,
2928
+ options: [
2929
+ { label: "Chat", value: "chat" },
2930
+ { label: "Complete", value: "complete" },
2931
+ { label: "Stream Chat", value: "stream_chat" },
2932
+ { label: "Chat With Tools", value: "chat_with_tools" },
2933
+ { label: "Generate Object", value: "generate_object" },
2934
+ { label: "Embed", value: "embed" }
2935
+ ]
2936
+ }),
2937
+ model: Field3.text({
2938
+ label: "Model",
2939
+ required: false,
2940
+ maxLength: 128,
2941
+ description: "Model identifier reported by the adapter"
2942
+ }),
2943
+ adapter: Field3.text({
2944
+ label: "Adapter",
2945
+ required: false,
2946
+ maxLength: 64,
2947
+ description: 'LLM adapter name (e.g. "vercel", "memory")'
2948
+ }),
2949
+ prompt_tokens: Field3.number({
2950
+ label: "Prompt Tokens",
2951
+ required: false,
2952
+ defaultValue: 0
2953
+ }),
2954
+ completion_tokens: Field3.number({
2955
+ label: "Completion Tokens",
2956
+ required: false,
2957
+ defaultValue: 0
2958
+ }),
2959
+ total_tokens: Field3.number({
2960
+ label: "Total Tokens",
2961
+ required: false,
2962
+ defaultValue: 0
2963
+ }),
2964
+ input_cost: Field3.number({
2965
+ label: "Input Cost",
2966
+ required: false,
2967
+ description: "Cost attributable to prompt tokens (currency in `currency` field)"
2968
+ }),
2969
+ output_cost: Field3.number({
2970
+ label: "Output Cost",
2971
+ required: false,
2972
+ description: "Cost attributable to completion tokens"
2973
+ }),
2974
+ total_cost: Field3.number({
2975
+ label: "Total Cost",
2976
+ required: false,
2977
+ description: "input_cost + output_cost"
2978
+ }),
2979
+ currency: Field3.text({
2980
+ label: "Currency",
2981
+ required: false,
2982
+ maxLength: 8,
2983
+ defaultValue: "USD"
2984
+ }),
2985
+ latency_ms: Field3.number({
2986
+ label: "Latency (ms)",
2987
+ required: true,
2988
+ defaultValue: 0,
2989
+ description: "Wall-clock duration of the LLM call"
2990
+ }),
2991
+ status: Field3.select({
2992
+ label: "Status",
2993
+ required: true,
2994
+ options: [
2995
+ { label: "Success", value: "success" },
2996
+ { label: "Error", value: "error" }
2997
+ ]
2998
+ }),
2999
+ error: Field3.textarea({
3000
+ label: "Error",
3001
+ required: false,
3002
+ description: "Error message when status=error"
3003
+ }),
3004
+ metadata: Field3.textarea({
3005
+ label: "Metadata",
3006
+ required: false,
3007
+ description: "JSON-serialized extra fields (request id, user id, \u2026)"
3008
+ }),
3009
+ created_at: Field3.datetime({
3010
+ label: "Created At",
3011
+ required: true,
3012
+ defaultValue: "NOW()",
3013
+ readonly: true
3014
+ })
3015
+ },
3016
+ indexes: [
3017
+ { fields: ["conversation_id"] },
3018
+ { fields: ["agent_id"] },
3019
+ { fields: ["model"] },
3020
+ { fields: ["status"] },
3021
+ { fields: ["created_at"] }
3022
+ ],
3023
+ enable: {
3024
+ trackHistory: false,
3025
+ searchable: false,
3026
+ apiEnabled: true,
3027
+ apiMethods: ["get", "list"],
3028
+ trash: false,
3029
+ mru: false
3030
+ }
3031
+ });
3032
+
3033
+ // src/views/ai-trace.view.ts
3034
+ import { defineView } from "@objectstack/spec";
3035
+ var AiTraceView = defineView({
3036
+ list: {
3037
+ type: "grid",
3038
+ data: { provider: "object", object: "ai_traces" },
3039
+ columns: [
3040
+ { field: "created_at", label: "Time" },
3041
+ { field: "operation" },
3042
+ { field: "model" },
3043
+ { field: "agent_id", label: "Agent" },
3044
+ { field: "latency_ms", label: "Latency (ms)" },
3045
+ { field: "total_tokens", label: "Tokens" },
3046
+ { field: "cost_total", label: "Cost" },
3047
+ { field: "status" }
3048
+ ],
3049
+ sort: [{ field: "created_at", order: "desc" }],
3050
+ pagination: { pageSize: 50 },
3051
+ searchableFields: ["conversation_id", "agent_id", "model", "error"],
3052
+ filterableFields: ["operation", "model", "status"]
3053
+ },
3054
+ listViews: {
3055
+ errors: {
3056
+ label: "Errors",
3057
+ type: "grid",
3058
+ data: { provider: "object", object: "ai_traces" },
3059
+ columns: [
3060
+ { field: "created_at", label: "Time" },
3061
+ { field: "operation" },
3062
+ { field: "model" },
3063
+ { field: "latency_ms", label: "Latency (ms)" },
3064
+ { field: "error" }
3065
+ ],
3066
+ filter: [
3067
+ { field: "status", operator: "=", value: "error" }
3068
+ ],
3069
+ sort: [{ field: "created_at", order: "desc" }]
3070
+ },
3071
+ by_model: {
3072
+ label: "By Model",
3073
+ type: "grid",
3074
+ data: { provider: "object", object: "ai_traces" },
3075
+ columns: [
3076
+ { field: "model" },
3077
+ { field: "operation" },
3078
+ { field: "latency_ms", label: "Latency (ms)" },
3079
+ { field: "total_tokens", label: "Tokens" },
3080
+ { field: "cost_total", label: "Cost" },
3081
+ { field: "status" },
3082
+ { field: "created_at", label: "Time" }
3083
+ ],
3084
+ grouping: { fields: [{ field: "model" }] },
3085
+ sort: [{ field: "created_at", order: "desc" }]
3086
+ }
3087
+ }
3088
+ });
3089
+
2496
3090
  // src/plugin.ts
2497
3091
  init_data_tools();
2498
3092
  init_metadata_tools();
2499
3093
 
3094
+ // src/tools/query-data.tool.ts
3095
+ import { z } from "zod";
3096
+
3097
+ // src/schema-retriever.ts
3098
+ var SchemaRetriever = class {
3099
+ constructor(metadata, options = {}) {
3100
+ this.metadata = metadata;
3101
+ this.options = {
3102
+ limit: options.limit ?? 3,
3103
+ minScore: options.minScore ?? 1,
3104
+ maxFieldsPerObject: options.maxFieldsPerObject ?? 12
3105
+ };
3106
+ }
3107
+ /**
3108
+ * Find object definitions whose name/label/fields match terms in the query.
3109
+ *
3110
+ * Returns matches sorted by score (descending) capped at `limit`. When
3111
+ * the query yields no matches, returns an empty array — callers may
3112
+ * fall back to a generic "describe what data exists" tool call.
3113
+ */
3114
+ async retrieve(query) {
3115
+ const terms = tokenise(query);
3116
+ if (terms.length === 0) return [];
3117
+ const objects = await this.metadata.listObjects();
3118
+ const hits = [];
3119
+ for (const raw of objects) {
3120
+ const obj = raw;
3121
+ if (!obj?.name) continue;
3122
+ const score = scoreObject(obj, terms);
3123
+ if (score >= this.options.minScore) {
3124
+ hits.push({ object: obj, score });
3125
+ }
3126
+ }
3127
+ hits.sort((a, b) => b.score - a.score);
3128
+ return hits.slice(0, this.options.limit);
3129
+ }
3130
+ /**
3131
+ * Render hits as a compact Markdown schema snippet.
3132
+ *
3133
+ * Designed to be appended to the system message — every line carries
3134
+ * exactly the information a model needs to choose object/field names
3135
+ * for query construction.
3136
+ */
3137
+ static renderSnippet(hits, maxFieldsPerObject = 12) {
3138
+ if (hits.length === 0) return "";
3139
+ const lines = ["## Schema context (auto-injected)"];
3140
+ for (const hit of hits) {
3141
+ const obj = hit.object;
3142
+ const parts = [];
3143
+ if (obj.label) parts.push(obj.label);
3144
+ if (obj.pluralLabel && obj.pluralLabel !== obj.label) parts.push(`(${obj.pluralLabel})`);
3145
+ const header = parts.length > 0 ? ` \u2014 ${parts.join(" ")}` : "";
3146
+ lines.push(`### ${obj.name}${header}`);
3147
+ const fields = Object.entries(obj.fields ?? {}).slice(0, maxFieldsPerObject);
3148
+ for (const [name, field] of fields) {
3149
+ lines.push(` - ${name}: ${describeField(field)}`);
3150
+ }
3151
+ const total = Object.keys(obj.fields ?? {}).length;
3152
+ if (total > fields.length) {
3153
+ lines.push(` - \u2026${total - fields.length} more field(s)`);
3154
+ }
3155
+ }
3156
+ return lines.join("\n");
3157
+ }
3158
+ };
3159
+ function tokenise(query) {
3160
+ const raw = query.toLowerCase().match(/[a-z0-9]+/g) ?? [];
3161
+ return raw.filter((t) => t.length >= 2 && !STOPWORDS.has(t));
3162
+ }
3163
+ var STOPWORDS = /* @__PURE__ */ new Set([
3164
+ "the",
3165
+ "and",
3166
+ "for",
3167
+ "with",
3168
+ "from",
3169
+ "are",
3170
+ "has",
3171
+ "have",
3172
+ "had",
3173
+ "was",
3174
+ "were",
3175
+ "this",
3176
+ "that",
3177
+ "these",
3178
+ "those",
3179
+ "all",
3180
+ "any",
3181
+ "how",
3182
+ "what",
3183
+ "when",
3184
+ "where",
3185
+ "who",
3186
+ "why",
3187
+ "which",
3188
+ "show",
3189
+ "list",
3190
+ "find",
3191
+ "get",
3192
+ "count",
3193
+ "of",
3194
+ "in",
3195
+ "on",
3196
+ "at",
3197
+ "to",
3198
+ "as",
3199
+ "by",
3200
+ "is",
3201
+ "it",
3202
+ "an",
3203
+ "or",
3204
+ "be",
3205
+ "me"
3206
+ ]);
3207
+ function scoreObject(obj, terms) {
3208
+ let score = 0;
3209
+ const nameTokens = splitSnake(obj.name);
3210
+ const labelTokens = obj.label ? tokenise(obj.label) : [];
3211
+ const pluralTokens = obj.pluralLabel ? tokenise(obj.pluralLabel) : [];
3212
+ const descTokens = obj.description ? tokenise(obj.description) : [];
3213
+ for (const term of terms) {
3214
+ if (nameTokens.includes(term)) score += 3;
3215
+ else if (labelTokens.includes(term) || pluralTokens.includes(term)) score += 2;
3216
+ else if (descTokens.includes(term)) score += 1;
3217
+ }
3218
+ for (const [fieldName, field] of Object.entries(obj.fields ?? {})) {
3219
+ const fnTokens = splitSnake(fieldName);
3220
+ const flTokens = field.label ? tokenise(field.label) : [];
3221
+ for (const term of terms) {
3222
+ if (fnTokens.includes(term)) score += 2;
3223
+ else if (flTokens.includes(term)) score += 1;
3224
+ }
3225
+ }
3226
+ return score;
3227
+ }
3228
+ function splitSnake(name) {
3229
+ return name.toLowerCase().split("_").filter(Boolean);
3230
+ }
3231
+ function describeField(field) {
3232
+ const t = field.type ?? "unknown";
3233
+ if (t === "lookup" && field.reference) return `lookup \u2192 ${field.reference}`;
3234
+ if (t === "select" && Array.isArray(field.options)) {
3235
+ const values = field.options.map(
3236
+ (o) => typeof o === "string" ? o : o.value
3237
+ ).filter(Boolean).slice(0, 6);
3238
+ return `select(${values.join("|")})`;
3239
+ }
3240
+ return t;
3241
+ }
3242
+
3243
+ // src/tools/query-data.tool.ts
3244
+ var QueryPlanSchema = z.object({
3245
+ objectName: z.string().min(1).describe('The snake_case object name to query (e.g. "task", "account").'),
3246
+ where: z.record(z.string(), z.unknown()).optional().describe(
3247
+ 'Filter conditions as key-value pairs. Use MongoDB-style operators for ranges, e.g. {"amount": {"$gt": 100}}.'
3248
+ ),
3249
+ fields: z.array(z.string()).optional().describe("Field names to return. Omit to return all fields."),
3250
+ orderBy: z.array(
3251
+ z.object({
3252
+ field: z.string(),
3253
+ order: z.enum(["asc", "desc"])
3254
+ })
3255
+ ).optional().describe("Sort order. First entry is primary sort key."),
3256
+ limit: z.number().int().min(1).max(200).optional().describe("Maximum number of records (default 20, max 200).")
3257
+ });
3258
+ var QUERY_DATA_TOOL = {
3259
+ name: "query_data",
3260
+ description: "Answer a natural-language question about the user's data. Internally retrieves the relevant object schema, generates an ObjectQL query, executes it, and returns the matching records. Prefer this tool over `query_records` / `aggregate_data` when the user's intent is expressed in plain language.",
3261
+ parameters: {
3262
+ type: "object",
3263
+ properties: {
3264
+ request: {
3265
+ type: "string",
3266
+ description: "The natural-language question to answer (paraphrase the user's request if needed for clarity)."
3267
+ },
3268
+ model: {
3269
+ type: "string",
3270
+ description: "Optional model id to use for query planning. Defaults to the AI service's default model."
3271
+ }
3272
+ },
3273
+ required: ["request"],
3274
+ additionalProperties: false
3275
+ }
3276
+ };
3277
+ function createQueryDataHandler(ctx) {
3278
+ const retriever = new SchemaRetriever(ctx.metadata);
3279
+ const maxLimit = ctx.maxLimit ?? 100;
3280
+ return async (args) => {
3281
+ const { request, model } = args;
3282
+ if (!request || typeof request !== "string") {
3283
+ return JSON.stringify({ error: "query_data: `request` is required" });
3284
+ }
3285
+ if (!ctx.ai.generateObject) {
3286
+ return JSON.stringify({
3287
+ error: "query_data requires structured-output support. Configure a Vercel-AI-SDK-backed adapter (OpenAI, Anthropic, Google)."
3288
+ });
3289
+ }
3290
+ const hits = await retriever.retrieve(request);
3291
+ if (hits.length === 0) {
3292
+ return JSON.stringify({
3293
+ error: "No matching objects in metadata. Ask the user which object(s) to query, or list available objects via list_objects."
3294
+ });
3295
+ }
3296
+ const snippet = SchemaRetriever.renderSnippet(hits);
3297
+ const planMessages = [
3298
+ {
3299
+ role: "system",
3300
+ content: "You translate user data questions into a single ObjectQL query plan. Use ONLY the objects and fields listed in the schema context below. Never invent field names. If the question is ambiguous, pick the most likely interpretation and use a reasonable `limit`.\n\n" + snippet
3301
+ },
3302
+ { role: "user", content: request }
3303
+ ];
3304
+ let plan;
3305
+ try {
3306
+ const generated = await ctx.ai.generateObject(planMessages, QueryPlanSchema, {
3307
+ model,
3308
+ schemaName: "ObjectQLQueryPlan",
3309
+ schemaDescription: "A single ObjectQL find() query to answer the user request."
3310
+ });
3311
+ plan = generated.object;
3312
+ } catch (err) {
3313
+ return JSON.stringify({
3314
+ error: `Failed to plan query: ${err instanceof Error ? err.message : String(err)}`
3315
+ });
3316
+ }
3317
+ const matchedObject = hits.find((h) => h.object.name === plan.objectName)?.object ?? hits[0].object;
3318
+ if (matchedObject.name !== plan.objectName) {
3319
+ return JSON.stringify({
3320
+ error: `Planned object "${plan.objectName}" is not in the retrieved schema. Available: ${hits.map((h) => h.object.name).join(", ")}`
3321
+ });
3322
+ }
3323
+ const limit = Math.min(plan.limit ?? 20, maxLimit);
3324
+ try {
3325
+ const records = await ctx.dataEngine.find(plan.objectName, {
3326
+ where: plan.where,
3327
+ fields: plan.fields,
3328
+ orderBy: plan.orderBy,
3329
+ limit
3330
+ });
3331
+ return JSON.stringify({
3332
+ plan,
3333
+ count: records.length,
3334
+ records
3335
+ });
3336
+ } catch (err) {
3337
+ return JSON.stringify({
3338
+ plan,
3339
+ error: `Query execution failed: ${err instanceof Error ? err.message : String(err)}`
3340
+ });
3341
+ }
3342
+ };
3343
+ }
3344
+ function registerQueryDataTool(registry, context) {
3345
+ registry.register(QUERY_DATA_TOOL, createQueryDataHandler(context));
3346
+ }
3347
+
3348
+ // src/tools/action-tools.ts
3349
+ function actionSkipReason(action) {
3350
+ if (action.aiExposed === false) {
3351
+ return "opted-out via aiExposed:false";
3352
+ }
3353
+ if (action.type !== "script") return `type='${action.type}' not yet supported`;
3354
+ if (!action.target && !action.body) return "no target or body";
3355
+ if (action.confirmText) return "requires confirmation (confirmText set)";
3356
+ if (action.mode === "delete") return "mode='delete' \u2014 destructive";
3357
+ if (action.variant === "danger") return "variant='danger' \u2014 destructive";
3358
+ return null;
3359
+ }
3360
+ function fieldTypeToJsonType(t) {
3361
+ switch (t) {
3362
+ case "number":
3363
+ case "currency":
3364
+ case "percent":
3365
+ case "rating":
3366
+ case "slider":
3367
+ case "autonumber":
3368
+ return "number";
3369
+ case "boolean":
3370
+ case "toggle":
3371
+ return "boolean";
3372
+ case "multiselect":
3373
+ case "checkboxes":
3374
+ case "tags":
3375
+ return "array";
3376
+ default:
3377
+ return "string";
3378
+ }
3379
+ }
3380
+ function resolveParam(param, ownerObject, allObjects) {
3381
+ const fieldRef = param.field;
3382
+ const owner = param.objectOverride && allObjects.get(param.objectOverride) ? allObjects.get(param.objectOverride) : ownerObject;
3383
+ const field = fieldRef ? owner?.fields?.[fieldRef] : void 0;
3384
+ const name = param.name ?? fieldRef;
3385
+ if (!name) return null;
3386
+ const type = param.type ?? field?.type;
3387
+ const jsonType = fieldTypeToJsonType(type);
3388
+ const schema = { type: jsonType };
3389
+ const label = typeof param.label === "string" ? param.label : field?.label;
3390
+ const help = param.helpText ?? field?.description;
3391
+ const description = [label, help].filter(Boolean).join(" \u2014 ") || void 0;
3392
+ if (description) schema.description = description;
3393
+ const optionSource = param.options ?? field?.options;
3394
+ if (Array.isArray(optionSource) && optionSource.length > 0) {
3395
+ const values = optionSource.map((o) => typeof o === "string" ? o : o.value).filter((v) => typeof v === "string");
3396
+ if (values.length > 0) {
3397
+ schema.enum = jsonType === "array" ? void 0 : values;
3398
+ if (jsonType === "array") {
3399
+ schema.items = { type: "string", enum: values };
3400
+ }
3401
+ }
3402
+ } else if (jsonType === "array") {
3403
+ schema.items = { type: "string" };
3404
+ }
3405
+ if (param.defaultValue !== void 0) {
3406
+ schema.default = param.defaultValue;
3407
+ }
3408
+ const required = Boolean(param.required ?? field?.required ?? false);
3409
+ return { name, schema, required };
3410
+ }
3411
+ function buildParametersSchema(action, ownerObject, allObjects) {
3412
+ const properties = {};
3413
+ const required = [];
3414
+ const isRowContext = Array.isArray(action.locations) && action.locations.some((l) => l === "list_item" || l === "record_header" || l === "record_more" || l === "record_related");
3415
+ if (action.objectName && isRowContext) {
3416
+ properties.recordId = {
3417
+ type: "string",
3418
+ description: `The ${action.objectName} record id to act on.`
3419
+ };
3420
+ if (action.recordIdParam || action.recordIdField) {
3421
+ required.push("recordId");
3422
+ }
3423
+ }
3424
+ for (const param of action.params ?? []) {
3425
+ const resolved = resolveParam(param, ownerObject, allObjects);
3426
+ if (!resolved) continue;
3427
+ properties[resolved.name] = resolved.schema;
3428
+ if (resolved.required) required.push(resolved.name);
3429
+ }
3430
+ return {
3431
+ type: "object",
3432
+ properties,
3433
+ ...required.length > 0 ? { required } : {},
3434
+ additionalProperties: false
3435
+ };
3436
+ }
3437
+ function actionToolName(action, prefix = "action_") {
3438
+ return `${prefix}${action.name}`;
3439
+ }
3440
+ function describeAction(action, ownerObject) {
3441
+ const label = typeof action.label === "string" ? action.label : action.name.replace(/_/g, " ");
3442
+ const target = action.objectName ?? ownerObject?.name;
3443
+ const targetLabel = ownerObject?.label ?? target;
3444
+ const parts = [];
3445
+ parts.push(`${label}${targetLabel ? ` \u2014 operates on ${targetLabel}` : ""}.`);
3446
+ if (action.successMessage && typeof action.successMessage === "string") {
3447
+ parts.push(`On success: ${action.successMessage}`);
3448
+ }
3449
+ if (action.mode) parts.push(`Mode: ${action.mode}.`);
3450
+ parts.push(
3451
+ "Use this when the user asks to perform this operation in natural language."
3452
+ );
3453
+ return parts.join(" ");
3454
+ }
3455
+ function actionToToolDefinition(action, ownerObject, allObjects, toolPrefix = "action_") {
3456
+ if (actionSkipReason(action) !== null) return null;
3457
+ return {
3458
+ name: actionToolName(action, toolPrefix),
3459
+ description: describeAction(action, ownerObject),
3460
+ parameters: buildParametersSchema(action, ownerObject, allObjects)
3461
+ };
3462
+ }
3463
+ function buildHandlerEngineAdapter(engine) {
3464
+ return {
3465
+ update: (object, id, data) => engine.update(object, { ...data, id }, { where: { id } }),
3466
+ insert: (object, data) => engine.insert(object, data),
3467
+ find: (object, where) => engine.find(object, { where }),
3468
+ delete: (object, ids) => engine.delete(object, { where: { id: ids.length === 1 ? ids[0] : { $in: ids } } })
3469
+ };
3470
+ }
3471
+ function createActionToolHandler(action, ctx) {
3472
+ const principal = ctx.principal ?? { id: "ai_agent", name: "AI Assistant" };
3473
+ const engineAdapter = buildHandlerEngineAdapter(ctx.dataEngine);
3474
+ const requiresRecord = Array.isArray(action.locations) && action.locations.some(
3475
+ (l) => l === "list_item" || l === "record_header" || l === "record_more" || l === "record_related"
3476
+ );
3477
+ return async (args) => {
3478
+ const objectName = action.objectName;
3479
+ const target = action.target;
3480
+ const result = {
3481
+ ok: false,
3482
+ action: action.name,
3483
+ objectName
3484
+ };
3485
+ if (!objectName) {
3486
+ result.error = "Action has no objectName; cannot dispatch.";
3487
+ return JSON.stringify(result);
3488
+ }
3489
+ if (!target) {
3490
+ result.error = "Action has no target handler.";
3491
+ return JSON.stringify(result);
3492
+ }
3493
+ const recordId = typeof args.recordId === "string" && args.recordId.length > 0 ? args.recordId : void 0;
3494
+ let record;
3495
+ if (requiresRecord) {
3496
+ if (!recordId) {
3497
+ result.error = `recordId is required for this action \u2014 supply the id of the ${objectName} record to act on (use query_data first if you don't have it).`;
3498
+ return JSON.stringify(result);
3499
+ }
3500
+ try {
3501
+ const found = await ctx.dataEngine.find(objectName, {
3502
+ where: { id: recordId },
3503
+ limit: 1
3504
+ });
3505
+ record = found[0];
3506
+ if (!record) {
3507
+ result.error = `Record ${recordId} not found in ${objectName}.`;
3508
+ return JSON.stringify(result);
3509
+ }
3510
+ result.recordId = recordId;
3511
+ } catch (err) {
3512
+ result.error = `Failed to load record: ${err instanceof Error ? err.message : String(err)}`;
3513
+ return JSON.stringify(result);
3514
+ }
3515
+ }
3516
+ const { recordId: _omit, ...userParams } = args;
3517
+ try {
3518
+ const handlerCtx = {
3519
+ record,
3520
+ user: principal,
3521
+ engine: engineAdapter,
3522
+ params: userParams
3523
+ };
3524
+ const out = await ctx.dataEngine.executeAction?.(objectName, target, handlerCtx);
3525
+ result.ok = true;
3526
+ result.result = out ?? null;
3527
+ const successMsg = typeof action.successMessage === "string" ? action.successMessage : void 0;
3528
+ result.message = successMsg ?? `Action '${action.name}' executed successfully.`;
3529
+ return JSON.stringify(result);
3530
+ } catch (err) {
3531
+ result.error = err instanceof Error ? err.message : String(err);
3532
+ return JSON.stringify(result);
3533
+ }
3534
+ };
3535
+ }
3536
+ async function registerActionsAsTools(registry, context) {
3537
+ const objects = await context.metadata.listObjects();
3538
+ const objMap = new Map(
3539
+ objects.filter((o) => Boolean(o?.name)).map((o) => [o.name, o])
3540
+ );
3541
+ const registered = [];
3542
+ const skipped = [];
3543
+ const prefix = context.toolPrefix ?? "action_";
3544
+ for (const obj of objects) {
3545
+ if (!obj?.actions || !Array.isArray(obj.actions)) continue;
3546
+ for (const action of obj.actions) {
3547
+ if (!action || typeof action.name !== "string") continue;
3548
+ const normalized = {
3549
+ ...action,
3550
+ objectName: action.objectName ?? obj.name
3551
+ };
3552
+ const reason = actionSkipReason(normalized);
3553
+ if (reason !== null) {
3554
+ skipped.push({ action: normalized.name, reason });
3555
+ continue;
3556
+ }
3557
+ const definition = actionToToolDefinition(normalized, obj, objMap, prefix);
3558
+ if (!definition) continue;
3559
+ if (registry.has(definition.name)) {
3560
+ skipped.push({ action: normalized.name, reason: "tool name already registered" });
3561
+ continue;
3562
+ }
3563
+ registry.register(definition, createActionToolHandler(normalized, context));
3564
+ registered.push(definition.name);
3565
+ }
3566
+ }
3567
+ return { registered, skipped };
3568
+ }
3569
+
2500
3570
  // src/agent-runtime.ts
2501
3571
  import { AgentSchema } from "@objectstack/spec/ai";
2502
3572
  var AgentRuntime = class {
@@ -2799,6 +3869,16 @@ var SkillRegistry = class {
2799
3869
  const resolved = [];
2800
3870
  for (const skill of skills) {
2801
3871
  for (const toolName of skill.tools) {
3872
+ if (toolName.endsWith("*")) {
3873
+ const prefix = toolName.slice(0, -1);
3874
+ for (const def2 of availableTools) {
3875
+ if (!def2.name.startsWith(prefix)) continue;
3876
+ if (seen.has(def2.name)) continue;
3877
+ resolved.push(def2);
3878
+ seen.add(def2.name);
3879
+ }
3880
+ continue;
3881
+ }
2802
3882
  if (seen.has(toolName)) continue;
2803
3883
  const def = toolMap.get(toolName);
2804
3884
  if (def) {
@@ -2862,7 +3942,8 @@ Always answer in the same language the user is using. Detailed tool-usage guidan
2862
3942
  maxTokens: 4096
2863
3943
  },
2864
3944
  // Capability bundle lives on the skill; the agent only references it.
2865
- skills: ["data_explorer"],
3945
+ // `data_explorer` = read side, `actions_executor` = write side.
3946
+ skills: ["data_explorer", "actions_executor"],
2866
3947
  active: true,
2867
3948
  visibility: "global",
2868
3949
  guardrails: {
@@ -2942,6 +4023,7 @@ Guidelines:
2942
4023
  7. Never expose internal IDs unless the user explicitly asks for them.
2943
4024
  8. Always answer in the same language the user is using.`,
2944
4025
  tools: [
4026
+ "query_data",
2945
4027
  "list_objects",
2946
4028
  "describe_object",
2947
4029
  "query_records",
@@ -3011,8 +4093,46 @@ Guidelines:
3011
4093
  active: true
3012
4094
  };
3013
4095
 
4096
+ // src/skills/actions-executor-skill.ts
4097
+ var ACTIONS_EXECUTOR_SKILL = {
4098
+ name: "actions_executor",
4099
+ label: "Action Executor",
4100
+ description: "Perform business operations on the user's data \u2014 invoke actions like 'mark as complete', 'start task', 'clone record' through natural language.",
4101
+ instructions: `You can perform business operations by invoking the user's registered actions.
4102
+
4103
+ Capabilities:
4104
+ - Each tool whose name starts with \`action_\` is a business operation declared on an object.
4105
+ - Read the tool description carefully \u2014 it tells you what the action does and what record types it applies to.
4106
+ - Most actions need a \`recordId\` argument. If you don't already have one from a prior \`query_data\` call, run \`query_data\` first to find the right record, then invoke the action with its id.
4107
+
4108
+ Guidelines:
4109
+ 1. Confirm intent \u2014 when the user says "complete it" / "start that one", make sure you know *which* record they mean. Ask if ambiguous.
4110
+ 2. Use \`query_data\` to look up records by natural-language description ("the design review task", "tickets assigned to me").
4111
+ 3. After invoking an action, the tool returns \`{ ok, message, result }\`. Summarise success in plain language; surface errors verbatim.
4112
+ 4. Never invent recordIds. If \`query_data\` didn't return one, tell the user instead of guessing.
4113
+ 5. Action tools are pre-filtered for safety \u2014 destructive operations (\`mode: 'delete'\`, \`variant: 'danger'\`, anything with \`confirmText\`) are *not* exposed here and require explicit user confirmation in the UI.
4114
+ 6. Always answer in the same language the user is using.`,
4115
+ // Dynamically materialised: the runtime registers one tool per Action,
4116
+ // and the skill subscribes to the whole family via the `action_*`
4117
+ // glob (resolved by SkillRegistry.flattenToTools).
4118
+ tools: ["action_*"],
4119
+ triggerPhrases: [
4120
+ "complete",
4121
+ "mark as",
4122
+ "start",
4123
+ "finish",
4124
+ "clone",
4125
+ "duplicate",
4126
+ "do it",
4127
+ "run",
4128
+ "invoke",
4129
+ "execute"
4130
+ ],
4131
+ active: true
4132
+ };
4133
+
3014
4134
  // src/adapters/vercel-adapter.ts
3015
- import { generateText, streamText, tool as vercelTool, jsonSchema } from "ai";
4135
+ import { generateText, streamText, generateObject, tool as vercelTool, jsonSchema } from "ai";
3016
4136
  function buildVercelOptions(options) {
3017
4137
  if (!options) return {};
3018
4138
  const opts = {};
@@ -3094,11 +4214,102 @@ var VercelLLMAdapter = class {
3094
4214
  "[VercelLLMAdapter] Embeddings require a dedicated EmbeddingModel. Configure an embedding adapter instead."
3095
4215
  );
3096
4216
  }
4217
+ async generateObject(messages, schema, options) {
4218
+ const { schemaName, schemaDescription, ...rest } = options ?? {};
4219
+ const result = await generateObject({
4220
+ model: this.model,
4221
+ messages,
4222
+ schema,
4223
+ schemaName,
4224
+ schemaDescription,
4225
+ ...buildVercelOptions(rest)
4226
+ });
4227
+ return {
4228
+ object: result.object,
4229
+ model: result.response?.modelId,
4230
+ usage: result.usage ? {
4231
+ promptTokens: result.usage.inputTokens ?? 0,
4232
+ completionTokens: result.usage.outputTokens ?? 0,
4233
+ totalTokens: result.usage.totalTokens ?? 0
4234
+ } : void 0
4235
+ };
4236
+ }
3097
4237
  async listModels() {
3098
4238
  return [];
3099
4239
  }
3100
4240
  };
3101
4241
 
4242
+ // src/model-registry.ts
4243
+ var ModelRegistry = class {
4244
+ constructor(config = {}) {
4245
+ this.models = /* @__PURE__ */ new Map();
4246
+ for (const model of config.models ?? []) {
4247
+ this.models.set(model.id, model);
4248
+ }
4249
+ this.defaultModelId = config.defaultModelId;
4250
+ }
4251
+ /** Register or replace a model. */
4252
+ register(model) {
4253
+ this.models.set(model.id, model);
4254
+ }
4255
+ /** Look up a model by id. */
4256
+ get(id) {
4257
+ return this.models.get(id);
4258
+ }
4259
+ /** Look up a model by id, throwing if missing. */
4260
+ getOrThrow(id) {
4261
+ const model = this.models.get(id);
4262
+ if (!model) {
4263
+ throw new Error(
4264
+ `[ModelRegistry] Unknown model "${id}". Registered: ${[...this.models.keys()].join(", ") || "(none)"}`
4265
+ );
4266
+ }
4267
+ return model;
4268
+ }
4269
+ /** Resolve the default model (explicit > first registered > undefined). */
4270
+ getDefault() {
4271
+ if (this.defaultModelId) {
4272
+ return this.models.get(this.defaultModelId);
4273
+ }
4274
+ return this.models.values().next().value;
4275
+ }
4276
+ /** Set the default model id (must already be registered). */
4277
+ setDefault(id) {
4278
+ this.getOrThrow(id);
4279
+ this.defaultModelId = id;
4280
+ }
4281
+ /** All registered models. */
4282
+ list() {
4283
+ return [...this.models.values()];
4284
+ }
4285
+ /** Number of registered models. */
4286
+ get size() {
4287
+ return this.models.size;
4288
+ }
4289
+ /**
4290
+ * Estimate cost in the model's currency (defaults to USD).
4291
+ *
4292
+ * Returns `undefined` when the model is unknown or has no pricing data.
4293
+ * Costs are computed as `(tokens / 1000) * pricePer1kTokens` for input and
4294
+ * output independently, then summed.
4295
+ */
4296
+ estimateCost(modelId, usage) {
4297
+ const model = this.models.get(modelId);
4298
+ if (!model?.pricing) return void 0;
4299
+ return computeCost(model.pricing, usage);
4300
+ }
4301
+ };
4302
+ function computeCost(pricing, usage) {
4303
+ const inputCost = pricing.inputCostPer1kTokens != null ? usage.promptTokens / 1e3 * pricing.inputCostPer1kTokens : 0;
4304
+ const outputCost = pricing.outputCostPer1kTokens != null ? usage.completionTokens / 1e3 * pricing.outputCostPer1kTokens : 0;
4305
+ return {
4306
+ inputCost,
4307
+ outputCost,
4308
+ totalCost: inputCost + outputCost,
4309
+ currency: pricing.currency ?? "USD"
4310
+ };
4311
+ }
4312
+
3102
4313
  // src/plugin.ts
3103
4314
  var AIServicePlugin = class {
3104
4315
  constructor(options = {}) {
@@ -3220,10 +4431,34 @@ var AIServicePlugin = class {
3220
4431
  adapterDescription = detected.description;
3221
4432
  }
3222
4433
  ctx.logger.info(`[AI] Using LLM adapter: ${adapterDescription}`);
4434
+ const modelRegistry = new ModelRegistry({
4435
+ models: this.options.models,
4436
+ defaultModelId: this.options.defaultModelId
4437
+ });
4438
+ if (modelRegistry.size > 0) {
4439
+ ctx.logger.info(`[AI] ModelRegistry initialised with ${modelRegistry.size} model(s)`);
4440
+ }
4441
+ let traceRecorder;
4442
+ if (this.options.traceRecorder === null) {
4443
+ ctx.logger.debug("[AI] Tracing disabled (traceRecorder=null)");
4444
+ } else if (this.options.traceRecorder) {
4445
+ traceRecorder = this.options.traceRecorder;
4446
+ } else {
4447
+ try {
4448
+ const engine = ctx.getService("data");
4449
+ if (engine && typeof engine.insert === "function") {
4450
+ traceRecorder = new ObjectQLTraceRecorder(engine, { logger: ctx.logger });
4451
+ ctx.logger.info("[AI] Using ObjectQLTraceRecorder (IDataEngine detected)");
4452
+ }
4453
+ } catch {
4454
+ }
4455
+ }
3223
4456
  const config = {
3224
4457
  adapter,
3225
4458
  logger: ctx.logger,
3226
- conversationService
4459
+ conversationService,
4460
+ modelRegistry,
4461
+ traceRecorder
3227
4462
  };
3228
4463
  this.service = new AIService(config);
3229
4464
  if (hasExisting) {
@@ -3238,7 +4473,8 @@ var AIServicePlugin = class {
3238
4473
  type: "plugin",
3239
4474
  scope: "project",
3240
4475
  namespace: "ai",
3241
- objects: [AiConversationObject, AiMessageObject]
4476
+ objects: [AiConversationObject, AiMessageObject, AiTraceObject],
4477
+ views: [AiTraceView]
3242
4478
  });
3243
4479
  if (this.options.debug) {
3244
4480
  ctx.hook("ai:beforeChat", async (messages) => {
@@ -3270,6 +4506,39 @@ var AIServicePlugin = class {
3270
4506
  if (dataEngine) {
3271
4507
  registerDataTools(this.service.toolRegistry, { dataEngine });
3272
4508
  ctx.logger.info("[AI] Built-in data tools registered");
4509
+ if (metadataService) {
4510
+ registerQueryDataTool(this.service.toolRegistry, {
4511
+ ai: this.service,
4512
+ metadata: metadataService,
4513
+ dataEngine
4514
+ });
4515
+ ctx.logger.info("[AI] query_data tool registered");
4516
+ try {
4517
+ const { registered, skipped } = await registerActionsAsTools(
4518
+ this.service.toolRegistry,
4519
+ {
4520
+ metadata: metadataService,
4521
+ dataEngine
4522
+ }
4523
+ );
4524
+ if (registered.length > 0) {
4525
+ ctx.logger.info(
4526
+ `[AI] ${registered.length} action tool(s) registered: ${registered.join(", ")}`
4527
+ );
4528
+ }
4529
+ if (skipped.length > 0) {
4530
+ ctx.logger.debug(
4531
+ `[AI] Skipped ${skipped.length} action(s) for AI exposure`,
4532
+ { skipped }
4533
+ );
4534
+ }
4535
+ } catch (err) {
4536
+ ctx.logger.warn(
4537
+ "[AI] Failed to register action tools",
4538
+ err instanceof Error ? { error: err.message } : { error: String(err) }
4539
+ );
4540
+ }
4541
+ }
3273
4542
  if (metadataService) {
3274
4543
  const { DATA_TOOL_DEFINITIONS: DATA_TOOL_DEFINITIONS2 } = await Promise.resolve().then(() => (init_data_tools(), data_tools_exports));
3275
4544
  for (const toolDef of DATA_TOOL_DEFINITIONS2) {
@@ -3320,6 +4589,19 @@ var AIServicePlugin = class {
3320
4589
  } catch (err) {
3321
4590
  ctx.logger.warn("[AI] Failed to register data_explorer skill", err instanceof Error ? { error: err.message } : { error: String(err) });
3322
4591
  }
4592
+ try {
4593
+ const skillExists = typeof metadataService.exists === "function" ? await withTimeout(metadataService.exists("skill", ACTIONS_EXECUTOR_SKILL.name)) : false;
4594
+ if (skillExists === null) {
4595
+ ctx.logger.warn("[AI] Metadata service timed out checking actions_executor skill, skipping");
4596
+ } else if (!skillExists) {
4597
+ await withTimeout(metadataService.register("skill", ACTIONS_EXECUTOR_SKILL.name, ACTIONS_EXECUTOR_SKILL));
4598
+ ctx.logger.info("[AI] actions_executor skill registered");
4599
+ } else {
4600
+ ctx.logger.debug("[AI] actions_executor skill already exists, skipping auto-registration");
4601
+ }
4602
+ } catch (err) {
4603
+ ctx.logger.warn("[AI] Failed to register actions_executor skill", err instanceof Error ? { error: err.message } : { error: String(err) });
4604
+ }
3323
4605
  }
3324
4606
  }
3325
4607
  } catch {
@@ -3779,29 +5061,45 @@ function registerPackageTools(registry, context) {
3779
5061
  registry.register(setActivePackageTool, createSetActivePackageHandler(context));
3780
5062
  }
3781
5063
  export {
5064
+ ACTIONS_EXECUTOR_SKILL,
3782
5065
  AIService,
3783
5066
  AIServicePlugin,
3784
5067
  AgentRuntime,
3785
5068
  AiConversationObject,
3786
5069
  AiMessageObject,
5070
+ AiTraceObject,
5071
+ AiTraceView,
3787
5072
  DATA_CHAT_AGENT,
5073
+ DATA_EXPLORER_SKILL,
3788
5074
  DATA_TOOL_DEFINITIONS,
3789
5075
  InMemoryConversationService,
3790
5076
  METADATA_ASSISTANT_AGENT,
5077
+ METADATA_AUTHORING_SKILL,
3791
5078
  METADATA_TOOL_DEFINITIONS,
3792
5079
  MemoryLLMAdapter,
5080
+ ModelRegistry,
5081
+ NullTraceRecorder,
3793
5082
  ObjectQLConversationService,
5083
+ ObjectQLTraceRecorder,
3794
5084
  PACKAGE_TOOL_DEFINITIONS,
5085
+ QUERY_DATA_TOOL,
5086
+ SchemaRetriever,
3795
5087
  SkillRegistry,
3796
5088
  ToolRegistry,
3797
5089
  VercelLLMAdapter,
5090
+ actionSkipReason,
5091
+ actionToToolDefinition,
5092
+ actionToolName,
3798
5093
  addFieldTool,
3799
5094
  buildAIRoutes,
3800
5095
  buildAgentRoutes,
3801
5096
  buildAssistantRoutes,
3802
5097
  buildToolRoutes,
5098
+ buildTraceEvent,
5099
+ computeCost,
3803
5100
  createObjectTool,
3804
5101
  createPackageTool,
5102
+ createQueryDataHandler,
3805
5103
  deleteFieldTool,
3806
5104
  describeObjectTool,
3807
5105
  encodeStreamPart,
@@ -3811,9 +5109,11 @@ export {
3811
5109
  listObjectsTool,
3812
5110
  listPackagesTool,
3813
5111
  modifyFieldTool,
5112
+ registerActionsAsTools,
3814
5113
  registerDataTools,
3815
5114
  registerMetadataTools,
3816
5115
  registerPackageTools,
5116
+ registerQueryDataTool,
3817
5117
  setActivePackageTool
3818
5118
  };
3819
5119
  //# sourceMappingURL=index.js.map