@superatomai/sdk-node 0.0.1-s → 0.0.2-dsp

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
@@ -37,6 +37,8 @@ __export(index_exports, {
37
37
  LLM: () => LLM,
38
38
  MainAgent: () => MainAgent,
39
39
  STORAGE_CONFIG: () => STORAGE_CONFIG,
40
+ ScriptMatcher: () => ScriptMatcher,
41
+ ScriptStore: () => ScriptStore,
40
42
  SuperatomSDK: () => SuperatomSDK,
41
43
  Thread: () => Thread,
42
44
  ThreadManager: () => ThreadManager,
@@ -50,10 +52,13 @@ __export(index_exports, {
50
52
  hybridRerank: () => hybridRerank,
51
53
  llmUsageLogger: () => llmUsageLogger,
52
54
  logger: () => logger,
55
+ normalizeScriptBody: () => normalizeScriptBody,
53
56
  openaiLLM: () => openaiLLM,
54
57
  queryCache: () => queryCache,
55
58
  rerankChromaResults: () => rerankChromaResults,
56
59
  rerankConversationResults: () => rerankConversationResults,
60
+ resolveScriptRecipeStore: () => resolveScriptRecipeStore,
61
+ runScript: () => runScript,
57
62
  userPromptErrorLogger: () => userPromptErrorLogger
58
63
  });
59
64
  module.exports = __toCommonJS(index_exports);
@@ -379,6 +384,24 @@ var ComponentListResponseMessageSchema = import_zod3.z.object({
379
384
  type: import_zod3.z.literal("COMPONENT_LIST_RES"),
380
385
  payload: ComponentListResponsePayloadSchema
381
386
  });
387
+ var WorkflowDescriptorSchema = import_zod3.z.object({
388
+ id: import_zod3.z.string(),
389
+ name: import_zod3.z.string(),
390
+ description: import_zod3.z.string(),
391
+ whenToUse: import_zod3.z.string(),
392
+ propsSchema: import_zod3.z.record(import_zod3.z.string()),
393
+ defaultProps: import_zod3.z.record(import_zod3.z.unknown()).optional()
394
+ });
395
+ var WorkflowsSchema = import_zod3.z.array(WorkflowDescriptorSchema);
396
+ var WorkflowListResponsePayloadSchema = import_zod3.z.object({
397
+ workflows: import_zod3.z.array(WorkflowDescriptorSchema)
398
+ });
399
+ var WorkflowListResponseMessageSchema = import_zod3.z.object({
400
+ id: import_zod3.z.string(),
401
+ from: MessageParticipantSchema,
402
+ type: import_zod3.z.literal("WORKFLOW_LIST_RES"),
403
+ payload: WorkflowListResponsePayloadSchema
404
+ });
382
405
  var OutputFieldSchema = import_zod3.z.object({
383
406
  name: import_zod3.z.string(),
384
407
  // Field name (column name in the result)
@@ -402,8 +425,10 @@ var ToolSchema = import_zod3.z.object({
402
425
  fullSchema: import_zod3.z.string().optional(),
403
426
  params: import_zod3.z.record(import_zod3.z.string()),
404
427
  fn: import_zod3.z.function().args(import_zod3.z.any()).returns(import_zod3.z.any()),
405
- outputSchema: OutputSchema.optional()
428
+ outputSchema: OutputSchema.optional(),
406
429
  // Optional: describes the data structure returned by this tool
430
+ /** Cache policy. `false` = never cache (live data, write ops). Mirrors HTTP `Cache-Control: no-store`. */
431
+ cache: import_zod3.z.union([import_zod3.z.literal(false), import_zod3.z.object({ ttlMs: import_zod3.z.number().optional() })]).optional()
407
432
  });
408
433
  var UserQueryFiltersSchema = import_zod3.z.object({
409
434
  username: import_zod3.z.string().optional(),
@@ -1518,7 +1543,7 @@ var QueryCache = class {
1518
1543
  this.cache = /* @__PURE__ */ new Map();
1519
1544
  this.ttlMs = 10 * 60 * 1e3;
1520
1545
  // Default: 10 minutes
1521
- this.maxCacheSize = 500;
1546
+ this.maxCacheSize = 5e3;
1522
1547
  // Max data cache entries
1523
1548
  this.cleanupInterval = null;
1524
1549
  // Encryption for queryId tokens
@@ -1545,9 +1570,13 @@ var QueryCache = class {
1545
1570
  return this.ttlMs / 60 / 1e3;
1546
1571
  }
1547
1572
  /**
1548
- * Store query result in data cache
1573
+ * Store query result in data cache.
1574
+ * If the key already exists, it's removed first so the re-insert places it
1575
+ * at the back of the iteration order (LRU). Eviction only fires when adding
1576
+ * a genuinely new key past the size limit.
1549
1577
  */
1550
1578
  set(query, data) {
1579
+ this.cache.delete(query);
1551
1580
  if (this.cache.size >= this.maxCacheSize) {
1552
1581
  const oldestKey = this.cache.keys().next().value;
1553
1582
  if (oldestKey) this.cache.delete(oldestKey);
@@ -1560,7 +1589,9 @@ var QueryCache = class {
1560
1589
  logger.debug(`[QueryCache] Stored result for query (${query.substring(0, 50)}...)`);
1561
1590
  }
1562
1591
  /**
1563
- * Get cached result if exists and not expired
1592
+ * Get cached result if exists and not expired.
1593
+ * On hit, re-inserts the entry so it moves to the back of the Map's
1594
+ * iteration order — turning FIFO eviction into true LRU.
1564
1595
  */
1565
1596
  get(query) {
1566
1597
  const entry = this.cache.get(query);
@@ -1569,6 +1600,8 @@ var QueryCache = class {
1569
1600
  this.cache.delete(query);
1570
1601
  return null;
1571
1602
  }
1603
+ this.cache.delete(query);
1604
+ this.cache.set(query, entry);
1572
1605
  logger.info(`[QueryCache] Cache HIT for query (${query.substring(0, 50)}...)`);
1573
1606
  return entry.data;
1574
1607
  }
@@ -1710,6 +1743,21 @@ var QueryCache = class {
1710
1743
  };
1711
1744
  var queryCache = new QueryCache();
1712
1745
 
1746
+ // src/utils/surrogate.ts
1747
+ var LONE_SURROGATE_RE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
1748
+ function stripLoneSurrogates(value) {
1749
+ if (typeof value !== "string") return value;
1750
+ if (!/[\uD800-\uDFFF]/.test(value)) return value;
1751
+ return value.replace(LONE_SURROGATE_RE, "\uFFFD");
1752
+ }
1753
+ function safeTruncate(text, maxUnits) {
1754
+ if (typeof text !== "string" || text.length <= maxUnits || maxUnits < 0) return text;
1755
+ let end = maxUnits;
1756
+ const lastCode = text.charCodeAt(end - 1);
1757
+ if (lastCode >= 55296 && lastCode <= 56319) end -= 1;
1758
+ return text.slice(0, end);
1759
+ }
1760
+
1713
1761
  // src/userResponse/llm-result-truncator.ts
1714
1762
  var DEFAULT_MAX_ROWS = 10;
1715
1763
  var DEFAULT_MAX_CHARS_PER_FIELD = 500;
@@ -1752,12 +1800,12 @@ function isDateString(value) {
1752
1800
  }
1753
1801
  function truncateTextField(value, maxLength) {
1754
1802
  if (value.length <= maxLength) {
1755
- return { text: value, wasTruncated: false };
1803
+ return { text: stripLoneSurrogates(value), wasTruncated: false };
1756
1804
  }
1757
- const truncated = value.substring(0, maxLength);
1758
- const remaining = value.length - maxLength;
1805
+ const truncated = safeTruncate(value, maxLength);
1806
+ const remaining = value.length - truncated.length;
1759
1807
  return {
1760
- text: `${truncated}... (${remaining} more chars)`,
1808
+ text: `${stripLoneSurrogates(truncated)}... (${remaining} more chars)`,
1761
1809
  wasTruncated: true
1762
1810
  };
1763
1811
  }
@@ -2055,6 +2103,21 @@ function formatResultAsString(formattedResult) {
2055
2103
  return JSON.stringify(formattedResult, null, 2);
2056
2104
  }
2057
2105
 
2106
+ // src/utils/cache-key.ts
2107
+ function stableStringify(value) {
2108
+ if (value === null || typeof value !== "object") {
2109
+ return JSON.stringify(value);
2110
+ }
2111
+ if (Array.isArray(value)) {
2112
+ return "[" + value.map(stableStringify).join(",") + "]";
2113
+ }
2114
+ const keys = Object.keys(value).sort();
2115
+ return "{" + keys.map((k) => JSON.stringify(k) + ":" + stableStringify(value[k])).join(",") + "}";
2116
+ }
2117
+ function buildDirectToolCacheKey(toolId, parameters) {
2118
+ return `et-direct:${toolId}:${stableStringify(parameters || {})}`;
2119
+ }
2120
+
2058
2121
  // src/handlers/data-request.ts
2059
2122
  function getQueryCacheKey(query) {
2060
2123
  if (typeof query === "string") {
@@ -2068,7 +2131,7 @@ function getQueryCacheKey(query) {
2068
2131
  }
2069
2132
  return "";
2070
2133
  }
2071
- function getCacheKey(collection, op, params) {
2134
+ function getCacheKey(collection, op, params, tools) {
2072
2135
  if (collection === "database" && op === "execute" && params?.sql) {
2073
2136
  return getQueryCacheKey(params.sql);
2074
2137
  }
@@ -2078,6 +2141,13 @@ function getCacheKey(collection, op, params) {
2078
2141
  const paramsKey = params.params ? JSON.stringify(params.params) : "";
2079
2142
  return sqlKey ? `et:${toolId}:${sqlKey}:${paramsKey}` : "";
2080
2143
  }
2144
+ if (collection === "external-tools" && op === "execute" && params?.toolId && !params?.sql) {
2145
+ const tool = tools?.find((t) => t.id === params.toolId);
2146
+ if (tool?.cache === false) return "";
2147
+ const { toolId, toolName, ...rest } = params;
2148
+ const paramsKey = stableStringify(rest);
2149
+ return `et-direct:${toolId}:${paramsKey}`;
2150
+ }
2081
2151
  if (collection === "external-tools" && op === "executeByQueryId" && params?.queryId) {
2082
2152
  const toolId = params.toolId || "";
2083
2153
  const filterKey = params.filterParams ? JSON.stringify(params.filterParams) : "";
@@ -2086,7 +2156,7 @@ function getCacheKey(collection, op, params) {
2086
2156
  }
2087
2157
  return "";
2088
2158
  }
2089
- async function handleDataRequest(data, collections, sendMessage) {
2159
+ async function handleDataRequest(data, collections, sendMessage, tools) {
2090
2160
  let requestId;
2091
2161
  let collection;
2092
2162
  let op;
@@ -2112,7 +2182,7 @@ async function handleDataRequest(data, collections, sendMessage) {
2112
2182
  const startTime = performance.now();
2113
2183
  let result;
2114
2184
  let fromCache = false;
2115
- const cacheKey = getCacheKey(collection, op, params);
2185
+ const cacheKey = getCacheKey(collection, op, params, tools);
2116
2186
  if (cacheKey) {
2117
2187
  const cachedResult = queryCache.get(cacheKey);
2118
2188
  if (cachedResult !== null) {
@@ -3777,6 +3847,12 @@ You MUST respond with ONLY a valid JSON object (no markdown, no code blocks):
3777
3847
  ---
3778
3848
 
3779
3849
  ## CONTEXT
3850
+
3851
+ ### Global Knowledge Base
3852
+ {{GLOBAL_KNOWLEDGE_BASE}}
3853
+
3854
+ ### Knowledge Base Context
3855
+ {{KNOWLEDGE_BASE_CONTEXT}}
3780
3856
  `,
3781
3857
  user: `{{USER_PROMPT}}`
3782
3858
  },
@@ -4329,13 +4405,11 @@ var PromptLoader = class {
4329
4405
  * @returns Processed string
4330
4406
  */
4331
4407
  replaceVariables(template, variables) {
4332
- let content = template;
4333
- for (const [key, value] of Object.entries(variables)) {
4334
- const pattern = new RegExp(`{{${key}}}`, "g");
4335
- const replacementValue = typeof value === "string" ? value : JSON.stringify(value);
4336
- content = content.replace(pattern, replacementValue);
4337
- }
4338
- return content;
4408
+ return template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
4409
+ if (!(key in variables)) return match;
4410
+ const value = variables[key];
4411
+ return typeof value === "string" ? value : JSON.stringify(value);
4412
+ });
4339
4413
  }
4340
4414
  /**
4341
4415
  * Load both system and user prompts from cache and replace variables
@@ -4358,8 +4432,6 @@ var PromptLoader = class {
4358
4432
  const [staticPart, contextPart] = template.system.split(contextMarker);
4359
4433
  const processedStatic = this.replaceVariables(staticPart, variables);
4360
4434
  const processedContext = this.replaceVariables(contextMarker + contextPart, variables);
4361
- const staticLength = processedStatic.length;
4362
- const contextLength = processedContext.length;
4363
4435
  return {
4364
4436
  system: [
4365
4437
  {
@@ -5289,9 +5361,36 @@ var searchConversationsWithReranking = async (options) => {
5289
5361
  return null;
5290
5362
  }
5291
5363
  };
5364
+ var findExactMatch = async ({
5365
+ userPrompt,
5366
+ collections,
5367
+ userId
5368
+ }) => {
5369
+ try {
5370
+ if (!collections || !collections["conversation-history"] || !collections["conversation-history"]["exactMatch"]) {
5371
+ logger.info("[ConversationSearch] conversation-history.exactMatch collection not registered, skipping");
5372
+ return null;
5373
+ }
5374
+ const result = await collections["conversation-history"]["exactMatch"]({
5375
+ userPrompt,
5376
+ userId
5377
+ });
5378
+ if (!result || !result.uiBlock) {
5379
+ logger.info("[ConversationSearch] No exact match found");
5380
+ return null;
5381
+ }
5382
+ logger.info(`[ConversationSearch] \u2713 Exact prompt match found for "${userPrompt.substring(0, 50)}..."`);
5383
+ return result;
5384
+ } catch (error) {
5385
+ const errorMsg = error instanceof Error ? error.message : String(error);
5386
+ logger.warn(`[ConversationSearch] Error in exact match lookup: ${errorMsg}`);
5387
+ return null;
5388
+ }
5389
+ };
5292
5390
  var ConversationSearch = {
5293
5391
  searchConversations,
5294
- searchConversationsWithReranking
5392
+ searchConversationsWithReranking,
5393
+ findExactMatch
5295
5394
  };
5296
5395
  var conversation_search_default = ConversationSearch;
5297
5396
 
@@ -5309,16 +5408,20 @@ var MAX_TOKENS_CLASSIFICATION = 1500;
5309
5408
  var MAX_TOKENS_ADAPTATION = 8192;
5310
5409
  var MAX_TOKENS_TEXT_RESPONSE = 4e3;
5311
5410
  var MAX_TOKENS_NEXT_QUESTIONS = 1200;
5312
- var DEFAULT_MAX_ROWS_FOR_LLM = 10;
5411
+ var MAX_ROWS_FETCHED = 50;
5412
+ var MAX_ROWS_TO_LLM = 10;
5413
+ var LLM_SAMPLE_ROWS = 10;
5414
+ var MAX_ROWS_RENDERED = 250;
5313
5415
  var DEFAULT_MAX_CHARS_PER_FIELD2 = 500;
5314
- var STREAM_PREVIEW_MAX_ROWS = 10;
5315
5416
  var STREAM_PREVIEW_MAX_CHARS = 200;
5316
- var TOOL_TRACKING_MAX_ROWS = 5;
5317
5417
  var TOOL_TRACKING_MAX_CHARS = 200;
5318
- var TOOL_TRACKING_SAMPLE_ROWS = 3;
5418
+ var DEFAULT_MAX_ROWS_FOR_LLM = LLM_SAMPLE_ROWS;
5419
+ var STREAM_PREVIEW_MAX_ROWS = LLM_SAMPLE_ROWS;
5420
+ var TOOL_TRACKING_SAMPLE_ROWS = LLM_SAMPLE_ROWS;
5421
+ var TOOL_TRACKING_MAX_ROWS = 5;
5319
5422
  var DEFAULT_QUERY_LIMIT = 24;
5320
- var MAX_AGENT_QUERY_LIMIT = 10;
5321
- var MAX_COMPONENT_QUERY_LIMIT = 100;
5423
+ var MAX_AGENT_QUERY_LIMIT = LLM_SAMPLE_ROWS;
5424
+ var MAX_COMPONENT_QUERY_LIMIT = MAX_ROWS_RENDERED;
5322
5425
  var EXACT_MATCH_SIMILARITY_THRESHOLD = 0.99;
5323
5426
  var DEFAULT_CONVERSATION_SIMILARITY_THRESHOLD = 0.8;
5324
5427
  var MAX_TOOL_CALLING_ITERATIONS = 20;
@@ -5815,6 +5918,7 @@ var LLM = class {
5815
5918
  /* Get a complete text response from an LLM (Anthropic or Groq) */
5816
5919
  static async text(messages, options = {}) {
5817
5920
  const [provider, modelName] = this._parseModel(options.model);
5921
+ messages = this._sanitizeMessages(messages);
5818
5922
  if (provider === "anthropic") {
5819
5923
  return this._anthropicText(messages, modelName, options);
5820
5924
  } else if (provider === "groq") {
@@ -5830,6 +5934,7 @@ var LLM = class {
5830
5934
  /* Stream response from an LLM (Anthropic or Groq) */
5831
5935
  static async stream(messages, options = {}, json) {
5832
5936
  const [provider, modelName] = this._parseModel(options.model);
5937
+ messages = this._sanitizeMessages(messages);
5833
5938
  if (provider === "anthropic") {
5834
5939
  return this._anthropicStream(messages, modelName, options, json);
5835
5940
  } else if (provider === "groq") {
@@ -5845,6 +5950,7 @@ var LLM = class {
5845
5950
  /* Stream response with tool calling support (Anthropic and Gemini) */
5846
5951
  static async streamWithTools(messages, tools, toolHandler, options = {}, maxIterations = 3) {
5847
5952
  const [provider, modelName] = this._parseModel(options.model);
5953
+ messages = this._sanitizeMessages(messages);
5848
5954
  if (provider === "anthropic") {
5849
5955
  return this._anthropicStreamWithTools(messages, tools, toolHandler, modelName, options, maxIterations);
5850
5956
  } else if (provider === "gemini") {
@@ -5870,6 +5976,26 @@ var LLM = class {
5870
5976
  }
5871
5977
  return sys;
5872
5978
  }
5979
+ /**
5980
+ * Strip unpaired UTF-16 surrogates from every text field of a message set.
5981
+ *
5982
+ * A lone surrogate (from mid-pair string slicing or corrupt source data)
5983
+ * serializes to a bare `\udXXX` escape that strict JSON parsers — including
5984
+ * the one on Anthropic's API — reject with "no low surrogate in string",
5985
+ * failing the whole request. Sanitizing here, at the single boundary every
5986
+ * provider call flows through, guarantees no request can carry one.
5987
+ */
5988
+ static _sanitizeMessages(messages) {
5989
+ const sys = typeof messages.sys === "string" ? stripLoneSurrogates(messages.sys) : messages.sys.map(
5990
+ (block) => block?.type === "text" && typeof block.text === "string" ? { ...block, text: stripLoneSurrogates(block.text) } : block
5991
+ );
5992
+ return {
5993
+ ...messages,
5994
+ sys,
5995
+ user: stripLoneSurrogates(messages.user),
5996
+ ...messages.prefill !== void 0 && { prefill: stripLoneSurrogates(messages.prefill) }
5997
+ };
5998
+ }
5873
5999
  /**
5874
6000
  * Log cache usage metrics from Anthropic API response
5875
6001
  * Shows cache hits, costs, and savings
@@ -6249,12 +6375,14 @@ var LLM = class {
6249
6375
  let resultContent = typeof result === "string" ? result : JSON.stringify(result);
6250
6376
  const MAX_RESULT_LENGTH = 5e4;
6251
6377
  if (resultContent.length > MAX_RESULT_LENGTH) {
6252
- resultContent = resultContent.substring(0, MAX_RESULT_LENGTH) + "\n\n... [Result truncated - showing first 50000 characters of " + resultContent.length + " total]";
6378
+ resultContent = safeTruncate(resultContent, MAX_RESULT_LENGTH) + "\n\n... [Result truncated - showing first 50000 characters of " + resultContent.length + " total]";
6253
6379
  }
6254
6380
  return {
6255
6381
  type: "tool_result",
6256
6382
  tool_use_id: toolUse.id,
6257
- content: resultContent
6383
+ // Final safety net: tool results carry source data and are built
6384
+ // mid-loop (after entry-point sanitize), so strip lone surrogates here.
6385
+ content: stripLoneSurrogates(resultContent)
6258
6386
  };
6259
6387
  } catch (error) {
6260
6388
  return {
@@ -6769,11 +6897,12 @@ var LLM = class {
6769
6897
  let resultContent = typeof result2 === "string" ? result2 : JSON.stringify(result2);
6770
6898
  const MAX_RESULT_LENGTH = 5e4;
6771
6899
  if (resultContent.length > MAX_RESULT_LENGTH) {
6772
- resultContent = resultContent.substring(0, MAX_RESULT_LENGTH) + "\n\n... [Result truncated - showing first 50000 characters of " + resultContent.length + " total]";
6900
+ resultContent = safeTruncate(resultContent, MAX_RESULT_LENGTH) + "\n\n... [Result truncated - showing first 50000 characters of " + resultContent.length + " total]";
6773
6901
  }
6774
6902
  return {
6775
6903
  name: fc.name,
6776
- response: { result: resultContent }
6904
+ // Final safety net: strip lone surrogates from source-data results.
6905
+ response: { result: stripLoneSurrogates(resultContent) }
6777
6906
  };
6778
6907
  } catch (error) {
6779
6908
  return {
@@ -7046,12 +7175,12 @@ var LLM = class {
7046
7175
  result = typeof toolResult === "string" ? toolResult : JSON.stringify(toolResult);
7047
7176
  const MAX_RESULT_LENGTH = 5e4;
7048
7177
  if (result.length > MAX_RESULT_LENGTH) {
7049
- result = result.substring(0, MAX_RESULT_LENGTH) + "\n\n... [Result truncated - showing first 50000 characters of " + result.length + " total]";
7178
+ result = safeTruncate(result, MAX_RESULT_LENGTH) + "\n\n... [Result truncated - showing first 50000 characters of " + result.length + " total]";
7050
7179
  }
7051
7180
  } catch (error) {
7052
7181
  result = JSON.stringify({ error: error instanceof Error ? error.message : String(error) });
7053
7182
  }
7054
- return { role: "tool", tool_call_id: tc.id, content: result };
7183
+ return { role: "tool", tool_call_id: tc.id, content: stripLoneSurrogates(result) };
7055
7184
  }));
7056
7185
  toolCallResults.forEach((r) => conversationMessages.push(r));
7057
7186
  }
@@ -7190,6 +7319,130 @@ function extractObjectText(obj) {
7190
7319
  return JSON.stringify(obj, null, 2);
7191
7320
  }
7192
7321
 
7322
+ // src/userResponse/agents/data-summary.ts
7323
+ var MAIN_AGENT_COMPLETE_ROWS = MAX_ROWS_TO_LLM;
7324
+ function summarizeRows(rows) {
7325
+ if (!rows.length) return { totalRows: 0, columns: {} };
7326
+ const MAX_CATEGORIES = 20;
7327
+ const keys = Object.keys(rows[0]);
7328
+ const columns = {};
7329
+ const fmtDate = (ms) => {
7330
+ const d = new Date(ms);
7331
+ const p = (n) => String(n).padStart(2, "0");
7332
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
7333
+ };
7334
+ for (const key of keys) {
7335
+ let nonNull = 0, zeros = 0, isNumeric = true, isDateLike = true, sum = 0;
7336
+ let min, max;
7337
+ let dMin, dMax;
7338
+ const counts = /* @__PURE__ */ new Map();
7339
+ let overflow = false;
7340
+ for (const row of rows) {
7341
+ const v = row[key];
7342
+ if (v === null || v === void 0 || v === "") continue;
7343
+ nonNull++;
7344
+ const num = typeof v === "number" ? v : typeof v === "string" && v.trim() !== "" && !isNaN(Number(v)) ? Number(v) : NaN;
7345
+ if (!Number.isNaN(num) && typeof v !== "boolean") {
7346
+ if (num === 0) zeros++;
7347
+ min = min === void 0 ? num : Math.min(min, num);
7348
+ max = max === void 0 ? num : Math.max(max, num);
7349
+ sum += num;
7350
+ } else {
7351
+ isNumeric = false;
7352
+ }
7353
+ const t = v instanceof Date ? v.getTime() : typeof v === "string" ? Date.parse(v) : NaN;
7354
+ if (!Number.isNaN(t) && typeof v !== "number") {
7355
+ dMin = dMin === void 0 ? t : Math.min(dMin, t);
7356
+ dMax = dMax === void 0 ? t : Math.max(dMax, t);
7357
+ } else {
7358
+ isDateLike = false;
7359
+ }
7360
+ if (!overflow) {
7361
+ const sv = String(v);
7362
+ counts.set(sv, (counts.get(sv) || 0) + 1);
7363
+ if (counts.size > MAX_CATEGORIES * 5) overflow = true;
7364
+ }
7365
+ }
7366
+ const nulls = rows.length - nonNull;
7367
+ if (nonNull === 0) {
7368
+ columns[key] = { type: "empty", nulls };
7369
+ continue;
7370
+ }
7371
+ if (isNumeric) {
7372
+ columns[key] = {
7373
+ type: "number",
7374
+ nonNull,
7375
+ nulls,
7376
+ zeros,
7377
+ min,
7378
+ max,
7379
+ avg: Math.round(sum / nonNull * 100) / 100
7380
+ };
7381
+ } else if (isDateLike) {
7382
+ columns[key] = {
7383
+ type: "date",
7384
+ nonNull,
7385
+ min: fmtDate(dMin),
7386
+ max: fmtDate(dMax),
7387
+ distinct: overflow ? `${MAX_CATEGORIES * 5}+` : counts.size
7388
+ };
7389
+ } else if (!overflow && counts.size <= MAX_CATEGORIES) {
7390
+ const dist = {};
7391
+ for (const [k, c] of [...counts.entries()].sort((a, b) => b[1] - a[1])) dist[k] = c;
7392
+ columns[key] = { type: "category", distinct: counts.size, counts: dist };
7393
+ } else {
7394
+ columns[key] = {
7395
+ type: "text",
7396
+ distinct: overflow ? `${MAX_CATEGORIES * 5}+` : counts.size,
7397
+ examples: [...counts.keys()].slice(0, 3)
7398
+ };
7399
+ }
7400
+ }
7401
+ const dims = keys.filter((k) => columns[k]?.type === "category" && columns[k].distinct <= 6).sort((a, b) => columns[a].distinct - columns[b].distinct);
7402
+ const measures = keys.filter((k) => columns[k]?.type === "number");
7403
+ let groups;
7404
+ if (dims.length && measures.length) {
7405
+ const MAX_GROUPS = 30;
7406
+ let useDims = dims.slice(0, 2);
7407
+ const keyOf = (row) => useDims.map((d) => `${d}=${row[d]}`).join(" | ");
7408
+ if (new Set(rows.map(keyOf)).size > MAX_GROUPS) useDims = dims.slice(0, 1);
7409
+ const agg = /* @__PURE__ */ new Map();
7410
+ for (const row of rows) {
7411
+ const key = useDims.map((d) => `${d}=${row[d]}`).join(" | ");
7412
+ let g = agg.get(key);
7413
+ if (!g) {
7414
+ g = { count: 0, sums: {}, ns: {} };
7415
+ agg.set(key, g);
7416
+ }
7417
+ g.count++;
7418
+ for (const m of measures) {
7419
+ const v = row[m];
7420
+ if (typeof v === "number" && !Number.isNaN(v)) {
7421
+ g.sums[m] = (g.sums[m] || 0) + v;
7422
+ g.ns[m] = (g.ns[m] || 0) + 1;
7423
+ }
7424
+ }
7425
+ }
7426
+ if (agg.size <= MAX_GROUPS) {
7427
+ const isCountLike = (name) => /count|\bobs\b|observation|qty|quantity|\bnum|total|evidential|volume|units/i.test(name);
7428
+ groups = {
7429
+ by: useDims,
7430
+ rows: [...agg.entries()].map(([key, g]) => {
7431
+ const avg = {};
7432
+ const sum = {};
7433
+ for (const m of measures) {
7434
+ if (!g.ns[m]) continue;
7435
+ avg[m] = Math.round(g.sums[m] / g.ns[m] * 100) / 100;
7436
+ if (isCountLike(m)) sum[m] = Math.round(g.sums[m] * 100) / 100;
7437
+ }
7438
+ return Object.keys(sum).length ? { key, count: g.count, avg, sum } : { key, count: g.count, avg };
7439
+ })
7440
+ };
7441
+ }
7442
+ }
7443
+ return groups ? { totalRows: rows.length, columns, groups } : { totalRows: rows.length, columns };
7444
+ }
7445
+
7193
7446
  // src/userResponse/agents/agent-prompt-builder.ts
7194
7447
  function buildSourceSummaries(externalTools) {
7195
7448
  return externalTools.map((tool) => {
@@ -7409,8 +7662,15 @@ ${result}`;
7409
7662
  await streamDelay();
7410
7663
  }
7411
7664
  const cappedInput = { ...toolInput };
7412
- if (cappedInput.limit === void 0 || cappedInput.limit > this.config.maxRowsPerSource) {
7413
- cappedInput.limit = this.config.maxRowsPerSource;
7665
+ if (cappedInput.limit === void 0 || cappedInput.limit > this.config.maxRowsFetched) {
7666
+ cappedInput.limit = this.config.maxRowsFetched;
7667
+ }
7668
+ const _st = this.extractSourceType();
7669
+ if (typeof cappedInput.sql === "string") {
7670
+ cappedInput.sql = ensureQueryLimit(cappedInput.sql, this.config.maxRowsFetched, this.config.maxRowsFetched, _st);
7671
+ }
7672
+ if (typeof cappedInput.query === "string") {
7673
+ cappedInput.query = ensureQueryLimit(cappedInput.query, this.config.maxRowsFetched, this.config.maxRowsFetched, _st);
7414
7674
  }
7415
7675
  queryExecuted = cappedInput.sql || cappedInput.query || JSON.stringify(cappedInput);
7416
7676
  if (this.streamBuffer.hasCallback() && queryExecuted) {
@@ -7461,8 +7721,11 @@ Analyze the error and try again with a corrected query.`;
7461
7721
  `);
7462
7722
  if (resultData.length > 0) {
7463
7723
  try {
7464
- const previewData = resultData.slice(0, this.config.maxRowsPerSource);
7465
- this.streamBuffer.write(`<DataTable>${JSON.stringify(previewData)}</DataTable>
7724
+ const preview = formatQueryResultForLLM(resultData, {
7725
+ maxRows: STREAM_PREVIEW_MAX_ROWS,
7726
+ maxCharsPerField: STREAM_PREVIEW_MAX_CHARS
7727
+ });
7728
+ this.streamBuffer.write(`<DataTable>${JSON.stringify(preview.data)}</DataTable>
7466
7729
 
7467
7730
  `);
7468
7731
  } catch {
@@ -7485,7 +7748,12 @@ Analyze the error and try again with a corrected query.`;
7485
7748
  _totalRecords: totalRowsMatched,
7486
7749
  _recordsShown: resultData.length,
7487
7750
  _metadata: result.metadata,
7488
- _sampleData: resultData.slice(0, 3)
7751
+ _sampleData: resultData.slice(0, TOOL_TRACKING_SAMPLE_ROWS),
7752
+ // For the main agent: a bounded summary over the FULL fetched
7753
+ // result (complete structure regardless of size) + a complete
7754
+ // slice for small results (so lookups arrive whole, not biased).
7755
+ _summary: summarizeRows(resultData),
7756
+ _mainAgentRows: resultData.slice(0, MAIN_AGENT_COMPLETE_ROWS)
7489
7757
  },
7490
7758
  outputSchema: this.tool.outputSchema,
7491
7759
  sourceSchema: this.tool.description,
@@ -7493,7 +7761,7 @@ Analyze the error and try again with a corrected query.`;
7493
7761
  };
7494
7762
  allExecutedTools.push(executedTool);
7495
7763
  const formatted = typeof formattedResult === "string" ? formattedResult : JSON.stringify(formattedResult);
7496
- const followUpNote = successfulQueries < 2 ? "You may make ONE follow-up query if this data is incomplete for the intent. Otherwise, STOP." : "Maximum queries reached. Use the data you have.";
7764
+ const followUpNote = successfulQueries < 2 ? "STOP now and return this data \u2014 it satisfies the intent in the common case. Make a second query ONLY if a column or row the intent explicitly requires is verifiably MISSING from these results (not merely to widen, re-sort, or double-check). A redundant follow-up wastes a full round-trip." : "Maximum queries reached. Use the data you have.";
7497
7765
  return `\u2705 Query executed successfully. ${resultData.length} rows returned (${totalRowsMatched} total matched). ${followUpNote}
7498
7766
 
7499
7767
  ${formatted}`;
@@ -7555,7 +7823,13 @@ Analyze the error and try again with a corrected query.`;
7555
7823
  sourceId: this.tool.id,
7556
7824
  sourceName: this.tool.name,
7557
7825
  success: true,
7558
- data: resultData,
7826
+ // Don't retain the full fetched result — only the bounded slice the
7827
+ // main agent can actually use. The complete-structure summary and the
7828
+ // complete-small rows already live on each executedTool (`_summary` /
7829
+ // `_mainAgentRows`); keeping the full resultData here would hold up to
7830
+ // MAX_ROWS_FETCHED rows × every query in memory for the whole request
7831
+ // for no functional gain.
7832
+ data: resultData.slice(0, MAIN_AGENT_COMPLETE_ROWS),
7559
7833
  metadata: {
7560
7834
  totalRowsMatched,
7561
7835
  rowsReturned: resultData.length,
@@ -7606,13 +7880,13 @@ Analyze the error and try again with a corrected query.`;
7606
7880
  const fullSchema = this.options.preResolvedSchema || this.tool.fullSchema || this.tool.description || "No schema available";
7607
7881
  const databaseRules = await promptLoader.loadDatabaseRulesForType(sourceType);
7608
7882
  const rowLimitSyntax = {
7609
- "mssql": `Use SELECT TOP ${this.config.maxRowsPerSource} in every SELECT statement`,
7610
- "postgres": `Add LIMIT ${this.config.maxRowsPerSource} at the end of every query`,
7611
- "mysql": `Add LIMIT ${this.config.maxRowsPerSource} at the end of every query`,
7612
- "excel": `Add LIMIT ${this.config.maxRowsPerSource} at the end of every query`,
7613
- "csv": `Add LIMIT ${this.config.maxRowsPerSource} at the end of every query`
7883
+ "mssql": `Use SELECT TOP ${this.config.maxRowsFetched} in every SELECT statement`,
7884
+ "postgres": `Add LIMIT ${this.config.maxRowsFetched} at the end of every query`,
7885
+ "mysql": `Add LIMIT ${this.config.maxRowsFetched} at the end of every query`,
7886
+ "excel": `Add LIMIT ${this.config.maxRowsFetched} at the end of every query`,
7887
+ "csv": `Add LIMIT ${this.config.maxRowsFetched} at the end of every query`
7614
7888
  };
7615
- const rowLimitInstruction = rowLimitSyntax[sourceType] || `Limit results to ${this.config.maxRowsPerSource} rows`;
7889
+ const rowLimitInstruction = rowLimitSyntax[sourceType] || `Limit results to ${this.config.maxRowsFetched} rows`;
7616
7890
  const hasSchemaSearch = !this.options.skipSchemaSearch && !!this.tool.schemaSearchFn;
7617
7891
  const schemaTier = this.tool.schemaTier || "";
7618
7892
  let schemaSearchInstructions = "";
@@ -7651,8 +7925,13 @@ Even if a table appears in the detailed schema above, search_schema returns samp
7651
7925
  FULL_SCHEMA: fullSchema,
7652
7926
  DATABASE_RULES: databaseRules,
7653
7927
  ROW_LIMIT_SYNTAX: rowLimitInstruction,
7928
+ // NOTE: MAX_ROWS here governs the SQL row cap (the {{MAX_ROWS}} in the
7929
+ // "limit results to N rows" line), so it MUST match the fetch cap that
7930
+ // ROW_LIMIT_SYNTAX states — not the smaller LLM-show cap — or the prompt
7931
+ // contradicts itself ("max 10 rows" + "LIMIT 50") and the LLM picks its
7932
+ // own number. See maxRowsFetched override below.
7654
7933
  SCHEMA_SEARCH_INSTRUCTIONS: schemaSearchInstructions,
7655
- MAX_ROWS: String(this.config.maxRowsPerSource),
7934
+ MAX_ROWS: String(this.config.maxRowsFetched),
7656
7935
  AGGREGATION_MODE: aggregation,
7657
7936
  GLOBAL_KNOWLEDGE_BASE: this.config.globalKnowledgeBase || "No global knowledge base available.",
7658
7937
  CURRENT_DATETIME: getCurrentDateTimeForPrompt(),
@@ -7774,13 +8053,27 @@ Even if a table appears in the detailed schema above, search_schema returns samp
7774
8053
  // src/userResponse/scripts/script-runner.ts
7775
8054
  var import_child_process = require("child_process");
7776
8055
  var path5 = __toESM(require("path"));
8056
+ var os = __toESM(require("os"));
7777
8057
 
7778
8058
  // src/userResponse/scripts/script-ipc.ts
7779
8059
  function encodeMessage(msg) {
7780
8060
  return JSON.stringify(msg) + "\n";
7781
8061
  }
8062
+ var LineOverflowError = class extends Error {
8063
+ constructor(buffered, limit) {
8064
+ super(`IPC message exceeded ${limit} bytes without a newline (${buffered} buffered) \u2014 aborting to avoid unbounded memory.`);
8065
+ this.buffered = buffered;
8066
+ this.limit = limit;
8067
+ this.name = "LineOverflowError";
8068
+ }
8069
+ };
8070
+ var DEFAULT_MAX_IPC_BYTES = (() => {
8071
+ const envVal = Number(process.env.SCRIPT_MAX_IPC_BYTES);
8072
+ return Number.isFinite(envVal) && envVal > 0 ? Math.floor(envVal) : 64 * 1024 * 1024;
8073
+ })();
7782
8074
  var LineSplitter = class {
7783
- constructor() {
8075
+ constructor(maxBytes = DEFAULT_MAX_IPC_BYTES) {
8076
+ this.maxBytes = maxBytes;
7784
8077
  this.buffer = "";
7785
8078
  }
7786
8079
  push(chunk) {
@@ -7792,6 +8085,11 @@ var LineSplitter = class {
7792
8085
  this.buffer = this.buffer.slice(idx + 1);
7793
8086
  if (line.length > 0) lines.push(line);
7794
8087
  }
8088
+ if (this.buffer.length > this.maxBytes) {
8089
+ const buffered = this.buffer.length;
8090
+ this.buffer = "";
8091
+ throw new LineOverflowError(buffered, this.maxBytes);
8092
+ }
7795
8093
  return lines;
7796
8094
  }
7797
8095
  /** Flush any remaining partial data (useful on stream close) */
@@ -7804,7 +8102,57 @@ var LineSplitter = class {
7804
8102
  };
7805
8103
 
7806
8104
  // src/userResponse/scripts/script-runner.ts
7807
- var SCRIPT_TIMEOUT_MS = 3e5;
8105
+ var DEFAULT_SCRIPT_TIMEOUT_MS = 6e4;
8106
+ var SCRIPT_TIMEOUT_MS = (() => {
8107
+ const envVal = Number(process.env.SCRIPT_TIMEOUT_MS);
8108
+ return Number.isFinite(envVal) && envVal > 0 ? envVal : DEFAULT_SCRIPT_TIMEOUT_MS;
8109
+ })();
8110
+ var MAX_CONCURRENT_SCRIPTS = (() => {
8111
+ const envVal = Number(process.env.SCRIPT_MAX_CONCURRENCY);
8112
+ if (Number.isFinite(envVal) && envVal > 0) return Math.floor(envVal);
8113
+ return Math.max(2, Math.min(8, (os.cpus()?.length || 4) - 1));
8114
+ })();
8115
+ var MAX_SCRIPT_QUEUE = (() => {
8116
+ const envVal = Number(process.env.SCRIPT_MAX_QUEUE);
8117
+ if (Number.isFinite(envVal) && envVal >= 0) return Math.floor(envVal);
8118
+ return 100;
8119
+ })();
8120
+ var ScriptCapacityError = class extends Error {
8121
+ constructor(message) {
8122
+ super(message);
8123
+ this.name = "ScriptCapacityError";
8124
+ }
8125
+ };
8126
+ var Semaphore = class {
8127
+ constructor(max, maxQueue) {
8128
+ this.max = max;
8129
+ this.maxQueue = maxQueue;
8130
+ this.active = 0;
8131
+ this.waiters = [];
8132
+ }
8133
+ async acquire() {
8134
+ if (this.active < this.max) {
8135
+ this.active++;
8136
+ return;
8137
+ }
8138
+ if (this.waiters.length >= this.maxQueue) {
8139
+ throw new ScriptCapacityError(
8140
+ `Script execution at capacity (${this.max} running, ${this.waiters.length} queued). Try again shortly.`
8141
+ );
8142
+ }
8143
+ return new Promise((resolve2) => this.waiters.push(resolve2));
8144
+ }
8145
+ release() {
8146
+ const next = this.waiters.shift();
8147
+ if (next) next();
8148
+ else this.active--;
8149
+ }
8150
+ get queued() {
8151
+ return this.waiters.length;
8152
+ }
8153
+ };
8154
+ var scriptSemaphore = new Semaphore(MAX_CONCURRENT_SCRIPTS, MAX_SCRIPT_QUEUE);
8155
+ logger.info(`[ScriptRunner] Concurrency cap: ${MAX_CONCURRENT_SCRIPTS} concurrent, queue ${MAX_SCRIPT_QUEUE}`);
7808
8156
  var tsxBinaryPath = null;
7809
8157
  function resolveTsxBinary() {
7810
8158
  if (tsxBinaryPath) return tsxBinaryPath;
@@ -7827,6 +8175,22 @@ async function runScript(recipe, scriptPath, params, options) {
7827
8175
  const resolvedParams = coerceParams(recipe, applyDefaults(recipe, params));
7828
8176
  const paramSchema = buildParamSchema(recipe);
7829
8177
  logger.info(`[ScriptRunner] Executing "${recipe.name}" (${recipe.id}) with params: ${JSON.stringify(resolvedParams)}`);
8178
+ try {
8179
+ if (scriptSemaphore.queued > 0) {
8180
+ logger.info(`[ScriptRunner] "${recipe.name}" queued (${scriptSemaphore.queued} ahead)`);
8181
+ }
8182
+ await scriptSemaphore.acquire();
8183
+ } catch (capErr) {
8184
+ const msg = capErr instanceof Error ? capErr.message : String(capErr);
8185
+ logger.warn(`[ScriptRunner] "${recipe.name}" shed: ${msg}`);
8186
+ return {
8187
+ success: false,
8188
+ data: [],
8189
+ executedQueries: [],
8190
+ error: msg,
8191
+ executionTimeMs: Date.now() - startedAt
8192
+ };
8193
+ }
7830
8194
  try {
7831
8195
  const result = await executeInSubprocess(
7832
8196
  scriptPath,
@@ -7868,6 +8232,8 @@ async function runScript(recipe, scriptPath, params, options) {
7868
8232
  error: msg,
7869
8233
  executionTimeMs: totalMs
7870
8234
  };
8235
+ } finally {
8236
+ scriptSemaphore.release();
7871
8237
  }
7872
8238
  }
7873
8239
  function executeInSubprocess(scriptPath, params, paramSchema, externalTools, streamBuffer, timeoutMs) {
@@ -7880,6 +8246,15 @@ function executeInSubprocess(scriptPath, params, paramSchema, externalTools, str
7880
8246
  [tsxBin, bootstrap, scriptPath],
7881
8247
  {
7882
8248
  stdio: ["pipe", "pipe", "pipe"],
8249
+ // `detached: true` makes the child a process-group LEADER. tsx 4.x
8250
+ // re-execs node to run the user script, so the script is a GRANDCHILD;
8251
+ // a plain `child.kill()` would hit only the tsx wrapper and orphan the
8252
+ // grandchild (a runaway/looping script that outlives the timeout — #4).
8253
+ // With its own group we can SIGKILL the whole tree via `-pid` in
8254
+ // cleanup(). We deliberately do NOT unref() — the parent still tracks
8255
+ // the child's exit and pipes normally. (POSIX; Windows falls back to
8256
+ // a direct kill in cleanup().)
8257
+ detached: process.platform !== "win32",
7883
8258
  env: {
7884
8259
  ...process.env,
7885
8260
  // Keep the child quiet about dotenv etc.
@@ -7887,22 +8262,23 @@ function executeInSubprocess(scriptPath, params, paramSchema, externalTools, str
7887
8262
  }
7888
8263
  }
7889
8264
  );
7890
- const toolsById = /* @__PURE__ */ new Map();
7891
- const toolsByName = /* @__PURE__ */ new Map();
7892
- for (const t of externalTools) {
7893
- toolsById.set(t.id, t);
7894
- toolsByName.set(t.name.toLowerCase(), t);
7895
- }
7896
8265
  let resolved = false;
7897
8266
  let executedQueries = [];
7898
8267
  let stderrBuffer = "";
7899
8268
  const cleanup = () => {
7900
- if (!child.killed) {
8269
+ if (child.killed) return;
8270
+ const pid = child.pid;
8271
+ if (pid && process.platform !== "win32") {
7901
8272
  try {
7902
- child.kill("SIGKILL");
8273
+ process.kill(-pid, "SIGKILL");
8274
+ return;
7903
8275
  } catch {
7904
8276
  }
7905
8277
  }
8278
+ try {
8279
+ child.kill("SIGKILL");
8280
+ } catch {
8281
+ }
7906
8282
  };
7907
8283
  const finish = (r) => {
7908
8284
  if (resolved) return;
@@ -7922,7 +8298,19 @@ function executeInSubprocess(scriptPath, params, paramSchema, externalTools, str
7922
8298
  const stdoutSplitter = new LineSplitter();
7923
8299
  child.stdout.setEncoding("utf-8");
7924
8300
  child.stdout.on("data", (chunk) => {
7925
- for (const line of stdoutSplitter.push(chunk)) {
8301
+ let lines;
8302
+ try {
8303
+ lines = stdoutSplitter.push(chunk);
8304
+ } catch (err) {
8305
+ finish({
8306
+ kind: "err",
8307
+ phase: "ipc",
8308
+ message: err instanceof Error ? err.message : String(err),
8309
+ executedQueries
8310
+ });
8311
+ return;
8312
+ }
8313
+ for (const line of lines) {
7926
8314
  let msg;
7927
8315
  try {
7928
8316
  msg = JSON.parse(line);
@@ -7966,9 +8354,6 @@ function executeInSubprocess(scriptPath, params, paramSchema, externalTools, str
7966
8354
  child.stdin.write(init);
7967
8355
  function handleChildMessage(msg) {
7968
8356
  switch (msg.type) {
7969
- case "query":
7970
- void handleQuery8(msg.id, msg.toolId, msg.sql);
7971
- return;
7972
8357
  case "stream":
7973
8358
  if (streamBuffer?.hasCallback()) {
7974
8359
  streamBuffer.write(msg.chunk);
@@ -7994,117 +8379,21 @@ ${msg.stack}` : ""),
7994
8379
  return;
7995
8380
  }
7996
8381
  }
7997
- async function handleQuery8(id, toolId, sql) {
7998
- const tool = resolveAuthorizedTool(toolId, toolsById, toolsByName);
7999
- if (!tool) {
8000
- sendToChild({
8001
- type: "query_error",
8002
- id,
8003
- error: `Tool "${toolId}" is not authorized for this request.`
8004
- });
8005
- return;
8006
- }
8007
- const startedAt = Date.now();
8008
- streamQueryStart(streamBuffer, tool.name, sql);
8009
- try {
8010
- const result = await tool.fn({ sql });
8011
- const elapsed = Date.now() - startedAt;
8012
- if (result && result.error) {
8013
- const errMsg = typeof result.error === "string" ? result.error : JSON.stringify(result.error);
8014
- streamQueryDone(streamBuffer, elapsed);
8015
- streamQueryError(streamBuffer, tool.name, errMsg);
8016
- sendToChild({ type: "query_error", id, error: errMsg });
8017
- return;
8018
- }
8019
- const data = result?.data || [];
8020
- const count = result?.count ?? data.length;
8021
- const totalCount = result?.metadata?.totalCount;
8022
- streamQueryDone(streamBuffer, elapsed);
8023
- streamQuerySuccess(streamBuffer, tool.name, data.length, totalCount ?? count);
8024
- streamDataPreview(streamBuffer, data);
8025
- sendToChild({
8026
- type: "query_result",
8027
- id,
8028
- data,
8029
- count,
8030
- metadata: { totalCount, executionTimeMs: elapsed }
8031
- });
8032
- } catch (err) {
8033
- const elapsed = Date.now() - startedAt;
8034
- const errMsg = err instanceof Error ? err.message : String(err);
8035
- streamQueryDone(streamBuffer, elapsed);
8036
- streamQueryError(streamBuffer, tool.name, errMsg);
8037
- sendToChild({ type: "query_error", id, error: errMsg });
8038
- }
8039
- }
8040
- function sendToChild(msg) {
8041
- try {
8042
- child.stdin.write(encodeMessage(msg));
8043
- } catch (err) {
8044
- logger.warn(`[ScriptRunner] Failed to write to child stdin: ${err.message}`);
8045
- }
8046
- }
8047
8382
  });
8048
8383
  }
8049
- function resolveAuthorizedTool(toolId, byId, byName) {
8050
- const exact = byId.get(toolId);
8051
- if (exact) return exact;
8052
- const exactName = byName.get(toolId.toLowerCase());
8053
- if (exactName) return exactName;
8054
- for (const [id, tool] of byId) {
8055
- if (id.includes(toolId) || toolId.includes(id)) return tool;
8056
- }
8057
- const lower = toolId.toLowerCase();
8058
- for (const [name, tool] of byName) {
8059
- if (name.includes(lower) || lower.includes(name)) return tool;
8060
- }
8061
- return null;
8062
- }
8063
- function streamQueryStart(sb, sourceName, sql) {
8064
- if (!sb?.hasCallback()) return;
8065
- sb.write(`
8066
- \u{1F4DD} **Querying ${sourceName}:**
8067
- \`\`\`sql
8068
- ${sql}
8069
- \`\`\`
8070
-
8071
- `);
8072
- sb.write(`__QUERY_TIMER_START_Executing query__`);
8073
- }
8074
- function streamQueryDone(sb, ms) {
8075
- if (!sb?.hasCallback()) return;
8076
- sb.write(`__QUERY_TIMER_DONE_${(ms / 1e3).toFixed(1)}__
8077
-
8078
- `);
8079
- }
8080
- function streamQuerySuccess(sb, sourceName, rows, total) {
8081
- if (!sb?.hasCallback()) return;
8082
- const totalInfo = total > rows ? ` of ${total} total` : "";
8083
- sb.write(`\u2705 **${rows} rows${totalInfo} from ${sourceName}**
8084
-
8085
- `);
8086
- }
8087
- function streamQueryError(sb, sourceName, msg) {
8088
- if (!sb?.hasCallback()) return;
8089
- sb.write(`\u274C **Query failed on ${sourceName}:** ${msg}
8090
-
8091
- `);
8092
- }
8093
- function streamDataPreview(sb, data) {
8094
- if (!sb?.hasCallback() || data.length === 0) return;
8095
- try {
8096
- sb.write(`<DataTable>${JSON.stringify(data.slice(0, 10))}</DataTable>
8097
-
8098
- `);
8099
- } catch {
8100
- sb.write(`_Data preview not available_
8101
-
8102
- `);
8103
- }
8104
- }
8105
8384
  function withSynthesizedFinalData(queries, finalData, finalCount) {
8106
8385
  if (!finalData || finalData.length === 0) return queries;
8107
- if (!queries || queries.length === 0) return queries;
8386
+ if (!queries || queries.length === 0) {
8387
+ return [{
8388
+ sourceId: "computed:_final",
8389
+ sourceName: "Script final dataset",
8390
+ sql: "-- script final returned data (runTool / pure JS, no ctx.query)",
8391
+ data: finalData,
8392
+ count: finalCount,
8393
+ executionTimeMs: 0,
8394
+ virtual: true
8395
+ }];
8396
+ }
8108
8397
  const firstSqlQuery = queries.find((q) => !q.virtual);
8109
8398
  const rawSample = firstSqlQuery?.data?.[0];
8110
8399
  const finalSample = finalData[0];
@@ -8244,10 +8533,11 @@ var EXECUTE_SCRIPT_TOOL_DEF = {
8244
8533
  };
8245
8534
  var MAX_SCRIPT_ATTEMPTS = 3;
8246
8535
  var MainAgent = class {
8247
- constructor(externalTools, config, scriptStore, turnId, streamBuffer) {
8536
+ constructor(externalTools, config, scriptStore, turnId, streamBuffer, workflows = []) {
8248
8537
  this.createdFromPrompt = "";
8249
8538
  this.scriptState = { recipeId: null, attempts: 0, lastSuccessfulResult: null };
8250
8539
  this.externalTools = externalTools;
8540
+ this.workflows = workflows;
8251
8541
  this.config = config;
8252
8542
  this.streamBuffer = streamBuffer || new StreamBuffer();
8253
8543
  this.scriptStore = scriptStore ?? null;
@@ -8273,22 +8563,31 @@ var MainAgent = class {
8273
8563
  this.createdFromPrompt = userPrompt;
8274
8564
  const sourceTools = this.externalTools.filter((t) => t.toolType !== "direct");
8275
8565
  const directTools = this.externalTools.filter((t) => t.toolType === "direct");
8276
- logger.info(`[MainAgent] ${sourceTools.length} source tool(s), ${directTools.length} direct tool(s)`);
8566
+ logger.info(`[MainAgent] ${sourceTools.length} source tool(s), ${directTools.length} direct tool(s), ${this.workflows.length} workflow(s)`);
8277
8567
  const summaries = buildSourceSummaries(sourceTools);
8278
- const systemPrompt = await this.buildSystemPrompt(summaries, directTools, conversationHistory);
8568
+ const systemPrompt = await this.buildSystemPrompt(summaries, directTools, this.workflows, conversationHistory);
8279
8569
  logger.logLLMPrompt("mainAgent", "system", extractPromptText(systemPrompt));
8280
8570
  logger.logLLMPrompt("mainAgent", "user", userPrompt);
8281
8571
  const sourceToolDefs = this.buildSourceToolDefinitions(summaries);
8282
8572
  const directToolDefs = this.buildDirectToolDefinitions(directTools);
8573
+ const workflowToolDefs = this.buildWorkflowToolDefinitions(this.workflows);
8283
8574
  const tools = [
8284
8575
  ...sourceToolDefs,
8285
8576
  ...directToolDefs,
8577
+ ...workflowToolDefs,
8286
8578
  ...this.scriptingEnabled ? [WRITE_SCRIPT_TOOL_DEF, EXECUTE_SCRIPT_TOOL_DEF] : []
8287
8579
  ];
8288
8580
  const sourceResults = [];
8289
8581
  const executedTools = [];
8290
8582
  let sourceCallCounter = 0;
8583
+ let selectedWorkflow;
8291
8584
  const toolHandler = async (toolName, toolInput) => {
8585
+ const workflow = this.workflows.find((w) => w.id === toolName);
8586
+ if (workflow) {
8587
+ return this.handleWorkflow(workflow, toolInput, (w) => {
8588
+ selectedWorkflow = w;
8589
+ });
8590
+ }
8292
8591
  if (toolName === "write_script") {
8293
8592
  if (!this.scriptingEnabled) return "Scripting is not enabled for this request.";
8294
8593
  return this.handleWriteScript(toolInput);
@@ -8353,24 +8652,34 @@ var MainAgent = class {
8353
8652
  this.config.maxIterations
8354
8653
  );
8355
8654
  const totalTime = Date.now() - startTime;
8356
- logger.info(`[MainAgent] Complete | ${sourceResults.length} source queries, ${executedTools.length} successful | ${totalTime}ms`);
8357
- const savedScript = this.buildSavedScript();
8655
+ logger.info(
8656
+ `[MainAgent] Complete | ${sourceResults.length} source queries, ${executedTools.length} successful${selectedWorkflow ? ` | workflow="${selectedWorkflow.name}"` : ""} | ${totalTime}ms`
8657
+ );
8658
+ const savedScript = await this.buildSavedScript();
8358
8659
  if (savedScript) {
8359
8660
  logger.info(`[MainAgent] Script authored: "${savedScript.name}" (${savedScript.parameters.length} params, ${this.scriptState.attempts} attempt${this.scriptState.attempts === 1 ? "" : "s"})`);
8360
8661
  } else if (sourceResults.some((r) => r.success)) {
8361
8662
  logger.warn(`[MainAgent] Source query succeeded but no script was authored \u2014 LLM skipped write_script/execute_script. Prompt policy may need tightening.`);
8362
8663
  }
8664
+ if (!savedScript && this.scriptStore && this.scriptState.recipeId) {
8665
+ try {
8666
+ await this.scriptStore.discardDraft(this.scriptState.recipeId);
8667
+ } catch (err) {
8668
+ logger.warn(`[MainAgent] Failed to discard failed draft ${this.scriptState.recipeId}: ${err}`);
8669
+ }
8670
+ }
8363
8671
  return {
8364
8672
  text,
8365
8673
  executedTools,
8366
8674
  sourceResults,
8367
- savedScript
8675
+ savedScript,
8676
+ workflow: selectedWorkflow
8368
8677
  };
8369
8678
  }
8370
8679
  // ============================================
8371
8680
  // Script-authoring tool handlers
8372
8681
  // ============================================
8373
- handleWriteScript(toolInput) {
8682
+ async handleWriteScript(toolInput) {
8374
8683
  const scriptBody = typeof toolInput?.scriptBody === "string" ? toolInput.scriptBody.trim() : "";
8375
8684
  if (!scriptBody) {
8376
8685
  return "write_script requires a non-empty `scriptBody` starting with `export async function getData(ctx, params)`.";
@@ -8379,7 +8688,7 @@ var MainAgent = class {
8379
8688
  const intentDescription = toolInput.description || "";
8380
8689
  const tags = Array.isArray(toolInput.tags) ? toolInput.tags : [];
8381
8690
  const parameters = Array.isArray(toolInput.parameters) ? this.normalizeParameterList(toolInput.parameters) : [];
8382
- const draft = this.scriptStore.saveDraft({
8691
+ const draft = await this.scriptStore.saveDraft({
8383
8692
  recipeId: this.scriptState.recipeId ?? void 0,
8384
8693
  turnId: this.turnId,
8385
8694
  name,
@@ -8400,7 +8709,7 @@ var MainAgent = class {
8400
8709
  if (!this.scriptState.recipeId) {
8401
8710
  return "No draft found. Call write_script first with the script you want to verify.";
8402
8711
  }
8403
- const draftRecipe = this.scriptStore.get(this.scriptState.recipeId);
8712
+ const draftRecipe = await this.scriptStore.get(this.scriptState.recipeId);
8404
8713
  if (!draftRecipe) {
8405
8714
  logger.error(`[MainAgent] execute_script: draft "${this.scriptState.recipeId}" missing from store`);
8406
8715
  return "Draft was lost from the store. Call write_script again.";
@@ -8418,10 +8727,17 @@ var MainAgent = class {
8418
8727
  );
8419
8728
  if (result.success) {
8420
8729
  this.scriptState.lastSuccessfulResult = result;
8730
+ const totalRows = result.data.length;
8421
8731
  const summary2 = {
8422
8732
  ok: true,
8423
8733
  executionTimeMs: result.executionTimeMs,
8734
+ totalRows,
8735
+ // dataSummary is computed over ALL rows — use it for any range/count/
8736
+ // min/max/total/"all-or-none" claim. sampleRows is just the first few
8737
+ // rows (head of result, NOT representative) for illustrating shape.
8738
+ dataSummary: summarizeRows(result.data),
8424
8739
  sampleRows: result.data.slice(0, 5),
8740
+ analysisGuidance: `Showing ${Math.min(totalRows, 5)} of ${totalRows} rows. The sample is the HEAD of the result and is NOT representative \u2014 do not generalize from it. Base every number, range, count, and "all/none" statement on dataSummary, which is computed over all ${totalRows} rows. The full result is rendered for the user by the on-screen component.`,
8425
8741
  executedQueries: result.executedQueries.map((q) => ({
8426
8742
  sourceId: q.sourceId,
8427
8743
  sourceName: q.sourceName,
@@ -8429,10 +8745,10 @@ var MainAgent = class {
8429
8745
  sample: q.data.slice(0, 2)
8430
8746
  }))
8431
8747
  };
8432
- logger.info(`[MainAgent] execute_script: ok \u2014 ${result.data.length} rows in ${result.executionTimeMs}ms (attempt ${this.scriptState.attempts})`);
8748
+ logger.info(`[MainAgent] execute_script: ok \u2014 ${totalRows} rows in ${result.executionTimeMs}ms (attempt ${this.scriptState.attempts})`);
8433
8749
  return JSON.stringify(summary2, null, 2);
8434
8750
  }
8435
- this.scriptStore.recordDraftError(draftRecipe.id, {
8751
+ await this.scriptStore.recordDraftError(draftRecipe.id, {
8436
8752
  phase: result.errorPhase ?? "runtime",
8437
8753
  message: result.error ?? "Unknown error",
8438
8754
  attempt: this.scriptState.attempts
@@ -8458,11 +8774,11 @@ var MainAgent = class {
8458
8774
  * `ScriptStore.promoteToVerified()`. Only returned when a verified
8459
8775
  * successful execution is on record.
8460
8776
  */
8461
- buildSavedScript() {
8777
+ async buildSavedScript() {
8462
8778
  const { recipeId, lastSuccessfulResult } = this.scriptState;
8463
8779
  if (!recipeId || !lastSuccessfulResult) return void 0;
8464
8780
  if (!this.scriptStore) return void 0;
8465
- const draft = this.scriptStore.get(recipeId);
8781
+ const draft = await this.scriptStore.get(recipeId);
8466
8782
  if (!draft) return void 0;
8467
8783
  const executedQueries = lastSuccessfulResult.executedQueries.map((q) => ({
8468
8784
  sourceId: q.sourceId,
@@ -8589,6 +8905,7 @@ ${lines.join("\n")}`;
8589
8905
  maxRows: 5,
8590
8906
  maxCharsPerField: 200
8591
8907
  });
8908
+ const sampleData = Array.isArray(resultData) ? resultData.slice(0, TOOL_TRACKING_SAMPLE_ROWS) : Array.isArray(formattedResult.data) ? formattedResult.data.slice(0, TOOL_TRACKING_SAMPLE_ROWS) : [];
8592
8909
  executedTools.push({
8593
8910
  id: tool.id,
8594
8911
  name: tool.name,
@@ -8597,7 +8914,7 @@ ${lines.join("\n")}`;
8597
8914
  _totalRecords: result.totalItems || result.count || rowCount,
8598
8915
  _recordsShown: rowCount,
8599
8916
  _metadata: result.metadata,
8600
- _sampleData: Array.isArray(resultData) ? resultData.slice(0, 3) : [resultData]
8917
+ _sampleData: sampleData
8601
8918
  },
8602
8919
  outputSchema: tool.outputSchema
8603
8920
  });
@@ -8623,9 +8940,10 @@ ${formatted}`;
8623
8940
  // System Prompt
8624
8941
  // ============================================
8625
8942
  /**
8626
- * Build the main agent's system prompt with source summaries and direct tool descriptions.
8943
+ * Build the main agent's system prompt with source summaries, direct tool descriptions,
8944
+ * and workflow component descriptions.
8627
8945
  */
8628
- async buildSystemPrompt(summaries, directTools, conversationHistory) {
8946
+ async buildSystemPrompt(summaries, directTools, workflows, conversationHistory) {
8629
8947
  const summariesText = formatSummariesForPrompt(summaries);
8630
8948
  const maxSourceCalls = Math.max(2, this.config.maxIterations - 2);
8631
8949
  let directToolsText = "";
@@ -8637,10 +8955,28 @@ ${formatted}`;
8637
8955
  ${t.description || "No description"}${paramList ? "\n Parameters:\n" + paramList : ""}`;
8638
8956
  }).join("\n\n");
8639
8957
  }
8958
+ let workflowsText = "";
8959
+ if (workflows.length > 0) {
8960
+ workflowsText = workflows.map((w, idx) => {
8961
+ const propLines = Object.entries(w.propsSchema || {}).map(([k, v]) => ` - ${k}: ${v}`).join("\n");
8962
+ return [
8963
+ `${idx + 1}. **${w.name}** (tool: ${w.id})`,
8964
+ ` ${w.description}`,
8965
+ ` When to use: ${w.whenToUse}`,
8966
+ propLines ? ` Props:
8967
+ ${propLines}` : ""
8968
+ ].filter(Boolean).join("\n");
8969
+ }).join("\n\n");
8970
+ } else {
8971
+ workflowsText = "No workflow components registered for this project.";
8972
+ }
8640
8973
  const prompts = await promptLoader.loadPrompts("agent-main", {
8641
8974
  SOURCE_SUMMARIES: summariesText,
8642
8975
  DIRECT_TOOLS: directToolsText,
8643
- MAX_ROWS: String(this.config.maxRowsPerSource),
8976
+ WORKFLOW_COMPONENTS: workflowsText,
8977
+ MAX_ROWS: String(MAIN_AGENT_COMPLETE_ROWS),
8978
+ MAX_ROWS_RENDERED: String(MAX_ROWS_RENDERED),
8979
+ MAX_ROWS_FETCHED: String(MAX_ROWS_FETCHED),
8644
8980
  MAX_SOURCE_CALLS: String(maxSourceCalls),
8645
8981
  GLOBAL_KNOWLEDGE_BASE: this.config.globalKnowledgeBase || "No global knowledge base available.",
8646
8982
  CURRENT_DATETIME: getCurrentDateTimeForPrompt(),
@@ -8726,6 +9062,70 @@ ${formatted}`;
8726
9062
  });
8727
9063
  }
8728
9064
  // ============================================
9065
+ // Workflow Handling
9066
+ // ============================================
9067
+ /**
9068
+ * Capture a workflow selection. We do NOT execute anything — the LLM has
9069
+ * already extracted the props it wants the workflow rendered with. We
9070
+ * record the selection (via the capture callback) and return a short
9071
+ * acknowledgement so the LLM ends its turn cleanly without writing
9072
+ * analysis text or calling more tools.
9073
+ */
9074
+ async handleWorkflow(workflow, toolInput, capture) {
9075
+ const props = { ...workflow.defaultProps || {}, ...toolInput || {} };
9076
+ logger.info(
9077
+ `[MainAgent] Workflow selected: "${workflow.name}" | props: ${JSON.stringify(props).substring(0, 200)}`
9078
+ );
9079
+ if (this.streamBuffer.hasCallback()) {
9080
+ this.streamBuffer.write(`
9081
+
9082
+ \u{1F9ED} **Launching ${workflow.name} workflow...**
9083
+
9084
+ `);
9085
+ await streamDelay();
9086
+ }
9087
+ capture({ name: workflow.name, props });
9088
+ return `\u2705 Workflow "${workflow.name}" selected. The UI will render now \u2014 do NOT write analysis text or call any other tools. End your turn.`;
9089
+ }
9090
+ /**
9091
+ * Build LLM tool definitions for workflow components. The workflow's
9092
+ * propsSchema becomes the tool's input_schema so the LLM extracts props
9093
+ * directly from the prompt — same mechanic as direct tools.
9094
+ */
9095
+ buildWorkflowToolDefinitions(workflows) {
9096
+ return workflows.map((workflow) => {
9097
+ const properties = {};
9098
+ const required = [];
9099
+ Object.entries(workflow.propsSchema || {}).forEach(([key, typeOrValue]) => {
9100
+ const valueStr = String(typeOrValue).toLowerCase();
9101
+ let schemaType = "string";
9102
+ const typeMatch = valueStr.match(/^(string|number|integer|boolean|array|object)\b/);
9103
+ if (typeMatch) {
9104
+ schemaType = typeMatch[1];
9105
+ }
9106
+ const isOptional = valueStr.includes("(optional)") || valueStr.includes("optional");
9107
+ const description = typeof typeOrValue === "string" ? typeOrValue : `Prop: ${key}`;
9108
+ if (schemaType === "array") {
9109
+ properties[key] = { type: "array", items: {}, description };
9110
+ } else if (schemaType === "object") {
9111
+ properties[key] = { type: "object", description };
9112
+ } else {
9113
+ properties[key] = { type: schemaType, description };
9114
+ }
9115
+ if (!isOptional) required.push(key);
9116
+ });
9117
+ return {
9118
+ name: workflow.id,
9119
+ description: `[WORKFLOW] ${workflow.description} \u2014 When to use: ${workflow.whenToUse}`,
9120
+ input_schema: {
9121
+ type: "object",
9122
+ properties,
9123
+ required: required.length > 0 ? required : void 0
9124
+ }
9125
+ };
9126
+ });
9127
+ }
9128
+ // ============================================
8729
9129
  // Format Result for Main Agent
8730
9130
  // ============================================
8731
9131
  /**
@@ -8735,7 +9135,7 @@ ${formatted}`;
8735
9135
  if (!result.success) {
8736
9136
  return `Data source "${result.sourceName}" could not fulfill the request: ${result.error}. Try rephrasing your intent or querying a different source.`;
8737
9137
  }
8738
- const { data, metadata } = result;
9138
+ const { metadata } = result;
8739
9139
  let output = `## Data from "${result.sourceName}"
8740
9140
  `;
8741
9141
  output += `Rows returned: ${metadata.rowsReturned}`;
@@ -8746,33 +9146,68 @@ ${formatted}`;
8746
9146
  Execution time: ${metadata.executionTimeMs}ms
8747
9147
 
8748
9148
  `;
8749
- if (data.length === 0) {
9149
+ const successfulTools = result.allExecutedTools && result.allExecutedTools.length > 0 ? result.allExecutedTools : result.executedTool ? [result.executedTool] : [];
9150
+ const queries = successfulTools.map((t) => ({
9151
+ sql: t?.params?.sql || t?.params?.query,
9152
+ rows: t?.result?._mainAgentRows ?? t?.result?._sampleData ?? [],
9153
+ summary: t?.result?._summary,
9154
+ total: t?.result?._totalRecords,
9155
+ shown: t?.result?._recordsShown
9156
+ })).filter((q) => Boolean(q.sql));
9157
+ if (queries.length === 0) {
8750
9158
  output += "No data returned.";
8751
9159
  return output;
8752
9160
  }
8753
- const maxRowsForLLM = Math.min(data.length, 10);
8754
- const truncatedData = data.slice(0, maxRowsForLLM).map((row) => {
8755
- const truncatedRow = {};
9161
+ const truncRows = (rowsArr) => rowsArr.map((row) => {
9162
+ const out = {};
8756
9163
  for (const [key, value] of Object.entries(row)) {
8757
- if (typeof value === "string" && value.length > 200) {
8758
- truncatedRow[key] = value.substring(0, 200) + "...";
8759
- } else {
8760
- truncatedRow[key] = value;
8761
- }
9164
+ out[key] = typeof value === "string" && value.length > 200 ? safeTruncate(value, 200) + "..." : value;
8762
9165
  }
8763
- return truncatedRow;
9166
+ return out;
8764
9167
  });
8765
- const truncationNote = data.length > maxRowsForLLM ? `
8766
- (showing ${maxRowsForLLM} of ${data.length} rows)` : "";
8767
- output += `### Results (${data.length} rows${truncationNote})
9168
+ const jsonBlock = (obj) => {
9169
+ try {
9170
+ return "```json\n" + JSON.stringify(obj, null, 2) + "\n```\n";
9171
+ } catch {
9172
+ return "```\n[Data could not be serialized]\n```\n";
9173
+ }
9174
+ };
9175
+ const scopeWarning = `PRESERVE every filter and scope \u2014 brand, product, date range, AND geography/segment (\`State\`/\`Region\`/\`District\`). Do NOT broaden, drop, or "simplify" any predicate.`;
9176
+ output += queries.length === 1 ? `### The SQL below and its result \u2014 copy the SQL VERBATIM into \`write_script\` (inside \`ctx.query(...)\`)
9177
+ ${scopeWarning}
9178
+
9179
+ ` : `### ${queries.length} successful queries \u2014 each block pairs ONE query's SQL with ITS OWN result. Do NOT mix a query's SQL with another query's data.
9180
+ ${scopeWarning} The script re-runs them, so you can include more than one \`ctx.query(...)\`.
9181
+
8768
9182
  `;
8769
- output += "```json\n";
8770
- try {
8771
- output += JSON.stringify(truncatedData, null, 2);
8772
- } catch {
8773
- output += "[Data could not be serialized]";
8774
- }
8775
- output += "\n```\n";
9183
+ queries.forEach((q, i) => {
9184
+ const total = q.total ?? q.rows.length;
9185
+ const capped = (q.shown ?? q.rows.length) >= MAX_ROWS_FETCHED;
9186
+ output += queries.length === 1 ? `**SQL:**
9187
+ ` : `### Query ${i + 1} \u2014 ${capped ? `${MAX_ROWS_FETCHED}+ (TRUNCATED)` : `${total}`} rows
9188
+ **SQL for Query ${i + 1}:**
9189
+ `;
9190
+ output += "```sql\n" + String(q.sql) + "\n```\n";
9191
+ if (capped) {
9192
+ output += `> \u26A0\uFE0F **TRUNCATED, NON-REPRESENTATIVE SAMPLE.** This query hit the ${MAX_ROWS_FETCHED}-row exploration cap, so it returned only the FIRST ${MAX_ROWS_FETCHED} rows \u2014 and because it is \`ORDER BY\`'d, those rows are likely ALL from one group (e.g. one District/category), not a cross-section. Do NOT conclude any group "has no data" or read any count/total/min/max from this. To reason over all rows, re-query with **aggregation** (\`GROUP BY\` the dimension, e.g. \`SELECT District, COUNT(*) ... GROUP BY District\`) or compute the metric in SQL \u2014 never from this slice.
9193
+ `;
9194
+ }
9195
+ if (total <= MAIN_AGENT_COMPLETE_ROWS) {
9196
+ const rows = truncRows(q.rows);
9197
+ output += `**All ${rows.length} rows (COMPLETE result \u2014 this is the entire result set, nothing hidden):**
9198
+ `;
9199
+ output += jsonBlock(rows);
9200
+ } else {
9201
+ const samples = truncRows(q.rows.slice(0, 3));
9202
+ output += `**Result summary \u2014 computed over ALL ${total} rows. Use THIS for every count, range, total, "all/none", and per-group number; do NOT infer them from the samples:**
9203
+ `;
9204
+ output += jsonBlock(q.summary ?? { totalRows: total });
9205
+ output += `**Sample rows (first ${samples.length} of ${total} \u2014 to show row SHAPE only, NOT representative of the distribution):**
9206
+ `;
9207
+ output += jsonBlock(samples);
9208
+ }
9209
+ output += "\n";
9210
+ });
8776
9211
  return output;
8777
9212
  }
8778
9213
  /**
@@ -8798,14 +9233,15 @@ function extractTablesFromSQL(sqls) {
8798
9233
  // src/userResponse/agents/types.ts
8799
9234
  var DEFAULT_AGENT_CONFIG = {
8800
9235
  maxRowsPerSource: 10,
9236
+ maxRowsFetched: MAX_ROWS_FETCHED,
8801
9237
  mainAgentModel: "",
8802
9238
  // will use the provider's default model
8803
9239
  sourceAgentModel: "",
8804
9240
  // will use the provider's default model
8805
9241
  maxRetries: 2,
8806
9242
  // 2 retries = 3 total query attempts (1 initial + 2 retries for SQL errors)
8807
- maxIterations: 8
8808
- // schema search (2-3) + query attempts (2) + LLM responses + final
9243
+ maxIterations: 12
9244
+ // schema search (2-3) + query attempts (2) + write_script/execute_script (2-3) + LLM responses + final
8809
9245
  };
8810
9246
 
8811
9247
  // src/userResponse/utils/component-props-processor.ts
@@ -9079,6 +9515,7 @@ function formatExecutedTools(executedTools) {
9079
9515
  Fields:
9080
9516
  ${fieldsText}`;
9081
9517
  }
9518
+ const MAX_SAMPLE_BLOCK_CHARS = 8e3;
9082
9519
  let sampleDataText = "";
9083
9520
  const sampleData = tool.result?._sampleData;
9084
9521
  if (Array.isArray(sampleData) && sampleData.length > 0) {
@@ -9086,8 +9523,11 @@ ${fieldsText}`;
9086
9523
  sampleDataText = `
9087
9524
  \u{1F511} RESULT FIELDS: ${sampleFields.join(", ")}`;
9088
9525
  try {
9526
+ const stringified = JSON.stringify(sampleData, null, 2);
9527
+ const capped = stringified.length > MAX_SAMPLE_BLOCK_CHARS ? `${stringified.substring(0, MAX_SAMPLE_BLOCK_CHARS)}
9528
+ ... (truncated; ${stringified.length - MAX_SAMPLE_BLOCK_CHARS} more chars)` : stringified;
9089
9529
  sampleDataText += `
9090
- \u{1F4C4} SAMPLE ROW: ${JSON.stringify(sampleData[0])}`;
9530
+ \u{1F4C4} SAMPLE ROWS (${sampleData.length}): ${capped}`;
9091
9531
  } catch {
9092
9532
  }
9093
9533
  }
@@ -9375,11 +9815,20 @@ Fixed SQL query:`;
9375
9815
  return validatedQuery;
9376
9816
  }
9377
9817
 
9818
+ // src/userResponse/scripts/script-metadata-store.ts
9819
+ function resolveScriptRecipeStore(collections) {
9820
+ const s = collections?.["script-recipes"];
9821
+ if (s && typeof s.search === "function" && typeof s.getById === "function") {
9822
+ return s;
9823
+ }
9824
+ return null;
9825
+ }
9826
+
9378
9827
  // src/userResponse/scripts/script-store.ts
9379
9828
  var fs6 = __toESM(require("fs"));
9380
9829
  var path6 = __toESM(require("path"));
9830
+ var import_crypto5 = require("crypto");
9381
9831
  var DEFAULT_STORE_DIR = "scripts-store";
9382
- var METADATA_SUBDIR = "metadata";
9383
9832
  function normalizeScriptBody(scriptBody) {
9384
9833
  let i = 0;
9385
9834
  const n = scriptBody.length;
@@ -9404,383 +9853,321 @@ function normalizeScriptBody(scriptBody) {
9404
9853
  } else if (/^\s*function\s+getData\b/.test(cleanBody)) {
9405
9854
  cleanBody = cleanBody.replace(/^\s*function\s+getData\b/, "export async function getData");
9406
9855
  }
9856
+ cleanBody = cleanBody.replace(/(?<!\\)\\([dwsDWS])/g, "\\\\$1");
9407
9857
  return cleanBody;
9408
9858
  }
9409
9859
  var ScriptStore = class {
9410
- constructor(baseDir) {
9411
- this.recipes = /* @__PURE__ */ new Map();
9412
- this.loaded = false;
9413
- this.storeDir = baseDir || path6.join(process.cwd(), DEFAULT_STORE_DIR);
9414
- }
9415
- metadataDir() {
9416
- return path6.join(this.storeDir, METADATA_SUBDIR);
9417
- }
9418
- /**
9419
- * Filename base for a recipe. Drafts include the per-turn suffix so two
9420
- * concurrent turns can never clobber each other's draft files; verified
9421
- * recipes use the bare slug.
9422
- */
9423
- fileBaseName(recipe) {
9424
- const slug = this.toFileName(recipe.name);
9425
- if (recipe.status === "draft" && recipe.turnId) {
9426
- return `${slug}-${recipe.turnId}`;
9860
+ constructor(opts) {
9861
+ this.store = opts?.store ?? resolveScriptRecipeStore(opts?.collections) ?? null;
9862
+ this.storeDir = opts?.baseDir || path6.join(process.cwd(), DEFAULT_STORE_DIR);
9863
+ this.projectId = opts?.projectId;
9864
+ if (!this.store) {
9865
+ logger.warn("[ScriptStore] No script-recipes metadata store injected \u2014 script reuse disabled this run.");
9427
9866
  }
9428
- return slug;
9429
- }
9430
- /**
9431
- * Absolute path to the .ts file for a recipe. Callers (e.g. ScriptRunner,
9432
- * MainAgent's execute_script) use this to hand off the path to the tsx child.
9433
- */
9434
- getScriptPath(recipe) {
9435
- return path6.join(this.storeDir, `${this.fileBaseName(recipe)}.ts`);
9436
9867
  }
9437
- /**
9438
- * Absolute path to the metadata JSON for a recipe.
9439
- */
9440
- getMetadataPath(recipe) {
9441
- return path6.join(this.metadataDir(), `${this.fileBaseName(recipe)}.json`);
9868
+ /** Whether a metadata store is wired (matcher / authoring are gated on this). */
9869
+ hasStore() {
9870
+ return this.store !== null;
9442
9871
  }
9443
- /**
9444
- * Get all VERIFIED recipes — drafts are filtered out so the matcher never
9445
- * considers an unverified script. Loads from disk on first access.
9446
- */
9447
- getAll() {
9448
- this.ensureLoaded();
9449
- return Array.from(this.recipes.values()).filter((r) => (r.status ?? "verified") === "verified");
9872
+ // ============================================
9873
+ // Read
9874
+ // ============================================
9875
+ /** Number of healthy verified recipes (gates the script-matching path). */
9876
+ async count() {
9877
+ if (!this.store) return 0;
9878
+ try {
9879
+ return await this.store.count({ projectId: this.projectId });
9880
+ } catch (err) {
9881
+ logger.warn(`[ScriptStore] count failed: ${err}`);
9882
+ return 0;
9883
+ }
9450
9884
  }
9451
9885
  /**
9452
- * Get a recipe by ID returns drafts as well as verified scripts.
9453
- * Used by MainAgent and the promotion path.
9886
+ * FTS shortlist for the matcher (metadata only bodies are loaded lazily by
9887
+ * `get()` once the LLM picks one). Returns verified, healthy recipes ranked
9888
+ * by relevance.
9454
9889
  */
9455
- get(id) {
9456
- this.ensureLoaded();
9457
- return this.recipes.get(id) || null;
9890
+ async search(prompt, limit) {
9891
+ if (!this.store) return [];
9892
+ try {
9893
+ const rows = await this.store.search({ prompt, projectId: this.projectId, limit });
9894
+ return rows.map((r) => this.rowToRecipe(r, ""));
9895
+ } catch (err) {
9896
+ logger.warn(`[ScriptStore] search failed: ${err}`);
9897
+ return [];
9898
+ }
9458
9899
  }
9459
- /**
9460
- * Number of verified recipes (matches `getAll().length`).
9461
- */
9462
- count() {
9463
- this.ensureLoaded();
9464
- return this.getAll().length;
9900
+ /** Fetch one recipe by id with its body loaded from disk. */
9901
+ async get(id) {
9902
+ if (!this.store) return null;
9903
+ try {
9904
+ const row = await this.store.getById(id);
9905
+ if (!row) return null;
9906
+ const body = this.readBody(row.fileBase);
9907
+ return this.rowToRecipe(row, body);
9908
+ } catch (err) {
9909
+ logger.warn(`[ScriptStore] get(${id}) failed: ${err}`);
9910
+ return null;
9911
+ }
9465
9912
  }
9466
- /**
9467
- * Save a recipe (create or update).
9468
- * File is named after the script: "order-status-distribution.json"
9469
- */
9470
- save(recipe) {
9471
- this.ensureLoaded();
9472
- recipe.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
9473
- this.recipes.set(recipe.id, recipe);
9474
- this.saveRecipeToDisk(recipe);
9475
- logger.info(`[ScriptStore] Saved script "${recipe.name}" (${recipe.id}) \u2014 ${this.recipes.size} total`);
9913
+ // ============================================
9914
+ // Write
9915
+ // ============================================
9916
+ /** Create or update a recipe (metadata upsert + body write when changed). */
9917
+ async save(recipe) {
9918
+ if (!this.store) return;
9919
+ try {
9920
+ if (!recipe.fileBase) {
9921
+ recipe.fileBase = await this.computeFileBase(recipe.name, recipe.id);
9922
+ }
9923
+ if (recipe.scriptBody) {
9924
+ const normalized = normalizeScriptBody(recipe.scriptBody);
9925
+ const hash = this.hash(normalized);
9926
+ if (hash !== recipe.bodyHash) {
9927
+ this.writeBody(recipe.fileBase, normalized);
9928
+ recipe.bodyHash = hash;
9929
+ }
9930
+ }
9931
+ recipe.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
9932
+ await this.store.upsert(this.recipeToRow(recipe));
9933
+ logger.info(`[ScriptStore] Saved "${recipe.name}" (${recipe.id})`);
9934
+ } catch (err) {
9935
+ logger.warn(`[ScriptStore] save("${recipe.name}") failed: ${err}`);
9936
+ }
9476
9937
  }
9477
9938
  /**
9478
- * Persist (or update) a draft recipe to disk. Always writes immediately so
9479
- * the `.ts` body is visible in the IDE the moment MainAgent calls
9480
- * `write_script`. Within one turn, retries that pass the same `recipeId`
9481
- * overwrite the same files (the LLM rewriting itself); a fresh `recipeId`
9482
- * is minted only on the first call of the turn.
9483
- *
9484
- * Filename includes the `turnId` suffix so concurrent turns never collide.
9939
+ * Persist (or update) a draft. Within a turn, retries that pass the same
9940
+ * `recipeId` overwrite the same row + file; a fresh `recipeId` mints a new
9941
+ * draft. The body is visible at scripts-store/<fileBase>.ts immediately.
9485
9942
  */
9486
- saveDraft(input) {
9487
- this.ensureLoaded();
9943
+ async saveDraft(input) {
9944
+ if (!this.store) {
9945
+ throw new Error("[ScriptStore] saveDraft called with no metadata store injected");
9946
+ }
9488
9947
  const now = (/* @__PURE__ */ new Date()).toISOString();
9489
- const existing = input.recipeId ? this.recipes.get(input.recipeId) : void 0;
9490
- const reuseExisting = existing && (existing.status ?? "verified") === "draft" && existing.turnId === input.turnId;
9491
- const recipe = reuseExisting ? {
9492
- ...existing,
9493
- name: input.name,
9494
- intentDescription: input.intentDescription,
9495
- tags: input.tags,
9496
- parameters: input.parameters,
9497
- scriptBody: input.scriptBody,
9498
- updatedAt: now
9499
- } : {
9500
- id: `script_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
9501
- version: 1,
9948
+ const existing = input.recipeId ? await this.store.getById(input.recipeId) : null;
9949
+ const reuse = existing && existing.status === "draft" && existing.turnId === input.turnId;
9950
+ const id = reuse ? existing.id : `script_${Date.now()}_${(0, import_crypto5.randomBytes)(3).toString("hex")}`;
9951
+ const fileBase = reuse ? existing.fileBase : await this.computeFileBase(input.name, id);
9952
+ const normalized = normalizeScriptBody(input.scriptBody);
9953
+ const bodyHash = this.hash(normalized);
9954
+ this.writeBody(fileBase, normalized);
9955
+ const recipe = {
9956
+ id,
9957
+ version: existing?.version ?? 1,
9502
9958
  name: input.name,
9503
9959
  intentDescription: input.intentDescription,
9504
9960
  tags: input.tags,
9505
- sourceIds: [],
9506
- tables: [],
9961
+ sourceIds: existing?.sourceIds ?? [],
9962
+ tables: existing?.tables ?? [],
9507
9963
  parameters: input.parameters,
9508
- scriptBody: input.scriptBody,
9509
- successCount: 0,
9510
- failureCount: 0,
9964
+ scriptBody: normalized,
9965
+ fileBase,
9966
+ bodyHash,
9967
+ projectId: this.projectId,
9968
+ successCount: existing?.successCount ?? 0,
9969
+ failureCount: existing?.failureCount ?? 0,
9511
9970
  lastUsed: now,
9512
9971
  createdFrom: input.createdFrom,
9513
- createdAt: now,
9972
+ createdAt: existing?.createdAt ?? now,
9514
9973
  updatedAt: now,
9515
- forkDepth: 0,
9974
+ forkDepth: existing?.forkDepth ?? 0,
9516
9975
  status: "draft",
9517
9976
  turnId: input.turnId
9518
9977
  };
9519
- this.recipes.set(recipe.id, recipe);
9520
- this.saveRecipeToDisk(recipe);
9521
- logger.info(
9522
- `[ScriptStore] ${reuseExisting ? "Updated" : "Saved"} draft "${recipe.name}" (${recipe.id}) at ${this.getScriptPath(recipe)}`
9523
- );
9978
+ await this.store.upsert(this.recipeToRow(recipe));
9979
+ logger.info(`[ScriptStore] ${reuse ? "Updated" : "Saved"} draft "${recipe.name}" (${id}) at ${this.getScriptPath(recipe)}`);
9524
9980
  return recipe;
9525
9981
  }
9526
- /**
9527
- * Stamp the draft with the most recent execution failure so it is visible
9528
- * in the metadata JSON without grepping logs. No-op if the recipe doesn't
9529
- * exist or has already been promoted.
9530
- */
9531
- recordDraftError(recipeId, err) {
9532
- this.ensureLoaded();
9533
- const recipe = this.recipes.get(recipeId);
9534
- if (!recipe || (recipe.status ?? "verified") !== "draft") return;
9535
- recipe.lastError = {
9536
- phase: err.phase,
9537
- message: err.message,
9538
- attempt: err.attempt,
9539
- at: (/* @__PURE__ */ new Date()).toISOString()
9540
- };
9541
- recipe.failureCount++;
9542
- recipe.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
9543
- this.saveRecipeToDisk(recipe);
9982
+ /** Stamp a draft's last execution error (metadata only). */
9983
+ async recordDraftError(recipeId, err) {
9984
+ if (!this.store) return;
9985
+ try {
9986
+ await this.store.recordDraftError(recipeId, { ...err, at: (/* @__PURE__ */ new Date()).toISOString() });
9987
+ } catch (e) {
9988
+ logger.warn(`[ScriptStore] recordDraftError failed: ${e}`);
9989
+ }
9544
9990
  }
9545
9991
  /**
9546
9992
  * Promote a successfully-executed draft into a verified script.
9547
- *
9548
- * - Renames the on-disk files from `<slug>-<turnId>.{ts,json}` to the bare
9549
- * `<slug>.{ts,json}`. If a verified file with the same slug already exists
9550
- * (concurrent turn won the race, or a prior session has it), the recipe
9551
- * keeps its turn-suffixed filename so we never overwrite verified work —
9552
- * the matcher keys on `recipe.id`, so two siblings with similar slugs is fine.
9553
- * - Flips `status: 'verified'`, clears `lastError`, applies provenance
9554
- * (`sourceIds`, `tables`) and optional fork lineage.
9555
- */
9556
- promoteToVerified(recipeId, input) {
9557
- this.ensureLoaded();
9558
- const recipe = this.recipes.get(recipeId);
9559
- if (!recipe) {
9560
- logger.warn(`[ScriptStore] promoteToVerified: recipe "${recipeId}" not found`);
9561
- return null;
9562
- }
9563
- if ((recipe.status ?? "verified") === "verified") {
9564
- logger.info(`[ScriptStore] promoteToVerified: recipe "${recipeId}" already verified \u2014 no-op`);
9565
- return recipe;
9566
- }
9567
- const slug = this.toFileName(recipe.name);
9568
- const canonicalTs = path6.join(this.storeDir, `${slug}.ts`);
9569
- const canonicalMeta = path6.join(this.metadataDir(), `${slug}.json`);
9570
- const canonicalFree = !fs6.existsSync(canonicalTs) && !fs6.existsSync(canonicalMeta);
9571
- const oldTsPath = this.getScriptPath(recipe);
9572
- const oldMetaPath = this.getMetadataPath(recipe);
9573
- recipe.status = "verified";
9574
- recipe.successCount = Math.max(1, recipe.successCount);
9575
- recipe.failureCount = 0;
9576
- recipe.lastError = void 0;
9577
- recipe.lastUsed = (/* @__PURE__ */ new Date()).toISOString();
9578
- recipe.updatedAt = recipe.lastUsed;
9579
- recipe.sourceIds = input.sourceIds;
9580
- recipe.tables = input.tables;
9581
- if (input.parentId !== void 0) recipe.parentId = input.parentId;
9582
- if (input.forkDepth !== void 0) recipe.forkDepth = input.forkDepth;
9583
- if (input.forkReason !== void 0) recipe.forkReason = input.forkReason;
9584
- if (canonicalFree) {
9585
- recipe.turnId = void 0;
9586
- try {
9587
- if (fs6.existsSync(oldTsPath)) fs6.unlinkSync(oldTsPath);
9588
- if (fs6.existsSync(oldMetaPath)) fs6.unlinkSync(oldMetaPath);
9589
- } catch (err) {
9590
- logger.warn(`[ScriptStore] promoteToVerified cleanup warning: ${err}`);
9993
+ * The on-disk body already exists at <fileBase>.ts (written at write_script
9994
+ * time) and keeps its name — only the DB row flips status + provenance.
9995
+ */
9996
+ async promoteToVerified(recipeId, input) {
9997
+ if (!this.store) return null;
9998
+ try {
9999
+ const row = await this.store.promote(recipeId, {
10000
+ sourceIds: input.sourceIds,
10001
+ tables: input.tables,
10002
+ parentId: input.parentId,
10003
+ forkDepth: input.forkDepth,
10004
+ forkReason: input.forkReason,
10005
+ components: input.components
10006
+ });
10007
+ if (!row) {
10008
+ logger.warn(`[ScriptStore] promoteToVerified: recipe "${recipeId}" not found`);
10009
+ return null;
9591
10010
  }
9592
- this.saveRecipeToDisk(recipe);
9593
- logger.info(
9594
- `[ScriptStore] Promoted draft "${recipe.name}" (${recipe.id}) \u2192 ${this.getScriptPath(recipe)}`
9595
- );
9596
- } else {
9597
- this.saveRecipeToDisk(recipe);
9598
- logger.info(
9599
- `[ScriptStore] Promoted draft "${recipe.name}" (${recipe.id}) \u2014 canonical slug "${slug}" was taken, kept turn-suffixed filename ${this.getScriptPath(recipe)}`
9600
- );
10011
+ logger.info(`[ScriptStore] Promoted "${row.name}" (${recipeId}) \u2192 verified`);
10012
+ return this.rowToRecipe(row, this.readBody(row.fileBase));
10013
+ } catch (err) {
10014
+ logger.warn(`[ScriptStore] promoteToVerified failed: ${err}`);
10015
+ return null;
9601
10016
  }
9602
- return recipe;
9603
10017
  }
9604
10018
  /**
9605
- * Drop a draft from disk + memory (e.g. when an outer error path wants to
9606
- * clean up). Per the agreed policy, MainAgent's normal failure path does
9607
- * NOT call this failed drafts are kept on disk for the user to inspect.
10019
+ * Drop a draft (row + body file). MainAgent calls this at end-of-turn when a
10020
+ * draft was authored but never verified failed drafts are never matched, so
10021
+ * deleting them immediately avoids unbounded accumulation (#5). No-op if the
10022
+ * recipe isn't a draft (so a promoted/verified script is never removed here).
9608
10023
  */
9609
- discardDraft(recipeId) {
9610
- this.ensureLoaded();
9611
- const recipe = this.recipes.get(recipeId);
9612
- if (!recipe || (recipe.status ?? "verified") !== "draft") return;
9613
- this.recipes.delete(recipeId);
9614
- this.deleteRecipeFromDisk(recipe);
9615
- logger.info(`[ScriptStore] Discarded draft "${recipe.name}" (${recipeId})`);
10024
+ async discardDraft(recipeId) {
10025
+ await this.removeById(recipeId, "draft");
9616
10026
  }
9617
- /**
9618
- * Delete a recipe by ID.
9619
- */
9620
- delete(id) {
9621
- this.ensureLoaded();
9622
- const recipe = this.recipes.get(id);
9623
- if (recipe) {
9624
- this.recipes.delete(id);
9625
- this.deleteRecipeFromDisk(recipe);
9626
- logger.info(`[ScriptStore] Deleted script "${recipe.name}" (${id})`);
9627
- }
10027
+ /** Delete a recipe (row + body file). */
10028
+ async delete(id) {
10029
+ await this.removeById(id);
9628
10030
  }
9629
- /**
9630
- * Record a successful execution for a recipe.
9631
- */
9632
- recordSuccess(id) {
9633
- this.ensureLoaded();
9634
- const recipe = this.recipes.get(id);
9635
- if (recipe) {
9636
- recipe.successCount++;
9637
- recipe.lastUsed = (/* @__PURE__ */ new Date()).toISOString();
9638
- this.saveRecipeToDisk(recipe);
10031
+ /** Record a successful execution (atomic counter bump). */
10032
+ async recordSuccess(id) {
10033
+ if (!this.store) return;
10034
+ try {
10035
+ await this.store.updateStats(id, { successDelta: 1, lastUsed: (/* @__PURE__ */ new Date()).toISOString() });
10036
+ } catch (err) {
10037
+ logger.warn(`[ScriptStore] recordSuccess failed: ${err}`);
9639
10038
  }
9640
10039
  }
9641
- /**
9642
- * Record a failed execution for a recipe.
9643
- */
9644
- recordFailure(id) {
9645
- this.ensureLoaded();
9646
- const recipe = this.recipes.get(id);
9647
- if (recipe) {
9648
- recipe.failureCount++;
9649
- recipe.lastUsed = (/* @__PURE__ */ new Date()).toISOString();
9650
- this.saveRecipeToDisk(recipe);
10040
+ /** Record a failed execution (atomic counter bump). */
10041
+ async recordFailure(id) {
10042
+ if (!this.store) return;
10043
+ try {
10044
+ await this.store.updateStats(id, { failureDelta: 1, lastUsed: (/* @__PURE__ */ new Date()).toISOString() });
10045
+ } catch (err) {
10046
+ logger.warn(`[ScriptStore] recordFailure failed: ${err}`);
9651
10047
  }
9652
10048
  }
9653
- /**
9654
- * Get the failure rate for a recipe (0 to 1).
9655
- * Returns 0 if the recipe has fewer than 5 total uses.
9656
- */
9657
- getFailureRate(id) {
9658
- const recipe = this.recipes.get(id);
9659
- if (!recipe) return 0;
9660
- const total = recipe.successCount + recipe.failureCount;
9661
- if (total < 5) return 0;
9662
- return recipe.failureCount / total;
10049
+ // ============================================
10050
+ // Paths (sync body lives on disk)
10051
+ // ============================================
10052
+ /** Absolute path to the .ts body for a recipe (used by the runner/MainAgent). */
10053
+ getScriptPath(recipe) {
10054
+ const base = recipe.fileBase || this.toSlug(recipe.name);
10055
+ return path6.join(this.storeDir, `${base}.ts`);
9663
10056
  }
9664
10057
  // ============================================
9665
- // Persistence — one JSON file per script
10058
+ // Internals
9666
10059
  // ============================================
9667
- ensureLoaded() {
9668
- if (!this.loaded) {
9669
- this.loadAllFromDisk();
9670
- this.loaded = true;
10060
+ async removeById(id, requireStatus) {
10061
+ if (!this.store) return;
10062
+ try {
10063
+ const row = await this.store.getById(id);
10064
+ if (!row) return;
10065
+ if (requireStatus && row.status !== requireStatus) return;
10066
+ await this.store.remove(id);
10067
+ this.unlinkBody(row.fileBase);
10068
+ logger.info(`[ScriptStore] Removed "${row.name}" (${id})`);
10069
+ } catch (err) {
10070
+ logger.warn(`[ScriptStore] remove(${id}) failed: ${err}`);
9671
10071
  }
9672
10072
  }
9673
- /**
9674
- * Convert a script name to a safe filename.
9675
- * "Order Status Distribution" → "order-status-distribution"
9676
- */
9677
- toFileName(name) {
9678
- return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unnamed-script";
10073
+ rowToRecipe(row, body) {
10074
+ return {
10075
+ id: row.id,
10076
+ version: row.version ?? 1,
10077
+ name: row.name,
10078
+ intentDescription: row.intentDescription,
10079
+ tags: row.tags ?? [],
10080
+ sourceIds: row.sourceIds ?? [],
10081
+ tables: row.tables ?? [],
10082
+ parameters: row.parameters ?? [],
10083
+ scriptBody: body,
10084
+ fileBase: row.fileBase,
10085
+ bodyHash: row.bodyHash ?? void 0,
10086
+ projectId: row.projectId ?? void 0,
10087
+ successCount: row.successCount ?? 0,
10088
+ failureCount: row.failureCount ?? 0,
10089
+ lastUsed: row.lastUsed ?? (/* @__PURE__ */ new Date()).toISOString(),
10090
+ createdFrom: row.createdFrom ?? "",
10091
+ createdAt: row.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
10092
+ updatedAt: row.updatedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
10093
+ parentId: row.parentId ?? void 0,
10094
+ forkDepth: typeof row.forkDepth === "number" ? row.forkDepth : 0,
10095
+ forkReason: row.forkReason ?? void 0,
10096
+ components: row.components ?? void 0,
10097
+ status: row.status ?? "verified",
10098
+ turnId: row.turnId ?? void 0,
10099
+ lastError: row.lastError ?? void 0
10100
+ };
9679
10101
  }
9680
- /**
9681
- * Load all script files from disk.
9682
- *
9683
- * Supports two layouts:
9684
- * - New (preferred): metadata/<name>.json + <name>.ts
9685
- * - Legacy: <name>.json with embedded scriptBody (auto-migrated on next save)
9686
- */
9687
- loadAllFromDisk() {
9688
- try {
9689
- if (!fs6.existsSync(this.storeDir)) return;
9690
- const metaDir = this.metadataDir();
9691
- const metaExists = fs6.existsSync(metaDir);
9692
- if (metaExists) {
9693
- const metaFiles = fs6.readdirSync(metaDir).filter((f) => f.endsWith(".json"));
9694
- for (const file of metaFiles) {
9695
- try {
9696
- const metaPath = path6.join(metaDir, file);
9697
- const meta = JSON.parse(fs6.readFileSync(metaPath, "utf-8"));
9698
- if (!meta.id) continue;
9699
- const baseName = file.replace(/\.json$/, "");
9700
- const bodyPath = path6.join(this.storeDir, `${baseName}.ts`);
9701
- if (!fs6.existsSync(bodyPath)) {
9702
- logger.warn(`[ScriptStore] Metadata ${file} has no sibling ${baseName}.ts \u2014 skipping`);
9703
- continue;
9704
- }
9705
- meta.scriptBody = fs6.readFileSync(bodyPath, "utf-8");
9706
- if (typeof meta.forkDepth !== "number") meta.forkDepth = 0;
9707
- if (!meta.status) meta.status = "verified";
9708
- this.recipes.set(meta.id, meta);
9709
- } catch (err) {
9710
- logger.warn(`[ScriptStore] Failed to load metadata ${file}: ${err}`);
9711
- }
9712
- }
9713
- }
9714
- const topLevelJson = fs6.readdirSync(this.storeDir).filter((f) => f.endsWith(".json"));
9715
- for (const file of topLevelJson) {
9716
- try {
9717
- const filePath = path6.join(this.storeDir, file);
9718
- const recipe = JSON.parse(fs6.readFileSync(filePath, "utf-8"));
9719
- if (!recipe.id || !recipe.scriptBody) continue;
9720
- if (this.recipes.has(recipe.id)) continue;
9721
- if (typeof recipe.forkDepth !== "number") recipe.forkDepth = 0;
9722
- if (!recipe.status) recipe.status = "verified";
9723
- this.recipes.set(recipe.id, recipe);
9724
- } catch (err) {
9725
- logger.warn(`[ScriptStore] Failed to load legacy script ${file}: ${err}`);
9726
- }
9727
- }
9728
- if (this.recipes.size > 0) {
9729
- logger.info(`[ScriptStore] Loaded ${this.recipes.size} scripts from ${this.storeDir}`);
10102
+ recipeToRow(recipe) {
10103
+ return {
10104
+ id: recipe.id,
10105
+ projectId: recipe.projectId ?? this.projectId ?? null,
10106
+ version: recipe.version ?? 1,
10107
+ name: recipe.name,
10108
+ intentDescription: recipe.intentDescription,
10109
+ tags: recipe.tags ?? null,
10110
+ createdFrom: recipe.createdFrom ?? null,
10111
+ sourceIds: recipe.sourceIds ?? null,
10112
+ tables: recipe.tables ?? null,
10113
+ parameters: recipe.parameters ?? null,
10114
+ components: recipe.components ?? null,
10115
+ fileBase: recipe.fileBase || this.toSlug(recipe.name),
10116
+ bodyHash: recipe.bodyHash ?? null,
10117
+ successCount: recipe.successCount ?? 0,
10118
+ failureCount: recipe.failureCount ?? 0,
10119
+ lastUsed: recipe.lastUsed ?? null,
10120
+ parentId: recipe.parentId ?? null,
10121
+ forkDepth: typeof recipe.forkDepth === "number" ? recipe.forkDepth : 0,
10122
+ forkReason: recipe.forkReason ?? null,
10123
+ status: recipe.status ?? "verified",
10124
+ turnId: recipe.turnId ?? null,
10125
+ lastError: recipe.lastError ?? null
10126
+ };
10127
+ }
10128
+ /** slug of name, with a short id suffix when the bare slug is already taken. */
10129
+ async computeFileBase(name, id) {
10130
+ const slug = this.toSlug(name);
10131
+ if (this.store) {
10132
+ try {
10133
+ const taken = await this.store.fileBaseTaken(slug, id, this.projectId);
10134
+ if (taken) return `${slug}-${id.slice(-6)}`;
10135
+ } catch {
9730
10136
  }
9731
- } catch (err) {
9732
- logger.warn(`[ScriptStore] Failed to read store directory ${this.storeDir}: ${err}`);
9733
10137
  }
10138
+ return slug;
9734
10139
  }
9735
- /**
9736
- * Save a recipe to disk in split format:
9737
- * metadata/<name>.json (metadata, no scriptBody)
9738
- * <name>.ts (just the function body)
9739
- *
9740
- * If a legacy top-level <name>.json exists for the same recipe, remove it
9741
- * so we don't end up with duplicate sources of truth.
9742
- */
9743
- saveRecipeToDisk(recipe) {
10140
+ toSlug(name) {
10141
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unnamed-script";
10142
+ }
10143
+ hash(body) {
10144
+ return (0, import_crypto5.createHash)("sha256").update(body).digest("hex");
10145
+ }
10146
+ bodyPath(fileBase) {
10147
+ return path6.join(this.storeDir, `${fileBase}.ts`);
10148
+ }
10149
+ readBody(fileBase) {
9744
10150
  try {
9745
- const metaDir = this.metadataDir();
9746
- if (!fs6.existsSync(metaDir)) {
9747
- fs6.mkdirSync(metaDir, { recursive: true });
9748
- }
9749
- const baseName = this.fileBaseName(recipe);
9750
- const metaPath = path6.join(metaDir, `${baseName}.json`);
9751
- const bodyPath = path6.join(this.storeDir, `${baseName}.ts`);
9752
- const { scriptBody, ...meta } = recipe;
9753
- fs6.writeFileSync(metaPath, JSON.stringify(meta, null, 2));
9754
- fs6.writeFileSync(bodyPath, normalizeScriptBody(scriptBody));
9755
- const legacyPath = path6.join(this.storeDir, `${this.toFileName(recipe.name)}.json`);
9756
- if (fs6.existsSync(legacyPath)) {
9757
- try {
9758
- fs6.unlinkSync(legacyPath);
9759
- } catch {
9760
- }
9761
- }
10151
+ return fs6.readFileSync(this.bodyPath(fileBase), "utf-8");
9762
10152
  } catch (err) {
9763
- logger.warn(`[ScriptStore] Failed to save script "${recipe.name}": ${err}`);
10153
+ logger.warn(`[ScriptStore] body file ${fileBase}.ts missing/unreadable: ${err}`);
10154
+ return "";
9764
10155
  }
9765
10156
  }
9766
- /**
9767
- * Delete a recipe's files from disk (both metadata + body, plus any legacy file).
9768
- */
9769
- deleteRecipeFromDisk(recipe) {
9770
- const baseName = this.fileBaseName(recipe);
9771
- const slug = this.toFileName(recipe.name);
9772
- const candidates = [
9773
- path6.join(this.metadataDir(), `${baseName}.json`),
9774
- path6.join(this.storeDir, `${baseName}.ts`),
9775
- path6.join(this.storeDir, `${slug}.json`)
9776
- // legacy single-file format
9777
- ];
9778
- for (const p of candidates) {
9779
- try {
9780
- if (fs6.existsSync(p)) fs6.unlinkSync(p);
9781
- } catch (err) {
9782
- logger.warn(`[ScriptStore] Failed to delete ${p}: ${err}`);
9783
- }
10157
+ /** Atomic body write (temp + rename) so concurrent reads never see a partial file. */
10158
+ writeBody(fileBase, normalizedBody) {
10159
+ if (!fs6.existsSync(this.storeDir)) fs6.mkdirSync(this.storeDir, { recursive: true });
10160
+ const finalPath = this.bodyPath(fileBase);
10161
+ const tmpPath = `${finalPath}.tmp-${(0, import_crypto5.randomBytes)(4).toString("hex")}`;
10162
+ fs6.writeFileSync(tmpPath, normalizedBody);
10163
+ fs6.renameSync(tmpPath, finalPath);
10164
+ }
10165
+ unlinkBody(fileBase) {
10166
+ try {
10167
+ const p = this.bodyPath(fileBase);
10168
+ if (fs6.existsSync(p)) fs6.unlinkSync(p);
10169
+ } catch (err) {
10170
+ logger.warn(`[ScriptStore] failed to delete body ${fileBase}.ts: ${err}`);
9784
10171
  }
9785
10172
  }
9786
10173
  };
@@ -9797,14 +10184,14 @@ var ScriptMatcher = class {
9797
10184
  * Returns null if no script matches.
9798
10185
  */
9799
10186
  async match(userPrompt, apiKey, model) {
9800
- const recipes = this.store.getAll();
10187
+ const recipes = await this.store.search(userPrompt);
9801
10188
  if (recipes.length === 0) return null;
9802
10189
  const healthyRecipes = recipes.filter((r) => {
9803
10190
  const total = r.successCount + r.failureCount;
9804
10191
  return total < 5 || r.failureCount / total <= MAX_FAILURE_RATE;
9805
10192
  });
9806
10193
  if (healthyRecipes.length === 0) {
9807
- logger.info(`[ScriptMatcher] All ${recipes.length} scripts have high failure rates \u2014 skipping`);
10194
+ logger.info(`[ScriptMatcher] No healthy candidates among ${recipes.length} FTS matches \u2014 skipping`);
9808
10195
  return null;
9809
10196
  }
9810
10197
  const scriptCatalog = this.buildScriptCatalog(healthyRecipes);
@@ -9840,7 +10227,7 @@ var ScriptMatcher = class {
9840
10227
  logger.info(`[ScriptMatcher] No match. Reason: ${reasoning}`);
9841
10228
  return null;
9842
10229
  }
9843
- const recipe = this.store.get(response.scriptId);
10230
+ const recipe = await this.store.get(response.scriptId);
9844
10231
  if (!recipe) {
9845
10232
  logger.warn(`[ScriptMatcher] LLM returned unknown script ID: "${response.scriptId}"`);
9846
10233
  return null;
@@ -9878,46 +10265,345 @@ var ScriptMatcher = class {
9878
10265
  return null;
9879
10266
  }
9880
10267
  }
9881
- /**
9882
- * Build the script catalog string for the LLM prompt.
9883
- * Each script gets: index, ID, name, description, and parameter definitions.
9884
- */
9885
- buildScriptCatalog(recipes) {
9886
- return recipes.map((r, idx) => {
9887
- const paramList = r.parameters.length > 0 ? r.parameters.map((p) => {
9888
- let desc = `${p.name} (${p.type}`;
9889
- if (!p.required) desc += ", optional";
9890
- if (p.default !== void 0) desc += `, default: ${JSON.stringify(p.default)}`;
9891
- if (p.enumValues) desc += `, values: ${Object.keys(p.enumValues).join("/")}`;
9892
- desc += `) \u2014 ${p.description}`;
9893
- return ` - ${desc}`;
9894
- }).join("\n") : " (no parameters)";
9895
- return `${idx + 1}. [${r.id}] "${r.name}"
9896
- ${r.intentDescription}
9897
- Parameters:
9898
- ${paramList}`;
9899
- }).join("\n\n");
10268
+ /**
10269
+ * Build the script catalog string for the LLM prompt.
10270
+ * Each script gets: index, ID, name, description, and parameter definitions.
10271
+ */
10272
+ buildScriptCatalog(recipes) {
10273
+ return recipes.map((r, idx) => {
10274
+ const paramList = r.parameters.length > 0 ? r.parameters.map((p) => {
10275
+ let desc = `${p.name} (${p.type}`;
10276
+ if (!p.required) desc += ", optional";
10277
+ if (p.default !== void 0) desc += `, default: ${JSON.stringify(p.default)}`;
10278
+ if (p.enumValues) desc += `, values: ${Object.keys(p.enumValues).join("/")}`;
10279
+ desc += `) \u2014 ${p.description}`;
10280
+ return ` - ${desc}`;
10281
+ }).join("\n") : " (no parameters)";
10282
+ return `${idx + 1}. [${r.id}] "${r.name}"
10283
+ ${r.intentDescription}
10284
+ Parameters:
10285
+ ${paramList}`;
10286
+ }).join("\n\n");
10287
+ }
10288
+ };
10289
+
10290
+ // src/userResponse/scripts/script-component-generator.ts
10291
+ function inferColumns(rows) {
10292
+ const cols = /* @__PURE__ */ new Map();
10293
+ if (!Array.isArray(rows) || rows.length === 0) return cols;
10294
+ const sample = rows.slice(0, 20);
10295
+ const keys = /* @__PURE__ */ new Set();
10296
+ for (const r of sample) {
10297
+ if (r && typeof r === "object") for (const k of Object.keys(r)) keys.add(k);
10298
+ }
10299
+ for (const key of keys) {
10300
+ let type = "TEXT";
10301
+ let typed = false;
10302
+ const distinct = /* @__PURE__ */ new Set();
10303
+ for (const r of sample) {
10304
+ const v = r?.[key];
10305
+ if (v === null || v === void 0) continue;
10306
+ if (!typed) {
10307
+ if (typeof v === "number") type = "NUMBER";
10308
+ else if (typeof v === "boolean") type = "BOOLEAN";
10309
+ else if (v instanceof Date) type = "DATE";
10310
+ else if (typeof v === "string" && v.trim() !== "" && !isNaN(Number(v))) type = "NUMBER";
10311
+ else type = "TEXT";
10312
+ typed = true;
10313
+ }
10314
+ distinct.add(v instanceof Date ? v.toISOString() : String(v));
10315
+ }
10316
+ cols.set(key.toLowerCase(), { name: key, type, invariant: distinct.size <= 1 });
10317
+ }
10318
+ return cols;
10319
+ }
10320
+ function resolveCol(cols, key) {
10321
+ if (key == null) return void 0;
10322
+ return cols.get(String(key).toLowerCase());
10323
+ }
10324
+ function firstOfType(cols, type) {
10325
+ for (const c of cols.values()) if (c.type === type) return c;
10326
+ return void 0;
10327
+ }
10328
+ var ALL_KEY_NAMES = ["xAxisKey", "yAxisKey", "valueKey", "nameKey", "seriesKey", "groupBy", "aggregationField", "categoryKey"];
10329
+ var AVG_KEYWORDS = /\b(average|avg|mean)\b/i;
10330
+ var COUNT_KEYWORDS = /\b(count|number of|how many|# of|no\.? of)\b/i;
10331
+ var SUM_KEYWORDS = /\b(total|sum|overall|combined|aggregate|all)\b/i;
10332
+ function validateAndRepairComponent(comp, componentType, cols, rowCount) {
10333
+ if (cols.size === 0) return comp;
10334
+ const props = comp.props || (comp.props = {});
10335
+ const cfg = props.config || (props.config = {});
10336
+ for (const k of ALL_KEY_NAMES) {
10337
+ if (cfg[k] == null) continue;
10338
+ const r = resolveCol(cols, cfg[k]);
10339
+ if (r) cfg[k] = r.name;
10340
+ else delete cfg[k];
10341
+ }
10342
+ if (Array.isArray(cfg.metricKeys)) {
10343
+ cfg.metricKeys = cfg.metricKeys.map((m) => resolveCol(cols, m)?.name).filter(Boolean);
10344
+ if (cfg.metricKeys.length === 0) delete cfg.metricKeys;
10345
+ }
10346
+ const numeric = () => firstOfType(cols, "NUMBER");
10347
+ const category = () => firstOfType(cols, "TEXT") || firstOfType(cols, "DATE");
10348
+ const hasMetrics = Array.isArray(cfg.metricKeys) && cfg.metricKeys.length > 0;
10349
+ switch (componentType) {
10350
+ case "KPICard":
10351
+ case "GaugeChart": {
10352
+ const vk = resolveCol(cols, cfg.valueKey) || numeric() || category();
10353
+ if (!vk) return null;
10354
+ cfg.valueKey = vk.name;
10355
+ const title = String(props.title || "");
10356
+ if (rowCount > 1 && !cfg.aggregation) {
10357
+ if (COUNT_KEYWORDS.test(title)) cfg.aggregation = "count";
10358
+ else if (!vk.invariant) {
10359
+ if (AVG_KEYWORDS.test(title)) cfg.aggregation = "avg";
10360
+ else if (SUM_KEYWORDS.test(title)) cfg.aggregation = "sum";
10361
+ }
10362
+ }
10363
+ if (cfg.aggregation === "sum" || cfg.aggregation === "avg") {
10364
+ const af = resolveCol(cols, cfg.aggregationField);
10365
+ if (af && af.type === "NUMBER") {
10366
+ cfg.aggregationField = af.name;
10367
+ } else {
10368
+ const n = vk.type === "NUMBER" ? vk : numeric();
10369
+ if (n) cfg.aggregationField = n.name;
10370
+ else {
10371
+ delete cfg.aggregation;
10372
+ delete cfg.aggregationField;
10373
+ }
10374
+ }
10375
+ }
10376
+ return comp;
10377
+ }
10378
+ case "PieChart":
10379
+ case "TreemapChart": {
10380
+ let nameC = resolveCol(cols, cfg.nameKey);
10381
+ let valC = resolveCol(cols, cfg.valueKey);
10382
+ if (nameC?.type === "NUMBER" && valC && valC.type !== "NUMBER") {
10383
+ const t = nameC;
10384
+ nameC = valC;
10385
+ valC = t;
10386
+ }
10387
+ if (!nameC) nameC = category();
10388
+ if (!valC || valC.type !== "NUMBER") valC = numeric() || valC;
10389
+ if (!nameC || !valC) return null;
10390
+ cfg.nameKey = nameC.name;
10391
+ cfg.valueKey = valC.name;
10392
+ return comp;
10393
+ }
10394
+ case "WaterfallChart": {
10395
+ const catC = resolveCol(cols, cfg.categoryKey) || category();
10396
+ let valC = resolveCol(cols, cfg.valueKey);
10397
+ if (!valC || valC.type !== "NUMBER") valC = numeric() || valC;
10398
+ if (!catC || !valC) return null;
10399
+ cfg.categoryKey = catC.name;
10400
+ cfg.valueKey = valC.name;
10401
+ return comp;
10402
+ }
10403
+ case "ScatterChart": {
10404
+ let xC = resolveCol(cols, cfg.xAxisKey);
10405
+ let yC = resolveCol(cols, cfg.yAxisKey);
10406
+ if (!xC || xC.type !== "NUMBER") xC = numeric() || xC;
10407
+ if (!yC || yC.type !== "NUMBER") {
10408
+ yC = [...cols.values()].find((c) => c.type === "NUMBER" && c.name !== xC?.name) || numeric() || yC;
10409
+ }
10410
+ if (!xC || !yC) return null;
10411
+ cfg.xAxisKey = xC.name;
10412
+ cfg.yAxisKey = yC.name;
10413
+ return comp;
10414
+ }
10415
+ case "HeatmapChart": {
10416
+ const xC = resolveCol(cols, cfg.xAxisKey) || category();
10417
+ const yC = resolveCol(cols, cfg.yAxisKey) || [...cols.values()].find((c) => (c.type === "TEXT" || c.type === "DATE") && c.name !== xC?.name);
10418
+ let valC = resolveCol(cols, cfg.valueKey);
10419
+ if (!valC || valC.type !== "NUMBER") valC = numeric() || valC;
10420
+ if (!xC || !yC || !valC) return null;
10421
+ cfg.xAxisKey = xC.name;
10422
+ cfg.yAxisKey = yC.name;
10423
+ cfg.valueKey = valC.name;
10424
+ return comp;
10425
+ }
10426
+ case "BarChart":
10427
+ case "LineChart": {
10428
+ let xC = resolveCol(cols, cfg.xAxisKey);
10429
+ let yC = resolveCol(cols, cfg.yAxisKey);
10430
+ if (!hasMetrics && xC?.type === "NUMBER" && yC && yC.type !== "NUMBER") {
10431
+ const t = xC;
10432
+ xC = yC;
10433
+ yC = t;
10434
+ }
10435
+ if (!xC) xC = (componentType === "LineChart" ? firstOfType(cols, "DATE") || category() : category()) || firstOfType(cols, "NUMBER");
10436
+ if (!xC) return null;
10437
+ cfg.xAxisKey = xC.name;
10438
+ if (!hasMetrics) {
10439
+ if (!yC || yC.type !== "NUMBER") yC = numeric();
10440
+ if (!yC) return null;
10441
+ cfg.yAxisKey = yC.name;
10442
+ }
10443
+ if (cfg.seriesKey) {
10444
+ const sC = resolveCol(cols, cfg.seriesKey);
10445
+ if (!sC || sC.type !== "TEXT" || sC.name === cfg.xAxisKey) delete cfg.seriesKey;
10446
+ else cfg.seriesKey = sC.name;
10447
+ }
10448
+ return comp;
10449
+ }
10450
+ case "RadarChart": {
10451
+ if (!hasMetrics) {
10452
+ const vk = resolveCol(cols, cfg.valueKey) || numeric();
10453
+ if (!vk) return null;
10454
+ cfg.valueKey = vk.name;
10455
+ }
10456
+ return comp;
10457
+ }
10458
+ default:
10459
+ return comp;
10460
+ }
10461
+ }
10462
+ function scriptCompKey(comp) {
10463
+ const id = comp?.componentId || comp?.componentName || "";
10464
+ const src = comp?.sourceIndex ?? "";
10465
+ return `${id}|${src}|${JSON.stringify(comp?.props?.config ?? comp?.config ?? {})}`;
10466
+ }
10467
+ function extractAnswerComponentObject(text) {
10468
+ const hasMatch = text.match(/"hasAnswerComponent"\s*:\s*(true|false)/);
10469
+ if (!hasMatch || hasMatch[1] !== "true") return null;
10470
+ const startMatch = text.match(/"answerComponent"\s*:\s*\{/);
10471
+ if (!startMatch) return null;
10472
+ const startPos = startMatch.index + startMatch[0].length - 1;
10473
+ let depth = 0, inString = false, escapeNext = false, endPos = -1;
10474
+ for (let i = startPos; i < text.length; i++) {
10475
+ const c = text[i];
10476
+ if (escapeNext) {
10477
+ escapeNext = false;
10478
+ continue;
10479
+ }
10480
+ if (c === "\\") {
10481
+ escapeNext = true;
10482
+ continue;
10483
+ }
10484
+ if (c === '"') {
10485
+ inString = !inString;
10486
+ continue;
10487
+ }
10488
+ if (!inString) {
10489
+ if (c === "{") depth++;
10490
+ else if (c === "}") {
10491
+ depth--;
10492
+ if (depth === 0) {
10493
+ endPos = i + 1;
10494
+ break;
10495
+ }
10496
+ }
10497
+ }
10498
+ }
10499
+ if (endPos <= startPos) return null;
10500
+ try {
10501
+ return JSON.parse(text.substring(startPos, endPos));
10502
+ } catch {
10503
+ return null;
9900
10504
  }
9901
- };
9902
-
9903
- // src/userResponse/scripts/script-component-generator.ts
10505
+ }
10506
+ async function buildScriptComponent(comp, ctx) {
10507
+ const { queries, queryIds, columnsByIndex, availableComponents } = ctx;
10508
+ let validationCols = /* @__PURE__ */ new Map();
10509
+ let validationRowCount = 0;
10510
+ let specSourceRef = "";
10511
+ const wantedId = comp.componentId || "";
10512
+ const wantedName = comp.componentName || "";
10513
+ const template = availableComponents.find(
10514
+ (c) => c.id === wantedId || c.name === wantedId || c.id === wantedName || c.name === wantedName
10515
+ );
10516
+ if (!template) {
10517
+ logger.warn(`[ScriptComponentGen] Component "${wantedId || wantedName}" not found \u2014 skipping`);
10518
+ return null;
10519
+ }
10520
+ if (template.type === "MarkdownBlock") {
10521
+ const p = comp.props || {};
10522
+ const content = p.content ?? p.config?.content ?? p.config?.markdown ?? p.config?.text ?? "";
10523
+ return {
10524
+ component: {
10525
+ id: `comp_${Math.random().toString(36).substring(2, 8)}`,
10526
+ name: template.name,
10527
+ displayName: template.displayName,
10528
+ type: template.type,
10529
+ description: template.description,
10530
+ props: { title: p.title, description: p.description, content },
10531
+ category: template.category,
10532
+ keywords: template.keywords
10533
+ },
10534
+ // Persist a content-bearing spec so the markdown survives deterministic
10535
+ // replay (it has no data source, so its text must be stored — otherwise
10536
+ // re-running a matched script rebuilds only the source-bound components
10537
+ // and silently drops the narrative).
10538
+ spec: {
10539
+ componentType: template.name,
10540
+ sourceRef: "markdown",
10541
+ title: p.title,
10542
+ description: p.description,
10543
+ content,
10544
+ config: {}
10545
+ }
10546
+ };
10547
+ }
10548
+ const sourceIdx = typeof comp.sourceIndex === "number" ? comp.sourceIndex : 0;
10549
+ const targetQuery = queries[sourceIdx] || queries[0];
10550
+ const targetQueryId = queryIds[targetQuery.sourceId];
10551
+ if (!targetQueryId) {
10552
+ logger.warn(`[ScriptComponentGen] No queryId for source "${targetQuery.sourceName}" \u2014 skipping`);
10553
+ return null;
10554
+ }
10555
+ validationCols = columnsByIndex[sourceIdx] || inferColumns(targetQuery.data);
10556
+ validationRowCount = targetQuery.count ?? (targetQuery.data?.length || 0);
10557
+ specSourceRef = targetQuery.sourceId;
10558
+ const renderedToolId = targetQuery.virtual ? "script_dataset" : targetQuery.sourceId;
10559
+ const externalToolProp = { toolId: renderedToolId, toolName: targetQuery.sourceName, parameters: { queryId: targetQueryId } };
10560
+ const repaired = validateAndRepairComponent(comp, template.type, validationCols, validationRowCount);
10561
+ if (!repaired) {
10562
+ logger.warn(`[ScriptComponentGen] Component "${template.name}" dropped \u2014 config keys could not be mapped to the data columns`);
10563
+ return null;
10564
+ }
10565
+ if (template.type === "DataTable" && Array.isArray(comp.props?.config?.columns)) {
10566
+ comp.props.config.columns = comp.props.config.columns.map(
10567
+ (c) => typeof c === "string" ? { key: c, label: c.replace(/[_-]+/g, " ").replace(/\b\w/g, (m) => m.toUpperCase()) } : c
10568
+ );
10569
+ }
10570
+ const component = {
10571
+ id: `comp_${Math.random().toString(36).substring(2, 8)}`,
10572
+ name: template.name,
10573
+ displayName: template.displayName,
10574
+ type: template.type,
10575
+ description: template.description,
10576
+ props: { ...comp.props, externalTool: externalToolProp },
10577
+ category: template.category,
10578
+ keywords: template.keywords
10579
+ };
10580
+ const spec = specSourceRef ? {
10581
+ componentType: template.name,
10582
+ sourceRef: specSourceRef,
10583
+ title: comp.props?.title,
10584
+ description: comp.props?.description,
10585
+ config: { ...comp.props?.config || {} }
10586
+ } : void 0;
10587
+ return { component, spec };
10588
+ }
9904
10589
  async function generateScriptComponents(params) {
9905
- const { userPrompt, scriptResult, queryIds, availableComponents, apiKey, model, componentStreamCallback, externalTools, collections } = params;
10590
+ const { userPrompt, scriptResult, queryIds, availableComponents, apiKey, model, componentStreamCallback, analysisText } = params;
9906
10591
  const queries = scriptResult.executedQueries;
9907
10592
  if (!queries || queries.length === 0) {
9908
10593
  logger.warn(`[ScriptComponentGen] No query data available \u2014 returning empty`);
9909
- return { components: [], layoutTitle: "", layoutDescription: "", actions: [] };
10594
+ return { components: [], layoutTitle: "", layoutDescription: "", actions: [], componentSpecs: [] };
9910
10595
  }
9911
10596
  logger.info(`[ScriptComponentGen] Starting | ${queries.length} queries | ${availableComponents.length} available components`);
9912
- const availableCompsText = availableComponents.filter((c) => c.name !== "DynamicMarkdownBlock").map((c) => `- ${c.name} (${c.type}): ${c.description || ""}`).join("\n");
10597
+ const columnsByIndex = queries.map((q) => {
10598
+ const data = q.data && q.data.length ? q.data : q.virtual ? scriptResult.data : q.data;
10599
+ return inferColumns(data || []);
10600
+ });
10601
+ const availableCompsText = availableComponents.map((c) => `- ${c.name} (${c.type}): ${c.description || ""}`).join("\n");
9913
10602
  const sourceDescriptions = queries.map((q, idx) => {
9914
10603
  if (!q.data || q.data.length === 0) return null;
9915
- const sampleRow = q.data[0];
9916
- const columns = Object.entries(sampleRow).map(([key, value]) => {
9917
- const type = typeof value === "number" ? "NUMBER" : value instanceof Date ? "DATE" : typeof value === "boolean" ? "BOOLEAN" : "TEXT";
9918
- return `${key} (${type})`;
9919
- }).join(", ");
9920
- const sampleData = JSON.stringify(q.data.slice(0, 3), null, 2);
10604
+ const cols = columnsByIndex[idx];
10605
+ const columns = cols && cols.size > 0 ? Array.from(cols.values()).map((c) => `${c.name} (${c.type})`).join(", ") : Object.keys(q.data[0]).join(", ");
10606
+ const sampleData = JSON.stringify(q.data.slice(0, TOOL_TRACKING_SAMPLE_ROWS), null, 2);
9921
10607
  return `### Source ${idx}: "${q.sourceName}" (sourceIndex: ${idx})
9922
10608
  - Rows: ${q.count}${q.totalCount && q.totalCount > q.count ? ` of ${q.totalCount} total` : ""}
9923
10609
  - Columns: ${columns}
@@ -9926,19 +10612,6 @@ async function generateScriptComponents(params) {
9926
10612
  ${sampleData}
9927
10613
  \`\`\``;
9928
10614
  }).filter(Boolean).join("\n\n");
9929
- const isMultiSource = queries.length > 1;
9930
- const federationTool = externalTools?.find((t) => t.id === "federation_query");
9931
- let federationContext = "";
9932
- if (isMultiSource && federationTool) {
9933
- federationContext = `### Cross-Source Federation Available
9934
- You can create components that JOIN data from multiple sources using DuckDB SQL.
9935
- Set sourceIndex to "federation" and provide federationSql.
9936
- ${federationTool.fullSchema || federationTool.description || "Use schema-qualified table names."}`;
9937
- } else if (isMultiSource) {
9938
- federationContext = `### Multiple Sources Available
9939
- Each component can use data from one source (specify sourceIndex: 0, 1, etc.).
9940
- Cross-source JOINs are not available for this project.`;
9941
- }
9942
10615
  try {
9943
10616
  const prompts = await promptLoader.loadPrompts("script-components", {
9944
10617
  USER_PROMPT: userPrompt,
@@ -9947,95 +10620,98 @@ Cross-source JOINs are not available for this project.`;
9947
10620
  ROW_COUNT: queries.map((q) => `${q.sourceName}: ${q.count}`).join(", "),
9948
10621
  COLUMN_DESCRIPTIONS: sourceDescriptions,
9949
10622
  SAMPLE_DATA: "",
9950
- FEDERATION_CONTEXT: federationContext
10623
+ ANALYSIS_TEXT: analysisText && analysisText.trim() ? analysisText.trim() : "No analysis text provided \u2014 infer intent from the user question and the returned data."
9951
10624
  });
10625
+ const ctx = {
10626
+ queries,
10627
+ queryIds,
10628
+ columnsByIndex,
10629
+ availableComponents
10630
+ };
10631
+ let fullText = "";
10632
+ let answerAttempted = false;
10633
+ let streamedAnswerKey = null;
10634
+ const partial = componentStreamCallback ? (chunk) => {
10635
+ fullText += chunk;
10636
+ if (answerAttempted) return;
10637
+ const answerComp = extractAnswerComponentObject(fullText);
10638
+ if (!answerComp) return;
10639
+ answerAttempted = true;
10640
+ streamedAnswerKey = scriptCompKey(answerComp);
10641
+ buildScriptComponent(answerComp, ctx).then((built) => {
10642
+ if (built) {
10643
+ componentStreamCallback(built.component);
10644
+ logger.info("[ScriptComponentGen] Streamed answer component early");
10645
+ } else {
10646
+ streamedAnswerKey = null;
10647
+ }
10648
+ }).catch(() => {
10649
+ streamedAnswerKey = null;
10650
+ });
10651
+ } : void 0;
9952
10652
  const response = await LLM.stream(
9953
10653
  { sys: prompts.system, user: prompts.user },
9954
10654
  {
9955
10655
  model: model || void 0,
9956
- maxTokens: 1500,
10656
+ maxTokens: 5e3,
9957
10657
  temperature: 0,
9958
- apiKey
10658
+ apiKey,
10659
+ partial
9959
10660
  },
9960
10661
  true
9961
10662
  );
9962
10663
  if (!response || !response.components || !Array.isArray(response.components)) {
9963
10664
  logger.warn(`[ScriptComponentGen] LLM returned invalid response`);
9964
- return { components: [], layoutTitle: "", layoutDescription: "", actions: [] };
10665
+ return { components: [], layoutTitle: "", layoutDescription: "", actions: [], componentSpecs: [] };
9965
10666
  }
9966
10667
  const layoutTitle = response.layoutTitle || "Dashboard";
9967
10668
  const layoutDescription = response.layoutDescription || "";
9968
10669
  const matchedComponents = [];
10670
+ const componentSpecs = [];
9969
10671
  for (const comp of response.components) {
9970
- const wantedId = comp.componentId || "";
9971
- const wantedName = comp.componentName || "";
9972
- const template = availableComponents.find(
9973
- (c) => c.id === wantedId || c.name === wantedId || c.id === wantedName || c.name === wantedName
9974
- );
9975
- if (!template) {
9976
- logger.warn(`[ScriptComponentGen] Component "${wantedId || wantedName}" not found \u2014 skipping`);
9977
- continue;
9978
- }
9979
- let externalToolProp;
9980
- if (comp.sourceIndex === "federation" && comp.federationSql) {
9981
- if (!isMultiSource || !federationTool) {
9982
- logger.warn(
9983
- `[ScriptComponentGen] Component "${comp.componentName || comp.componentId}" requested federation, but ${!isMultiSource ? "only one source query exists" : "no federation_query tool is registered"} \u2014 falling back to single-source. Computed columns from script's JS post-processing will not appear in this component.`
9984
- );
9985
- comp.sourceIndex = 0;
9986
- delete comp.federationSql;
9987
- } else {
9988
- logger.info(`[ScriptComponentGen] Federation component \u2014 executing cross-source SQL`);
9989
- try {
9990
- const fedResult = await executeFederationQuery(comp.federationSql, externalTools, collections);
9991
- if (fedResult) {
9992
- const fedQueryId = queryCache.storeQuery(comp.federationSql, fedResult);
9993
- externalToolProp = {
9994
- toolId: "federation_query",
9995
- toolName: "Cross-Source Query",
9996
- parameters: { queryId: fedQueryId }
9997
- };
9998
- } else {
9999
- logger.warn(`[ScriptComponentGen] Federation query failed \u2014 skipping component`);
10000
- continue;
10001
- }
10002
- } catch (fedErr) {
10003
- logger.warn(`[ScriptComponentGen] Federation query error: ${fedErr} \u2014 skipping component`);
10004
- continue;
10005
- }
10672
+ const built = await buildScriptComponent(comp, ctx);
10673
+ if (!built) continue;
10674
+ matchedComponents.push(built.component);
10675
+ if (built.spec) componentSpecs.push(built.spec);
10676
+ if (componentStreamCallback && scriptCompKey(comp) !== streamedAnswerKey) {
10677
+ componentStreamCallback(built.component);
10678
+ }
10679
+ }
10680
+ if (response.hasAnswerComponent && response.answerComponent) {
10681
+ const aKey = scriptCompKey(response.answerComponent);
10682
+ const alreadyIncluded = response.components.some((c) => scriptCompKey(c) === aKey);
10683
+ if (!alreadyIncluded) {
10684
+ const built = await buildScriptComponent(response.answerComponent, ctx);
10685
+ if (built) {
10686
+ matchedComponents.unshift(built.component);
10687
+ if (built.spec) componentSpecs.unshift(built.spec);
10688
+ if (componentStreamCallback && aKey !== streamedAnswerKey) componentStreamCallback(built.component);
10006
10689
  }
10007
10690
  }
10008
- if (!externalToolProp) {
10009
- const sourceIdx = typeof comp.sourceIndex === "number" ? comp.sourceIndex : 0;
10010
- const targetQuery = queries[sourceIdx] || queries[0];
10011
- const targetQueryId = queryIds[targetQuery.sourceId];
10012
- if (!targetQueryId) {
10013
- logger.warn(`[ScriptComponentGen] No queryId for source "${targetQuery.sourceName}" \u2014 skipping`);
10014
- continue;
10015
- }
10016
- const renderedToolId = targetQuery.virtual ? "script_dataset" : targetQuery.sourceId;
10017
- externalToolProp = {
10018
- toolId: renderedToolId,
10019
- toolName: targetQuery.sourceName,
10020
- parameters: { queryId: targetQueryId }
10691
+ }
10692
+ const hasMarkdown = matchedComponents.some((c) => c.type === "MarkdownBlock");
10693
+ if (!hasMarkdown && analysisText && analysisText.trim()) {
10694
+ const mdTemplate = availableComponents.find((c) => c.type === "MarkdownBlock" || c.name === "DynamicMarkdownBlock");
10695
+ if (mdTemplate) {
10696
+ const mdComponent = {
10697
+ id: `comp_${Math.random().toString(36).substring(2, 8)}`,
10698
+ name: mdTemplate.name,
10699
+ displayName: mdTemplate.displayName,
10700
+ type: mdTemplate.type,
10701
+ description: mdTemplate.description,
10702
+ props: { content: analysisText.trim() },
10703
+ category: mdTemplate.category,
10704
+ keywords: mdTemplate.keywords
10021
10705
  };
10022
- }
10023
- const component = {
10024
- id: `comp_${Math.random().toString(36).substring(2, 8)}`,
10025
- name: template.name,
10026
- displayName: template.displayName,
10027
- type: template.type,
10028
- description: template.description,
10029
- props: {
10030
- ...comp.props,
10031
- externalTool: externalToolProp
10032
- },
10033
- category: template.category,
10034
- keywords: template.keywords
10035
- };
10036
- matchedComponents.push(component);
10037
- if (componentStreamCallback) {
10038
- componentStreamCallback(component);
10706
+ matchedComponents.push(mdComponent);
10707
+ componentSpecs.push({
10708
+ componentType: mdTemplate.name,
10709
+ sourceRef: "markdown",
10710
+ content: analysisText.trim(),
10711
+ config: {}
10712
+ });
10713
+ if (componentStreamCallback) componentStreamCallback(mdComponent);
10714
+ logger.info("[ScriptComponentGen] Injected fallback DynamicMarkdownBlock (LLM omitted one)");
10039
10715
  }
10040
10716
  }
10041
10717
  logger.info(`[ScriptComponentGen] Generated ${matchedComponents.length} components`);
@@ -10049,37 +10725,97 @@ Cross-source JOINs are not available for this project.`;
10049
10725
  components: matchedComponents,
10050
10726
  layoutTitle,
10051
10727
  layoutDescription,
10052
- actions
10728
+ actions,
10729
+ componentSpecs
10053
10730
  };
10054
10731
  } catch (error) {
10055
10732
  const msg = error instanceof Error ? error.message : String(error);
10056
10733
  logger.error(`[ScriptComponentGen] Failed: ${msg}`);
10057
- return { components: [], layoutTitle: "", layoutDescription: "", actions: [] };
10734
+ return { components: [], layoutTitle: "", layoutDescription: "", actions: [], componentSpecs: [] };
10058
10735
  }
10059
10736
  }
10060
- async function executeFederationQuery(sql, externalTools, collections) {
10061
- const fedTool = externalTools?.find((t) => t.id === "federation_query");
10062
- if (fedTool) {
10063
- try {
10064
- const result = await fedTool.fn({ sql });
10065
- return result;
10066
- } catch (err) {
10067
- logger.warn(`[ScriptComponentGen] Federation tool execution failed: ${err}`);
10737
+ function assembleComponentsFromSpecs(params) {
10738
+ const { specs, scriptResult, queryIds, availableComponents, componentStreamCallback, analysisText } = params;
10739
+ if (!specs || specs.length === 0) return null;
10740
+ const queries = scriptResult.executedQueries || [];
10741
+ const byId = new Map(queries.map((q) => [q.sourceId, q]));
10742
+ const out = [];
10743
+ let answerComponentStreamed = false;
10744
+ const mdTemplate = availableComponents.find((c) => c.type === "MarkdownBlock" || c.name === "DynamicMarkdownBlock");
10745
+ const buildMarkdown = (content, title, description) => {
10746
+ if (!mdTemplate || !content || !content.trim()) return null;
10747
+ return {
10748
+ id: `comp_${Math.random().toString(36).substring(2, 8)}`,
10749
+ name: mdTemplate.name,
10750
+ displayName: mdTemplate.displayName,
10751
+ type: mdTemplate.type,
10752
+ description: mdTemplate.description,
10753
+ props: { title, description, content: content.trim() },
10754
+ category: mdTemplate.category,
10755
+ keywords: mdTemplate.keywords
10756
+ };
10757
+ };
10758
+ for (const spec of specs) {
10759
+ if (spec.sourceRef === "federation") {
10760
+ logger.info("[ScriptComponentGen] Recipe has a federation spec \u2014 not replayable deterministically, falling back to LLM");
10761
+ return null;
10762
+ }
10763
+ if (spec.sourceRef === "markdown") {
10764
+ const md = buildMarkdown(spec.content || analysisText || "", spec.title, spec.description);
10765
+ if (md) {
10766
+ out.push(md);
10767
+ if (componentStreamCallback) componentStreamCallback(md);
10768
+ }
10769
+ continue;
10770
+ }
10771
+ const q = byId.get(spec.sourceRef) || queries.find((x) => x.virtual);
10772
+ if (!q || !q.data || q.data.length === 0) {
10773
+ logger.info(`[ScriptComponentGen] Recipe spec source "${spec.sourceRef}" missing/empty on replay \u2014 falling back to LLM`);
10774
+ return null;
10775
+ }
10776
+ const template = availableComponents.find((c) => c.name === spec.componentType || c.id === spec.componentType);
10777
+ if (!template) {
10778
+ logger.info(`[ScriptComponentGen] Recipe spec component "${spec.componentType}" not in library \u2014 falling back to LLM`);
10779
+ return null;
10780
+ }
10781
+ const cols = inferColumns(q.data);
10782
+ const comp = { props: { title: spec.title, description: spec.description, config: { ...spec.config || {} } } };
10783
+ const repaired = validateAndRepairComponent(comp, template.type, cols, q.count ?? q.data.length);
10784
+ if (!repaired) {
10785
+ logger.info(`[ScriptComponentGen] Recipe spec "${spec.componentType}" no longer maps to columns (shape drift) \u2014 falling back to LLM`);
10786
+ return null;
10787
+ }
10788
+ const queryId = queryIds[q.sourceId] || queryCache.storeQuery(q.sql, { data: q.data, count: q.count });
10789
+ const toolId = q.virtual ? "script_dataset" : q.sourceId;
10790
+ const component = {
10791
+ id: `comp_${Math.random().toString(36).substring(2, 8)}`,
10792
+ name: template.name,
10793
+ displayName: template.displayName,
10794
+ type: template.type,
10795
+ description: template.description,
10796
+ props: {
10797
+ ...comp.props,
10798
+ externalTool: { toolId, toolName: q.sourceName, parameters: { queryId } }
10799
+ },
10800
+ category: template.category,
10801
+ keywords: template.keywords
10802
+ };
10803
+ out.push(component);
10804
+ if (componentStreamCallback && !answerComponentStreamed) {
10805
+ componentStreamCallback(component);
10806
+ answerComponentStreamed = true;
10068
10807
  }
10069
10808
  }
10070
- if (collections?.["external-tools"]?.execute) {
10071
- try {
10072
- const result = await collections["external-tools"].execute({
10073
- toolId: "federation_query",
10074
- toolName: "Cross-Source Query",
10075
- parameters: { sql }
10076
- });
10077
- return result;
10078
- } catch (err) {
10079
- logger.warn(`[ScriptComponentGen] Federation via collections failed: ${err}`);
10809
+ if (out.length > 0 && !out.some((c) => c.type === "MarkdownBlock")) {
10810
+ const md = buildMarkdown(analysisText || "");
10811
+ if (md) {
10812
+ out.push(md);
10813
+ if (componentStreamCallback) componentStreamCallback(md);
10814
+ logger.info("[ScriptComponentGen] Injected markdown on replay (recipe had no markdown spec)");
10080
10815
  }
10081
10816
  }
10082
- return null;
10817
+ logger.info(`[ScriptComponentGen] Deterministically assembled ${out.length} component(s) from recipe \u2014 no LLM call`);
10818
+ return out.length > 0 ? out : null;
10083
10819
  }
10084
10820
 
10085
10821
  // src/userResponse/anthropic.ts
@@ -12047,6 +12783,24 @@ var OpenAILLM = class extends BaseLLM {
12047
12783
  var openaiLLM = new OpenAILLM();
12048
12784
 
12049
12785
  // src/userResponse/agent-user-response.ts
12786
+ function hasExpiredScriptDataset(component) {
12787
+ if (!component || typeof component !== "object") return false;
12788
+ const et = component?.props?.externalTool;
12789
+ if (et?.toolId === "script_dataset") {
12790
+ const qid = et?.parameters?.queryId;
12791
+ if (typeof qid === "string" && qid.length > 0) {
12792
+ const stored = queryCache.getQuery(qid);
12793
+ if (!stored || stored.data == null) return true;
12794
+ }
12795
+ }
12796
+ const children = component?.props?.config?.components;
12797
+ if (Array.isArray(children)) {
12798
+ for (const child of children) {
12799
+ if (hasExpiredScriptDataset(child)) return true;
12800
+ }
12801
+ }
12802
+ return false;
12803
+ }
12050
12804
  function rehydrateCachedComponent(component) {
12051
12805
  const qMap = component?.props?.config?._queryMap;
12052
12806
  if (!qMap || typeof qMap !== "object" || Object.keys(qMap).length === 0) return component;
@@ -12096,7 +12850,24 @@ function getLLMInstance(provider) {
12096
12850
  return anthropicLLM;
12097
12851
  }
12098
12852
  }
12099
- var get_agent_user_response = async (prompt, components, anthropicApiKey, groqApiKey, geminiApiKey, openaiApiKey, llmProviders, conversationHistory, streamCallback, collections, externalTools, userId, mainAgentModel, sourceAgentModel) => {
12853
+ function storeScriptDatasetQuery(q, ctx) {
12854
+ const data = q.data.length > MAX_ROWS_RENDERED ? q.data.slice(0, MAX_ROWS_RENDERED) : q.data;
12855
+ if (q.data.length > MAX_ROWS_RENDERED) {
12856
+ logger.warn(`[AgentFlow] Script dataset "${q.sourceId}" had ${q.data.length} rows \u2014 capped to ${MAX_ROWS_RENDERED} for render/cache. Consider pre-aggregating in the script.`);
12857
+ }
12858
+ const count = data.length;
12859
+ if (q.virtual && ctx.recipeId) {
12860
+ const descriptor = {
12861
+ kind: "script_dataset",
12862
+ recipeId: ctx.recipeId,
12863
+ params: ctx.params || {},
12864
+ datasetRef: q.sourceId
12865
+ };
12866
+ return queryCache.storeQuery(JSON.stringify(descriptor), { data, count });
12867
+ }
12868
+ return queryCache.storeQuery(q.sql, { data, count });
12869
+ }
12870
+ var get_agent_user_response = async (prompt, components, anthropicApiKey, groqApiKey, geminiApiKey, openaiApiKey, llmProviders, conversationHistory, streamCallback, collections, externalTools, userId, mainAgentModel, sourceAgentModel, workflows) => {
12100
12871
  const startTime = Date.now();
12101
12872
  const providers = llmProviders || ["anthropic"];
12102
12873
  const provider = providers[0];
@@ -12105,19 +12876,19 @@ var get_agent_user_response = async (prompt, components, anthropicApiKey, groqAp
12105
12876
  logger.logLLMPrompt("agentUserResponse", "user", `User Prompt: ${prompt}`);
12106
12877
  logger.info(`[AgentFlow] Starting | provider: ${provider} | prompt: "${prompt.substring(0, 50)}..."`);
12107
12878
  try {
12108
- const conversationMatch = await conversation_search_default.searchConversationsWithReranking({
12879
+ const conversationMatch = await conversation_search_default.findExactMatch({
12109
12880
  userPrompt: prompt,
12110
12881
  collections,
12111
- userId,
12112
- similarityThreshold: EXACT_MATCH_SIMILARITY_THRESHOLD
12882
+ userId
12113
12883
  });
12114
12884
  if (conversationMatch) {
12115
- logger.info(`[AgentFlow] Found matching conversation (${(conversationMatch.similarity * 100).toFixed(2)}% similarity)`);
12116
12885
  const rawComponent = conversationMatch.uiBlock?.component || conversationMatch.uiBlock?.generatedComponentMetadata;
12117
12886
  const isValidComponent = rawComponent && typeof rawComponent === "object" && Object.keys(rawComponent).length > 0;
12118
12887
  const component = isValidComponent ? rawComponent : null;
12119
12888
  const cachedTextResponse = conversationMatch.uiBlock?.analysis || conversationMatch.uiBlock?.textResponse || conversationMatch.uiBlock?.text || "";
12120
- if (conversationMatch.similarity >= EXACT_MATCH_SIMILARITY_THRESHOLD) {
12889
+ if (component && hasExpiredScriptDataset(component)) {
12890
+ logger.info(`[AgentFlow] Exact match found but cached UIBlock has expired script_dataset \u2014 falling through to script matcher`);
12891
+ } else {
12121
12892
  if (streamCallback && cachedTextResponse) {
12122
12893
  streamCallback(cachedTextResponse);
12123
12894
  }
@@ -12131,16 +12902,14 @@ var get_agent_user_response = async (prompt, components, anthropicApiKey, groqAp
12131
12902
  component: rehydratedComponent,
12132
12903
  actions: conversationMatch.uiBlock?.actions || [],
12133
12904
  reasoning: `Exact match from previous conversation`,
12134
- method: `${provider}-agent-semantic-match`,
12135
- semanticSimilarity: conversationMatch.similarity
12905
+ method: `${provider}-agent-exact-match`,
12906
+ semanticSimilarity: 1
12136
12907
  },
12137
12908
  errors: []
12138
12909
  };
12139
12910
  }
12140
- logger.info(`[AgentFlow] Similar match but below exact threshold \u2014 proceeding with agent`);
12141
- } else {
12142
- logger.info(`[AgentFlow] No matching conversations \u2014 proceeding with agent`);
12143
12911
  }
12912
+ logger.info(`[AgentFlow] No exact match \u2014 proceeding with agent`);
12144
12913
  const apiKey = (() => {
12145
12914
  switch (provider) {
12146
12915
  case "anthropic":
@@ -12205,11 +12974,11 @@ var get_agent_user_response = async (prompt, components, anthropicApiKey, groqAp
12205
12974
  projectId
12206
12975
  };
12207
12976
  const streamBuffer = new StreamBuffer(streamCallback);
12208
- const scriptStore = new ScriptStore();
12977
+ const scriptStore = new ScriptStore({ collections, projectId });
12209
12978
  const turnId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
12210
12979
  const FORK_DEPTH_CAP = 3;
12211
12980
  let forkContext;
12212
- if (scriptStore.count() > 0) {
12981
+ if (await scriptStore.count() > 0) {
12213
12982
  const scriptMatcher = new ScriptMatcher(scriptStore);
12214
12983
  const scriptMatch = await scriptMatcher.match(prompt, apiKey, sourceAgentModel);
12215
12984
  if (scriptMatch && scriptMatch.tier === "near") {
@@ -12243,7 +13012,7 @@ var get_agent_user_response = async (prompt, components, anthropicApiKey, groqAp
12243
13012
  { externalTools: agentTools, streamBuffer }
12244
13013
  );
12245
13014
  if (scriptResult.success && scriptResult.data.length > 0) {
12246
- scriptStore.recordSuccess(scriptMatch.recipe.id);
13015
+ await scriptStore.recordSuccess(scriptMatch.recipe.id);
12247
13016
  const dataContext = scriptResult.executedQueries.map((q) => {
12248
13017
  const preview = q.data.slice(0, 15);
12249
13018
  return `## Data from "${q.sourceName}"
@@ -12281,7 +13050,7 @@ ${JSON.stringify(preview, null, 2)}
12281
13050
  result: {
12282
13051
  _totalRecords: q.totalCount || q.count,
12283
13052
  _recordsShown: q.data.length,
12284
- _sampleData: q.data.slice(0, 3)
13053
+ _sampleData: q.data.slice(0, TOOL_TRACKING_SAMPLE_ROWS)
12285
13054
  }
12286
13055
  }));
12287
13056
  let matchedComponents2 = [];
@@ -12295,19 +13064,31 @@ ${JSON.stringify(preview, null, 2)}
12295
13064
  streamBuffer.write("__TEXT_COMPLETE__COMPONENT_GENERATION_START__");
12296
13065
  streamBuffer.flush();
12297
13066
  }
12298
- const componentStreamCallback = streamBuffer.hasCallback() ? (component) => {
12299
- const answerMarker = `__ANSWER_COMPONENT_START__${JSON.stringify(component)}__ANSWER_COMPONENT_END__`;
12300
- streamBuffer.write(answerMarker);
12301
- streamBuffer.flush();
12302
- } : void 0;
13067
+ const componentStreamCallback = void 0;
12303
13068
  const scriptQueryIds = {};
12304
13069
  for (const q of scriptResult.executedQueries) {
12305
- scriptQueryIds[q.sourceId] = queryCache.storeQuery(q.sql, {
12306
- data: q.data,
12307
- count: q.count
13070
+ scriptQueryIds[q.sourceId] = storeScriptDatasetQuery(q, {
13071
+ recipeId: scriptMatch.recipe.id,
13072
+ params: scriptMatch.extractedParams || {}
12308
13073
  });
12309
13074
  }
12310
- try {
13075
+ let usedRecipeComponents = false;
13076
+ if (scriptMatch.recipe.components && scriptMatch.recipe.components.length > 0) {
13077
+ const assembled = assembleComponentsFromSpecs({
13078
+ specs: scriptMatch.recipe.components,
13079
+ scriptResult,
13080
+ queryIds: scriptQueryIds,
13081
+ availableComponents: components,
13082
+ componentStreamCallback,
13083
+ analysisText: scriptTextResponse
13084
+ });
13085
+ if (assembled && assembled.length > 0) {
13086
+ matchedComponents2 = assembled;
13087
+ layoutTitle2 = scriptMatch.recipe.name || layoutTitle2;
13088
+ usedRecipeComponents = true;
13089
+ }
13090
+ }
13091
+ if (!usedRecipeComponents) try {
12311
13092
  const compResult = await generateScriptComponents({
12312
13093
  userPrompt: prompt,
12313
13094
  scriptResult,
@@ -12317,13 +13098,22 @@ ${JSON.stringify(preview, null, 2)}
12317
13098
  model: sourceAgentModel,
12318
13099
  componentStreamCallback,
12319
13100
  externalTools: agentTools,
12320
- collections
13101
+ collections,
13102
+ analysisText: scriptTextResponse
12321
13103
  });
12322
13104
  if (compResult.components.length > 0) {
12323
13105
  matchedComponents2 = compResult.components;
12324
13106
  layoutTitle2 = compResult.layoutTitle;
12325
13107
  layoutDescription2 = compResult.layoutDescription;
12326
13108
  actions2 = compResult.actions;
13109
+ if (compResult.componentSpecs.length > 0) {
13110
+ try {
13111
+ scriptMatch.recipe.components = compResult.componentSpecs;
13112
+ await scriptStore.save(scriptMatch.recipe);
13113
+ } catch (e) {
13114
+ logger.warn(`[AgentFlow] Failed to backfill component recipe: ${e}`);
13115
+ }
13116
+ }
12327
13117
  } else {
12328
13118
  logger.info(`[AgentFlow] Lightweight component gen returned no components \u2014 falling back to full generator`);
12329
13119
  const matchResult = await generateAgentComponents({
@@ -12416,12 +13206,12 @@ ${JSON.stringify(preview, null, 2)}
12416
13206
  };
12417
13207
  } else {
12418
13208
  logger.warn(`[AgentFlow] Script "${scriptMatch.recipe.name}" failed: ${scriptResult.error || "no data returned"}`);
12419
- scriptStore.recordFailure(scriptMatch.recipe.id);
13209
+ await scriptStore.recordFailure(scriptMatch.recipe.id);
12420
13210
  }
12421
13211
  } catch (scriptErr) {
12422
13212
  const errMsg = scriptErr instanceof Error ? scriptErr.message : String(scriptErr);
12423
13213
  logger.warn(`[AgentFlow] Script execution error (falling back to agent flow): ${errMsg}`);
12424
- scriptStore.recordFailure(scriptMatch.recipe.id);
13214
+ await scriptStore.recordFailure(scriptMatch.recipe.id);
12425
13215
  }
12426
13216
  }
12427
13217
  } else {
@@ -12478,13 +13268,35 @@ User question: ${prompt}`;
12478
13268
  logger.warn(`[AgentFlow] Source routing pre-filter failed (using all tools): ${msg}`);
12479
13269
  }
12480
13270
  }
12481
- const mainAgent = new MainAgent(filteredTools, agentConfig, scriptStore, turnId, streamBuffer);
13271
+ const mainAgent = new MainAgent(filteredTools, agentConfig, scriptStore, turnId, streamBuffer, workflows || []);
12482
13272
  const agentResponse = await mainAgent.handleQuestion(
12483
13273
  effectivePrompt,
12484
13274
  apiKey,
12485
13275
  conversationHistory,
12486
13276
  streamBuffer.hasCallback() ? (chunk) => streamBuffer.write(chunk) : void 0
12487
13277
  );
13278
+ if (agentResponse.workflow) {
13279
+ streamBuffer.flush();
13280
+ const workflowComponent = {
13281
+ id: `workflow_${Date.now()}`,
13282
+ name: agentResponse.workflow.name,
13283
+ type: `Workflow_${agentResponse.workflow.name}`,
13284
+ description: `Workflow: ${agentResponse.workflow.name}`,
13285
+ props: agentResponse.workflow.props
13286
+ };
13287
+ const elapsedTime2 = Date.now() - startTime;
13288
+ logger.info(`[AgentFlow] Workflow short-circuit | "${agentResponse.workflow.name}" | ${elapsedTime2}ms`);
13289
+ return {
13290
+ success: true,
13291
+ data: {
13292
+ text: "",
13293
+ component: workflowComponent,
13294
+ actions: [],
13295
+ method: `${provider}-agent-workflow`
13296
+ },
13297
+ errors: []
13298
+ };
13299
+ }
12488
13300
  const rawText = streamBuffer.getFullText() || agentResponse.text || "I apologize, but I was unable to generate a response.";
12489
13301
  const textResponse = rawText.replace(/_?_?SB_END_?_?/g, "").replace(/__SB_\w+_(?:START|MSG)_?_?/g, "").replace(/__QUERY_TIMER_START_[^_]*__/g, "").replace(/__QUERY_TIMER_DONE_[\d.]+__/g, "").replace(/__TEXT_COMPLETE__COMPONENT_GENERATION_START__/g, "").replace(/\[COMPLEXITY:\s*(?:simple|medium|complex)\]/gi, "");
12490
13302
  streamBuffer.flush();
@@ -12524,6 +13336,7 @@ User question: ${prompt}`;
12524
13336
  let layoutTitle = "Dashboard";
12525
13337
  let layoutDescription = "Multi-component dashboard";
12526
13338
  let actions = [];
13339
+ let authoredComponentSpecs = [];
12527
13340
  if (!hasExecutedTools) {
12528
13341
  logger.info(`[AgentFlow] No tools executed \u2014 general question, wrapping in DynamicMarkdownBlock`);
12529
13342
  const mainAgentText = agentResponse.text || textResponse;
@@ -12571,9 +13384,9 @@ User question: ${prompt}`;
12571
13384
  logger.info(`[AgentFlow] Using savedScript.executedQueries (${agentResponse.savedScript.executedQueries.length} entries, includes computed columns) for component gen`);
12572
13385
  const scriptQueries = agentResponse.savedScript.executedQueries;
12573
13386
  for (const q of scriptQueries) {
12574
- agentQueryIds[q.sourceId] = queryCache.storeQuery(q.sql, {
12575
- data: q.data,
12576
- count: q.count
13387
+ agentQueryIds[q.sourceId] = storeScriptDatasetQuery(q, {
13388
+ recipeId: agentResponse.savedScript.recipeId,
13389
+ params: {}
12577
13390
  });
12578
13391
  }
12579
13392
  agentScriptResult = {
@@ -12611,7 +13424,7 @@ User question: ${prompt}`;
12611
13424
  executionTimeMs: 0
12612
13425
  };
12613
13426
  }
12614
- const compResult = await generateScriptComponents({
13427
+ const compGenArgs = {
12615
13428
  userPrompt: prompt,
12616
13429
  scriptResult: agentScriptResult,
12617
13430
  queryIds: agentQueryIds,
@@ -12620,13 +13433,29 @@ User question: ${prompt}`;
12620
13433
  model: sourceAgentModel,
12621
13434
  componentStreamCallback,
12622
13435
  externalTools: agentTools,
12623
- collections
12624
- });
12625
- if (compResult.components.length > 0) {
13436
+ collections,
13437
+ analysisText: agentResponse.text
13438
+ };
13439
+ const MAX_COMP_ATTEMPTS = 3;
13440
+ let compResult;
13441
+ for (let attempt = 1; attempt <= MAX_COMP_ATTEMPTS; attempt++) {
13442
+ try {
13443
+ compResult = await generateScriptComponents(compGenArgs);
13444
+ break;
13445
+ } catch (err) {
13446
+ const msg = err instanceof Error ? err.message : String(err);
13447
+ const transient = /connection error|econnreset|etimedout|fetch failed|socket hang up|overloaded|rate.?limit|\b429\b|\b5\d\d\b|network|timeout/i.test(msg);
13448
+ if (!transient || attempt === MAX_COMP_ATTEMPTS) throw err;
13449
+ logger.warn(`[AgentFlow] Lightweight component gen transient error (attempt ${attempt}/${MAX_COMP_ATTEMPTS}) \u2014 retrying: ${msg}`);
13450
+ await new Promise((res) => setTimeout(res, 400 * attempt));
13451
+ }
13452
+ }
13453
+ if (compResult && compResult.components.length > 0) {
12626
13454
  matchedComponents = compResult.components;
12627
13455
  layoutTitle = compResult.layoutTitle;
12628
13456
  layoutDescription = compResult.layoutDescription;
12629
13457
  actions = compResult.actions;
13458
+ authoredComponentSpecs = compResult.componentSpecs;
12630
13459
  usedLightweight = true;
12631
13460
  logger.info(`[AgentFlow] Lightweight component gen succeeded \u2014 ${matchedComponents.length} components`);
12632
13461
  }
@@ -12729,10 +13558,11 @@ User question: ${prompt}`;
12729
13558
  forkDepth: Math.min(FORK_DEPTH_CAP, forkContext.parentDepth + 1),
12730
13559
  forkReason: forkContext.modificationHint
12731
13560
  } : void 0;
12732
- const promoted = scriptStore.promoteToVerified(authored.recipeId, {
13561
+ const promoted = await scriptStore.promoteToVerified(authored.recipeId, {
12733
13562
  sourceIds: authored.sourceIds,
12734
13563
  tables: authored.tables,
12735
- ...lineage
13564
+ ...lineage,
13565
+ components: authoredComponentSpecs
12736
13566
  });
12737
13567
  if (promoted && lineage?.parentId) {
12738
13568
  logger.info(
@@ -12968,7 +13798,7 @@ var CONTEXT_CONFIG = {
12968
13798
  };
12969
13799
 
12970
13800
  // src/handlers/user-prompt-request.ts
12971
- var get_user_request = async (data, components, sendMessage, anthropicApiKey, groqApiKey, geminiApiKey, openaiApiKey, llmProviders, collections, externalTools, mainAgentModel, sourceAgentModel) => {
13801
+ var get_user_request = async (data, components, sendMessage, anthropicApiKey, groqApiKey, geminiApiKey, openaiApiKey, llmProviders, collections, externalTools, mainAgentModel, sourceAgentModel, workflows) => {
12972
13802
  const errors = [];
12973
13803
  const parseResult = UserPromptRequestMessageSchema.safeParse(data);
12974
13804
  if (!parseResult.success) {
@@ -13054,7 +13884,8 @@ var get_user_request = async (data, components, sendMessage, anthropicApiKey, gr
13054
13884
  externalTools,
13055
13885
  userId,
13056
13886
  mainAgentModel,
13057
- sourceAgentModel
13887
+ sourceAgentModel,
13888
+ workflows
13058
13889
  );
13059
13890
  logger.info("User prompt request completed");
13060
13891
  const uiBlockId = existingUiBlockId;
@@ -13207,8 +14038,8 @@ var get_user_request = async (data, components, sendMessage, anthropicApiKey, gr
13207
14038
  wsId
13208
14039
  };
13209
14040
  };
13210
- async function handleUserPromptRequest(data, components, sendMessage, anthropicApiKey, groqApiKey, geminiApiKey, openaiApiKey, llmProviders, collections, externalTools, mainAgentModel, sourceAgentModel) {
13211
- const response = await get_user_request(data, components, sendMessage, anthropicApiKey, groqApiKey, geminiApiKey, openaiApiKey, llmProviders, collections, externalTools, mainAgentModel, sourceAgentModel);
14041
+ async function handleUserPromptRequest(data, components, sendMessage, anthropicApiKey, groqApiKey, geminiApiKey, openaiApiKey, llmProviders, collections, externalTools, mainAgentModel, sourceAgentModel, workflows) {
14042
+ const response = await get_user_request(data, components, sendMessage, anthropicApiKey, groqApiKey, geminiApiKey, openaiApiKey, llmProviders, collections, externalTools, mainAgentModel, sourceAgentModel, workflows);
13212
14043
  if (response.data?.component?.props?.config?.components) {
13213
14044
  response.data.component.props.config.components = response.data.component.props.config.components.map((comp) => ({
13214
14045
  ...comp,
@@ -13640,6 +14471,24 @@ async function handleComponentListResponse(data, storeComponents, collections) {
13640
14471
  }
13641
14472
  }
13642
14473
 
14474
+ // src/handlers/workflow-list-response.ts
14475
+ async function handleWorkflowListResponse(data, storeWorkflows) {
14476
+ try {
14477
+ const parsed = WorkflowListResponseMessageSchema.parse(data);
14478
+ const { payload } = parsed;
14479
+ const workflowsList = payload.workflows;
14480
+ if (!workflowsList) {
14481
+ logger.error("Workflows list not found in WORKFLOW_LIST_RES payload");
14482
+ return;
14483
+ }
14484
+ const workflows = WorkflowsSchema.parse(workflowsList);
14485
+ storeWorkflows(workflows);
14486
+ logger.info(`Stored ${workflows.length} workflow descriptor(s) from frontend`);
14487
+ } catch (error) {
14488
+ logger.error("Failed to handle workflow list response:", error);
14489
+ }
14490
+ }
14491
+
13643
14492
  // src/handlers/users.ts
13644
14493
  async function handleUsersRequest(data, collections, sendMessage) {
13645
14494
  const executeCollection = async (collection, op, params) => {
@@ -16456,17 +17305,45 @@ function formatComponentsForPrompt(components) {
16456
17305
  Props Structure: ${propsPreview}`;
16457
17306
  }).join("\n\n");
16458
17307
  }
17308
+ function classifyTool(tool) {
17309
+ if (tool.toolType === "direct") return "direct";
17310
+ if (tool.id.endsWith("_query")) return "sql";
17311
+ if (tool.id.endsWith("_call")) return "rest";
17312
+ if (tool.id.endsWith("_graphql")) return "graphql";
17313
+ return "direct";
17314
+ }
16459
17315
  function formatToolsForPrompt(tools) {
16460
17316
  if (!tools || tools.length === 0) {
16461
17317
  return "No external tools available.";
16462
17318
  }
16463
- return tools.map((tool, idx) => {
17319
+ const directTools = [];
17320
+ const sourceTools = [];
17321
+ for (const t of tools) {
17322
+ (classifyTool(t) === "direct" ? directTools : sourceTools).push(t);
17323
+ }
17324
+ const renderTool = (tool, idx, label) => {
16464
17325
  const paramsStr = Object.entries(tool.params || {}).map(([key, type]) => `${key}: ${type}`).join(", ");
16465
- return `${idx + 1}. ID: ${tool.id}
17326
+ return `${idx + 1}. [${label}] ID: ${tool.id}
16466
17327
  Name: ${tool.name}
16467
17328
  Description: ${tool.description}
16468
17329
  Parameters: { ${paramsStr} }`;
16469
- }).join("\n\n");
17330
+ };
17331
+ const sections = [];
17332
+ if (directTools.length > 0) {
17333
+ const body = directTools.map((t, i) => renderTool(t, i, "DIRECT \u2014 PREFER")).join("\n\n");
17334
+ sections.push(`### Direct Tools (try these first \u2014 they answer most questions without SQL)
17335
+ ${body}`);
17336
+ }
17337
+ if (sourceTools.length > 0) {
17338
+ const body = sourceTools.map((t, i) => {
17339
+ const kind = classifyTool(t);
17340
+ const label = kind === "sql" ? "SQL" : kind === "rest" ? "REST" : "GRAPHQL";
17341
+ return renderTool(t, i, label);
17342
+ }).join("\n\n");
17343
+ sections.push(`### Source Tools (fallback \u2014 use only when no direct tool fits)
17344
+ ${body}`);
17345
+ }
17346
+ return sections.join("\n\n");
16470
17347
  }
16471
17348
  function formatExistingComponentsForPrompt(existingComponents) {
16472
17349
  if (!existingComponents || existingComponents.length === 0) {
@@ -16491,7 +17368,7 @@ function sendDashCompResponse(id, res, sendMessage, clientId) {
16491
17368
  }
16492
17369
 
16493
17370
  // src/dashComp/pick-component.ts
16494
- async function pickComponentWithLLM(prompt, components, anthropicApiKey, groqApiKey, geminiApiKey, openaiApiKey, llmProviders, collections, tools, dashCompModels, conversationHistory) {
17371
+ async function pickComponentWithLLM(prompt, components, anthropicApiKey, groqApiKey, geminiApiKey, openaiApiKey, llmProviders, collections, tools, dashCompModels, conversationHistory, userId) {
16495
17372
  const errors = [];
16496
17373
  const availableComponentsText = formatComponentsForPrompt(components);
16497
17374
  const availableToolsText = formatToolsForPrompt(tools);
@@ -16507,6 +17384,25 @@ async function pickComponentWithLLM(prompt, components, anthropicApiKey, groqApi
16507
17384
  schemaDoc = schema.generateSchemaDocumentation();
16508
17385
  }
16509
17386
  const databaseRules = await promptLoader.loadDatabaseRules();
17387
+ let globalKnowledgeBase = "No global knowledge base available.";
17388
+ let knowledgeBaseContext = "No additional knowledge base context available.";
17389
+ if (collections) {
17390
+ const kbResult = await knowledge_base_default.getAllKnowledgeBase({
17391
+ prompt,
17392
+ collections,
17393
+ userId,
17394
+ topK: KNOWLEDGE_BASE_TOP_K
17395
+ });
17396
+ globalKnowledgeBase = kbResult.globalContext || globalKnowledgeBase;
17397
+ const dynamicParts = [];
17398
+ if (kbResult.userContext) {
17399
+ dynamicParts.push("## User-Specific Knowledge Base\n" + kbResult.userContext);
17400
+ }
17401
+ if (kbResult.queryContext) {
17402
+ dynamicParts.push("## Relevant Knowledge Base (Query-Matched)\n" + kbResult.queryContext);
17403
+ }
17404
+ knowledgeBaseContext = dynamicParts.join("\n\n") || knowledgeBaseContext;
17405
+ }
16510
17406
  const prompts = await promptLoader.loadPrompts("dash-comp-picker", {
16511
17407
  USER_PROMPT: prompt,
16512
17408
  AVAILABLE_COMPONENTS: availableComponentsText,
@@ -16514,8 +17410,12 @@ async function pickComponentWithLLM(prompt, components, anthropicApiKey, groqApi
16514
17410
  DATABASE_RULES: databaseRules,
16515
17411
  AVAILABLE_TOOLS: availableToolsText,
16516
17412
  CURRENT_DATETIME: getCurrentDateTimeForPrompt(),
16517
- CONVERSATION_HISTORY: conversationHistory || "No previous conversation"
17413
+ CONVERSATION_HISTORY: conversationHistory || "No previous conversation",
17414
+ GLOBAL_KNOWLEDGE_BASE: globalKnowledgeBase,
17415
+ KNOWLEDGE_BASE_CONTEXT: knowledgeBaseContext
16518
17416
  });
17417
+ logger.logLLMPrompt("dashCompPicker", "system", extractPromptText(prompts.system));
17418
+ logger.logLLMPrompt("dashCompPicker", "user", prompts.user);
16519
17419
  logger.debug("[DASH_COMP_REQ] Loaded dash-comp-picker prompts with schema and tools");
16520
17420
  const { apiKey, model } = getApiKeyAndModel(
16521
17421
  anthropicApiKey,
@@ -16881,6 +17781,18 @@ Fixed SQL query:`;
16881
17781
  logger.info(`[DASH_COMP_REQ] Replaced direct query with queryId: ${queryId}`);
16882
17782
  }
16883
17783
  finalComponent = { ...finalComponent, props };
17784
+ const ext = finalComponent.props?.externalTool;
17785
+ if (ext?.toolId && !ext?.parameters?.sql) {
17786
+ const matchedTool = tools?.find((t) => t.id === ext.toolId);
17787
+ if (matchedTool && matchedTool.cache !== false) {
17788
+ const match = executedTools.find((t) => t.id === ext.toolId);
17789
+ if (match) {
17790
+ const cacheKey = buildDirectToolCacheKey(ext.toolId, ext.parameters);
17791
+ queryCache.set(cacheKey, { success: true, data: match.result });
17792
+ logger.info(`[DASH_COMP_REQ] Pre-populated et-direct cache for ${ext.toolId}`);
17793
+ }
17794
+ }
17795
+ }
16884
17796
  if (parsedResult.props.query) {
16885
17797
  logger.info(`[DASH_COMP_REQ] Data source: Database query`);
16886
17798
  }
@@ -16915,7 +17827,7 @@ Fixed SQL query:`;
16915
17827
  }
16916
17828
 
16917
17829
  // src/dashComp/create-filter.ts
16918
- async function createFilterWithLLM(prompt, components, existingComponents, anthropicApiKey, groqApiKey, geminiApiKey, openaiApiKey, llmProviders, tools, dashCompModels, collections) {
17830
+ async function createFilterWithLLM(prompt, components, existingComponents, anthropicApiKey, groqApiKey, geminiApiKey, openaiApiKey, llmProviders, tools, dashCompModels, collections, userId) {
16919
17831
  const errors = [];
16920
17832
  try {
16921
17833
  const filterComponents = components.filter((c) => c.type.startsWith("Filter"));
@@ -16935,6 +17847,25 @@ async function createFilterWithLLM(prompt, components, existingComponents, anthr
16935
17847
  schemaDoc = schema.generateSchemaDocumentation();
16936
17848
  }
16937
17849
  const databaseRules = await promptLoader.loadDatabaseRules();
17850
+ let globalKnowledgeBase = "No global knowledge base available.";
17851
+ let knowledgeBaseContext = "No additional knowledge base context available.";
17852
+ if (collections) {
17853
+ const kbResult = await knowledge_base_default.getAllKnowledgeBase({
17854
+ prompt,
17855
+ collections,
17856
+ userId,
17857
+ topK: KNOWLEDGE_BASE_TOP_K
17858
+ });
17859
+ globalKnowledgeBase = kbResult.globalContext || globalKnowledgeBase;
17860
+ const dynamicParts = [];
17861
+ if (kbResult.userContext) {
17862
+ dynamicParts.push("## User-Specific Knowledge Base\n" + kbResult.userContext);
17863
+ }
17864
+ if (kbResult.queryContext) {
17865
+ dynamicParts.push("## Relevant Knowledge Base (Query-Matched)\n" + kbResult.queryContext);
17866
+ }
17867
+ knowledgeBaseContext = dynamicParts.join("\n\n") || knowledgeBaseContext;
17868
+ }
16938
17869
  const prompts = await promptLoader.loadPrompts("dash-filter-picker", {
16939
17870
  USER_PROMPT: prompt,
16940
17871
  AVAILABLE_COMPONENTS: formatComponentsForPrompt(filterComponents),
@@ -16942,8 +17873,12 @@ async function createFilterWithLLM(prompt, components, existingComponents, anthr
16942
17873
  SCHEMA_DOC: schemaDoc || "No database schema available",
16943
17874
  DATABASE_RULES: databaseRules,
16944
17875
  AVAILABLE_TOOLS: formatToolsForPrompt(tools),
16945
- CURRENT_DATETIME: getCurrentDateTimeForPrompt()
17876
+ CURRENT_DATETIME: getCurrentDateTimeForPrompt(),
17877
+ GLOBAL_KNOWLEDGE_BASE: globalKnowledgeBase,
17878
+ KNOWLEDGE_BASE_CONTEXT: knowledgeBaseContext
16946
17879
  });
17880
+ logger.logLLMPrompt("dashFilterPicker", "system", extractPromptText(prompts.system));
17881
+ logger.logLLMPrompt("dashFilterPicker", "user", prompts.user);
16947
17882
  logger.debug("[DASH_COMP_REQ:FILTER] Loaded dash-filter-picker prompts");
16948
17883
  const { apiKey, model } = getApiKeyAndModel(
16949
17884
  anthropicApiKey,
@@ -17052,7 +17987,35 @@ async function createFilterWithLLM(prompt, components, existingComponents, anthr
17052
17987
  const updatedComponents = result.updatedComponents || [];
17053
17988
  for (const comp of updatedComponents) {
17054
17989
  const extTool = comp.props?.externalTool;
17055
- if (!extTool?.parameters?.sql || !extTool?.toolId) continue;
17990
+ if (!extTool?.toolId) continue;
17991
+ const isDirectTool = !extTool?.parameters?.sql;
17992
+ if (isDirectTool) {
17993
+ const directTool = tools?.find((t) => t.id === extTool.toolId);
17994
+ if (!directTool) {
17995
+ logger.warn(`[DASH_COMP_REQ:FILTER] direct tool ${extTool.toolId} not found in tool registry`);
17996
+ continue;
17997
+ }
17998
+ const declared = new Set(Object.keys(directTool.params || {}));
17999
+ const sentKeys = Object.keys(extTool.parameters || {});
18000
+ const unknown = sentKeys.filter((k) => !declared.has(k));
18001
+ if (unknown.length > 0) {
18002
+ logger.warn(`[DASH_COMP_REQ:FILTER] dropping unknown params on ${extTool.toolId}: ${unknown.join(", ")}`);
18003
+ unknown.forEach((k) => delete extTool.parameters[k]);
18004
+ }
18005
+ if (directTool.cache !== false) {
18006
+ try {
18007
+ const directResult = await directTool.fn(extTool.parameters || {});
18008
+ const cacheKey2 = buildDirectToolCacheKey(extTool.toolId, extTool.parameters);
18009
+ queryCache.set(cacheKey2, { success: true, data: directResult });
18010
+ logger.info(`[DASH_COMP_REQ:FILTER] direct tool ${extTool.toolId} validated and cached for component: ${comp.id}`);
18011
+ } catch (err) {
18012
+ const errMsg = err instanceof Error ? err.message : String(err);
18013
+ logger.warn(`[DASH_COMP_REQ:FILTER] direct tool validation failed for ${extTool.toolId} on component ${comp.id}: ${errMsg}`);
18014
+ }
18015
+ }
18016
+ continue;
18017
+ }
18018
+ if (!extTool?.parameters?.sql) continue;
17056
18019
  let sql = extTool.parameters.sql;
17057
18020
  const defaultParams = extTool.parameters.params || {};
17058
18021
  const toolId = extTool.toolId;
@@ -17289,7 +18252,8 @@ var processDashCompRequest = async (data, components, _sendMessage, anthropicApi
17289
18252
  llmProviders,
17290
18253
  tools,
17291
18254
  dashCompModels,
17292
- collections
18255
+ collections,
18256
+ userId
17293
18257
  );
17294
18258
  } else {
17295
18259
  llmResponse = await pickComponentWithLLM(
@@ -17303,7 +18267,8 @@ var processDashCompRequest = async (data, components, _sendMessage, anthropicApi
17303
18267
  collections,
17304
18268
  tools,
17305
18269
  dashCompModels,
17306
- conversationHistory
18270
+ conversationHistory,
18271
+ userId
17307
18272
  );
17308
18273
  }
17309
18274
  if (llmResponse.success && dashboardId && prompt) {
@@ -17442,7 +18407,7 @@ function sendReportCompResponse(id, res, sendMessage, clientId) {
17442
18407
  }
17443
18408
 
17444
18409
  // src/reportComp/generate-report.ts
17445
- async function generateReportComponents(prompt, components, anthropicApiKey, groqApiKey, geminiApiKey, openaiApiKey, llmProviders, collections, tools, modelConfig, conversationHistory) {
18410
+ async function generateReportComponents(prompt, components, anthropicApiKey, groqApiKey, geminiApiKey, openaiApiKey, llmProviders, collections, tools, modelConfig, conversationHistory, userId) {
17446
18411
  const errors = [];
17447
18412
  const availableComponentsText = formatComponentsForPrompt2(components);
17448
18413
  const availableToolsText = formatToolsForPrompt2(tools);
@@ -17458,6 +18423,25 @@ async function generateReportComponents(prompt, components, anthropicApiKey, gro
17458
18423
  schemaDoc = schema.generateSchemaDocumentation();
17459
18424
  }
17460
18425
  const databaseRules = await promptLoader.loadDatabaseRules();
18426
+ let globalKnowledgeBase = "No global knowledge base available.";
18427
+ let knowledgeBaseContext = "No additional knowledge base context available.";
18428
+ if (collections) {
18429
+ const kbResult = await knowledge_base_default.getAllKnowledgeBase({
18430
+ prompt,
18431
+ collections,
18432
+ userId,
18433
+ topK: KNOWLEDGE_BASE_TOP_K
18434
+ });
18435
+ globalKnowledgeBase = kbResult.globalContext || globalKnowledgeBase;
18436
+ const dynamicParts = [];
18437
+ if (kbResult.userContext) {
18438
+ dynamicParts.push("## User-Specific Knowledge Base\n" + kbResult.userContext);
18439
+ }
18440
+ if (kbResult.queryContext) {
18441
+ dynamicParts.push("## Relevant Knowledge Base (Query-Matched)\n" + kbResult.queryContext);
18442
+ }
18443
+ knowledgeBaseContext = dynamicParts.join("\n\n") || knowledgeBaseContext;
18444
+ }
17461
18445
  const prompts = await promptLoader.loadPrompts("report-comp-picker", {
17462
18446
  USER_PROMPT: prompt,
17463
18447
  AVAILABLE_COMPONENTS: availableComponentsText,
@@ -17465,8 +18449,12 @@ async function generateReportComponents(prompt, components, anthropicApiKey, gro
17465
18449
  DATABASE_RULES: databaseRules,
17466
18450
  AVAILABLE_TOOLS: availableToolsText,
17467
18451
  CURRENT_DATETIME: getCurrentDateTimeForPrompt(),
17468
- CONVERSATION_HISTORY: conversationHistory || "No previous conversation"
18452
+ CONVERSATION_HISTORY: conversationHistory || "No previous conversation",
18453
+ GLOBAL_KNOWLEDGE_BASE: globalKnowledgeBase,
18454
+ KNOWLEDGE_BASE_CONTEXT: knowledgeBaseContext
17469
18455
  });
18456
+ logger.logLLMPrompt("reportCompPicker", "system", extractPromptText(prompts.system));
18457
+ logger.logLLMPrompt("reportCompPicker", "user", prompts.user);
17470
18458
  logger.debug("[REPORT_COMP_REQ] Loaded report-comp-picker prompts with schema and tools");
17471
18459
  const { apiKey, model } = getApiKeyAndModel2(
17472
18460
  anthropicApiKey,
@@ -17691,13 +18679,21 @@ async function validateAllExternalToolQueries(components, collections, tools, mo
17691
18679
  data: {}
17692
18680
  });
17693
18681
  if (result?.success !== false && !result?.error) {
17694
- const resultData = result?.data?.data ?? result?.data ?? [];
17695
- const dataArray = Array.isArray(resultData) ? resultData : [resultData];
18682
+ const toolResult = result?.data ?? result;
18683
+ const valueKey = comp.props?.config?.valueKey;
18684
+ const isKpi = comp.type === "KPICard" || comp.name === "DynamicKPICard";
18685
+ let dataArray;
18686
+ if (isKpi && valueKey && toolResult && typeof toolResult === "object" && !Array.isArray(toolResult) && toolResult[valueKey] !== void 0) {
18687
+ dataArray = [toolResult];
18688
+ } else {
18689
+ const resultData = toolResult?.data ?? toolResult ?? [];
18690
+ dataArray = Array.isArray(resultData) ? resultData : [resultData];
18691
+ }
17696
18692
  if (!comp.props.config) {
17697
18693
  comp.props.config = {};
17698
18694
  }
17699
18695
  comp.props.config.data = dataArray;
17700
- logger.info(`[REPORT_COMP_REQ] \u2713 ${comp.name} prefetched ${dataArray.length} rows (non-SQL tool)`);
18696
+ logger.info(`[REPORT_COMP_REQ] \u2713 ${comp.name} prefetched ${dataArray.length} ${dataArray.length === 1 && isKpi ? "aggregate" : "rows"} (non-SQL tool)`);
17701
18697
  }
17702
18698
  } catch (err) {
17703
18699
  logger.warn(`[REPORT_COMP_REQ] \u26A0 ${comp.name} non-SQL prefetch failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -17837,7 +18833,8 @@ var processReportCompRequest = async (data, components, _sendMessage, anthropicA
17837
18833
  collections,
17838
18834
  tools,
17839
18835
  modelConfig,
17840
- conversationHistory
18836
+ conversationHistory,
18837
+ userId
17841
18838
  );
17842
18839
  if (llmResponse.success && reportId && prompt) {
17843
18840
  const comps = llmResponse.data?.components;
@@ -18962,23 +19959,27 @@ var CleanupService = class _CleanupService {
18962
19959
  // src/index.ts
18963
19960
  var DEFAULT_WS_URL = "wss://ws.superatom.ai/websocket";
18964
19961
  var SuperatomSDK = class {
18965
- // 3.5 minutes (PING_INTERVAL + 30s grace)
18966
19962
  constructor(config) {
18967
19963
  this.ws = null;
18968
19964
  this.messageHandlers = /* @__PURE__ */ new Map();
18969
19965
  this.messageTypeHandlers = /* @__PURE__ */ new Map();
18970
19966
  this.connected = false;
18971
19967
  this.reconnectAttempts = 0;
18972
- this.maxReconnectAttempts = 5;
19968
+ // Retry forever — the backend must self-heal across NAT timeouts, DO
19969
+ // redeploys, and long network outages. The previous cap (5) gave up after
19970
+ // ~30s and left the singleton SDK dead until process restart.
19971
+ this.maxReconnectAttempts = Infinity;
18973
19972
  this.collections = {};
18974
19973
  this.components = [];
18975
19974
  this.tools = [];
18976
- // Heartbeat properties for keeping WebSocket connection alive
19975
+ this.workflows = [];
19976
+ // Heartbeat properties for keeping WebSocket connection alive.
19977
+ // 25s ping + 10s grace stays under common NAT/LB idle thresholds (~60-100s)
19978
+ // so we detect dead sockets within seconds instead of minutes.
18977
19979
  this.pingInterval = null;
18978
19980
  this.lastPong = Date.now();
18979
- this.PING_INTERVAL_MS = 18e4;
18980
- // 3 minutes
18981
- this.PONG_TIMEOUT_MS = 21e4;
19981
+ this.PING_INTERVAL_MS = 25e3;
19982
+ this.PONG_TIMEOUT_MS = 35e3;
18982
19983
  if (config.logLevel) {
18983
19984
  logger.setLogLevel(config.logLevel);
18984
19985
  }
@@ -19134,7 +20135,7 @@ var SuperatomSDK = class {
19134
20135
  this.handlePong();
19135
20136
  break;
19136
20137
  case "DATA_REQ":
19137
- handleDataRequest(parsed, this.collections, (msg) => this.send(msg)).catch((error) => {
20138
+ handleDataRequest(parsed, this.collections, (msg) => this.send(msg), this.tools).catch((error) => {
19138
20139
  logger.error("Failed to handle data request:", error);
19139
20140
  });
19140
20141
  break;
@@ -19154,7 +20155,7 @@ var SuperatomSDK = class {
19154
20155
  });
19155
20156
  break;
19156
20157
  case "USER_PROMPT_REQ":
19157
- handleUserPromptRequest(parsed, this.components, (msg) => this.send(msg), this.anthropicApiKey, this.groqApiKey, this.geminiApiKey, this.openaiApiKey, this.llmProviders, this.collections, this.tools, this.mainAgentModel, this.sourceAgentModel).catch((error) => {
20158
+ handleUserPromptRequest(parsed, this.components, (msg) => this.send(msg), this.anthropicApiKey, this.groqApiKey, this.geminiApiKey, this.openaiApiKey, this.llmProviders, this.collections, this.tools, this.mainAgentModel, this.sourceAgentModel, this.workflows).catch((error) => {
19158
20159
  logger.error("Failed to handle user prompt request:", error);
19159
20160
  });
19160
20161
  break;
@@ -19173,6 +20174,11 @@ var SuperatomSDK = class {
19173
20174
  logger.error("Failed to handle component list request:", error);
19174
20175
  });
19175
20176
  break;
20177
+ case "WORKFLOW_LIST_RES":
20178
+ handleWorkflowListResponse(parsed, (wf) => this.setWorkflows(wf)).catch((error) => {
20179
+ logger.error("Failed to handle workflow list request:", error);
20180
+ });
20181
+ break;
19176
20182
  case "USERS":
19177
20183
  handleUsersRequest(parsed, this.collections, (msg) => this.send(msg)).catch((error) => {
19178
20184
  logger.error("Failed to handle users request:", error);
@@ -19332,9 +20338,9 @@ var SuperatomSDK = class {
19332
20338
  handleReconnect() {
19333
20339
  if (this.reconnectAttempts < this.maxReconnectAttempts) {
19334
20340
  this.reconnectAttempts++;
19335
- const delay = Math.min(1e3 * Math.pow(2, this.reconnectAttempts), 1e4);
20341
+ const delay = Math.min(1e3 * Math.pow(2, this.reconnectAttempts), 3e4);
19336
20342
  setTimeout(() => {
19337
- logger.info(`Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`);
20343
+ logger.info(`Attempting to reconnect (attempt ${this.reconnectAttempts})...`);
19338
20344
  this.connect().catch((error) => {
19339
20345
  logger.error("Reconnection failed:", error);
19340
20346
  });
@@ -19412,6 +20418,24 @@ var SuperatomSDK = class {
19412
20418
  getTools() {
19413
20419
  return this.tools;
19414
20420
  }
20421
+ /**
20422
+ * Register workflow components for the SDK instance.
20423
+ *
20424
+ * Workflows are pre-built multi-step UI flows the main agent can pick when
20425
+ * the user's prompt matches a workflow's `whenToUse` trigger. Picking a
20426
+ * workflow short-circuits analysis text + dashboard component generation —
20427
+ * the workflow component is returned directly, with the LLM-extracted props.
20428
+ */
20429
+ setWorkflows(workflows) {
20430
+ this.workflows = workflows;
20431
+ logger.info(`Workflows stored in SDK: ${workflows.length} workflow(s)`);
20432
+ }
20433
+ /**
20434
+ * Get the registered workflow components.
20435
+ */
20436
+ getWorkflows() {
20437
+ return this.workflows;
20438
+ }
19415
20439
  /**
19416
20440
  * Apply model strategy to all LLM provider singletons
19417
20441
  * @param strategy - 'best', 'fast', or 'balanced'
@@ -19472,6 +20496,8 @@ var SuperatomSDK = class {
19472
20496
  LLM,
19473
20497
  MainAgent,
19474
20498
  STORAGE_CONFIG,
20499
+ ScriptMatcher,
20500
+ ScriptStore,
19475
20501
  SuperatomSDK,
19476
20502
  Thread,
19477
20503
  ThreadManager,
@@ -19485,10 +20511,13 @@ var SuperatomSDK = class {
19485
20511
  hybridRerank,
19486
20512
  llmUsageLogger,
19487
20513
  logger,
20514
+ normalizeScriptBody,
19488
20515
  openaiLLM,
19489
20516
  queryCache,
19490
20517
  rerankChromaResults,
19491
20518
  rerankConversationResults,
20519
+ resolveScriptRecipeStore,
20520
+ runScript,
19492
20521
  userPromptErrorLogger
19493
20522
  });
19494
20523
  //# sourceMappingURL=index.js.map