@objectstack/service-ai 5.1.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.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
  },
@@ -877,6 +880,59 @@ var MemoryLLMAdapter = class {
877
880
  async listModels() {
878
881
  return ["memory"];
879
882
  }
883
+ /**
884
+ * Heuristic structured-output for testing & demos — NOT a real LLM.
885
+ *
886
+ * Strategy:
887
+ * 1. Extract candidate object names from the system messages by matching
888
+ * schema-context headers (`### name — Label`) emitted by
889
+ * {@link SchemaRetriever.renderSnippet}.
890
+ * 2. Pick the candidate whose tokens overlap most with the last user
891
+ * message (falls back to the first candidate).
892
+ * 3. Try `schema.safeParse({ objectName, limit: 20 })` — this satisfies the
893
+ * `QueryPlanSchema` used by the built-in `query_data` tool.
894
+ * 4. If that fails, fall back to `schema.safeParse({})` for schemas that
895
+ * accept defaults.
896
+ * 5. Otherwise throw with a clear message — the demo needs a real provider.
897
+ */
898
+ async generateObject(messages, schema, options) {
899
+ const sys = messages.filter((m) => m.role === "system").map((m) => typeof m.content === "string" ? m.content : "").join("\n");
900
+ const headerRe = /^###\s+([a-z0-9_]+)\b/gim;
901
+ const candidates = [];
902
+ for (const match of sys.matchAll(headerRe)) {
903
+ if (match[1]) candidates.push(match[1]);
904
+ }
905
+ const lastUser = [...messages].reverse().find((m) => m.role === "user");
906
+ const userText = typeof lastUser?.content === "string" ? lastUser.content.toLowerCase() : "";
907
+ const userTokens = new Set(
908
+ userText.split(/[^a-z0-9_]+/).filter((t) => t.length > 1)
909
+ );
910
+ let chosen = candidates[0];
911
+ let bestScore = -1;
912
+ for (const name of candidates) {
913
+ const score = name.split(/[^a-z0-9]+/).reduce((acc, tok) => acc + (tok && userTokens.has(tok) ? 1 : 0), 0);
914
+ if (score > bestScore) {
915
+ bestScore = score;
916
+ chosen = name;
917
+ }
918
+ }
919
+ const attempts = [];
920
+ if (chosen) attempts.push({ objectName: chosen, limit: 20 });
921
+ attempts.push({});
922
+ for (const attempt of attempts) {
923
+ const result = schema.safeParse(attempt);
924
+ if (result.success) {
925
+ return {
926
+ object: result.data,
927
+ model: options?.model ?? "memory",
928
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
929
+ };
930
+ }
931
+ }
932
+ throw new Error(
933
+ "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."
934
+ );
935
+ }
880
936
  };
881
937
 
882
938
  // src/tools/tool-registry.ts
@@ -1043,6 +1099,70 @@ var InMemoryConversationService = class {
1043
1099
  }
1044
1100
  };
1045
1101
 
1102
+ // src/trace-recorder.ts
1103
+ import { randomUUID } from "crypto";
1104
+ var TRACE_OBJECT = "ai_traces";
1105
+ var NullTraceRecorder = class {
1106
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
1107
+ record(_event) {
1108
+ }
1109
+ };
1110
+ var ObjectQLTraceRecorder = class {
1111
+ constructor(engine, options = {}) {
1112
+ this.engine = engine;
1113
+ this.logger = options.logger;
1114
+ }
1115
+ async record(event) {
1116
+ const row = {
1117
+ id: `trace_${randomUUID()}`,
1118
+ conversation_id: event.conversationId ?? null,
1119
+ agent_id: event.agentId ?? null,
1120
+ operation: event.operation,
1121
+ model: event.model ?? null,
1122
+ adapter: event.adapter,
1123
+ prompt_tokens: event.promptTokens,
1124
+ completion_tokens: event.completionTokens,
1125
+ total_tokens: event.totalTokens,
1126
+ input_cost: event.cost?.inputCost ?? null,
1127
+ output_cost: event.cost?.outputCost ?? null,
1128
+ total_cost: event.cost?.totalCost ?? null,
1129
+ currency: event.cost?.currency ?? null,
1130
+ latency_ms: event.latencyMs,
1131
+ status: event.status,
1132
+ error: event.error ?? null,
1133
+ metadata: event.metadata ? JSON.stringify(event.metadata) : null,
1134
+ created_at: (/* @__PURE__ */ new Date()).toISOString()
1135
+ };
1136
+ try {
1137
+ await this.engine.insert(TRACE_OBJECT, row);
1138
+ } catch (err) {
1139
+ this.logger?.warn(
1140
+ "[AI] Failed to record trace (non-fatal)",
1141
+ err instanceof Error ? { error: err.message } : { error: String(err) }
1142
+ );
1143
+ }
1144
+ }
1145
+ };
1146
+ function buildTraceEvent(input) {
1147
+ const usage = input.usage ?? { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
1148
+ const cost = input.model && input.registry ? input.registry.estimateCost(input.model, usage) : void 0;
1149
+ return {
1150
+ operation: input.operation,
1151
+ adapter: input.adapter,
1152
+ model: input.model,
1153
+ agentId: input.agentId,
1154
+ conversationId: input.conversationId,
1155
+ promptTokens: usage.promptTokens,
1156
+ completionTokens: usage.completionTokens,
1157
+ totalTokens: usage.totalTokens,
1158
+ latencyMs: input.latencyMs,
1159
+ status: input.status,
1160
+ error: input.error,
1161
+ cost,
1162
+ metadata: input.metadata
1163
+ };
1164
+ }
1165
+
1046
1166
  // src/ai-service.ts
1047
1167
  function textDeltaPart(id, text) {
1048
1168
  return { type: "text-delta", id, text };
@@ -1061,22 +1181,83 @@ var _AIService = class _AIService {
1061
1181
  this.logger = config.logger ?? createLogger({ level: "info", format: "pretty" });
1062
1182
  this.toolRegistry = config.toolRegistry ?? new ToolRegistry();
1063
1183
  this.conversationService = config.conversationService ?? new InMemoryConversationService();
1184
+ this.modelRegistry = config.modelRegistry;
1185
+ this.traceRecorder = config.traceRecorder ?? new NullTraceRecorder();
1064
1186
  this.logger.info(
1065
- `[AI] Service initialized with adapter="${this.adapter.name}", tools=${this.toolRegistry.size}`
1187
+ `[AI] Service initialized with adapter="${this.adapter.name}", tools=${this.toolRegistry.size}, models=${this.modelRegistry?.size ?? 0}`
1066
1188
  );
1067
1189
  }
1068
1190
  /** The name of the active LLM adapter. */
1069
1191
  get adapterName() {
1070
1192
  return this.adapter.name;
1071
1193
  }
1194
+ /**
1195
+ * Run an adapter call and emit a trace event.
1196
+ *
1197
+ * Records both success and failure. Tracing failures never escape — the
1198
+ * recorder is expected to be defensive.
1199
+ */
1200
+ async instrument(operation, options, fn) {
1201
+ const started = Date.now();
1202
+ try {
1203
+ const result = await fn();
1204
+ void this.traceRecorder.record(buildTraceEvent({
1205
+ operation,
1206
+ adapter: this.adapter.name,
1207
+ model: result.model ?? options?.model,
1208
+ usage: result.usage,
1209
+ latencyMs: Date.now() - started,
1210
+ status: "success",
1211
+ registry: this.modelRegistry
1212
+ }));
1213
+ return result;
1214
+ } catch (err) {
1215
+ void this.traceRecorder.record(buildTraceEvent({
1216
+ operation,
1217
+ adapter: this.adapter.name,
1218
+ model: options?.model,
1219
+ latencyMs: Date.now() - started,
1220
+ status: "error",
1221
+ error: err instanceof Error ? err.message : String(err),
1222
+ registry: this.modelRegistry
1223
+ }));
1224
+ throw err;
1225
+ }
1226
+ }
1072
1227
  // ── IAIService implementation ──────────────────────────────────
1073
1228
  async chat(messages, options) {
1074
1229
  this.logger.debug("[AI] chat", { messageCount: messages.length, model: options?.model });
1075
- return this.adapter.chat(messages, options);
1230
+ return this.instrument("chat", options, () => this.adapter.chat(messages, options));
1076
1231
  }
1077
1232
  async complete(prompt, options) {
1078
1233
  this.logger.debug("[AI] complete", { promptLength: prompt.length, model: options?.model });
1079
- return this.adapter.complete(prompt, options);
1234
+ return this.instrument("complete", options, () => this.adapter.complete(prompt, options));
1235
+ }
1236
+ /**
1237
+ * Generate a strongly-typed object validated against a Zod schema.
1238
+ *
1239
+ * Delegates to the adapter's `generateObject` when supported; throws a
1240
+ * descriptive error when the adapter does not implement structured output.
1241
+ *
1242
+ * @example
1243
+ * ```ts
1244
+ * import { z } from 'zod';
1245
+ * const Schema = z.object({ name: z.string(), priority: z.number().int() });
1246
+ * const { object } = await ai.generateObject(messages, Schema);
1247
+ * ```
1248
+ */
1249
+ async generateObject(messages, schema, options) {
1250
+ this.logger.debug("[AI] generateObject", { messageCount: messages.length, model: options?.model });
1251
+ if (!this.adapter.generateObject) {
1252
+ throw new Error(
1253
+ `[AI] Adapter "${this.adapter.name}" does not support generateObject. Use VercelLLMAdapter with a structured-output-capable model.`
1254
+ );
1255
+ }
1256
+ return this.instrument(
1257
+ "generate_object",
1258
+ options,
1259
+ () => this.adapter.generateObject(messages, schema, options)
1260
+ );
1080
1261
  }
1081
1262
  async *streamChat(messages, options) {
1082
1263
  this.logger.debug("[AI] streamChat", { messageCount: messages.length, model: options?.model });
@@ -2158,7 +2339,7 @@ function buildToolRoutes(aiService, logger) {
2158
2339
  }
2159
2340
 
2160
2341
  // src/conversation/objectql-conversation-service.ts
2161
- import { randomUUID } from "crypto";
2342
+ import { randomUUID as randomUUID2 } from "crypto";
2162
2343
  var CONVERSATIONS_OBJECT = "ai_conversations";
2163
2344
  var MESSAGES_OBJECT = "ai_messages";
2164
2345
  var CONVERSATION_ORDER = [
@@ -2175,7 +2356,7 @@ var ObjectQLConversationService = class {
2175
2356
  }
2176
2357
  async create(options = {}) {
2177
2358
  const now = (/* @__PURE__ */ new Date()).toISOString();
2178
- const id = `conv_${randomUUID()}`;
2359
+ const id = `conv_${randomUUID2()}`;
2179
2360
  const record = {
2180
2361
  id,
2181
2362
  title: options.title ?? null,
@@ -2248,7 +2429,7 @@ var ObjectQLConversationService = class {
2248
2429
  throw new Error(`Conversation "${conversationId}" not found`);
2249
2430
  }
2250
2431
  const now = (/* @__PURE__ */ new Date()).toISOString();
2251
- const msgId = `msg_${randomUUID()}`;
2432
+ const msgId = `msg_${randomUUID2()}`;
2252
2433
  let contentStr;
2253
2434
  let toolCallsJson = null;
2254
2435
  let toolCallId = null;
@@ -2493,10 +2674,395 @@ var AiMessageObject = ObjectSchema2.create({
2493
2674
  }
2494
2675
  });
2495
2676
 
2677
+ // src/objects/ai-trace.object.ts
2678
+ import { ObjectSchema as ObjectSchema3, Field as Field3 } from "@objectstack/spec/data";
2679
+ var AiTraceObject = ObjectSchema3.create({
2680
+ name: "ai_traces",
2681
+ label: "AI Trace",
2682
+ pluralLabel: "AI Traces",
2683
+ icon: "activity",
2684
+ isSystem: true,
2685
+ description: "Per-call LLM invocation trace with token usage and cost",
2686
+ fields: {
2687
+ id: Field3.text({
2688
+ label: "Trace ID",
2689
+ required: true,
2690
+ readonly: true
2691
+ }),
2692
+ conversation_id: Field3.lookup("ai_conversations", {
2693
+ label: "Conversation",
2694
+ required: false,
2695
+ description: "Parent conversation, if any"
2696
+ }),
2697
+ agent_id: Field3.text({
2698
+ label: "Agent",
2699
+ required: false,
2700
+ maxLength: 128,
2701
+ description: "Agent metadata name that originated the call"
2702
+ }),
2703
+ operation: Field3.select({
2704
+ label: "Operation",
2705
+ required: true,
2706
+ options: [
2707
+ { label: "Chat", value: "chat" },
2708
+ { label: "Complete", value: "complete" },
2709
+ { label: "Stream Chat", value: "stream_chat" },
2710
+ { label: "Chat With Tools", value: "chat_with_tools" },
2711
+ { label: "Generate Object", value: "generate_object" },
2712
+ { label: "Embed", value: "embed" }
2713
+ ]
2714
+ }),
2715
+ model: Field3.text({
2716
+ label: "Model",
2717
+ required: false,
2718
+ maxLength: 128,
2719
+ description: "Model identifier reported by the adapter"
2720
+ }),
2721
+ adapter: Field3.text({
2722
+ label: "Adapter",
2723
+ required: false,
2724
+ maxLength: 64,
2725
+ description: 'LLM adapter name (e.g. "vercel", "memory")'
2726
+ }),
2727
+ prompt_tokens: Field3.number({
2728
+ label: "Prompt Tokens",
2729
+ required: false,
2730
+ defaultValue: 0
2731
+ }),
2732
+ completion_tokens: Field3.number({
2733
+ label: "Completion Tokens",
2734
+ required: false,
2735
+ defaultValue: 0
2736
+ }),
2737
+ total_tokens: Field3.number({
2738
+ label: "Total Tokens",
2739
+ required: false,
2740
+ defaultValue: 0
2741
+ }),
2742
+ input_cost: Field3.number({
2743
+ label: "Input Cost",
2744
+ required: false,
2745
+ description: "Cost attributable to prompt tokens (currency in `currency` field)"
2746
+ }),
2747
+ output_cost: Field3.number({
2748
+ label: "Output Cost",
2749
+ required: false,
2750
+ description: "Cost attributable to completion tokens"
2751
+ }),
2752
+ total_cost: Field3.number({
2753
+ label: "Total Cost",
2754
+ required: false,
2755
+ description: "input_cost + output_cost"
2756
+ }),
2757
+ currency: Field3.text({
2758
+ label: "Currency",
2759
+ required: false,
2760
+ maxLength: 8,
2761
+ defaultValue: "USD"
2762
+ }),
2763
+ latency_ms: Field3.number({
2764
+ label: "Latency (ms)",
2765
+ required: true,
2766
+ defaultValue: 0,
2767
+ description: "Wall-clock duration of the LLM call"
2768
+ }),
2769
+ status: Field3.select({
2770
+ label: "Status",
2771
+ required: true,
2772
+ options: [
2773
+ { label: "Success", value: "success" },
2774
+ { label: "Error", value: "error" }
2775
+ ]
2776
+ }),
2777
+ error: Field3.textarea({
2778
+ label: "Error",
2779
+ required: false,
2780
+ description: "Error message when status=error"
2781
+ }),
2782
+ metadata: Field3.textarea({
2783
+ label: "Metadata",
2784
+ required: false,
2785
+ description: "JSON-serialized extra fields (request id, user id, \u2026)"
2786
+ }),
2787
+ created_at: Field3.datetime({
2788
+ label: "Created At",
2789
+ required: true,
2790
+ defaultValue: "NOW()",
2791
+ readonly: true
2792
+ })
2793
+ },
2794
+ indexes: [
2795
+ { fields: ["conversation_id"] },
2796
+ { fields: ["agent_id"] },
2797
+ { fields: ["model"] },
2798
+ { fields: ["status"] },
2799
+ { fields: ["created_at"] }
2800
+ ],
2801
+ enable: {
2802
+ trackHistory: false,
2803
+ searchable: false,
2804
+ apiEnabled: true,
2805
+ apiMethods: ["get", "list"],
2806
+ trash: false,
2807
+ mru: false
2808
+ }
2809
+ });
2810
+
2496
2811
  // src/plugin.ts
2497
2812
  init_data_tools();
2498
2813
  init_metadata_tools();
2499
2814
 
2815
+ // src/tools/query-data.tool.ts
2816
+ import { z } from "zod";
2817
+
2818
+ // src/schema-retriever.ts
2819
+ var SchemaRetriever = class {
2820
+ constructor(metadata, options = {}) {
2821
+ this.metadata = metadata;
2822
+ this.options = {
2823
+ limit: options.limit ?? 3,
2824
+ minScore: options.minScore ?? 1,
2825
+ maxFieldsPerObject: options.maxFieldsPerObject ?? 12
2826
+ };
2827
+ }
2828
+ /**
2829
+ * Find object definitions whose name/label/fields match terms in the query.
2830
+ *
2831
+ * Returns matches sorted by score (descending) capped at `limit`. When
2832
+ * the query yields no matches, returns an empty array — callers may
2833
+ * fall back to a generic "describe what data exists" tool call.
2834
+ */
2835
+ async retrieve(query) {
2836
+ const terms = tokenise(query);
2837
+ if (terms.length === 0) return [];
2838
+ const objects = await this.metadata.listObjects();
2839
+ const hits = [];
2840
+ for (const raw of objects) {
2841
+ const obj = raw;
2842
+ if (!obj?.name) continue;
2843
+ const score = scoreObject(obj, terms);
2844
+ if (score >= this.options.minScore) {
2845
+ hits.push({ object: obj, score });
2846
+ }
2847
+ }
2848
+ hits.sort((a, b) => b.score - a.score);
2849
+ return hits.slice(0, this.options.limit);
2850
+ }
2851
+ /**
2852
+ * Render hits as a compact Markdown schema snippet.
2853
+ *
2854
+ * Designed to be appended to the system message — every line carries
2855
+ * exactly the information a model needs to choose object/field names
2856
+ * for query construction.
2857
+ */
2858
+ static renderSnippet(hits, maxFieldsPerObject = 12) {
2859
+ if (hits.length === 0) return "";
2860
+ const lines = ["## Schema context (auto-injected)"];
2861
+ for (const hit of hits) {
2862
+ const obj = hit.object;
2863
+ const label = obj.label ? ` \u2014 ${obj.label}` : "";
2864
+ lines.push(`### ${obj.name}${label}`);
2865
+ const fields = Object.entries(obj.fields ?? {}).slice(0, maxFieldsPerObject);
2866
+ for (const [name, field] of fields) {
2867
+ lines.push(` - ${name}: ${describeField(field)}`);
2868
+ }
2869
+ const total = Object.keys(obj.fields ?? {}).length;
2870
+ if (total > fields.length) {
2871
+ lines.push(` - \u2026${total - fields.length} more field(s)`);
2872
+ }
2873
+ }
2874
+ return lines.join("\n");
2875
+ }
2876
+ };
2877
+ function tokenise(query) {
2878
+ const raw = query.toLowerCase().match(/[a-z0-9]+/g) ?? [];
2879
+ return raw.filter((t) => t.length >= 2 && !STOPWORDS.has(t));
2880
+ }
2881
+ var STOPWORDS = /* @__PURE__ */ new Set([
2882
+ "the",
2883
+ "and",
2884
+ "for",
2885
+ "with",
2886
+ "from",
2887
+ "are",
2888
+ "has",
2889
+ "have",
2890
+ "had",
2891
+ "was",
2892
+ "were",
2893
+ "this",
2894
+ "that",
2895
+ "these",
2896
+ "those",
2897
+ "all",
2898
+ "any",
2899
+ "how",
2900
+ "what",
2901
+ "when",
2902
+ "where",
2903
+ "who",
2904
+ "why",
2905
+ "which",
2906
+ "show",
2907
+ "list",
2908
+ "find",
2909
+ "get",
2910
+ "count",
2911
+ "of",
2912
+ "in",
2913
+ "on",
2914
+ "at",
2915
+ "to",
2916
+ "as",
2917
+ "by",
2918
+ "is",
2919
+ "it",
2920
+ "an",
2921
+ "or",
2922
+ "be",
2923
+ "me"
2924
+ ]);
2925
+ function scoreObject(obj, terms) {
2926
+ let score = 0;
2927
+ const nameTokens = splitSnake(obj.name);
2928
+ const labelTokens = obj.label ? tokenise(obj.label) : [];
2929
+ const pluralTokens = obj.pluralLabel ? tokenise(obj.pluralLabel) : [];
2930
+ const descTokens = obj.description ? tokenise(obj.description) : [];
2931
+ for (const term of terms) {
2932
+ if (nameTokens.includes(term)) score += 3;
2933
+ else if (labelTokens.includes(term) || pluralTokens.includes(term)) score += 2;
2934
+ else if (descTokens.includes(term)) score += 1;
2935
+ }
2936
+ for (const [fieldName, field] of Object.entries(obj.fields ?? {})) {
2937
+ const fnTokens = splitSnake(fieldName);
2938
+ const flTokens = field.label ? tokenise(field.label) : [];
2939
+ for (const term of terms) {
2940
+ if (fnTokens.includes(term)) score += 2;
2941
+ else if (flTokens.includes(term)) score += 1;
2942
+ }
2943
+ }
2944
+ return score;
2945
+ }
2946
+ function splitSnake(name) {
2947
+ return name.toLowerCase().split("_").filter(Boolean);
2948
+ }
2949
+ function describeField(field) {
2950
+ const t = field.type ?? "unknown";
2951
+ if (t === "lookup" && field.reference) return `lookup \u2192 ${field.reference}`;
2952
+ if (t === "select" && Array.isArray(field.options)) {
2953
+ const values = field.options.map(
2954
+ (o) => typeof o === "string" ? o : o.value
2955
+ ).filter(Boolean).slice(0, 6);
2956
+ return `select(${values.join("|")})`;
2957
+ }
2958
+ return t;
2959
+ }
2960
+
2961
+ // src/tools/query-data.tool.ts
2962
+ var QueryPlanSchema = z.object({
2963
+ objectName: z.string().min(1).describe('The snake_case object name to query (e.g. "task", "account").'),
2964
+ where: z.record(z.string(), z.unknown()).optional().describe(
2965
+ 'Filter conditions as key-value pairs. Use MongoDB-style operators for ranges, e.g. {"amount": {"$gt": 100}}.'
2966
+ ),
2967
+ fields: z.array(z.string()).optional().describe("Field names to return. Omit to return all fields."),
2968
+ orderBy: z.array(
2969
+ z.object({
2970
+ field: z.string(),
2971
+ order: z.enum(["asc", "desc"])
2972
+ })
2973
+ ).optional().describe("Sort order. First entry is primary sort key."),
2974
+ limit: z.number().int().min(1).max(200).optional().describe("Maximum number of records (default 20, max 200).")
2975
+ });
2976
+ var QUERY_DATA_TOOL = {
2977
+ name: "query_data",
2978
+ 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.",
2979
+ parameters: {
2980
+ type: "object",
2981
+ properties: {
2982
+ request: {
2983
+ type: "string",
2984
+ description: "The natural-language question to answer (paraphrase the user's request if needed for clarity)."
2985
+ },
2986
+ model: {
2987
+ type: "string",
2988
+ description: "Optional model id to use for query planning. Defaults to the AI service's default model."
2989
+ }
2990
+ },
2991
+ required: ["request"],
2992
+ additionalProperties: false
2993
+ }
2994
+ };
2995
+ function createQueryDataHandler(ctx) {
2996
+ const retriever = new SchemaRetriever(ctx.metadata);
2997
+ const maxLimit = ctx.maxLimit ?? 100;
2998
+ return async (args) => {
2999
+ const { request, model } = args;
3000
+ if (!request || typeof request !== "string") {
3001
+ return JSON.stringify({ error: "query_data: `request` is required" });
3002
+ }
3003
+ if (!ctx.ai.generateObject) {
3004
+ return JSON.stringify({
3005
+ error: "query_data requires structured-output support. Configure a Vercel-AI-SDK-backed adapter (OpenAI, Anthropic, Google)."
3006
+ });
3007
+ }
3008
+ const hits = await retriever.retrieve(request);
3009
+ if (hits.length === 0) {
3010
+ return JSON.stringify({
3011
+ error: "No matching objects in metadata. Ask the user which object(s) to query, or list available objects via list_objects."
3012
+ });
3013
+ }
3014
+ const snippet = SchemaRetriever.renderSnippet(hits);
3015
+ const planMessages = [
3016
+ {
3017
+ role: "system",
3018
+ 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
3019
+ },
3020
+ { role: "user", content: request }
3021
+ ];
3022
+ let plan;
3023
+ try {
3024
+ const generated = await ctx.ai.generateObject(planMessages, QueryPlanSchema, {
3025
+ model,
3026
+ schemaName: "ObjectQLQueryPlan",
3027
+ schemaDescription: "A single ObjectQL find() query to answer the user request."
3028
+ });
3029
+ plan = generated.object;
3030
+ } catch (err) {
3031
+ return JSON.stringify({
3032
+ error: `Failed to plan query: ${err instanceof Error ? err.message : String(err)}`
3033
+ });
3034
+ }
3035
+ const matchedObject = hits.find((h) => h.object.name === plan.objectName)?.object ?? hits[0].object;
3036
+ if (matchedObject.name !== plan.objectName) {
3037
+ return JSON.stringify({
3038
+ error: `Planned object "${plan.objectName}" is not in the retrieved schema. Available: ${hits.map((h) => h.object.name).join(", ")}`
3039
+ });
3040
+ }
3041
+ const limit = Math.min(plan.limit ?? 20, maxLimit);
3042
+ try {
3043
+ const records = await ctx.dataEngine.find(plan.objectName, {
3044
+ where: plan.where,
3045
+ fields: plan.fields,
3046
+ orderBy: plan.orderBy,
3047
+ limit
3048
+ });
3049
+ return JSON.stringify({
3050
+ plan,
3051
+ count: records.length,
3052
+ records
3053
+ });
3054
+ } catch (err) {
3055
+ return JSON.stringify({
3056
+ plan,
3057
+ error: `Query execution failed: ${err instanceof Error ? err.message : String(err)}`
3058
+ });
3059
+ }
3060
+ };
3061
+ }
3062
+ function registerQueryDataTool(registry, context) {
3063
+ registry.register(QUERY_DATA_TOOL, createQueryDataHandler(context));
3064
+ }
3065
+
2500
3066
  // src/agent-runtime.ts
2501
3067
  import { AgentSchema } from "@objectstack/spec/ai";
2502
3068
  var AgentRuntime = class {
@@ -3012,7 +3578,7 @@ Guidelines:
3012
3578
  };
3013
3579
 
3014
3580
  // src/adapters/vercel-adapter.ts
3015
- import { generateText, streamText, tool as vercelTool, jsonSchema } from "ai";
3581
+ import { generateText, streamText, generateObject, tool as vercelTool, jsonSchema } from "ai";
3016
3582
  function buildVercelOptions(options) {
3017
3583
  if (!options) return {};
3018
3584
  const opts = {};
@@ -3094,11 +3660,102 @@ var VercelLLMAdapter = class {
3094
3660
  "[VercelLLMAdapter] Embeddings require a dedicated EmbeddingModel. Configure an embedding adapter instead."
3095
3661
  );
3096
3662
  }
3663
+ async generateObject(messages, schema, options) {
3664
+ const { schemaName, schemaDescription, ...rest } = options ?? {};
3665
+ const result = await generateObject({
3666
+ model: this.model,
3667
+ messages,
3668
+ schema,
3669
+ schemaName,
3670
+ schemaDescription,
3671
+ ...buildVercelOptions(rest)
3672
+ });
3673
+ return {
3674
+ object: result.object,
3675
+ model: result.response?.modelId,
3676
+ usage: result.usage ? {
3677
+ promptTokens: result.usage.inputTokens ?? 0,
3678
+ completionTokens: result.usage.outputTokens ?? 0,
3679
+ totalTokens: result.usage.totalTokens ?? 0
3680
+ } : void 0
3681
+ };
3682
+ }
3097
3683
  async listModels() {
3098
3684
  return [];
3099
3685
  }
3100
3686
  };
3101
3687
 
3688
+ // src/model-registry.ts
3689
+ var ModelRegistry = class {
3690
+ constructor(config = {}) {
3691
+ this.models = /* @__PURE__ */ new Map();
3692
+ for (const model of config.models ?? []) {
3693
+ this.models.set(model.id, model);
3694
+ }
3695
+ this.defaultModelId = config.defaultModelId;
3696
+ }
3697
+ /** Register or replace a model. */
3698
+ register(model) {
3699
+ this.models.set(model.id, model);
3700
+ }
3701
+ /** Look up a model by id. */
3702
+ get(id) {
3703
+ return this.models.get(id);
3704
+ }
3705
+ /** Look up a model by id, throwing if missing. */
3706
+ getOrThrow(id) {
3707
+ const model = this.models.get(id);
3708
+ if (!model) {
3709
+ throw new Error(
3710
+ `[ModelRegistry] Unknown model "${id}". Registered: ${[...this.models.keys()].join(", ") || "(none)"}`
3711
+ );
3712
+ }
3713
+ return model;
3714
+ }
3715
+ /** Resolve the default model (explicit > first registered > undefined). */
3716
+ getDefault() {
3717
+ if (this.defaultModelId) {
3718
+ return this.models.get(this.defaultModelId);
3719
+ }
3720
+ return this.models.values().next().value;
3721
+ }
3722
+ /** Set the default model id (must already be registered). */
3723
+ setDefault(id) {
3724
+ this.getOrThrow(id);
3725
+ this.defaultModelId = id;
3726
+ }
3727
+ /** All registered models. */
3728
+ list() {
3729
+ return [...this.models.values()];
3730
+ }
3731
+ /** Number of registered models. */
3732
+ get size() {
3733
+ return this.models.size;
3734
+ }
3735
+ /**
3736
+ * Estimate cost in the model's currency (defaults to USD).
3737
+ *
3738
+ * Returns `undefined` when the model is unknown or has no pricing data.
3739
+ * Costs are computed as `(tokens / 1000) * pricePer1kTokens` for input and
3740
+ * output independently, then summed.
3741
+ */
3742
+ estimateCost(modelId, usage) {
3743
+ const model = this.models.get(modelId);
3744
+ if (!model?.pricing) return void 0;
3745
+ return computeCost(model.pricing, usage);
3746
+ }
3747
+ };
3748
+ function computeCost(pricing, usage) {
3749
+ const inputCost = pricing.inputCostPer1kTokens != null ? usage.promptTokens / 1e3 * pricing.inputCostPer1kTokens : 0;
3750
+ const outputCost = pricing.outputCostPer1kTokens != null ? usage.completionTokens / 1e3 * pricing.outputCostPer1kTokens : 0;
3751
+ return {
3752
+ inputCost,
3753
+ outputCost,
3754
+ totalCost: inputCost + outputCost,
3755
+ currency: pricing.currency ?? "USD"
3756
+ };
3757
+ }
3758
+
3102
3759
  // src/plugin.ts
3103
3760
  var AIServicePlugin = class {
3104
3761
  constructor(options = {}) {
@@ -3220,10 +3877,34 @@ var AIServicePlugin = class {
3220
3877
  adapterDescription = detected.description;
3221
3878
  }
3222
3879
  ctx.logger.info(`[AI] Using LLM adapter: ${adapterDescription}`);
3880
+ const modelRegistry = new ModelRegistry({
3881
+ models: this.options.models,
3882
+ defaultModelId: this.options.defaultModelId
3883
+ });
3884
+ if (modelRegistry.size > 0) {
3885
+ ctx.logger.info(`[AI] ModelRegistry initialised with ${modelRegistry.size} model(s)`);
3886
+ }
3887
+ let traceRecorder;
3888
+ if (this.options.traceRecorder === null) {
3889
+ ctx.logger.debug("[AI] Tracing disabled (traceRecorder=null)");
3890
+ } else if (this.options.traceRecorder) {
3891
+ traceRecorder = this.options.traceRecorder;
3892
+ } else {
3893
+ try {
3894
+ const engine = ctx.getService("data");
3895
+ if (engine && typeof engine.insert === "function") {
3896
+ traceRecorder = new ObjectQLTraceRecorder(engine, { logger: ctx.logger });
3897
+ ctx.logger.info("[AI] Using ObjectQLTraceRecorder (IDataEngine detected)");
3898
+ }
3899
+ } catch {
3900
+ }
3901
+ }
3223
3902
  const config = {
3224
3903
  adapter,
3225
3904
  logger: ctx.logger,
3226
- conversationService
3905
+ conversationService,
3906
+ modelRegistry,
3907
+ traceRecorder
3227
3908
  };
3228
3909
  this.service = new AIService(config);
3229
3910
  if (hasExisting) {
@@ -3238,7 +3919,7 @@ var AIServicePlugin = class {
3238
3919
  type: "plugin",
3239
3920
  scope: "project",
3240
3921
  namespace: "ai",
3241
- objects: [AiConversationObject, AiMessageObject]
3922
+ objects: [AiConversationObject, AiMessageObject, AiTraceObject]
3242
3923
  });
3243
3924
  if (this.options.debug) {
3244
3925
  ctx.hook("ai:beforeChat", async (messages) => {
@@ -3270,6 +3951,14 @@ var AIServicePlugin = class {
3270
3951
  if (dataEngine) {
3271
3952
  registerDataTools(this.service.toolRegistry, { dataEngine });
3272
3953
  ctx.logger.info("[AI] Built-in data tools registered");
3954
+ if (metadataService) {
3955
+ registerQueryDataTool(this.service.toolRegistry, {
3956
+ ai: this.service,
3957
+ metadata: metadataService,
3958
+ dataEngine
3959
+ });
3960
+ ctx.logger.info("[AI] query_data tool registered");
3961
+ }
3273
3962
  if (metadataService) {
3274
3963
  const { DATA_TOOL_DEFINITIONS: DATA_TOOL_DEFINITIONS2 } = await Promise.resolve().then(() => (init_data_tools(), data_tools_exports));
3275
3964
  for (const toolDef of DATA_TOOL_DEFINITIONS2) {
@@ -3784,14 +4473,20 @@ export {
3784
4473
  AgentRuntime,
3785
4474
  AiConversationObject,
3786
4475
  AiMessageObject,
4476
+ AiTraceObject,
3787
4477
  DATA_CHAT_AGENT,
3788
4478
  DATA_TOOL_DEFINITIONS,
3789
4479
  InMemoryConversationService,
3790
4480
  METADATA_ASSISTANT_AGENT,
3791
4481
  METADATA_TOOL_DEFINITIONS,
3792
4482
  MemoryLLMAdapter,
4483
+ ModelRegistry,
4484
+ NullTraceRecorder,
3793
4485
  ObjectQLConversationService,
4486
+ ObjectQLTraceRecorder,
3794
4487
  PACKAGE_TOOL_DEFINITIONS,
4488
+ QUERY_DATA_TOOL,
4489
+ SchemaRetriever,
3795
4490
  SkillRegistry,
3796
4491
  ToolRegistry,
3797
4492
  VercelLLMAdapter,
@@ -3800,8 +4495,11 @@ export {
3800
4495
  buildAgentRoutes,
3801
4496
  buildAssistantRoutes,
3802
4497
  buildToolRoutes,
4498
+ buildTraceEvent,
4499
+ computeCost,
3803
4500
  createObjectTool,
3804
4501
  createPackageTool,
4502
+ createQueryDataHandler,
3805
4503
  deleteFieldTool,
3806
4504
  describeObjectTool,
3807
4505
  encodeStreamPart,
@@ -3814,6 +4512,7 @@ export {
3814
4512
  registerDataTools,
3815
4513
  registerMetadataTools,
3816
4514
  registerPackageTools,
4515
+ registerQueryDataTool,
3817
4516
  setActivePackageTool
3818
4517
  };
3819
4518
  //# sourceMappingURL=index.js.map