@saltcorn/copilot 0.8.0 → 0.8.2

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.
@@ -46,12 +46,13 @@ const task_tool = {
46
46
  type: "array",
47
47
  items: {
48
48
  type: "object",
49
- required: ["requirement", "priority"],
49
+ required: ["name", "description", "priority", "depends_on"],
50
50
  additionalProperties: false,
51
51
  properties: {
52
52
  name: {
53
53
  type: "string",
54
- description: "A short name for the task",
54
+ description:
55
+ "A short unique name for the task (snake_case). Every other task that depends on this task must use exactly this name in their depends_on array.",
55
56
  },
56
57
  description: {
57
58
  type: "string",
@@ -65,7 +66,7 @@ const task_tool = {
65
66
  depends_on: {
66
67
  type: "array",
67
68
  description:
68
- "The names of the tasks that must be completed before this tasks can be started",
69
+ "Names of tasks in THIS plan that must complete before this task starts. Every name listed here MUST exactly match the name of another task in this same plan_tasks call. Never reference a task name that is not present in the tasks array.",
69
70
  items: {
70
71
  type: "string",
71
72
  },
@@ -78,4 +79,4 @@ const task_tool = {
78
79
  },
79
80
  };
80
81
 
81
- module.exports = { requirements_tool,task_tool };
82
+ module.exports = { requirements_tool, task_tool };
@@ -159,6 +159,13 @@ const submit_specs = async (table_id, viewname, config, body, { req, res }) => {
159
159
  user_id: req.user?.id || undefined,
160
160
  body: spec,
161
161
  });
162
+ return {
163
+ json: {
164
+ success: "ok",
165
+ notify: "Specification saved",
166
+ notify_type: "success",
167
+ },
168
+ };
162
169
  };
163
170
 
164
171
  const virtual_triggers = () => {
package/builder-gen.js CHANGED
@@ -6,8 +6,12 @@ const { edit_build_in_actions } = require("@saltcorn/data/viewable_fields");
6
6
  const { buildBuilderSchema } = require("./builder-schema");
7
7
  const { getLlmConfigurationSafe, canUseResponseFormat } = require("./common");
8
8
  const { build_schema_data } = require("@saltcorn/data/plugin-helper");
9
- const { RelationsFinder } = require("@saltcorn/common-code");
10
- const { RelationType } = require("@saltcorn/common-code");
9
+ const {
10
+ GET_RELATION_PATHS_FUNCTION,
11
+ getRelationPaths,
12
+ getRelationPathsForPairs,
13
+ pickBestRelation,
14
+ } = require("./relation-paths");
11
15
 
12
16
  const ACTION_SIZES = ["btn-sm", "btn-lg"];
13
17
 
@@ -40,7 +44,9 @@ Alternatively wrap groups of fields in a card with a descriptive title.`;
40
44
  const EDIT_ACTIONS = `\
41
45
  Use edit fieldviews, group related inputs, and finish with a row of actions: \
42
46
  a Save button (action_name "Save", style "btn btn-primary") and \
43
- a Cancel button (action_name "GoBack", style "btn btn-outline-secondary").`;
47
+ a Cancel button (action_name "GoBack", style "btn btn-outline-secondary"). \
48
+ To add a trigger button, add an extra action segment with action_name set to the trigger's name \
49
+ exactly as it appears in the available actions list (ctx.actions).`;
44
50
 
45
51
  // ── Show-mode guidance blocks ─────────────────────────────────────────────────
46
52
 
@@ -73,7 +79,9 @@ only use "show_with_html" when the task explicitly requires rendering HTML conte
73
79
 
74
80
  const LISTCOLUMNS_VIEW_LINK = `\
75
81
  A view_link that opens the detail of a row MUST point to a Show-type view (viewtemplate "Show"). \
76
- Only include such a view_link if a Show view for this table is listed in the available views.`;
82
+ Only include such a view_link if a Show view for this table is listed in the available views. \
83
+ Every view_link requires a "relation" string — you MUST call get_relation_paths before generating the layout \
84
+ whenever the layout will contain any view_link or embedded view segment.`;
77
85
 
78
86
  // ── Composed MODE_GUIDANCE ────────────────────────────────────────────────────
79
87
 
@@ -482,7 +490,10 @@ const prettifyActionName = (name) =>
482
490
  // Picks a valid fieldview from the field's available fieldviews only.
483
491
  // Never returns a fieldview that doesn't exist in field.fieldviews
484
492
  const pickFieldview = (field, mode, requestedFieldview = null) => {
485
- const availableViews = field?.fieldviews || [];
493
+ const isEditMode = mode === "edit" || mode === "filter";
494
+ const availableViews = isEditMode
495
+ ? field?.editFieldviews || field?.fieldviews || []
496
+ : field?.fieldviews || [];
486
497
 
487
498
  // If no available fieldviews, return the first one or a safe default
488
499
  if (!availableViews.length) {
@@ -779,18 +790,13 @@ const normalizeSegment = (segment, ctx) => {
779
790
  let relation = clone.relation;
780
791
  if (!relation && ctx.table && ctx.schemaData) {
781
792
  try {
782
- const finder = new RelationsFinder(
783
- ctx.schemaData.tables,
784
- ctx.schemaData.views,
785
- 6
786
- );
787
- const relations = finder.findRelations(
793
+ const relations = getRelationPaths(
788
794
  ctx.table.name,
789
795
  resolvedView,
790
- []
796
+ ctx.schemaData
791
797
  );
792
798
  if (relations.length > 0) {
793
- const picked = pickRelation(relations);
799
+ const picked = pickBestRelation(relations);
794
800
  if (picked) relation = picked.relationString;
795
801
  }
796
802
  } catch (e) {
@@ -801,7 +807,8 @@ const normalizeSegment = (segment, ctx) => {
801
807
  let viewLabel = clone.view_label || clone.view;
802
808
  let isFormula = clone.isFormula || {};
803
809
  const tmplMatch =
804
- typeof viewLabel === "string" && viewLabel.match(/^\{\{\s*(.+?)\s*\}\}$/);
810
+ typeof viewLabel === "string" &&
811
+ viewLabel.match(/^\{\{\s*(.+?)\s*\}\}$/);
805
812
  if (tmplMatch) {
806
813
  viewLabel = tmplMatch[1];
807
814
  isFormula = { ...isFormula, label: true };
@@ -839,10 +846,19 @@ const normalizeSegment = (segment, ctx) => {
839
846
  child == null ? null : normalizeSegment(child, ctx)
840
847
  );
841
848
  if (!besides.some(Boolean)) return null;
842
- return { ...clone, besides, list_columns: true };
849
+ // Drop 'type' — List viewtemplate expects { besides, list_columns: true } with no type field
850
+ const { type: _t, ...rest } = clone;
851
+ return { ...rest, besides, list_columns: true };
843
852
  }
844
853
  case "list_column": {
845
- const contents = normalizeChild(clone.contents, ctx);
854
+ let raw = clone.contents;
855
+ // Saltcorn's list renderer handles `above` in a cell (wraps in Container)
856
+ // but silently drops a typeless `besides`. Convert it so both links render
857
+ // stacked in one column rather than disappearing.
858
+ if (raw && !raw.type && Array.isArray(raw.besides)) {
859
+ raw = { above: raw.besides };
860
+ }
861
+ const contents = normalizeChild(raw, ctx);
846
862
  return contents
847
863
  ? { ...clone, contents, header_label: clone.header_label || "" }
848
864
  : null;
@@ -950,7 +966,21 @@ const normalizeLayoutCandidate = (candidate, ctx) => {
950
966
  normalized = convertForeignLayout(candidate, ctx);
951
967
  }
952
968
  if (!normalized) return null;
953
- const layout = Array.isArray(normalized) ? { above: normalized } : normalized;
969
+ let layout = Array.isArray(normalized) ? { above: normalized } : normalized;
970
+
971
+ // For listcolumns mode: if the LLM wrapped the list in a stack, unwrap it
972
+ if (
973
+ ctx.mode === "listcolumns" &&
974
+ layout.type === "stack" &&
975
+ Array.isArray(layout.above)
976
+ ) {
977
+ const listChild = layout.above.find((c) => c?.list_columns);
978
+ if (listChild) {
979
+ const { type: _t, ...rest } = listChild;
980
+ layout = rest;
981
+ }
982
+ }
983
+
954
984
  return sanitizeNoHtmlSegments(layout);
955
985
  };
956
986
 
@@ -989,6 +1019,11 @@ const buildPromptText = (userPrompt, ctx, schema) => {
989
1019
  schema
990
1020
  )}`
991
1021
  );
1022
+ parts.push(
1023
+ `TOOL CALL REQUIRED: If the layout you are about to generate contains any view_link or embedded view (type "view") segment, ` +
1024
+ `you MUST call get_relation_paths with all required source_table/target_view pairs BEFORE returning JSON. ` +
1025
+ `Do NOT guess or omit the "relation" field — return the tool call first and use the result in the layout.`
1026
+ );
992
1027
  parts.push(
993
1028
  `Based on the schema above, process the following user request and generate the layout JSON. Reminder: ONLY output valid JSON starting with { and ending with }, no markdown fences.\nUser request:\n"${userPrompt}"`
994
1029
  );
@@ -1209,22 +1244,6 @@ const buildBracketObject = (node) => {
1209
1244
  return obj;
1210
1245
  };
1211
1246
 
1212
- const pickRelation = (relations) => {
1213
- let own = null,
1214
- parent = null,
1215
- child = null;
1216
- for (const r of relations) {
1217
- if (r.type === RelationType.OWN) own = r;
1218
- else if (r.type === RelationType.PARENT_SHOW) parent = r;
1219
- else if (
1220
- r.type === RelationType.CHILD_LIST ||
1221
- r.type === RelationType.ONE_TO_ONE_SHOW
1222
- )
1223
- child = r;
1224
- }
1225
- return own || parent || child || relations[0];
1226
- };
1227
-
1228
1247
  const buildContext = async (mode, tableName) => {
1229
1248
  const normalizedMode = (mode || "show").toLowerCase();
1230
1249
  const ctx = {
@@ -1282,13 +1301,19 @@ const buildContext = async (mode, tableName) => {
1282
1301
  }
1283
1302
  if (rawFields?.then) rawFields = await rawFields;
1284
1303
  const fields = (rawFields || []).map((field) => {
1285
- let fieldviews = Object.keys(field.type?.fieldviews || {});
1304
+ const fvDefs = field.type?.fieldviews || {};
1305
+ let fieldviews = Object.keys(fvDefs);
1286
1306
  // FK fields have type "Key" (a string) so field.type?.fieldviews is empty;
1287
1307
  // ensure select and show are always available for them
1288
1308
  const typeName = field.type?.name || field.type || "";
1289
1309
  if (!fieldviews.length && String(typeName).startsWith("Key")) {
1290
1310
  fieldviews = ["select", "show"];
1291
1311
  }
1312
+ // editFieldviews: only fieldviews where isEdit is not explicitly false
1313
+ const editFieldviews = fieldviews.filter(
1314
+ (fv) => fvDefs[fv]?.isEdit !== false
1315
+ );
1316
+
1292
1317
  const isPkName =
1293
1318
  table.pk_name &&
1294
1319
  typeof field.name === "string" &&
@@ -1313,6 +1338,7 @@ const buildContext = async (mode, tableName) => {
1313
1338
  is_pk_name: !!isPkName,
1314
1339
  default_fieldview: defaultFieldview,
1315
1340
  fieldviews: fieldviews.length ? fieldviews : ["show"],
1341
+ editFieldviews: editFieldviews.length ? editFieldviews : fieldviews,
1316
1342
  attributes: field.attributes || {},
1317
1343
  };
1318
1344
  });
@@ -1397,6 +1423,79 @@ const buildErrorLayout = ({ message, mode, table }) => {
1397
1423
  };
1398
1424
  };
1399
1425
 
1426
+ const GET_RELATION_PATHS_TOOL = {
1427
+ type: "function",
1428
+ function: GET_RELATION_PATHS_FUNCTION,
1429
+ };
1430
+
1431
+ /**
1432
+ * Run the LLM allowing up to MAX_TOOL_ROUNDS get_relation_paths calls (depth
1433
+ * escalation: 2 → 4 → 6) before producing the final layout JSON.
1434
+ */
1435
+ const MAX_TOOL_ROUNDS = 4;
1436
+ const runWithRelationTools = async (llm, mainPrompt, opts) => {
1437
+ const llm_add_message = getState().functions.llm_add_message;
1438
+
1439
+ const runChat = Array.isArray(opts.chat) ? [...opts.chat] : [];
1440
+ runChat.push({ role: "user", content: mainPrompt });
1441
+
1442
+ // Drop response_format during tool phase; repair chain handles JSON after.
1443
+ const toolOpts = { ...opts, tools: [GET_RELATION_PATHS_TOOL] };
1444
+ delete toolOpts.response_format;
1445
+ delete toolOpts.chat;
1446
+
1447
+ const schemaData = await build_schema_data();
1448
+
1449
+ for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
1450
+ const raw = await llm.run(null, { ...toolOpts, chat: runChat });
1451
+
1452
+ if (!raw?.hasToolCalls) {
1453
+ return typeof raw === "string" ? raw : raw?.content ?? "";
1454
+ }
1455
+
1456
+ // Append the assistant message so the next round has full context.
1457
+ if (raw.ai_sdk && Array.isArray(raw.messages)) {
1458
+ raw.messages
1459
+ .filter((m) => m.role === "assistant")
1460
+ .forEach((m) => runChat.push(m));
1461
+ } else if (raw.tool_calls) {
1462
+ runChat.push({
1463
+ role: "assistant",
1464
+ content: raw.content || null,
1465
+ tool_calls: raw.tool_calls,
1466
+ });
1467
+ }
1468
+
1469
+ for (const tc of raw.getToolCalls()) {
1470
+ let resultText;
1471
+ if (tc.tool_name === "get_relation_paths") {
1472
+ const maxDepth = tc.input.max_depth ?? 2;
1473
+ const sections = getRelationPathsForPairs(
1474
+ tc.input.pairs || [],
1475
+ schemaData,
1476
+ maxDepth
1477
+ );
1478
+ resultText =
1479
+ sections.join("\n\n") +
1480
+ `\n\nFor each pair above: analyse whether the listed paths include a suitable one for the intended relation type (ChildList, ParentShow, Own, etc.). ` +
1481
+ `If a suitable path exists, set the "relation" property to the chosen path string for all types including Own. ` +
1482
+ `If no suitable path exists for a pair (no paths listed, or none match the intended type), call get_relation_paths again with a higher max_depth (4, then 6). ` +
1483
+ `Do not escalate just because multiple paths are listed — only escalate when none is appropriate.`;
1484
+ } else {
1485
+ resultText = `Unknown tool: ${tc.tool_name}`;
1486
+ }
1487
+ await llm_add_message.run("tool_response", resultText, {
1488
+ chat: runChat,
1489
+ tool_call: tc,
1490
+ });
1491
+ }
1492
+ }
1493
+
1494
+ // Exhausted tool rounds — force final JSON answer.
1495
+ const final = await llm.run(null, { ...opts, chat: runChat });
1496
+ return typeof final === "string" ? final : final?.content ?? "";
1497
+ };
1498
+
1400
1499
  module.exports = {
1401
1500
  normalizeLayoutCandidate,
1402
1501
  run: async (prompt, mode, table, existing_layout, chat) => {
@@ -1467,7 +1566,7 @@ module.exports = {
1467
1566
  if (!schema || !schema.schema) {
1468
1567
  throw new Error("Builder schema unavailable");
1469
1568
  }
1470
- rawResponse = await llm.run(llmPrompt, options);
1569
+ rawResponse = await runWithRelationTools(llm, llmPrompt, options);
1471
1570
  payload = parseJsonPayload(rawResponse);
1472
1571
  const candidate = payload.layout ?? payload;
1473
1572
  const result = normalizeLayoutCandidate(candidate, ctx);
@@ -1510,6 +1609,7 @@ module.exports = {
1510
1609
  },
1511
1610
  isAsync: true,
1512
1611
  description: "Generate a builder layout",
1612
+ hidden: true,
1513
1613
  arguments: [
1514
1614
  { name: "prompt", type: "String" },
1515
1615
  { name: "mode", type: "String" },
@@ -49,6 +49,7 @@ const get_agent_view = () => {
49
49
  { skill_type: "Generate View" },
50
50
  { skill_type: "Registry editor" },
51
51
  { skill_type: "Javascript Action" },
52
+ { skill_type: "Generate trigger" },
52
53
  ...(typeof Plugin.loadAndSaveNewPlugin === "function"
53
54
  ? [{ skill_type: "Install Plugin" }]
54
55
  : []),
@@ -61,6 +62,7 @@ const get_agent_view = () => {
61
62
  min_role: 1,
62
63
  configuration: {
63
64
  agent_action,
65
+ show_prev_runs: true,
64
66
  viewname,
65
67
  },
66
68
  });
package/index.js CHANGED
@@ -18,7 +18,6 @@ module.exports = {
18
18
  dependencies: ["@saltcorn/large-language-model", "@saltcorn/agents"],
19
19
  viewtemplates: features.workflows
20
20
  ? [
21
- require("./chat-copilot"),
22
21
  require("./user-copilot"),
23
22
  require("./copilot-as-agent"),
24
23
  require("./app-constructor/view.js"),
@@ -44,7 +43,7 @@ module.exports = {
44
43
  require("./agent-skills/viewgen.js"),
45
44
  require("./agent-skills/registry-editor.js"),
46
45
  require("./agent-skills/js-action.js"),
47
- require("./agent-skills/app-constructor-context.js"),
46
+ require("./agent-skills/triggergen.js"),
48
47
  ...(typeof Plugin.loadAndSaveNewPlugin === "function"
49
48
  ? [require("./agent-skills/install-plugin.js")]
50
49
  : []),
package/js-code-gen.js CHANGED
@@ -57,6 +57,7 @@ module.exports = {
57
57
  },
58
58
  isAsync: true,
59
59
  description: "Generate JavaScript code for an action",
60
+ hidden: true,
60
61
  arguments: [
61
62
  { name: "description", type: "String" },
62
63
  { name: "existing_code", type: "String" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saltcorn/copilot",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "AI assistant for building Saltcorn applications",
5
5
  "main": "index.js",
6
6
  "dependencies": {
@@ -0,0 +1,269 @@
1
+ const { RelationsFinder, RelationType } = require("@saltcorn/common-code");
2
+
3
+ /**
4
+ * Relation path documentation included in LLM system prompts (viewgen, builder-gen).
5
+ *
6
+ * TWO FORMATS EXIST:
7
+ *
8
+ * New format (always generate this):
9
+ * view: "viewname" + relation: ".sourcetable.segment1.segment2..."
10
+ *
11
+ * Segment types:
12
+ * Outbound FK (to parent): FK field name alone, e.g. trip_id
13
+ * Inbound FK (child rows): childtable$fkfield, e.g. packing_items$trip_id
14
+ *
15
+ * Examples:
16
+ * .trips.packing_items$trip_id ChildList: all packing_items for a trip
17
+ * .packing_items.trip_id ParentShow: the trip that owns a packing item
18
+ * .artists.artist_plays_on_album$artist.album ChildList through a join table
19
+ * .users.orders$user_id.order_lines$order_id RelationPath: multi-level
20
+ *
21
+ * Legacy format (may appear in existing configs — do not generate, understand only):
22
+ * The type and path are encoded together in the view field, no separate relation field.
23
+ * "Own:viewname" → same table, no relation
24
+ * "ParentShow:viewname.table.fkfield" → outbound FK to parent
25
+ * "ChildList:viewname.table.inbkey" → inbound FK, one-to-many
26
+ * "OneToOneShow:viewname.table.inbkey" → inbound FK, unique
27
+ * "Independent:viewname" → no FK relationship
28
+ *
29
+ * Relation types by new-format path structure:
30
+ * Own – zero segments, source and target are the same table
31
+ * ParentShow – single outbound-FK segment
32
+ * OneToOneShow – single inbound-FK segment on a unique field
33
+ * ChildList – one or more inbound-FK segments (may mix outbound for join tables)
34
+ * RelationPath – complex multi-level path mixing both segment types
35
+ */
36
+ const RELATION_PATH_DOC = `
37
+ ## Relation paths
38
+
39
+ Every view_link and embedded view segment requires two fields:
40
+ - \`view\`: the view name (plain string, e.g. \`"packing_items_list"\`)
41
+ - \`relation\`: a dot-separated path string (e.g. \`".trips.packing_items$trip_id"\`)
42
+
43
+ **Always use this format. Never generate anything else.**
44
+
45
+ ---
46
+
47
+ ### Relation path format
48
+
49
+ \`.sourcetable.segment1.segment2...\`
50
+
51
+ Segment types:
52
+ - **Outbound FK** (to a parent): FK field name alone — e.g. \`trip_id\`
53
+ - **Inbound FK** (child rows): \`childtable$fkfield\` — e.g. \`packing_items$trip_id\`
54
+ - **Same table** (no FK traversal): just the source table, no segments — e.g. \`".invoice_line_items"\`
55
+
56
+ Examples:
57
+ | relation string | meaning |
58
+ |---|---|
59
+ | \`.invoice_line_items\` | same-table link (no FK traversal) |
60
+ | \`.trips.packing_items$trip_id\` | all packing_items for a trip |
61
+ | \`.packing_items.trip_id\` | the trip that owns a packing_item |
62
+ | \`.artists.artist_plays_on_album$artist.album\` | albums via join table |
63
+ | \`.users.orders$user_id.order_lines$order_id\` | multi-level |
64
+
65
+ ---
66
+
67
+ ### Legacy format — read-only, never generate
68
+
69
+ Any \`view\` field value that contains a colon (e.g. \`"ChildList:trips_list.packing_items.trip_id"\`,
70
+ \`"Own:viewname"\`, \`"ParentShow:viewname.table.fkfield"\`) is legacy. You may encounter it in
71
+ existing configs. Parse it to understand the relation, then always write back in the new format.
72
+
73
+ | legacy view field | new format equivalent |
74
+ |---|---|
75
+ | \`"Own:viewname"\` | \`relation: ".sourcetable"\` |
76
+ | \`"Independent:viewname"\` | no \`relation\` field needed |
77
+ | \`"ParentShow:viewname.table.fkfield"\` | \`relation: ".sourcetable.fkfield"\` |
78
+ | \`"ChildList:viewname.table.inbkey"\` | \`relation: ".sourcetable.childtable$inbkey"\` |
79
+ | \`"OneToOneShow:viewname.table.inbkey"\` | \`relation: ".sourcetable.childtable$inbkey"\` |
80
+
81
+ ---
82
+
83
+ ### Using get_relation_paths
84
+
85
+ Call it **once** with all source_table/target_view pairs you need. The tool always returns new-format
86
+ path strings — use them as the \`relation\` field directly.
87
+
88
+ **Depth escalation:** Start with \`max_depth=2\`. After receiving results, analyse each pair: does the
89
+ result contain a path that matches the intended relation type? If yes, use it. If a pair has no
90
+ suitable path (no paths at all, or none of the right type), call again with \`max_depth=4\`, then
91
+ \`max_depth=6\` if still none. Do not escalate just because multiple paths are listed.
92
+
93
+ Selecting among returned paths:
94
+ - **ChildList** — target view shows multiple rows belonging to the current row.
95
+ - **ParentShow** — target view shows the single parent the current row belongs to.
96
+ - **OneToOneShow** — exactly one related child row via a unique FK.
97
+ - **Own** — same table, no FK traversal. Relation string is just \`.sourcetable\`.
98
+ - If multiple paths of the same type exist, pick the one whose FK field name best matches the task.
99
+ - Prefer shorter paths (fewer segments) unless a longer one is clearly more appropriate.
100
+ `;
101
+
102
+ const typeToLabel = (type) => {
103
+ if (type === RelationType.OWN)
104
+ return "Own – source and target are the same table. Use this relation string as-is (no extra segments after the table name).";
105
+ if (type === RelationType.INDEPENDENT)
106
+ return "Independent – no FK relationship exists";
107
+ if (type === RelationType.PARENT_SHOW)
108
+ return "ParentShow – outbound FK to a parent record (many-to-one)";
109
+ if (type === RelationType.ONE_TO_ONE_SHOW)
110
+ return "OneToOneShow – unique inbound FK (one-to-one)";
111
+ if (type === RelationType.CHILD_LIST)
112
+ return "ChildList – inbound FK, one parent → many child rows";
113
+ return "RelationPath – complex multi-level path";
114
+ };
115
+
116
+ /**
117
+ * @param {string} sourceTableName
118
+ * @param {string} targetViewName
119
+ * @param {{ tables, views }} schemaData pre-fetched via build_schema_data()
120
+ * @returns {Array<Relation>} raw Relation objects from RelationsFinder
121
+ */
122
+ function getRelationPaths(
123
+ sourceTableName,
124
+ targetViewName,
125
+ schemaData,
126
+ maxDepth = 6
127
+ ) {
128
+ if (!schemaData) return [];
129
+ try {
130
+ const finder = new RelationsFinder(
131
+ schemaData.tables,
132
+ schemaData.views,
133
+ maxDepth
134
+ );
135
+ return finder.findRelations(sourceTableName, targetViewName, []);
136
+ } catch {
137
+ return [];
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Resolve multiple source_table/target_view pairs against pre-fetched schema data.
143
+ * All per-pair work is synchronous — call build_schema_data() once before invoking this.
144
+ * @param {Array<{source_table: string, target_view: string}>} pairs
145
+ * @param {{ tables, views }} schemaData
146
+ * @returns {Array<string>} one formatted result string per pair
147
+ */
148
+ function getRelationPathsForPairs(pairs, schemaData, maxDepth = 6) {
149
+ if (!schemaData)
150
+ return pairs.map(({ source_table, target_view }) =>
151
+ formatRelationPathResult(source_table, target_view, {
152
+ error: "Schema data unavailable",
153
+ })
154
+ );
155
+ const finder = new RelationsFinder(
156
+ schemaData.tables,
157
+ schemaData.views,
158
+ maxDepth
159
+ );
160
+ return pairs.map(({ source_table, target_view }) => {
161
+ const targetView = (schemaData.views || []).find(
162
+ (v) => v.name === target_view
163
+ );
164
+ if (!targetView)
165
+ return formatRelationPathResult(source_table, target_view, {
166
+ error: `View "${target_view}" not found in current schema`,
167
+ });
168
+ let relations;
169
+ try {
170
+ relations = finder.findRelations(source_table, target_view, []);
171
+ } catch (e) {
172
+ return formatRelationPathResult(source_table, target_view, {
173
+ error: `Failed to find relations: ${e.message}`,
174
+ });
175
+ }
176
+ return formatRelationPathResult(source_table, target_view, {
177
+ paths: relations.map((r) => ({
178
+ relation_string: r.relationString,
179
+ type: String(r.type),
180
+ label: typeToLabel(r.type),
181
+ })),
182
+ });
183
+ });
184
+ }
185
+
186
+ /**
187
+ * Pick the most useful relation from a list: Own > Parent > Child > first.
188
+ * Used as a fallback in builder-gen when the model doesn't specify a relation.
189
+ */
190
+ function pickBestRelation(relations) {
191
+ if (!relations.length) return null;
192
+ let own = null,
193
+ parent = null,
194
+ child = null;
195
+ for (const r of relations) {
196
+ if (r.type === RelationType.OWN) own = r;
197
+ else if (r.type === RelationType.PARENT_SHOW) parent = r;
198
+ else if (
199
+ r.type === RelationType.CHILD_LIST ||
200
+ r.type === RelationType.ONE_TO_ONE_SHOW
201
+ )
202
+ child = r;
203
+ }
204
+ return own || parent || child || relations[0];
205
+ }
206
+
207
+ /**
208
+ * Format the result of getRelationPaths into a human-readable string for the model.
209
+ * Handles both found and not-found cases for one source_table/target_view pair.
210
+ */
211
+ function formatRelationPathResult(source_table, target_view, result) {
212
+ if (result.error) return `${source_table} → ${target_view}: ${result.error}`;
213
+ if (!result.paths.length)
214
+ return `${source_table} → ${target_view}: no relation paths found (no FK relationship)`;
215
+ const lines = result.paths
216
+ .map((p) => ` "${p.relation_string}" — ${p.label}`)
217
+ .join("\n");
218
+ return `${source_table} → ${target_view}:\n${lines}`;
219
+ }
220
+
221
+ const GET_RELATION_PATHS_FUNCTION = {
222
+ name: "get_relation_paths",
223
+ description:
224
+ "Get all valid relation path strings for one or more source_table/target_view pairs. " +
225
+ "Call this before setting any 'relation' property on view_link columns or embedded view segments. " +
226
+ "Always start with max_depth=2 to keep the result compact. " +
227
+ "After receiving the results, analyse whether each pair has a suitable path for its intended relation type. " +
228
+ "If a pair has no suitable path, call again with max_depth=4, then max_depth=6 if still none. " +
229
+ "Stop as soon as every pair has a suitable path.",
230
+ parameters: {
231
+ type: "object",
232
+ required: ["pairs"],
233
+ properties: {
234
+ pairs: {
235
+ type: "array",
236
+ description:
237
+ "All source_table/target_view pairs you need relation paths for. Include every pair in one call.",
238
+ items: {
239
+ type: "object",
240
+ required: ["source_table", "target_view"],
241
+ properties: {
242
+ source_table: {
243
+ type: "string",
244
+ description: "The table of the view being built or updated.",
245
+ },
246
+ target_view: {
247
+ type: "string",
248
+ description: "The view to link to or embed.",
249
+ },
250
+ },
251
+ },
252
+ },
253
+ max_depth: {
254
+ type: "integer",
255
+ description:
256
+ "Maximum join depth to search. Start with 2. Escalate to 4, then 6, only if a pair has no suitable path in the current results — not just because paths exist, but because none match the intended relation type.",
257
+ default: 2,
258
+ },
259
+ },
260
+ },
261
+ };
262
+
263
+ module.exports = {
264
+ RELATION_PATH_DOC,
265
+ GET_RELATION_PATHS_FUNCTION,
266
+ getRelationPaths,
267
+ getRelationPathsForPairs,
268
+ pickBestRelation,
269
+ };
@@ -73,6 +73,7 @@ ${ds.join("\n")}`);
73
73
  },
74
74
  isAsync: true,
75
75
  description: "Return a standard prompt for writing code",
76
+ hidden: true,
76
77
  arguments: [
77
78
  {
78
79
  name: "options",
package/user-copilot.js CHANGED
@@ -933,5 +933,7 @@ module.exports = {
933
933
  get_state_fields,
934
934
  tableless: true,
935
935
  run,
936
+ deprecated: true,
937
+ description: "Use Agents from the agent module instead",
936
938
  routes: { interact, delprevrun },
937
939
  };
package/workflow-gen.js CHANGED
@@ -41,5 +41,6 @@ module.exports = {
41
41
  },
42
42
  isAsync: true,
43
43
  description: "Generate a workflow",
44
+ hidden: true,
44
45
  arguments: [{ name: "description", type: "String" }],
45
46
  };