@objectstack/service-ai 5.2.0 → 6.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.cjs CHANGED
@@ -124,7 +124,9 @@ var init_data_tools = __esm({
124
124
  properties: {
125
125
  field: { type: "string" },
126
126
  order: { type: "string", enum: ["asc", "desc"] }
127
- }
127
+ },
128
+ required: ["field", "order"],
129
+ additionalProperties: false
128
130
  },
129
131
  description: 'Sort order (e.g. [{ "field": "created_at", "order": "desc" }])'
130
132
  },
@@ -194,7 +196,8 @@ var init_data_tools = __esm({
194
196
  description: "Result column alias"
195
197
  }
196
198
  },
197
- required: ["function", "alias"]
199
+ required: ["function", "alias"],
200
+ additionalProperties: false
198
201
  },
199
202
  description: "Aggregation definitions"
200
203
  },
@@ -850,14 +853,20 @@ __export(index_exports, {
850
853
  AgentRuntime: () => AgentRuntime,
851
854
  AiConversationObject: () => AiConversationObject,
852
855
  AiMessageObject: () => AiMessageObject,
856
+ AiTraceObject: () => AiTraceObject,
853
857
  DATA_CHAT_AGENT: () => DATA_CHAT_AGENT,
854
858
  DATA_TOOL_DEFINITIONS: () => DATA_TOOL_DEFINITIONS,
855
859
  InMemoryConversationService: () => InMemoryConversationService,
856
860
  METADATA_ASSISTANT_AGENT: () => METADATA_ASSISTANT_AGENT,
857
861
  METADATA_TOOL_DEFINITIONS: () => METADATA_TOOL_DEFINITIONS,
858
862
  MemoryLLMAdapter: () => MemoryLLMAdapter,
863
+ ModelRegistry: () => ModelRegistry,
864
+ NullTraceRecorder: () => NullTraceRecorder,
859
865
  ObjectQLConversationService: () => ObjectQLConversationService,
866
+ ObjectQLTraceRecorder: () => ObjectQLTraceRecorder,
860
867
  PACKAGE_TOOL_DEFINITIONS: () => PACKAGE_TOOL_DEFINITIONS,
868
+ QUERY_DATA_TOOL: () => QUERY_DATA_TOOL,
869
+ SchemaRetriever: () => SchemaRetriever,
861
870
  SkillRegistry: () => SkillRegistry,
862
871
  ToolRegistry: () => ToolRegistry,
863
872
  VercelLLMAdapter: () => VercelLLMAdapter,
@@ -866,8 +875,11 @@ __export(index_exports, {
866
875
  buildAgentRoutes: () => buildAgentRoutes,
867
876
  buildAssistantRoutes: () => buildAssistantRoutes,
868
877
  buildToolRoutes: () => buildToolRoutes,
878
+ buildTraceEvent: () => buildTraceEvent,
879
+ computeCost: () => computeCost,
869
880
  createObjectTool: () => createObjectTool,
870
881
  createPackageTool: () => createPackageTool,
882
+ createQueryDataHandler: () => createQueryDataHandler,
871
883
  deleteFieldTool: () => deleteFieldTool,
872
884
  describeObjectTool: () => describeObjectTool,
873
885
  encodeStreamPart: () => encodeStreamPart,
@@ -880,6 +892,7 @@ __export(index_exports, {
880
892
  registerDataTools: () => registerDataTools,
881
893
  registerMetadataTools: () => registerMetadataTools,
882
894
  registerPackageTools: () => registerPackageTools,
895
+ registerQueryDataTool: () => registerQueryDataTool,
883
896
  setActivePackageTool: () => setActivePackageTool
884
897
  });
885
898
  module.exports = __toCommonJS(index_exports);
@@ -931,6 +944,59 @@ var MemoryLLMAdapter = class {
931
944
  async listModels() {
932
945
  return ["memory"];
933
946
  }
947
+ /**
948
+ * Heuristic structured-output for testing & demos — NOT a real LLM.
949
+ *
950
+ * Strategy:
951
+ * 1. Extract candidate object names from the system messages by matching
952
+ * schema-context headers (`### name — Label`) emitted by
953
+ * {@link SchemaRetriever.renderSnippet}.
954
+ * 2. Pick the candidate whose tokens overlap most with the last user
955
+ * message (falls back to the first candidate).
956
+ * 3. Try `schema.safeParse({ objectName, limit: 20 })` — this satisfies the
957
+ * `QueryPlanSchema` used by the built-in `query_data` tool.
958
+ * 4. If that fails, fall back to `schema.safeParse({})` for schemas that
959
+ * accept defaults.
960
+ * 5. Otherwise throw with a clear message — the demo needs a real provider.
961
+ */
962
+ async generateObject(messages, schema, options) {
963
+ const sys = messages.filter((m) => m.role === "system").map((m) => typeof m.content === "string" ? m.content : "").join("\n");
964
+ const headerRe = /^###\s+([a-z0-9_]+)\b/gim;
965
+ const candidates = [];
966
+ for (const match of sys.matchAll(headerRe)) {
967
+ if (match[1]) candidates.push(match[1]);
968
+ }
969
+ const lastUser = [...messages].reverse().find((m) => m.role === "user");
970
+ const userText = typeof lastUser?.content === "string" ? lastUser.content.toLowerCase() : "";
971
+ const userTokens = new Set(
972
+ userText.split(/[^a-z0-9_]+/).filter((t) => t.length > 1)
973
+ );
974
+ let chosen = candidates[0];
975
+ let bestScore = -1;
976
+ for (const name of candidates) {
977
+ const score = name.split(/[^a-z0-9]+/).reduce((acc, tok) => acc + (tok && userTokens.has(tok) ? 1 : 0), 0);
978
+ if (score > bestScore) {
979
+ bestScore = score;
980
+ chosen = name;
981
+ }
982
+ }
983
+ const attempts = [];
984
+ if (chosen) attempts.push({ objectName: chosen, limit: 20 });
985
+ attempts.push({});
986
+ for (const attempt of attempts) {
987
+ const result = schema.safeParse(attempt);
988
+ if (result.success) {
989
+ return {
990
+ object: result.data,
991
+ model: options?.model ?? "memory",
992
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
993
+ };
994
+ }
995
+ }
996
+ throw new Error(
997
+ "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."
998
+ );
999
+ }
934
1000
  };
935
1001
 
936
1002
  // src/tools/tool-registry.ts
@@ -1097,6 +1163,70 @@ var InMemoryConversationService = class {
1097
1163
  }
1098
1164
  };
1099
1165
 
1166
+ // src/trace-recorder.ts
1167
+ var import_node_crypto = require("crypto");
1168
+ var TRACE_OBJECT = "ai_traces";
1169
+ var NullTraceRecorder = class {
1170
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
1171
+ record(_event) {
1172
+ }
1173
+ };
1174
+ var ObjectQLTraceRecorder = class {
1175
+ constructor(engine, options = {}) {
1176
+ this.engine = engine;
1177
+ this.logger = options.logger;
1178
+ }
1179
+ async record(event) {
1180
+ const row = {
1181
+ id: `trace_${(0, import_node_crypto.randomUUID)()}`,
1182
+ conversation_id: event.conversationId ?? null,
1183
+ agent_id: event.agentId ?? null,
1184
+ operation: event.operation,
1185
+ model: event.model ?? null,
1186
+ adapter: event.adapter,
1187
+ prompt_tokens: event.promptTokens,
1188
+ completion_tokens: event.completionTokens,
1189
+ total_tokens: event.totalTokens,
1190
+ input_cost: event.cost?.inputCost ?? null,
1191
+ output_cost: event.cost?.outputCost ?? null,
1192
+ total_cost: event.cost?.totalCost ?? null,
1193
+ currency: event.cost?.currency ?? null,
1194
+ latency_ms: event.latencyMs,
1195
+ status: event.status,
1196
+ error: event.error ?? null,
1197
+ metadata: event.metadata ? JSON.stringify(event.metadata) : null,
1198
+ created_at: (/* @__PURE__ */ new Date()).toISOString()
1199
+ };
1200
+ try {
1201
+ await this.engine.insert(TRACE_OBJECT, row);
1202
+ } catch (err) {
1203
+ this.logger?.warn(
1204
+ "[AI] Failed to record trace (non-fatal)",
1205
+ err instanceof Error ? { error: err.message } : { error: String(err) }
1206
+ );
1207
+ }
1208
+ }
1209
+ };
1210
+ function buildTraceEvent(input) {
1211
+ const usage = input.usage ?? { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
1212
+ const cost = input.model && input.registry ? input.registry.estimateCost(input.model, usage) : void 0;
1213
+ return {
1214
+ operation: input.operation,
1215
+ adapter: input.adapter,
1216
+ model: input.model,
1217
+ agentId: input.agentId,
1218
+ conversationId: input.conversationId,
1219
+ promptTokens: usage.promptTokens,
1220
+ completionTokens: usage.completionTokens,
1221
+ totalTokens: usage.totalTokens,
1222
+ latencyMs: input.latencyMs,
1223
+ status: input.status,
1224
+ error: input.error,
1225
+ cost,
1226
+ metadata: input.metadata
1227
+ };
1228
+ }
1229
+
1100
1230
  // src/ai-service.ts
1101
1231
  function textDeltaPart(id, text) {
1102
1232
  return { type: "text-delta", id, text };
@@ -1115,22 +1245,83 @@ var _AIService = class _AIService {
1115
1245
  this.logger = config.logger ?? (0, import_core.createLogger)({ level: "info", format: "pretty" });
1116
1246
  this.toolRegistry = config.toolRegistry ?? new ToolRegistry();
1117
1247
  this.conversationService = config.conversationService ?? new InMemoryConversationService();
1248
+ this.modelRegistry = config.modelRegistry;
1249
+ this.traceRecorder = config.traceRecorder ?? new NullTraceRecorder();
1118
1250
  this.logger.info(
1119
- `[AI] Service initialized with adapter="${this.adapter.name}", tools=${this.toolRegistry.size}`
1251
+ `[AI] Service initialized with adapter="${this.adapter.name}", tools=${this.toolRegistry.size}, models=${this.modelRegistry?.size ?? 0}`
1120
1252
  );
1121
1253
  }
1122
1254
  /** The name of the active LLM adapter. */
1123
1255
  get adapterName() {
1124
1256
  return this.adapter.name;
1125
1257
  }
1258
+ /**
1259
+ * Run an adapter call and emit a trace event.
1260
+ *
1261
+ * Records both success and failure. Tracing failures never escape — the
1262
+ * recorder is expected to be defensive.
1263
+ */
1264
+ async instrument(operation, options, fn) {
1265
+ const started = Date.now();
1266
+ try {
1267
+ const result = await fn();
1268
+ void this.traceRecorder.record(buildTraceEvent({
1269
+ operation,
1270
+ adapter: this.adapter.name,
1271
+ model: result.model ?? options?.model,
1272
+ usage: result.usage,
1273
+ latencyMs: Date.now() - started,
1274
+ status: "success",
1275
+ registry: this.modelRegistry
1276
+ }));
1277
+ return result;
1278
+ } catch (err) {
1279
+ void this.traceRecorder.record(buildTraceEvent({
1280
+ operation,
1281
+ adapter: this.adapter.name,
1282
+ model: options?.model,
1283
+ latencyMs: Date.now() - started,
1284
+ status: "error",
1285
+ error: err instanceof Error ? err.message : String(err),
1286
+ registry: this.modelRegistry
1287
+ }));
1288
+ throw err;
1289
+ }
1290
+ }
1126
1291
  // ── IAIService implementation ──────────────────────────────────
1127
1292
  async chat(messages, options) {
1128
1293
  this.logger.debug("[AI] chat", { messageCount: messages.length, model: options?.model });
1129
- return this.adapter.chat(messages, options);
1294
+ return this.instrument("chat", options, () => this.adapter.chat(messages, options));
1130
1295
  }
1131
1296
  async complete(prompt, options) {
1132
1297
  this.logger.debug("[AI] complete", { promptLength: prompt.length, model: options?.model });
1133
- return this.adapter.complete(prompt, options);
1298
+ return this.instrument("complete", options, () => this.adapter.complete(prompt, options));
1299
+ }
1300
+ /**
1301
+ * Generate a strongly-typed object validated against a Zod schema.
1302
+ *
1303
+ * Delegates to the adapter's `generateObject` when supported; throws a
1304
+ * descriptive error when the adapter does not implement structured output.
1305
+ *
1306
+ * @example
1307
+ * ```ts
1308
+ * import { z } from 'zod';
1309
+ * const Schema = z.object({ name: z.string(), priority: z.number().int() });
1310
+ * const { object } = await ai.generateObject(messages, Schema);
1311
+ * ```
1312
+ */
1313
+ async generateObject(messages, schema, options) {
1314
+ this.logger.debug("[AI] generateObject", { messageCount: messages.length, model: options?.model });
1315
+ if (!this.adapter.generateObject) {
1316
+ throw new Error(
1317
+ `[AI] Adapter "${this.adapter.name}" does not support generateObject. Use VercelLLMAdapter with a structured-output-capable model.`
1318
+ );
1319
+ }
1320
+ return this.instrument(
1321
+ "generate_object",
1322
+ options,
1323
+ () => this.adapter.generateObject(messages, schema, options)
1324
+ );
1134
1325
  }
1135
1326
  async *streamChat(messages, options) {
1136
1327
  this.logger.debug("[AI] streamChat", { messageCount: messages.length, model: options?.model });
@@ -2212,7 +2403,7 @@ function buildToolRoutes(aiService, logger) {
2212
2403
  }
2213
2404
 
2214
2405
  // src/conversation/objectql-conversation-service.ts
2215
- var import_node_crypto = require("crypto");
2406
+ var import_node_crypto2 = require("crypto");
2216
2407
  var CONVERSATIONS_OBJECT = "ai_conversations";
2217
2408
  var MESSAGES_OBJECT = "ai_messages";
2218
2409
  var CONVERSATION_ORDER = [
@@ -2229,7 +2420,7 @@ var ObjectQLConversationService = class {
2229
2420
  }
2230
2421
  async create(options = {}) {
2231
2422
  const now = (/* @__PURE__ */ new Date()).toISOString();
2232
- const id = `conv_${(0, import_node_crypto.randomUUID)()}`;
2423
+ const id = `conv_${(0, import_node_crypto2.randomUUID)()}`;
2233
2424
  const record = {
2234
2425
  id,
2235
2426
  title: options.title ?? null,
@@ -2302,7 +2493,7 @@ var ObjectQLConversationService = class {
2302
2493
  throw new Error(`Conversation "${conversationId}" not found`);
2303
2494
  }
2304
2495
  const now = (/* @__PURE__ */ new Date()).toISOString();
2305
- const msgId = `msg_${(0, import_node_crypto.randomUUID)()}`;
2496
+ const msgId = `msg_${(0, import_node_crypto2.randomUUID)()}`;
2306
2497
  let contentStr;
2307
2498
  let toolCallsJson = null;
2308
2499
  let toolCallId = null;
@@ -2547,10 +2738,395 @@ var AiMessageObject = import_data2.ObjectSchema.create({
2547
2738
  }
2548
2739
  });
2549
2740
 
2741
+ // src/objects/ai-trace.object.ts
2742
+ var import_data3 = require("@objectstack/spec/data");
2743
+ var AiTraceObject = import_data3.ObjectSchema.create({
2744
+ name: "ai_traces",
2745
+ label: "AI Trace",
2746
+ pluralLabel: "AI Traces",
2747
+ icon: "activity",
2748
+ isSystem: true,
2749
+ description: "Per-call LLM invocation trace with token usage and cost",
2750
+ fields: {
2751
+ id: import_data3.Field.text({
2752
+ label: "Trace ID",
2753
+ required: true,
2754
+ readonly: true
2755
+ }),
2756
+ conversation_id: import_data3.Field.lookup("ai_conversations", {
2757
+ label: "Conversation",
2758
+ required: false,
2759
+ description: "Parent conversation, if any"
2760
+ }),
2761
+ agent_id: import_data3.Field.text({
2762
+ label: "Agent",
2763
+ required: false,
2764
+ maxLength: 128,
2765
+ description: "Agent metadata name that originated the call"
2766
+ }),
2767
+ operation: import_data3.Field.select({
2768
+ label: "Operation",
2769
+ required: true,
2770
+ options: [
2771
+ { label: "Chat", value: "chat" },
2772
+ { label: "Complete", value: "complete" },
2773
+ { label: "Stream Chat", value: "stream_chat" },
2774
+ { label: "Chat With Tools", value: "chat_with_tools" },
2775
+ { label: "Generate Object", value: "generate_object" },
2776
+ { label: "Embed", value: "embed" }
2777
+ ]
2778
+ }),
2779
+ model: import_data3.Field.text({
2780
+ label: "Model",
2781
+ required: false,
2782
+ maxLength: 128,
2783
+ description: "Model identifier reported by the adapter"
2784
+ }),
2785
+ adapter: import_data3.Field.text({
2786
+ label: "Adapter",
2787
+ required: false,
2788
+ maxLength: 64,
2789
+ description: 'LLM adapter name (e.g. "vercel", "memory")'
2790
+ }),
2791
+ prompt_tokens: import_data3.Field.number({
2792
+ label: "Prompt Tokens",
2793
+ required: false,
2794
+ defaultValue: 0
2795
+ }),
2796
+ completion_tokens: import_data3.Field.number({
2797
+ label: "Completion Tokens",
2798
+ required: false,
2799
+ defaultValue: 0
2800
+ }),
2801
+ total_tokens: import_data3.Field.number({
2802
+ label: "Total Tokens",
2803
+ required: false,
2804
+ defaultValue: 0
2805
+ }),
2806
+ input_cost: import_data3.Field.number({
2807
+ label: "Input Cost",
2808
+ required: false,
2809
+ description: "Cost attributable to prompt tokens (currency in `currency` field)"
2810
+ }),
2811
+ output_cost: import_data3.Field.number({
2812
+ label: "Output Cost",
2813
+ required: false,
2814
+ description: "Cost attributable to completion tokens"
2815
+ }),
2816
+ total_cost: import_data3.Field.number({
2817
+ label: "Total Cost",
2818
+ required: false,
2819
+ description: "input_cost + output_cost"
2820
+ }),
2821
+ currency: import_data3.Field.text({
2822
+ label: "Currency",
2823
+ required: false,
2824
+ maxLength: 8,
2825
+ defaultValue: "USD"
2826
+ }),
2827
+ latency_ms: import_data3.Field.number({
2828
+ label: "Latency (ms)",
2829
+ required: true,
2830
+ defaultValue: 0,
2831
+ description: "Wall-clock duration of the LLM call"
2832
+ }),
2833
+ status: import_data3.Field.select({
2834
+ label: "Status",
2835
+ required: true,
2836
+ options: [
2837
+ { label: "Success", value: "success" },
2838
+ { label: "Error", value: "error" }
2839
+ ]
2840
+ }),
2841
+ error: import_data3.Field.textarea({
2842
+ label: "Error",
2843
+ required: false,
2844
+ description: "Error message when status=error"
2845
+ }),
2846
+ metadata: import_data3.Field.textarea({
2847
+ label: "Metadata",
2848
+ required: false,
2849
+ description: "JSON-serialized extra fields (request id, user id, \u2026)"
2850
+ }),
2851
+ created_at: import_data3.Field.datetime({
2852
+ label: "Created At",
2853
+ required: true,
2854
+ defaultValue: "NOW()",
2855
+ readonly: true
2856
+ })
2857
+ },
2858
+ indexes: [
2859
+ { fields: ["conversation_id"] },
2860
+ { fields: ["agent_id"] },
2861
+ { fields: ["model"] },
2862
+ { fields: ["status"] },
2863
+ { fields: ["created_at"] }
2864
+ ],
2865
+ enable: {
2866
+ trackHistory: false,
2867
+ searchable: false,
2868
+ apiEnabled: true,
2869
+ apiMethods: ["get", "list"],
2870
+ trash: false,
2871
+ mru: false
2872
+ }
2873
+ });
2874
+
2550
2875
  // src/plugin.ts
2551
2876
  init_data_tools();
2552
2877
  init_metadata_tools();
2553
2878
 
2879
+ // src/tools/query-data.tool.ts
2880
+ var import_zod = require("zod");
2881
+
2882
+ // src/schema-retriever.ts
2883
+ var SchemaRetriever = class {
2884
+ constructor(metadata, options = {}) {
2885
+ this.metadata = metadata;
2886
+ this.options = {
2887
+ limit: options.limit ?? 3,
2888
+ minScore: options.minScore ?? 1,
2889
+ maxFieldsPerObject: options.maxFieldsPerObject ?? 12
2890
+ };
2891
+ }
2892
+ /**
2893
+ * Find object definitions whose name/label/fields match terms in the query.
2894
+ *
2895
+ * Returns matches sorted by score (descending) capped at `limit`. When
2896
+ * the query yields no matches, returns an empty array — callers may
2897
+ * fall back to a generic "describe what data exists" tool call.
2898
+ */
2899
+ async retrieve(query) {
2900
+ const terms = tokenise(query);
2901
+ if (terms.length === 0) return [];
2902
+ const objects = await this.metadata.listObjects();
2903
+ const hits = [];
2904
+ for (const raw of objects) {
2905
+ const obj = raw;
2906
+ if (!obj?.name) continue;
2907
+ const score = scoreObject(obj, terms);
2908
+ if (score >= this.options.minScore) {
2909
+ hits.push({ object: obj, score });
2910
+ }
2911
+ }
2912
+ hits.sort((a, b) => b.score - a.score);
2913
+ return hits.slice(0, this.options.limit);
2914
+ }
2915
+ /**
2916
+ * Render hits as a compact Markdown schema snippet.
2917
+ *
2918
+ * Designed to be appended to the system message — every line carries
2919
+ * exactly the information a model needs to choose object/field names
2920
+ * for query construction.
2921
+ */
2922
+ static renderSnippet(hits, maxFieldsPerObject = 12) {
2923
+ if (hits.length === 0) return "";
2924
+ const lines = ["## Schema context (auto-injected)"];
2925
+ for (const hit of hits) {
2926
+ const obj = hit.object;
2927
+ const label = obj.label ? ` \u2014 ${obj.label}` : "";
2928
+ lines.push(`### ${obj.name}${label}`);
2929
+ const fields = Object.entries(obj.fields ?? {}).slice(0, maxFieldsPerObject);
2930
+ for (const [name, field] of fields) {
2931
+ lines.push(` - ${name}: ${describeField(field)}`);
2932
+ }
2933
+ const total = Object.keys(obj.fields ?? {}).length;
2934
+ if (total > fields.length) {
2935
+ lines.push(` - \u2026${total - fields.length} more field(s)`);
2936
+ }
2937
+ }
2938
+ return lines.join("\n");
2939
+ }
2940
+ };
2941
+ function tokenise(query) {
2942
+ const raw = query.toLowerCase().match(/[a-z0-9]+/g) ?? [];
2943
+ return raw.filter((t) => t.length >= 2 && !STOPWORDS.has(t));
2944
+ }
2945
+ var STOPWORDS = /* @__PURE__ */ new Set([
2946
+ "the",
2947
+ "and",
2948
+ "for",
2949
+ "with",
2950
+ "from",
2951
+ "are",
2952
+ "has",
2953
+ "have",
2954
+ "had",
2955
+ "was",
2956
+ "were",
2957
+ "this",
2958
+ "that",
2959
+ "these",
2960
+ "those",
2961
+ "all",
2962
+ "any",
2963
+ "how",
2964
+ "what",
2965
+ "when",
2966
+ "where",
2967
+ "who",
2968
+ "why",
2969
+ "which",
2970
+ "show",
2971
+ "list",
2972
+ "find",
2973
+ "get",
2974
+ "count",
2975
+ "of",
2976
+ "in",
2977
+ "on",
2978
+ "at",
2979
+ "to",
2980
+ "as",
2981
+ "by",
2982
+ "is",
2983
+ "it",
2984
+ "an",
2985
+ "or",
2986
+ "be",
2987
+ "me"
2988
+ ]);
2989
+ function scoreObject(obj, terms) {
2990
+ let score = 0;
2991
+ const nameTokens = splitSnake(obj.name);
2992
+ const labelTokens = obj.label ? tokenise(obj.label) : [];
2993
+ const pluralTokens = obj.pluralLabel ? tokenise(obj.pluralLabel) : [];
2994
+ const descTokens = obj.description ? tokenise(obj.description) : [];
2995
+ for (const term of terms) {
2996
+ if (nameTokens.includes(term)) score += 3;
2997
+ else if (labelTokens.includes(term) || pluralTokens.includes(term)) score += 2;
2998
+ else if (descTokens.includes(term)) score += 1;
2999
+ }
3000
+ for (const [fieldName, field] of Object.entries(obj.fields ?? {})) {
3001
+ const fnTokens = splitSnake(fieldName);
3002
+ const flTokens = field.label ? tokenise(field.label) : [];
3003
+ for (const term of terms) {
3004
+ if (fnTokens.includes(term)) score += 2;
3005
+ else if (flTokens.includes(term)) score += 1;
3006
+ }
3007
+ }
3008
+ return score;
3009
+ }
3010
+ function splitSnake(name) {
3011
+ return name.toLowerCase().split("_").filter(Boolean);
3012
+ }
3013
+ function describeField(field) {
3014
+ const t = field.type ?? "unknown";
3015
+ if (t === "lookup" && field.reference) return `lookup \u2192 ${field.reference}`;
3016
+ if (t === "select" && Array.isArray(field.options)) {
3017
+ const values = field.options.map(
3018
+ (o) => typeof o === "string" ? o : o.value
3019
+ ).filter(Boolean).slice(0, 6);
3020
+ return `select(${values.join("|")})`;
3021
+ }
3022
+ return t;
3023
+ }
3024
+
3025
+ // src/tools/query-data.tool.ts
3026
+ var QueryPlanSchema = import_zod.z.object({
3027
+ objectName: import_zod.z.string().min(1).describe('The snake_case object name to query (e.g. "task", "account").'),
3028
+ where: import_zod.z.record(import_zod.z.string(), import_zod.z.unknown()).optional().describe(
3029
+ 'Filter conditions as key-value pairs. Use MongoDB-style operators for ranges, e.g. {"amount": {"$gt": 100}}.'
3030
+ ),
3031
+ fields: import_zod.z.array(import_zod.z.string()).optional().describe("Field names to return. Omit to return all fields."),
3032
+ orderBy: import_zod.z.array(
3033
+ import_zod.z.object({
3034
+ field: import_zod.z.string(),
3035
+ order: import_zod.z.enum(["asc", "desc"])
3036
+ })
3037
+ ).optional().describe("Sort order. First entry is primary sort key."),
3038
+ limit: import_zod.z.number().int().min(1).max(200).optional().describe("Maximum number of records (default 20, max 200).")
3039
+ });
3040
+ var QUERY_DATA_TOOL = {
3041
+ name: "query_data",
3042
+ 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.",
3043
+ parameters: {
3044
+ type: "object",
3045
+ properties: {
3046
+ request: {
3047
+ type: "string",
3048
+ description: "The natural-language question to answer (paraphrase the user's request if needed for clarity)."
3049
+ },
3050
+ model: {
3051
+ type: "string",
3052
+ description: "Optional model id to use for query planning. Defaults to the AI service's default model."
3053
+ }
3054
+ },
3055
+ required: ["request"],
3056
+ additionalProperties: false
3057
+ }
3058
+ };
3059
+ function createQueryDataHandler(ctx) {
3060
+ const retriever = new SchemaRetriever(ctx.metadata);
3061
+ const maxLimit = ctx.maxLimit ?? 100;
3062
+ return async (args) => {
3063
+ const { request, model } = args;
3064
+ if (!request || typeof request !== "string") {
3065
+ return JSON.stringify({ error: "query_data: `request` is required" });
3066
+ }
3067
+ if (!ctx.ai.generateObject) {
3068
+ return JSON.stringify({
3069
+ error: "query_data requires structured-output support. Configure a Vercel-AI-SDK-backed adapter (OpenAI, Anthropic, Google)."
3070
+ });
3071
+ }
3072
+ const hits = await retriever.retrieve(request);
3073
+ if (hits.length === 0) {
3074
+ return JSON.stringify({
3075
+ error: "No matching objects in metadata. Ask the user which object(s) to query, or list available objects via list_objects."
3076
+ });
3077
+ }
3078
+ const snippet = SchemaRetriever.renderSnippet(hits);
3079
+ const planMessages = [
3080
+ {
3081
+ role: "system",
3082
+ 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
3083
+ },
3084
+ { role: "user", content: request }
3085
+ ];
3086
+ let plan;
3087
+ try {
3088
+ const generated = await ctx.ai.generateObject(planMessages, QueryPlanSchema, {
3089
+ model,
3090
+ schemaName: "ObjectQLQueryPlan",
3091
+ schemaDescription: "A single ObjectQL find() query to answer the user request."
3092
+ });
3093
+ plan = generated.object;
3094
+ } catch (err) {
3095
+ return JSON.stringify({
3096
+ error: `Failed to plan query: ${err instanceof Error ? err.message : String(err)}`
3097
+ });
3098
+ }
3099
+ const matchedObject = hits.find((h) => h.object.name === plan.objectName)?.object ?? hits[0].object;
3100
+ if (matchedObject.name !== plan.objectName) {
3101
+ return JSON.stringify({
3102
+ error: `Planned object "${plan.objectName}" is not in the retrieved schema. Available: ${hits.map((h) => h.object.name).join(", ")}`
3103
+ });
3104
+ }
3105
+ const limit = Math.min(plan.limit ?? 20, maxLimit);
3106
+ try {
3107
+ const records = await ctx.dataEngine.find(plan.objectName, {
3108
+ where: plan.where,
3109
+ fields: plan.fields,
3110
+ orderBy: plan.orderBy,
3111
+ limit
3112
+ });
3113
+ return JSON.stringify({
3114
+ plan,
3115
+ count: records.length,
3116
+ records
3117
+ });
3118
+ } catch (err) {
3119
+ return JSON.stringify({
3120
+ plan,
3121
+ error: `Query execution failed: ${err instanceof Error ? err.message : String(err)}`
3122
+ });
3123
+ }
3124
+ };
3125
+ }
3126
+ function registerQueryDataTool(registry, context) {
3127
+ registry.register(QUERY_DATA_TOOL, createQueryDataHandler(context));
3128
+ }
3129
+
2554
3130
  // src/agent-runtime.ts
2555
3131
  var import_ai7 = require("@objectstack/spec/ai");
2556
3132
  var AgentRuntime = class {
@@ -3148,11 +3724,102 @@ var VercelLLMAdapter = class {
3148
3724
  "[VercelLLMAdapter] Embeddings require a dedicated EmbeddingModel. Configure an embedding adapter instead."
3149
3725
  );
3150
3726
  }
3727
+ async generateObject(messages, schema, options) {
3728
+ const { schemaName, schemaDescription, ...rest } = options ?? {};
3729
+ const result = await (0, import_ai9.generateObject)({
3730
+ model: this.model,
3731
+ messages,
3732
+ schema,
3733
+ schemaName,
3734
+ schemaDescription,
3735
+ ...buildVercelOptions(rest)
3736
+ });
3737
+ return {
3738
+ object: result.object,
3739
+ model: result.response?.modelId,
3740
+ usage: result.usage ? {
3741
+ promptTokens: result.usage.inputTokens ?? 0,
3742
+ completionTokens: result.usage.outputTokens ?? 0,
3743
+ totalTokens: result.usage.totalTokens ?? 0
3744
+ } : void 0
3745
+ };
3746
+ }
3151
3747
  async listModels() {
3152
3748
  return [];
3153
3749
  }
3154
3750
  };
3155
3751
 
3752
+ // src/model-registry.ts
3753
+ var ModelRegistry = class {
3754
+ constructor(config = {}) {
3755
+ this.models = /* @__PURE__ */ new Map();
3756
+ for (const model of config.models ?? []) {
3757
+ this.models.set(model.id, model);
3758
+ }
3759
+ this.defaultModelId = config.defaultModelId;
3760
+ }
3761
+ /** Register or replace a model. */
3762
+ register(model) {
3763
+ this.models.set(model.id, model);
3764
+ }
3765
+ /** Look up a model by id. */
3766
+ get(id) {
3767
+ return this.models.get(id);
3768
+ }
3769
+ /** Look up a model by id, throwing if missing. */
3770
+ getOrThrow(id) {
3771
+ const model = this.models.get(id);
3772
+ if (!model) {
3773
+ throw new Error(
3774
+ `[ModelRegistry] Unknown model "${id}". Registered: ${[...this.models.keys()].join(", ") || "(none)"}`
3775
+ );
3776
+ }
3777
+ return model;
3778
+ }
3779
+ /** Resolve the default model (explicit > first registered > undefined). */
3780
+ getDefault() {
3781
+ if (this.defaultModelId) {
3782
+ return this.models.get(this.defaultModelId);
3783
+ }
3784
+ return this.models.values().next().value;
3785
+ }
3786
+ /** Set the default model id (must already be registered). */
3787
+ setDefault(id) {
3788
+ this.getOrThrow(id);
3789
+ this.defaultModelId = id;
3790
+ }
3791
+ /** All registered models. */
3792
+ list() {
3793
+ return [...this.models.values()];
3794
+ }
3795
+ /** Number of registered models. */
3796
+ get size() {
3797
+ return this.models.size;
3798
+ }
3799
+ /**
3800
+ * Estimate cost in the model's currency (defaults to USD).
3801
+ *
3802
+ * Returns `undefined` when the model is unknown or has no pricing data.
3803
+ * Costs are computed as `(tokens / 1000) * pricePer1kTokens` for input and
3804
+ * output independently, then summed.
3805
+ */
3806
+ estimateCost(modelId, usage) {
3807
+ const model = this.models.get(modelId);
3808
+ if (!model?.pricing) return void 0;
3809
+ return computeCost(model.pricing, usage);
3810
+ }
3811
+ };
3812
+ function computeCost(pricing, usage) {
3813
+ const inputCost = pricing.inputCostPer1kTokens != null ? usage.promptTokens / 1e3 * pricing.inputCostPer1kTokens : 0;
3814
+ const outputCost = pricing.outputCostPer1kTokens != null ? usage.completionTokens / 1e3 * pricing.outputCostPer1kTokens : 0;
3815
+ return {
3816
+ inputCost,
3817
+ outputCost,
3818
+ totalCost: inputCost + outputCost,
3819
+ currency: pricing.currency ?? "USD"
3820
+ };
3821
+ }
3822
+
3156
3823
  // src/plugin.ts
3157
3824
  var AIServicePlugin = class {
3158
3825
  constructor(options = {}) {
@@ -3274,10 +3941,34 @@ var AIServicePlugin = class {
3274
3941
  adapterDescription = detected.description;
3275
3942
  }
3276
3943
  ctx.logger.info(`[AI] Using LLM adapter: ${adapterDescription}`);
3944
+ const modelRegistry = new ModelRegistry({
3945
+ models: this.options.models,
3946
+ defaultModelId: this.options.defaultModelId
3947
+ });
3948
+ if (modelRegistry.size > 0) {
3949
+ ctx.logger.info(`[AI] ModelRegistry initialised with ${modelRegistry.size} model(s)`);
3950
+ }
3951
+ let traceRecorder;
3952
+ if (this.options.traceRecorder === null) {
3953
+ ctx.logger.debug("[AI] Tracing disabled (traceRecorder=null)");
3954
+ } else if (this.options.traceRecorder) {
3955
+ traceRecorder = this.options.traceRecorder;
3956
+ } else {
3957
+ try {
3958
+ const engine = ctx.getService("data");
3959
+ if (engine && typeof engine.insert === "function") {
3960
+ traceRecorder = new ObjectQLTraceRecorder(engine, { logger: ctx.logger });
3961
+ ctx.logger.info("[AI] Using ObjectQLTraceRecorder (IDataEngine detected)");
3962
+ }
3963
+ } catch {
3964
+ }
3965
+ }
3277
3966
  const config = {
3278
3967
  adapter,
3279
3968
  logger: ctx.logger,
3280
- conversationService
3969
+ conversationService,
3970
+ modelRegistry,
3971
+ traceRecorder
3281
3972
  };
3282
3973
  this.service = new AIService(config);
3283
3974
  if (hasExisting) {
@@ -3292,7 +3983,7 @@ var AIServicePlugin = class {
3292
3983
  type: "plugin",
3293
3984
  scope: "project",
3294
3985
  namespace: "ai",
3295
- objects: [AiConversationObject, AiMessageObject]
3986
+ objects: [AiConversationObject, AiMessageObject, AiTraceObject]
3296
3987
  });
3297
3988
  if (this.options.debug) {
3298
3989
  ctx.hook("ai:beforeChat", async (messages) => {
@@ -3324,6 +4015,14 @@ var AIServicePlugin = class {
3324
4015
  if (dataEngine) {
3325
4016
  registerDataTools(this.service.toolRegistry, { dataEngine });
3326
4017
  ctx.logger.info("[AI] Built-in data tools registered");
4018
+ if (metadataService) {
4019
+ registerQueryDataTool(this.service.toolRegistry, {
4020
+ ai: this.service,
4021
+ metadata: metadataService,
4022
+ dataEngine
4023
+ });
4024
+ ctx.logger.info("[AI] query_data tool registered");
4025
+ }
3327
4026
  if (metadataService) {
3328
4027
  const { DATA_TOOL_DEFINITIONS: DATA_TOOL_DEFINITIONS2 } = await Promise.resolve().then(() => (init_data_tools(), data_tools_exports));
3329
4028
  for (const toolDef of DATA_TOOL_DEFINITIONS2) {
@@ -3839,14 +4538,20 @@ function registerPackageTools(registry, context) {
3839
4538
  AgentRuntime,
3840
4539
  AiConversationObject,
3841
4540
  AiMessageObject,
4541
+ AiTraceObject,
3842
4542
  DATA_CHAT_AGENT,
3843
4543
  DATA_TOOL_DEFINITIONS,
3844
4544
  InMemoryConversationService,
3845
4545
  METADATA_ASSISTANT_AGENT,
3846
4546
  METADATA_TOOL_DEFINITIONS,
3847
4547
  MemoryLLMAdapter,
4548
+ ModelRegistry,
4549
+ NullTraceRecorder,
3848
4550
  ObjectQLConversationService,
4551
+ ObjectQLTraceRecorder,
3849
4552
  PACKAGE_TOOL_DEFINITIONS,
4553
+ QUERY_DATA_TOOL,
4554
+ SchemaRetriever,
3850
4555
  SkillRegistry,
3851
4556
  ToolRegistry,
3852
4557
  VercelLLMAdapter,
@@ -3855,8 +4560,11 @@ function registerPackageTools(registry, context) {
3855
4560
  buildAgentRoutes,
3856
4561
  buildAssistantRoutes,
3857
4562
  buildToolRoutes,
4563
+ buildTraceEvent,
4564
+ computeCost,
3858
4565
  createObjectTool,
3859
4566
  createPackageTool,
4567
+ createQueryDataHandler,
3860
4568
  deleteFieldTool,
3861
4569
  describeObjectTool,
3862
4570
  encodeStreamPart,
@@ -3869,6 +4577,7 @@ function registerPackageTools(registry, context) {
3869
4577
  registerDataTools,
3870
4578
  registerMetadataTools,
3871
4579
  registerPackageTools,
4580
+ registerQueryDataTool,
3872
4581
  setActivePackageTool
3873
4582
  });
3874
4583
  //# sourceMappingURL=index.cjs.map