@saltcorn/copilot 0.7.5 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -39,7 +39,7 @@ const summarizeTables = (tables) =>
39
39
  })
40
40
  .join(", ");
41
41
  const ellipsis = table.fields.length > fields.length ? "..." : "";
42
- return `${idx + 1}. ${table.table_name || "(missing name)"} ${
42
+ return `${idx + 1}. ${table.table_name || "(missing name)"} \u2013 ${
43
43
  table.fields.length
44
44
  } field(s)${fieldSummary ? ` (${fieldSummary}${ellipsis})` : ""}`;
45
45
  });
@@ -96,12 +96,14 @@ const partitionTablesByExistence = async (tables = []) => {
96
96
  const newTables = [];
97
97
  const skippedExisting = [];
98
98
  const skippedDuplicates = [];
99
+ const existingTablesData = [];
99
100
  tables.forEach((table) => {
100
101
  const tableName =
101
102
  typeof table?.table_name === "string" ? table.table_name.trim() : "";
102
103
  const normalized = tableName.toLowerCase();
103
104
  if (tableName && existingNames.has(normalized)) {
104
105
  skippedExisting.push(tableName);
106
+ existingTablesData.push(table);
105
107
  return;
106
108
  }
107
109
  if (tableName && seenNewNames.has(normalized)) {
@@ -111,7 +113,7 @@ const partitionTablesByExistence = async (tables = []) => {
111
113
  if (tableName) seenNewNames.add(normalized);
112
114
  newTables.push(table);
113
115
  });
114
- return { newTables, skippedExisting, skippedDuplicates };
116
+ return { newTables, skippedExisting, skippedDuplicates, existingTablesData };
115
117
  };
116
118
 
117
119
  const partitionTablesByValidity = (tables = []) => {
@@ -136,6 +138,34 @@ const partitionTablesByValidity = (tables = []) => {
136
138
  return { validTables, skippedMissingNames, skippedMissingFields };
137
139
  };
138
140
 
141
+ const renderExistingTablesPreview = (existingTablesData) => {
142
+ if (!existingTablesData.length) return "";
143
+ return existingTablesData
144
+ .map((table) => {
145
+ const rows = (table.fields || [])
146
+ .filter((f) => (f?.name || "").toLowerCase() !== "id")
147
+ .map((f) => {
148
+ const cfg = f.type_and_configuration || {};
149
+ const typeParts = [
150
+ cfg.data_type === "ForeignKey"
151
+ ? `ForeignKey \u2192 ${cfg.reference_table || "?"}`
152
+ : cfg.data_type || "Unknown",
153
+ ];
154
+ if (f.calculated) typeParts.push(f.stored ? "stored calc" : "calc");
155
+ return `<tr><td>${f.name || ""}</td><td>${f.label || ""}</td><td>${typeParts.join(" ")}</td><td>${f.expression || ""}</td></tr>`;
156
+ })
157
+ .join("");
158
+ return (
159
+ `<div class="mt-3"><b>Add / update fields on existing table: ` +
160
+ `<code>${table.table_name}</code></b>` +
161
+ `<table class="table table-sm mt-1"><thead><tr>` +
162
+ `<th>Name</th><th>Label</th><th>Type</th><th>Expression</th></tr></thead>` +
163
+ `<tbody>${rows}</tbody></table></div>`
164
+ );
165
+ })
166
+ .join("");
167
+ };
168
+
139
169
  const payloadFromToolCall = (tool_call) => {
140
170
  if (!tool_call) return { tables: [] };
141
171
  if (tool_call.input) return normalizeTablesPayload(tool_call.input);
@@ -161,67 +191,64 @@ class GenerateTablesSkill {
161
191
 
162
192
  get userActions() {
163
193
  return {
164
- async apply_copilot_tables({ user, tables }) {
165
- if (!tables?.length) return { notify: "Nothing to create." };
166
- const { newTables, skippedExisting, skippedDuplicates } =
167
- await partitionTablesByExistence(tables);
168
- const { validTables, skippedMissingNames, skippedMissingFields } =
169
- partitionTablesByValidity(newTables);
170
- if (!validTables.length) {
171
- const skippedMessages = [];
172
- if (skippedExisting.length)
173
- skippedMessages.push(
174
- `Existing tables: ${skippedExisting.join(", ")}`,
194
+ async apply_copilot_tables({ user, tables, existing_tables }) {
195
+ const notifyParts = [];
196
+
197
+ // Create brand-new tables
198
+ if (tables?.length) {
199
+ const { newTables, skippedDuplicates } =
200
+ await partitionTablesByExistence(tables);
201
+ const { validTables, skippedMissingNames, skippedMissingFields } =
202
+ partitionTablesByValidity(newTables);
203
+ if (validTables.length) {
204
+ await GenerateTables.execute({ tables: validTables }, { user });
205
+ notifyParts.push(
206
+ `Created tables: ${validTables.map((t) => t.table_name).join(", ")}`,
175
207
  );
208
+ }
176
209
  if (skippedDuplicates.length)
177
- skippedMessages.push(
178
- `Duplicate definitions: ${skippedDuplicates.join(", ")}`,
210
+ notifyParts.push(
211
+ `Ignored duplicate definitions: ${skippedDuplicates.join(", ")}`,
179
212
  );
180
213
  if (skippedMissingNames.length)
181
- skippedMessages.push(
214
+ notifyParts.push(
182
215
  `Missing table_name: ${skippedMissingNames.join(", ")}`,
183
216
  );
184
217
  if (skippedMissingFields.length)
185
- skippedMessages.push(
218
+ notifyParts.push(
186
219
  `Tables without fields: ${skippedMissingFields.join(", ")}`,
187
220
  );
188
- return {
189
- notify:
190
- skippedMessages.length > 0
191
- ? `Nothing to create. Skipped ${skippedMessages.join("; ")}.`
192
- : "Nothing to create.",
193
- };
194
221
  }
195
- await GenerateTables.execute({ tables: validTables }, { user });
196
- const createdNames = validTables.map((t) => t.table_name).join(", ");
197
- const skippedMessages = [];
198
- if (skippedExisting.length)
199
- skippedMessages.push(
200
- `Skipped existing tables: ${skippedExisting.join(", ")}`,
201
- );
202
- if (skippedDuplicates.length)
203
- skippedMessages.push(
204
- `Ignored duplicate definitions: ${skippedDuplicates.join(", ")}`,
205
- );
206
- if (skippedMissingNames.length)
207
- skippedMessages.push(
208
- `Missing table_name: ${skippedMissingNames.join(", ")}`,
209
- );
210
- if (skippedMissingFields.length)
211
- skippedMessages.push(
212
- `Tables without fields: ${skippedMissingFields.join(", ")}`,
213
- );
222
+
223
+ // Add/update fields on existing tables
224
+ if (existing_tables?.length) {
225
+ for (const table of existing_tables) {
226
+ if (!table?.table_name || !Array.isArray(table.fields)) continue;
227
+ const { added, updated } =
228
+ await GenerateTables.execute_add_or_update_fields(
229
+ { table_name: table.table_name, fields: table.fields },
230
+ { user },
231
+ );
232
+ const parts = [];
233
+ if (added.length) parts.push(`added: ${added.join(", ")}`);
234
+ if (updated.length) parts.push(`updated: ${updated.join(", ")}`);
235
+ if (parts.length)
236
+ notifyParts.push(
237
+ `${table.table_name} \u2014 ${parts.join("; ")}`,
238
+ );
239
+ }
240
+ }
241
+
214
242
  return {
215
- notify: [`Created tables: ${createdNames}`, ...skippedMessages].join(
216
- ". ",
217
- ),
243
+ notify: notifyParts.length
244
+ ? notifyParts.join(". ")
245
+ : "Nothing to apply.",
218
246
  };
219
247
  },
220
248
  };
221
249
  }
222
250
 
223
251
  provideTools = () => {
224
- const parameters = GenerateTables.json_schema();
225
252
  return {
226
253
  type: "function",
227
254
  process: async (input) => {
@@ -230,24 +257,28 @@ class GenerateTablesSkill {
230
257
  if (!tables.length) {
231
258
  return "No tables were provided for generate_tables.";
232
259
  }
233
- const { newTables, skippedExisting, skippedDuplicates } =
234
- await partitionTablesByExistence(tables);
260
+ const {
261
+ newTables,
262
+ skippedExisting,
263
+ skippedDuplicates,
264
+ existingTablesData,
265
+ } = await partitionTablesByExistence(tables);
235
266
  const { validTables, skippedMissingNames, skippedMissingFields } =
236
267
  partitionTablesByValidity(newTables);
237
268
  const summaryLines = validTables.length
238
269
  ? summarizeTables(validTables).map((line) => `- ${line}`)
239
270
  : [];
240
- const warnings = collectTableWarnings(tables);
241
- if (skippedExisting.length)
242
- skippedExisting.forEach((name) =>
243
- warnings.push(
244
- `Table "${name}" already exists and will not be recreated by generate_tables.`,
245
- ),
246
- );
271
+ const existingSummaryLines = existingTablesData.map(
272
+ (t) =>
273
+ `- "${t.table_name}" (exists) \u2014 ${(t.fields || []).length} field(s) will be added/updated`,
274
+ );
275
+ const warnings = collectTableWarnings(
276
+ tables.filter((t) => !skippedExisting.includes(t.table_name)),
277
+ );
247
278
  if (skippedDuplicates.length)
248
279
  skippedDuplicates.forEach((name) =>
249
280
  warnings.push(
250
- `Table "${name}" was defined multiple times in this request; only the first definition will be used.`,
281
+ `Table "${name}" was defined multiple times; only the first definition will be used.`,
251
282
  ),
252
283
  );
253
284
  skippedMissingNames.forEach((label) =>
@@ -263,26 +294,31 @@ class GenerateTablesSkill {
263
294
  const warningLines = warnings.length
264
295
  ? ["Warnings:", ...warnings.map((w) => `- ${w}`)]
265
296
  : [];
266
- const summarySection = summaryLines.length
297
+ const newSection = summaryLines.length
267
298
  ? [
268
- `Ready to create ${validTables.length} new table${
269
- validTables.length === 1 ? "" : "s"
270
- }:`,
299
+ `Ready to create ${validTables.length} new table${validTables.length === 1 ? "" : "s"}:`,
271
300
  ...summaryLines,
272
301
  ]
273
- : [
274
- "No new tables remain after removing existing, duplicate, or invalid table definitions.",
275
- ];
302
+ : [];
303
+ const existingSection = existingSummaryLines.length
304
+ ? [
305
+ "Existing tables (fields will be added/updated):",
306
+ ...existingSummaryLines,
307
+ ]
308
+ : [];
309
+ const nothingToDo = !newSection.length && !existingSection.length;
276
310
  return [
277
311
  `Received ${tables.length} table definition${tables.length === 1 ? "" : "s"}.`,
278
- ...summarySection,
312
+ ...(nothingToDo
313
+ ? ["Nothing to do after filtering."]
314
+ : [...newSection, ...existingSection]),
279
315
  ...warningLines,
280
316
  ].join("\n");
281
317
  },
282
- postProcess: async ({ tool_call }) => {
318
+ postProcess: async ({ tool_call, req }) => {
283
319
  const payload = payloadFromToolCall(tool_call);
284
320
  const tables = payload.tables || [];
285
- const { newTables, skippedExisting, skippedDuplicates } =
321
+ const { newTables, skippedDuplicates, existingTablesData } =
286
322
  await partitionTablesByExistence(tables);
287
323
  const { validTables, skippedMissingNames, skippedMissingFields } =
288
324
  partitionTablesByValidity(newTables);
@@ -290,22 +326,16 @@ class GenerateTablesSkill {
290
326
  try {
291
327
  if (validTables.length) {
292
328
  preview = GenerateTables.render_html({ tables: validTables });
293
- } else {
294
- preview =
295
- '<div class="alert alert-info">No new tables to preview because every provided table already exists or was invalid.</div>';
296
329
  }
297
330
  } catch (e) {
298
- console.log("We are in postProcess but rendering failed", {
331
+ console.log("generate_tables postProcess render failed", {
299
332
  e,
300
333
  time: new Date(),
301
334
  });
302
335
  preview = `<pre>${JSON.stringify(payload, null, 2)}</pre>`;
303
336
  }
337
+ const existingPreview = renderExistingTablesPreview(existingTablesData);
304
338
  const warningChunks = [];
305
- if (skippedExisting.length)
306
- warningChunks.push(
307
- `Skipped existing tables: ${skippedExisting.join(", ")}`,
308
- );
309
339
  if (skippedDuplicates.length)
310
340
  warningChunks.push(
311
341
  `Ignored duplicate definitions: ${skippedDuplicates.join(", ")}`,
@@ -321,26 +351,48 @@ class GenerateTablesSkill {
321
351
  const warningHtml = warningChunks.length
322
352
  ? `<div class="alert alert-warning">${warningChunks.join("<br/>")}</div>`
323
353
  : "";
354
+
355
+ if (this.yoloMode) {
356
+ this.userActions.apply_copilot_tables({
357
+ user: req?.user,
358
+ tables: validTables,
359
+ });
360
+ return { stop: true, add_response: `${warningHtml}${preview}` };
361
+ }
362
+
363
+ const hasAnything =
364
+ validTables.length > 0 || existingTablesData.length > 0;
365
+ const labelParts = [];
366
+ if (validTables.length)
367
+ labelParts.push(
368
+ `Create ${validTables.map((t) => t.table_name).join(", ")}`,
369
+ );
370
+ if (existingTablesData.length)
371
+ labelParts.push(
372
+ `Update fields on ${existingTablesData
373
+ .map((t) => t.table_name)
374
+ .join(", ")}`,
375
+ );
324
376
  return {
325
377
  stop: true,
326
- add_response: `${warningHtml}${preview}`,
327
- add_user_action:
328
- validTables.length > 0
329
- ? {
330
- name: "apply_copilot_tables",
331
- type: "button",
332
- label: `Create tables (${validTables
333
- .map((t) => t.table_name)
334
- .join(", ")})`,
335
- input: { tables: validTables },
336
- }
337
- : undefined,
378
+ add_response: `${warningHtml}${preview}${existingPreview}`,
379
+ add_user_action: hasAnything
380
+ ? {
381
+ name: "apply_copilot_tables",
382
+ type: "button",
383
+ label: labelParts.join(" + "),
384
+ input: {
385
+ tables: validTables,
386
+ existing_tables: existingTablesData,
387
+ },
388
+ }
389
+ : undefined,
338
390
  };
339
391
  },
340
392
  function: {
341
393
  name: GenerateTables.function_name,
342
394
  description: GenerateTables.description,
343
- parameters,
395
+ parameters: GenerateTables.json_schema(),
344
396
  },
345
397
  };
346
398
  };
@@ -0,0 +1,111 @@
1
+ const InstallPluginAction = require("../actions/install-plugin-action");
2
+ const Plugin = require("@saltcorn/data/models/plugin");
3
+
4
+ class InstallPluginSkill {
5
+ static skill_name = "Install Plugin";
6
+
7
+ get skill_label() {
8
+ return "Install Plugin";
9
+ }
10
+
11
+ constructor(cfg) {
12
+ Object.assign(this, cfg);
13
+ }
14
+
15
+ async systemPrompt() {
16
+ return (
17
+ `Use list_plugins to browse available Saltcorn store plugins before installing. ` +
18
+ `Use install_plugin to install a plugin. Prefer store plugins (plugin_name) over npm packages. ` +
19
+ `Do not install a plugin that is already installed.`
20
+ );
21
+ }
22
+
23
+ get userActions() {
24
+ return {
25
+ async install_copilot_plugin(input, req) {
26
+ const result = await InstallPluginAction.execute(input, req);
27
+ return {
28
+ notify: result.postExec || "Plugin installation complete.",
29
+ };
30
+ },
31
+ };
32
+ }
33
+
34
+ provideTools = () => {
35
+ return [
36
+ {
37
+ type: "function",
38
+ process: async ({ category }) => {
39
+ try {
40
+ const plugins = await Plugin.store_plugins_available();
41
+ const filtered =
42
+ category === "theme"
43
+ ? plugins.filter((p) => p.has_theme)
44
+ : category === "auth"
45
+ ? plugins.filter((p) => p.has_auth)
46
+ : plugins;
47
+ if (!filtered.length) return "No plugins found.";
48
+ return filtered
49
+ .map(
50
+ (p) => `${p.name}${p.description ? `: ${p.description}` : ""}`,
51
+ )
52
+ .join("\n");
53
+ } catch (e) {
54
+ return `Error listing plugins: ${e.message}`;
55
+ }
56
+ },
57
+ function: {
58
+ name: "list_plugins",
59
+ description:
60
+ "List available plugins from the Saltcorn store. Call this before installing to find the right plugin name.",
61
+ parameters: {
62
+ type: "object",
63
+ properties: {
64
+ category: {
65
+ type: "string",
66
+ enum: ["theme", "auth", "all"],
67
+ description:
68
+ "Filter plugins: 'theme' for UI themes, 'auth' for authentication plugins, 'all' for everything",
69
+ },
70
+ },
71
+ required: ["category"],
72
+ },
73
+ },
74
+ },
75
+ {
76
+ type: "function",
77
+ process: async (input) => {
78
+ const label = input.plugin_name || input.npm_package;
79
+ if (!label) return "Please provide a plugin name or npm package.";
80
+ return `Installing plugin: ${label}...`;
81
+ },
82
+ postProcess: async ({ tool_call, req }) => {
83
+ const input = tool_call.input || {};
84
+ if (this.yoloMode) {
85
+ const result = await InstallPluginAction.execute(input, req);
86
+ return {
87
+ stop: true,
88
+ add_response: result.postExec || "Plugin installation complete.",
89
+ };
90
+ }
91
+ return {
92
+ stop: true,
93
+ add_user_action: {
94
+ name: "install_copilot_plugin",
95
+ type: "button",
96
+ label: `Install plugin ${input.plugin_name || input.npm_package}`,
97
+ input,
98
+ },
99
+ };
100
+ },
101
+ function: {
102
+ name: InstallPluginAction.function_name,
103
+ description: InstallPluginAction.description,
104
+ parameters: InstallPluginAction.json_schema(),
105
+ },
106
+ },
107
+ ];
108
+ };
109
+ }
110
+
111
+ module.exports = InstallPluginSkill;
@@ -0,0 +1,183 @@
1
+ const GenerateJsAction = require("../actions/generate-js-action");
2
+ const { getPromptFromTemplate } = require("../common");
3
+
4
+ class GenerateJsActionSkill {
5
+ static skill_name = "Javascript Action";
6
+
7
+ get skill_label() {
8
+ return "Javascript Action";
9
+ }
10
+
11
+ constructor(cfg) {
12
+ Object.assign(this, cfg);
13
+ }
14
+
15
+ get userActions() {
16
+ return {
17
+ async build_copilot_js_action(input) {
18
+ const name = input.name;
19
+ const code = input.code;
20
+ const description = input.description;
21
+ const when_trigger = input.when_trigger;
22
+ const trigger_table = input.trigger_table;
23
+ const user = input.user;
24
+ if (!name || !code) {
25
+ return {
26
+ notify:
27
+ "Both name and code are required to generate a Javascript action.",
28
+ };
29
+ }
30
+ const result = await GenerateJsAction.execute(
31
+ {
32
+ action_javascript_code: code,
33
+ action_name: name,
34
+ action_description: description,
35
+ when_trigger,
36
+ trigger_table,
37
+ },
38
+ { user },
39
+ );
40
+ return {
41
+ notify: result?.postExec || `Javascript action saved: ${name}`,
42
+ name,
43
+ code,
44
+ description,
45
+ };
46
+ },
47
+ };
48
+ }
49
+
50
+ provideTools = () => {
51
+ return {
52
+ type: "function",
53
+ process: async (input) => {
54
+ const name = input.name;
55
+ const description = input.description;
56
+ if (!name) {
57
+ return "A name is required to generate a Javascript action.";
58
+ }
59
+ return [
60
+ `Generating Javascript action: ${name}.`,
61
+ description ? `Description: ${description}` : null,
62
+ ]
63
+ .filter(Boolean)
64
+ .join("\n");
65
+ },
66
+ postProcess: async ({ tool_call, generate }) => {
67
+ const input = tool_call.input || {};
68
+ const name = input.name;
69
+ const description = input.description;
70
+ const when_trigger = input.when_trigger;
71
+ const trigger_table = input.trigger_table;
72
+ if (!name) {
73
+ return {
74
+ stop: true,
75
+ add_response: "Cannot create Javascript action: name is required.",
76
+ };
77
+ }
78
+
79
+ const partPrompt = await getPromptFromTemplate(
80
+ "action-builder.txt",
81
+ "",
82
+ );
83
+ const contextParts = [
84
+ description ? `Action description: ${description}` : null,
85
+ when_trigger ? `Trigger: ${when_trigger}` : null,
86
+ trigger_table ? `Table: ${trigger_table}` : null,
87
+ ].filter(Boolean);
88
+
89
+ const prompt = [
90
+ partPrompt,
91
+ contextParts.length ? contextParts.join("\n") : null,
92
+ `Generate the JavaScript code for the action named "${name}" by calling the generate_js_code tool.`,
93
+ ]
94
+ .filter(Boolean)
95
+ .join("\n\n");
96
+
97
+ const answer = await generate(prompt, {
98
+ tools: [
99
+ {
100
+ type: "function",
101
+ function: {
102
+ name: "generate_js_code",
103
+ description: "Provide the JavaScript code for the action",
104
+ parameters: {
105
+ type: "object",
106
+ required: ["code"],
107
+ properties: {
108
+ code: {
109
+ type: "string",
110
+ description: "JavaScript code for the action",
111
+ },
112
+ },
113
+ },
114
+ },
115
+ },
116
+ ],
117
+ tool_choice: {
118
+ type: "function",
119
+ function: { name: "generate_js_code" },
120
+ },
121
+ });
122
+
123
+ const tc = answer.getToolCalls()[0];
124
+ const code = tc?.input?.code;
125
+
126
+ if (!code) {
127
+ return {
128
+ stop: true,
129
+ add_response: "Failed to generate JavaScript code for the action.",
130
+ };
131
+ }
132
+
133
+ return {
134
+ stop: true,
135
+ add_response: GenerateJsAction.render_html({
136
+ action_javascript_code: code,
137
+ action_name: name,
138
+ action_description: description,
139
+ when_trigger,
140
+ trigger_table,
141
+ }),
142
+ add_user_action: {
143
+ name: "build_copilot_js_action",
144
+ type: "button",
145
+ label: `Save Javascript action (${name})`,
146
+ input: { name, code, description, when_trigger, trigger_table },
147
+ },
148
+ };
149
+ },
150
+ function: {
151
+ name: GenerateJsAction.function_name,
152
+ description: GenerateJsAction.description,
153
+ parameters: {
154
+ type: "object",
155
+ required: ["name"],
156
+ properties: {
157
+ name: {
158
+ type: "string",
159
+ description: "Name of the Javascript action.",
160
+ },
161
+ description: {
162
+ type: "string",
163
+ description: "Description of the action.",
164
+ },
165
+ when_trigger: {
166
+ type: "string",
167
+ enum: ["Insert", "Update", "Delete", "Daily", "Hourly", "Weekly"],
168
+ description:
169
+ "The event that fires this action. Only set if the user explicitly wants this trigger to run on a table row event or schedule. Leave unset if the user has not specified when it should run.",
170
+ },
171
+ trigger_table: {
172
+ type: "string",
173
+ description:
174
+ "The table whose row events (Insert/Update/Delete) should fire this action. Only set if the user explicitly says this action should be triggered by changes to that table. Do NOT set just because the code reads data from a table.",
175
+ },
176
+ },
177
+ },
178
+ },
179
+ };
180
+ };
181
+ }
182
+
183
+ module.exports = GenerateJsActionSkill;