@kairos-sdk/core 0.4.5 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.cjs CHANGED
@@ -28,7 +28,13 @@ var import_sdk = __toESM(require("@anthropic-ai/sdk"), 1);
28
28
 
29
29
  // src/utils/uuid.ts
30
30
  function generateUUID() {
31
- return crypto.randomUUID();
31
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
32
+ return crypto.randomUUID();
33
+ }
34
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
35
+ const r = Math.random() * 16 | 0;
36
+ return (c === "x" ? r : r & 3 | 8).toString(16);
37
+ });
32
38
  }
33
39
 
34
40
  // src/library/null-library.ts
@@ -84,7 +90,26 @@ var ProviderError = class extends KairosError {
84
90
  }
85
91
  };
86
92
 
93
+ // src/errors/guard-error.ts
94
+ var GuardError = class extends KairosError {
95
+ constructor(message) {
96
+ super(message);
97
+ this.name = "GuardError";
98
+ }
99
+ };
100
+
87
101
  // src/utils/retry.ts
102
+ function isTransientNetworkError(err) {
103
+ const TRANSIENT_CODES = /* @__PURE__ */ new Set(["ECONNRESET", "ETIMEDOUT", "ECONNREFUSED", "ENOTFOUND", "ECONNABORTED"]);
104
+ let current = err;
105
+ for (let i = 0; i < 4; i++) {
106
+ if (current === null || typeof current !== "object") break;
107
+ const code = current.code;
108
+ if (typeof code === "string" && TRANSIENT_CODES.has(code)) return true;
109
+ current = current.cause;
110
+ }
111
+ return false;
112
+ }
88
113
  async function withRetry(fn, maxAttempts, delayMs, shouldRetry) {
89
114
  let lastError;
90
115
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
@@ -109,6 +134,7 @@ function fetchWithTimeout(url, init, timeoutMs) {
109
134
 
110
135
  // src/providers/n8n/api-client.ts
111
136
  var EXECUTION_LIMIT_CAP = 100;
137
+ var N8N_API_PAGE_SIZE = 250;
112
138
  var REQUEST_TIMEOUT_MS = 3e4;
113
139
  var RETRY_ATTEMPTS = 3;
114
140
  var RETRY_DELAY_MS = 1e3;
@@ -117,6 +143,17 @@ var N8nApiClient = class {
117
143
  this.baseUrl = baseUrl;
118
144
  this.apiKey = apiKey;
119
145
  this.logger = logger;
146
+ if (!baseUrl || typeof baseUrl !== "string") {
147
+ throw new GuardError("N8nApiClient: baseUrl must be a non-empty string");
148
+ }
149
+ try {
150
+ new URL(baseUrl);
151
+ } catch {
152
+ throw new GuardError(`N8nApiClient: baseUrl is not a valid URL: "${baseUrl}"`);
153
+ }
154
+ if (!apiKey || typeof apiKey !== "string") {
155
+ throw new GuardError("N8nApiClient: apiKey must be a non-empty string");
156
+ }
120
157
  }
121
158
  baseUrl;
122
159
  apiKey;
@@ -126,7 +163,12 @@ var N8nApiClient = class {
126
163
  this.logger.debug(`n8n ${method} ${path}`);
127
164
  const isSafe = method === "GET";
128
165
  if (!isSafe) {
129
- return this.singleRequest(url, method, path, body);
166
+ return withRetry(
167
+ () => this.singleRequest(url, method, path, body),
168
+ 2,
169
+ RETRY_DELAY_MS,
170
+ isTransientNetworkError
171
+ );
130
172
  }
131
173
  return withRetry(
132
174
  () => this.singleRequest(url, method, path, body),
@@ -181,7 +223,7 @@ var N8nApiClient = class {
181
223
  }
182
224
  async listWorkflows() {
183
225
  const all = [];
184
- let path = "/workflows?limit=250";
226
+ let path = `/workflows?limit=${N8N_API_PAGE_SIZE}`;
185
227
  for (; ; ) {
186
228
  const response = await this.request("GET", path);
187
229
  for (const w of response.data) {
@@ -195,7 +237,7 @@ var N8nApiClient = class {
195
237
  });
196
238
  }
197
239
  if (!response.nextCursor) break;
198
- path = `/workflows?limit=250&cursor=${response.nextCursor}`;
240
+ path = `/workflows?limit=${N8N_API_PAGE_SIZE}&cursor=${response.nextCursor}`;
199
241
  }
200
242
  return all;
201
243
  }
@@ -225,14 +267,14 @@ var N8nApiClient = class {
225
267
  }
226
268
  async listTags() {
227
269
  const all = [];
228
- let path = "/tags?limit=250";
270
+ let path = `/tags?limit=${N8N_API_PAGE_SIZE}`;
229
271
  for (; ; ) {
230
272
  const response = await this.request("GET", path);
231
273
  for (const t of response.data) {
232
274
  all.push({ id: t.id, name: t.name });
233
275
  }
234
276
  if (!response.nextCursor) break;
235
- path = `/tags?limit=250&cursor=${response.nextCursor}`;
277
+ path = `/tags?limit=${N8N_API_PAGE_SIZE}&cursor=${response.nextCursor}`;
236
278
  }
237
279
  return all;
238
280
  }
@@ -256,6 +298,32 @@ var N8nApiClient = class {
256
298
  return [];
257
299
  }
258
300
  }
301
+ async triggerManual(workflowId) {
302
+ const raw = await this.request("POST", `/workflows/${workflowId}/run`);
303
+ const inner = raw["data"];
304
+ const execId = inner?.["executionId"] ?? raw["executionId"];
305
+ if (execId === void 0 || execId === null) {
306
+ throw new ProviderError(
307
+ `n8n trigger response missing executionId \u2014 got: ${JSON.stringify(raw)}`
308
+ );
309
+ }
310
+ return String(execId);
311
+ }
312
+ async triggerWebhookTest(path) {
313
+ const cleanPath = path.startsWith("/") ? path : `/${path}`;
314
+ const url = `${this.baseUrl.replace(/\/$/, "")}/webhook-test${cleanPath}`;
315
+ this.logger.debug(`n8n POST webhook-test ${cleanPath}`);
316
+ try {
317
+ const response = await fetchWithTimeout(
318
+ url,
319
+ { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}) },
320
+ REQUEST_TIMEOUT_MS
321
+ );
322
+ return response.status;
323
+ } catch (err) {
324
+ throw new ProviderError(`Webhook test request failed for path "${path}"`, err);
325
+ }
326
+ }
259
327
  mapExecution(e) {
260
328
  return {
261
329
  id: e.id,
@@ -303,15 +371,9 @@ var N8nFieldStripper = class {
303
371
  }
304
372
  };
305
373
 
306
- // src/errors/guard-error.ts
307
- var GuardError = class extends KairosError {
308
- constructor(message) {
309
- super(message);
310
- this.name = "GuardError";
311
- }
312
- };
313
-
314
374
  // src/providers/n8n/provider.ts
375
+ var SMOKE_TEST_TIMEOUT_MS = 3e4;
376
+ var SMOKE_TEST_POLL_INTERVAL_MS = 1e3;
315
377
  var N8nProvider = class {
316
378
  constructor(client, stripper) {
317
379
  this.client = client;
@@ -373,6 +435,71 @@ var N8nProvider = class {
373
435
  async untag(workflowId, tagIds) {
374
436
  await this.client.untagWorkflow(workflowId, tagIds);
375
437
  }
438
+ async smokeTest(workflowId, workflow) {
439
+ const start = Date.now();
440
+ const trigger = this.detectTrigger(workflow);
441
+ if (trigger.type === "unsupported") {
442
+ return { status: "not-applicable", triggerType: "not-applicable" };
443
+ }
444
+ if (trigger.type === "manual") {
445
+ let executionId;
446
+ try {
447
+ executionId = await this.client.triggerManual(workflowId);
448
+ } catch (err) {
449
+ return { status: "error", triggerType: "manual", durationMs: Date.now() - start, error: String(err) };
450
+ }
451
+ try {
452
+ const execution = await this.pollExecution(executionId);
453
+ const durationMs = Date.now() - start;
454
+ if (execution.status === "success") {
455
+ return { status: "passed", triggerType: "manual", executionId, durationMs };
456
+ }
457
+ return {
458
+ status: "failed",
459
+ triggerType: "manual",
460
+ executionId,
461
+ durationMs,
462
+ error: `Execution ended with status: ${execution.status}`
463
+ };
464
+ } catch (err) {
465
+ return { status: "error", triggerType: "manual", executionId, durationMs: Date.now() - start, error: String(err) };
466
+ }
467
+ }
468
+ try {
469
+ const statusCode = await this.client.triggerWebhookTest(trigger.path);
470
+ const durationMs = Date.now() - start;
471
+ if (statusCode >= 200 && statusCode < 300) {
472
+ return { status: "passed", triggerType: "webhook", durationMs };
473
+ }
474
+ return { status: "failed", triggerType: "webhook", durationMs, error: `Webhook returned HTTP ${statusCode}` };
475
+ } catch (err) {
476
+ return { status: "error", triggerType: "webhook", durationMs: Date.now() - start, error: String(err) };
477
+ }
478
+ }
479
+ detectTrigger(workflow) {
480
+ for (const node of workflow.nodes) {
481
+ if (node.type === "n8n-nodes-base.manualTrigger") return { type: "manual" };
482
+ if (node.type === "n8n-nodes-base.webhook") {
483
+ const params = node.parameters;
484
+ const path = typeof params?.["path"] === "string" ? params["path"] : "webhook";
485
+ return { type: "webhook", path };
486
+ }
487
+ }
488
+ return { type: "unsupported" };
489
+ }
490
+ async pollExecution(executionId) {
491
+ const deadline = Date.now() + SMOKE_TEST_TIMEOUT_MS;
492
+ for (; ; ) {
493
+ const execution = await this.client.getExecution(executionId);
494
+ if (execution.status !== "running" && execution.status !== "waiting") {
495
+ return execution;
496
+ }
497
+ const remaining = deadline - Date.now();
498
+ if (remaining <= 0) break;
499
+ await new Promise((resolve) => setTimeout(resolve, Math.min(SMOKE_TEST_POLL_INTERVAL_MS, remaining)));
500
+ }
501
+ throw new ProviderError(`Smoke test: execution ${executionId} did not complete within ${SMOKE_TEST_TIMEOUT_MS}ms`);
502
+ }
376
503
  };
377
504
 
378
505
  // src/validation/registry.ts
@@ -475,6 +602,14 @@ var NodeRegistry = class {
475
602
  if (!def) return true;
476
603
  return def.safeTypeVersions.includes(version);
477
604
  }
605
+ // Returns true when the version is a positive integer greater than the highest
606
+ // known safe version — indicates a newer release rather than a bad value.
607
+ isVersionNewer(type, version) {
608
+ const def = this.byType.get(type);
609
+ if (!def || def.safeTypeVersions.length === 0) return false;
610
+ const max = Math.max(...def.safeTypeVersions);
611
+ return Number.isInteger(version) && version > max;
612
+ }
478
613
  getRequiredParams(type) {
479
614
  return this.byType.get(type)?.requiredParams ?? [];
480
615
  }
@@ -527,6 +662,14 @@ var N8nValidator = class {
527
662
  this.checkRule24(workflow, issues);
528
663
  this.checkRule25(workflow, issues);
529
664
  this.checkRule26(workflow, issues);
665
+ this.checkRule27(workflow, issues);
666
+ this.checkRule28(workflow, issues);
667
+ this.checkRule29(workflow, issues);
668
+ this.checkRule30(workflow, issues);
669
+ this.checkRule31(workflow, issues);
670
+ this.checkRule32(workflow, issues);
671
+ this.checkRule33(workflow, issues);
672
+ this.checkRule34(workflow, issues);
530
673
  if (Array.isArray(workflow.nodes)) {
531
674
  const nodeById = new Map(workflow.nodes.map((n) => [n.id, n.type]));
532
675
  for (const issue of issues) {
@@ -778,19 +921,22 @@ var N8nValidator = class {
778
921
  }
779
922
  }
780
923
  }
781
- // Rule 19 (WARN): typeVersion is within known safe range for registered node types
924
+ // Rule 19 (WARN): typeVersion is within known safe range for registered node types.
925
+ // In lenient mode (KAIROS_REGISTRY_STRICT != 'true'), versions higher than the known
926
+ // max are allowed — they likely represent newer n8n releases Kairos hasn't catalogued yet.
782
927
  checkRule19(w, issues) {
783
928
  if (!Array.isArray(w.nodes)) return;
929
+ const strict = process.env["KAIROS_REGISTRY_STRICT"] === "true";
784
930
  for (const node of w.nodes) {
785
931
  if (typeof node.type !== "string" || typeof node.typeVersion !== "number") continue;
786
- if (!this.registry.isVersionSafe(node.type, node.typeVersion)) {
787
- this.warn(
788
- issues,
789
- 19,
790
- `Node "${node.name}" uses typeVersion ${node.typeVersion} for type "${node.type}" which is not in the known safe list`,
791
- node.id
792
- );
793
- }
932
+ if (this.registry.isVersionSafe(node.type, node.typeVersion)) continue;
933
+ if (!strict && this.registry.isVersionNewer(node.type, node.typeVersion)) continue;
934
+ this.warn(
935
+ issues,
936
+ 19,
937
+ `Node "${node.name}" uses typeVersion ${node.typeVersion} for type "${node.type}" which is not in the known safe list`,
938
+ node.id
939
+ );
794
940
  }
795
941
  }
796
942
  // Rule 20 (WARN): cycle detection — no node should be reachable from itself
@@ -839,6 +985,27 @@ var N8nValidator = class {
839
985
  }
840
986
  }
841
987
  }
988
+ // Rule 21 (WARN): webhook with responseMode="responseNode" must have respondToWebhook node
989
+ checkRule21(w, issues) {
990
+ if (!Array.isArray(w.nodes)) return;
991
+ const webhooksNeedingResponse = w.nodes.filter((n) => {
992
+ if (!n.type.includes("webhook")) return false;
993
+ const params = n.parameters;
994
+ return params?.responseMode === "responseNode";
995
+ });
996
+ if (webhooksNeedingResponse.length === 0) return;
997
+ const hasRespondNode = w.nodes.some((n) => n.type.includes("respondToWebhook"));
998
+ if (!hasRespondNode) {
999
+ for (const wh of webhooksNeedingResponse) {
1000
+ this.warn(
1001
+ issues,
1002
+ 21,
1003
+ `Webhook "${wh.name}" uses responseMode "responseNode" but no respondToWebhook node exists in the workflow`,
1004
+ wh.id
1005
+ );
1006
+ }
1007
+ }
1008
+ }
842
1009
  // Rule 22 (WARN): check requiredParams from registry
843
1010
  checkRule22(w, issues) {
844
1011
  if (!Array.isArray(w.nodes)) return;
@@ -947,23 +1114,162 @@ var N8nValidator = class {
947
1114
  walk(params);
948
1115
  return expressions;
949
1116
  }
950
- // Rule 21 (WARN): webhook with responseMode="responseNode" must have respondToWebhook node
951
- checkRule21(w, issues) {
1117
+ // Rule 27 (WARN): httpRequest URL is a placeholder
1118
+ checkRule27(w, issues) {
952
1119
  if (!Array.isArray(w.nodes)) return;
953
- const webhooksNeedingResponse = w.nodes.filter((n) => {
954
- if (!n.type.includes("webhook")) return false;
955
- const params = n.parameters;
956
- return params?.responseMode === "responseNode";
957
- });
958
- if (webhooksNeedingResponse.length === 0) return;
959
- const hasRespondNode = w.nodes.some((n) => n.type.includes("respondToWebhook"));
960
- if (!hasRespondNode) {
961
- for (const wh of webhooksNeedingResponse) {
1120
+ const PLACEHOLDER_RE = [
1121
+ /^https?:\/\/example\.com/i,
1122
+ /your[-_]?(api[-_]?)?url/i,
1123
+ /^https?:\/\/$/,
1124
+ /^<.+>$/,
1125
+ /placeholder/i
1126
+ ];
1127
+ for (const node of w.nodes) {
1128
+ if (node.type !== "n8n-nodes-base.httpRequest") continue;
1129
+ const params = node.parameters;
1130
+ const url = params?.["url"];
1131
+ if (typeof url !== "string" || url.trim() === "") continue;
1132
+ if (PLACEHOLDER_RE.some((re) => re.test(url.trim()))) {
962
1133
  this.warn(
963
1134
  issues,
964
- 21,
965
- `Webhook "${wh.name}" uses responseMode "responseNode" but no respondToWebhook node exists in the workflow`,
966
- wh.id
1135
+ 27,
1136
+ `Node "${node.name}" httpRequest URL appears to be a placeholder: "${url}" \u2014 replace with your actual endpoint`,
1137
+ node.id
1138
+ );
1139
+ }
1140
+ }
1141
+ }
1142
+ // Rule 28 (WARN): code node with empty or comment-only code
1143
+ checkRule28(w, issues) {
1144
+ if (!Array.isArray(w.nodes)) return;
1145
+ for (const node of w.nodes) {
1146
+ if (node.type !== "n8n-nodes-base.code") continue;
1147
+ const params = node.parameters;
1148
+ const jsCode = typeof params?.["jsCode"] === "string" ? params["jsCode"] : "";
1149
+ const pythonCode = typeof params?.["pythonCode"] === "string" ? params["pythonCode"] : "";
1150
+ const code = jsCode || pythonCode;
1151
+ const stripped = code.replace(/\/\/[^\n]*/g, "").replace(/\/\*[\s\S]*?\*\//g, "").replace(/#[^\n]*/g, "").trim();
1152
+ if (!stripped) {
1153
+ this.warn(issues, 28, `Node "${node.name}" code node has no executable code`, node.id);
1154
+ }
1155
+ }
1156
+ }
1157
+ // Rule 29 (WARN): slack node message operation missing channel
1158
+ checkRule29(w, issues) {
1159
+ if (!Array.isArray(w.nodes)) return;
1160
+ for (const node of w.nodes) {
1161
+ if (node.type !== "n8n-nodes-base.slack") continue;
1162
+ const params = node.parameters;
1163
+ const resource = params?.["resource"];
1164
+ const operation = params?.["operation"];
1165
+ const isMessageOp = resource === "message" || operation === "sendMessage" || operation === "post";
1166
+ if (!isMessageOp) continue;
1167
+ const channel = params?.["channel"] ?? params?.["channelId"];
1168
+ const rlValue = typeof channel === "object" && channel !== null ? channel["value"] : void 0;
1169
+ const isEmpty = channel === void 0 || channel === null || typeof channel === "string" && channel.trim() === "" || typeof channel === "object" && (!rlValue || typeof rlValue === "string" && rlValue.trim() === "");
1170
+ if (isEmpty) {
1171
+ this.warn(issues, 29, `Node "${node.name}" Slack message has no channel specified`, node.id);
1172
+ }
1173
+ }
1174
+ }
1175
+ // Rule 30 (WARN): gmail node send operation missing recipient
1176
+ checkRule30(w, issues) {
1177
+ if (!Array.isArray(w.nodes)) return;
1178
+ for (const node of w.nodes) {
1179
+ if (node.type !== "n8n-nodes-base.gmail") continue;
1180
+ const params = node.parameters;
1181
+ const operation = params?.["operation"];
1182
+ if (operation !== "send") continue;
1183
+ const to = params?.["to"] ?? params?.["toList"];
1184
+ const isEmpty = to === void 0 || to === null || typeof to === "string" && to.trim() === "" || Array.isArray(to) && to.length === 0;
1185
+ if (isEmpty) {
1186
+ this.warn(issues, 30, `Node "${node.name}" gmail send has no recipient (to) specified`, node.id);
1187
+ }
1188
+ }
1189
+ }
1190
+ // Rule 31 (WARN): if node with empty conditions
1191
+ checkRule31(w, issues) {
1192
+ if (!Array.isArray(w.nodes)) return;
1193
+ for (const node of w.nodes) {
1194
+ if (node.type !== "n8n-nodes-base.if") continue;
1195
+ const params = node.parameters;
1196
+ const conditions = params?.["conditions"];
1197
+ if (conditions === void 0 || conditions === null) {
1198
+ this.warn(issues, 31, `Node "${node.name}" if node has no conditions defined`, node.id);
1199
+ continue;
1200
+ }
1201
+ if (typeof conditions === "object" && !Array.isArray(conditions)) {
1202
+ const conds = conditions["conditions"];
1203
+ if (!Array.isArray(conds) || conds.length === 0) {
1204
+ this.warn(issues, 31, `Node "${node.name}" if node conditions array is empty`, node.id);
1205
+ }
1206
+ } else if (Array.isArray(conditions) && conditions.length === 0) {
1207
+ this.warn(issues, 31, `Node "${node.name}" if node conditions array is empty`, node.id);
1208
+ }
1209
+ }
1210
+ }
1211
+ // Rule 32 (WARN): set node with no assignments
1212
+ checkRule32(w, issues) {
1213
+ if (!Array.isArray(w.nodes)) return;
1214
+ for (const node of w.nodes) {
1215
+ if (node.type !== "n8n-nodes-base.set") continue;
1216
+ const params = node.parameters;
1217
+ const assignmentsObj = params?.["assignments"];
1218
+ const assignmentsArr = assignmentsObj?.["assignments"];
1219
+ const valuesObj = params?.["values"];
1220
+ const hasV1 = valuesObj && Object.values(valuesObj).some((v) => Array.isArray(v) && v.length > 0);
1221
+ const hasV3 = Array.isArray(assignmentsArr) && assignmentsArr.length > 0;
1222
+ if (!hasV1 && !hasV3) {
1223
+ this.warn(
1224
+ issues,
1225
+ 32,
1226
+ `Node "${node.name}" set node has no fields defined \u2014 it will pass data through unchanged`,
1227
+ node.id
1228
+ );
1229
+ }
1230
+ }
1231
+ }
1232
+ // Rule 33 (WARN): scheduleTrigger with no schedule rules
1233
+ checkRule33(w, issues) {
1234
+ if (!Array.isArray(w.nodes)) return;
1235
+ for (const node of w.nodes) {
1236
+ if (node.type !== "n8n-nodes-base.scheduleTrigger") continue;
1237
+ const params = node.parameters;
1238
+ const rule = params?.["rule"];
1239
+ const intervals = rule?.["interval"];
1240
+ if (!Array.isArray(intervals) || intervals.length === 0) {
1241
+ this.warn(issues, 33, `Node "${node.name}" scheduleTrigger has no schedule rules defined`, node.id);
1242
+ }
1243
+ }
1244
+ }
1245
+ // Rule 34 (WARN): webhook path contains spaces, starts with slash, or looks like a full URL
1246
+ checkRule34(w, issues) {
1247
+ if (!Array.isArray(w.nodes)) return;
1248
+ for (const node of w.nodes) {
1249
+ if (node.type !== "n8n-nodes-base.webhook") continue;
1250
+ const params = node.parameters;
1251
+ const path = params?.["path"];
1252
+ if (typeof path !== "string") continue;
1253
+ if (/\s/.test(path)) {
1254
+ this.warn(
1255
+ issues,
1256
+ 34,
1257
+ `Node "${node.name}" webhook path contains spaces: "${path}" \u2014 use hyphens or underscores instead`,
1258
+ node.id
1259
+ );
1260
+ } else if (/^https?:\/\//i.test(path)) {
1261
+ this.warn(
1262
+ issues,
1263
+ 34,
1264
+ `Node "${node.name}" webhook path looks like a full URL \u2014 it should be a relative path (e.g. "my-hook")`,
1265
+ node.id
1266
+ );
1267
+ } else if (path.startsWith("/")) {
1268
+ this.warn(
1269
+ issues,
1270
+ 34,
1271
+ `Node "${node.name}" webhook path starts with "/" \u2014 n8n adds the leading slash automatically`,
1272
+ node.id
967
1273
  );
968
1274
  }
969
1275
  }
@@ -1204,6 +1510,14 @@ Cron: { "rule": { "interval": [{ "field": "cronExpression", "expression": "0 9 *
1204
1510
  8. No deprecated $node["NodeName"].json \u2014 use $('NodeName').item.json.field
1205
1511
  9. No $json.items[0] array indexing \u2014 access fields directly as $json.field
1206
1512
  10. No bare $('NodeName').json \u2014 always use .first().json.field or .all()
1513
+ 11. httpRequest URL is a real endpoint (not "example.com" or "YOUR_URL")
1514
+ 12. code nodes contain actual logic \u2014 not empty or comment-only
1515
+ 13. Slack message nodes have a channel specified (channelId or channel)
1516
+ 14. Gmail send nodes have a recipient (to field non-empty)
1517
+ 15. if nodes have at least one condition in conditions.conditions[]
1518
+ 16. set nodes have at least one entry in assignments.assignments[]
1519
+ 17. scheduleTrigger has at least one rule in rule.interval[]
1520
+ 18. webhook path is relative (no spaces, no leading slash, no http://)
1207
1521
 
1208
1522
  ---
1209
1523
 
@@ -1220,7 +1534,7 @@ function scoreToMode(score) {
1220
1534
  }
1221
1535
 
1222
1536
  // src/validation/rule-metadata.ts
1223
- var VALIDATOR_RULE_IDS = Array.from({ length: 26 }, (_, i) => i + 1);
1537
+ var VALIDATOR_RULE_IDS = Array.from({ length: 34 }, (_, i) => i + 1);
1224
1538
  var RULE_PIPELINE_STAGES = {
1225
1539
  1: "node_generation",
1226
1540
  2: "node_generation",
@@ -1247,7 +1561,15 @@ var RULE_PIPELINE_STAGES = {
1247
1561
  23: "node_generation",
1248
1562
  24: "expression_syntax",
1249
1563
  25: "expression_syntax",
1250
- 26: "expression_syntax"
1564
+ 26: "expression_syntax",
1565
+ 27: "node_generation",
1566
+ 28: "node_generation",
1567
+ 29: "node_generation",
1568
+ 30: "node_generation",
1569
+ 31: "node_generation",
1570
+ 32: "node_generation",
1571
+ 33: "node_generation",
1572
+ 34: "node_generation"
1251
1573
  };
1252
1574
  var RULE_EXAMPLES = {
1253
1575
  17: {
@@ -1265,6 +1587,38 @@ var RULE_EXAMPLES = {
1265
1587
  26: {
1266
1588
  bad: "$('Fetch Data').json.email",
1267
1589
  good: "$('Fetch Data').first().json.email"
1590
+ },
1591
+ 27: {
1592
+ bad: '"url": "https://example.com/api/data"',
1593
+ good: '"url": "https://api.yourservice.com/v1/endpoint"'
1594
+ },
1595
+ 28: {
1596
+ bad: '"jsCode": "// TODO: implement this"',
1597
+ good: '"jsCode": "return items.map(item => ({ json: { result: item.json.value * 2 } }))"'
1598
+ },
1599
+ 29: {
1600
+ bad: '"channelId": ""',
1601
+ good: '"channelId": { "__rl": true, "value": "C0123456789", "mode": "id" }'
1602
+ },
1603
+ 30: {
1604
+ bad: '"operation": "send", "to": ""',
1605
+ good: '"operation": "send", "to": "recipient@example.com"'
1606
+ },
1607
+ 31: {
1608
+ bad: '"conditions": { "combinator": "and", "conditions": [] }',
1609
+ good: '"conditions": { "combinator": "and", "conditions": [{ "leftValue": "={{ $json.status }}", "rightValue": "active", "operator": { "type": "string", "operation": "equals" } }] }'
1610
+ },
1611
+ 32: {
1612
+ bad: '"assignments": { "assignments": [] }',
1613
+ good: '"assignments": { "assignments": [{ "id": "f1", "name": "status", "value": "processed", "type": "string" }] }'
1614
+ },
1615
+ 33: {
1616
+ bad: '"rule": { "interval": [] }',
1617
+ good: '"rule": { "interval": [{ "field": "cronExpression", "expression": "0 9 * * 1-5" }] }'
1618
+ },
1619
+ 34: {
1620
+ bad: '"path": "/my webhook"',
1621
+ good: '"path": "my-webhook"'
1268
1622
  }
1269
1623
  };
1270
1624
  var RULE_MITIGATIONS = {
@@ -1293,7 +1647,15 @@ var RULE_MITIGATIONS = {
1293
1647
  23: "Use node types that exist in the n8n registry \u2014 check with kairos_sync",
1294
1648
  24: 'Use modern accessor syntax: $("NodeName").item.json.field instead of deprecated $node["NodeName"].json.field',
1295
1649
  25: "Access item fields directly with $json.field \u2014 n8n flattens items automatically, do not use $json.items[0]",
1296
- 26: 'Use $("NodeName").first().json.field or $("NodeName").all() \u2014 bare $("NodeName").json without .first() or .all() throws at runtime'
1650
+ 26: 'Use $("NodeName").first().json.field or $("NodeName").all() \u2014 bare $("NodeName").json without .first() or .all() throws at runtime',
1651
+ 27: 'Replace placeholder URLs with your actual API endpoint \u2014 do not use "example.com" or "YOUR_URL" patterns',
1652
+ 28: "Add executable code to the code node \u2014 empty or comment-only code nodes do nothing at runtime",
1653
+ 29: "Set the channel parameter for Slack message operations (channelId with __rl object, or channel as string)",
1654
+ 30: "Set the to parameter for Gmail send operations with at least one recipient email address",
1655
+ 31: "Add at least one condition to the if node \u2014 conditions.conditions array must be non-empty",
1656
+ 32: "Add field assignments to the set node \u2014 assignments.assignments array must be non-empty for typeVersion 3.x",
1657
+ 33: "Add at least one schedule rule to scheduleTrigger \u2014 rule.interval array must have at least one entry",
1658
+ 34: 'Webhook path must be a relative path without spaces, leading slashes, or protocol prefixes (e.g. "my-hook")'
1297
1659
  };
1298
1660
 
1299
1661
  // src/generation/prompt-builder.ts
@@ -1325,18 +1687,37 @@ var PromptBuilder = class {
1325
1687
  }
1326
1688
  build(request, matches, globalFailureRates = [], dynamicCatalog) {
1327
1689
  const mode = this.resolveMode(matches);
1328
- const system = this.buildSystem(matches, mode, globalFailureRates, dynamicCatalog);
1690
+ const system = this.buildSystem(matches, mode, globalFailureRates, dynamicCatalog, request.description);
1329
1691
  const userMessage = this.buildUserMessage(request, matches, mode);
1330
1692
  return { system, userMessage, mode, matches };
1331
1693
  }
1332
- buildCorrectionMessage(request, matches, allIssues, attempt) {
1694
+ buildCorrectionMessage(request, matches, allIssues, attempt, failingRuleIds) {
1333
1695
  const base = this.buildUserMessage(request, matches, this.resolveMode(matches));
1696
+ let examplesSection = "";
1697
+ if (failingRuleIds && failingRuleIds.length > 0) {
1698
+ const uniqueRules = [...new Set(failingRuleIds)];
1699
+ const exampleLines = [];
1700
+ for (const rule of uniqueRules) {
1701
+ const ex = RULE_EXAMPLES[rule];
1702
+ if (ex) {
1703
+ exampleLines.push(`Rule ${rule}:
1704
+ Bad: ${ex.bad}
1705
+ Good: ${ex.good}`);
1706
+ }
1707
+ }
1708
+ if (exampleLines.length > 0) {
1709
+ examplesSection = `
1710
+
1711
+ ## Concrete Fix Examples
1712
+ ${exampleLines.join("\n\n")}`;
1713
+ }
1714
+ }
1334
1715
  return `${base}
1335
1716
 
1336
1717
  IMPORTANT: A previous generation attempt (attempt ${attempt}) failed validation with these issues:
1337
1718
  ${allIssues.join("\n")}
1338
1719
 
1339
- Fix ALL of the above issues in your new response. Do not repeat any of these mistakes.`;
1720
+ Fix ALL of the above issues in your new response. Do not repeat any of these mistakes.${examplesSection}`;
1340
1721
  }
1341
1722
  resolveMode(matches) {
1342
1723
  if (matches.length === 0) return "scratch";
@@ -1344,7 +1725,7 @@ Fix ALL of the above issues in your new response. Do not repeat any of these mis
1344
1725
  if (!top) return "scratch";
1345
1726
  return scoreToMode(top.score);
1346
1727
  }
1347
- buildSystem(matches, mode, globalFailureRates = [], dynamicCatalog) {
1728
+ buildSystem(matches, mode, globalFailureRates = [], dynamicCatalog, description) {
1348
1729
  let basePrompt = SYSTEM_PROMPT_V1;
1349
1730
  if (dynamicCatalog) {
1350
1731
  basePrompt = basePrompt.replace(
@@ -1404,7 +1785,7 @@ A loosely similar workflow (score: ${hint.score.toFixed(2)}) used these node typ
1404
1785
  });
1405
1786
  }
1406
1787
  }
1407
- const warnings = this.buildFailureWarnings(matches, globalFailureRates);
1788
+ const warnings = this.buildFailureWarnings(matches, globalFailureRates, description);
1408
1789
  if (warnings) {
1409
1790
  blocks.push({ type: "text", text: warnings });
1410
1791
  }
@@ -1431,15 +1812,34 @@ A loosely similar workflow (score: ${hint.score.toFixed(2)}) used these node typ
1431
1812
  const patterns = this._lastActivePatterns ?? this.getActivePatterns(this.resolveMaxPatterns());
1432
1813
  return patterns.map((p) => p.rule);
1433
1814
  }
1434
- getActivePatterns(maxCount = 10) {
1815
+ getActivePatterns(maxCount = 10, description) {
1435
1816
  const all = this.loadPatterns().filter((p) => p.state !== "resolved" && p.confidence > 0);
1436
1817
  const regressed = all.filter((p) => p.regressed).sort((a, b) => b.compositeScore - a.compositeScore);
1437
1818
  const confirmed = all.filter((p) => !p.regressed && p.state === "confirmed").sort((a, b) => b.compositeScore - a.compositeScore);
1438
1819
  const drafts = all.filter((p) => !p.regressed && p.state !== "confirmed").sort((a, b) => b.compositeScore - a.compositeScore);
1439
- return [...regressed, ...confirmed, ...drafts].slice(0, maxCount);
1440
- }
1441
- buildFailureWarnings(matches, globalFailureRates) {
1442
- const richPatterns = this.getActivePatterns(this.resolveMaxPatterns());
1820
+ const ordered = [...regressed, ...confirmed, ...drafts];
1821
+ if (this.profile === "minimal" && description) {
1822
+ return this.rankByRelevance(ordered, description).slice(0, maxCount);
1823
+ }
1824
+ return ordered.slice(0, maxCount);
1825
+ }
1826
+ rankByRelevance(patterns, description) {
1827
+ const lower = description.toLowerCase();
1828
+ const STAGE_KEYWORDS = {
1829
+ credential_injection: ["credential", "auth", "api key", "token", "oauth", "smtp", "imap", "password", "secret"],
1830
+ connection_wiring: ["connect", "link", "wire", "chain", "merge", "branch", "join"],
1831
+ expression_syntax: ["expression", "variable", "json", "field", "data", "$json", "item"],
1832
+ workflow_structure: ["trigger", "webhook", "schedule", "structure", "workflow"],
1833
+ node_generation: ["node", "generate", "create", "build", "send", "fetch", "email", "slack", "http"]
1834
+ };
1835
+ return patterns.map((p) => {
1836
+ const keywords = STAGE_KEYWORDS[p.pipelineStage] ?? [];
1837
+ const relevanceBoost = keywords.some((kw) => lower.includes(kw)) ? 1 : 0;
1838
+ return { pattern: p, sort: relevanceBoost * 10 + p.compositeScore };
1839
+ }).sort((a, b) => b.sort - a.sort).map((x) => x.pattern);
1840
+ }
1841
+ buildFailureWarnings(matches, globalFailureRates, description) {
1842
+ const richPatterns = this.getActivePatterns(this.resolveMaxPatterns(), description);
1443
1843
  this._lastActivePatterns = richPatterns;
1444
1844
  if (richPatterns.length > 0) {
1445
1845
  return this.buildStageGroupedWarnings(richPatterns, matches);
@@ -1619,7 +2019,8 @@ var WorkflowDesigner = class {
1619
2019
  const issueLines = lastErrors.map(
1620
2020
  (i) => `- [Rule ${i.rule}] ${i.message}${i.nodeId ? ` (node: ${i.nodeId})` : ""}`
1621
2021
  );
1622
- userMessage = this.promptBuilder.buildCorrectionMessage(request, matches, issueLines, attempt - 1);
2022
+ const failingRuleIds = lastErrors.map((i) => i.rule);
2023
+ userMessage = this.promptBuilder.buildCorrectionMessage(request, matches, issueLines, attempt - 1, failingRuleIds);
1623
2024
  this.logger.debug(`WorkflowDesigner: correction attempt ${attempt}`, { issueCount: lastErrors.length });
1624
2025
  }
1625
2026
  const start = Date.now();
@@ -1805,19 +2206,20 @@ var TelemetryReader = class {
1805
2206
  }
1806
2207
  const events = await this.readRecentEvents(days);
1807
2208
  const buildSessions = new Set(
1808
- events.filter((e) => e.eventType === "build_complete").map((e) => e.sessionId)
2209
+ events.filter((e) => e.eventType === "build_complete").map((e) => e.runId ?? e.sessionId)
1809
2210
  );
1810
2211
  const MIN_BUILDS_FOR_RATES = 3;
1811
2212
  if (buildSessions.size < MIN_BUILDS_FOR_RATES) return [];
1812
2213
  const ruleSessions = /* @__PURE__ */ new Map();
1813
2214
  for (const event of events) {
1814
2215
  if (event.eventType !== "generation_attempt") continue;
1815
- if (!buildSessions.has(event.sessionId)) continue;
2216
+ const eventKey = event.runId ?? event.sessionId;
2217
+ if (!buildSessions.has(eventKey)) continue;
1816
2218
  const data = event.data;
1817
2219
  if (data.validationPassed || !data.issues) continue;
1818
2220
  for (const issue of data.issues) {
1819
2221
  const entry = ruleSessions.get(issue.rule) ?? { sessions: /* @__PURE__ */ new Set(), messages: /* @__PURE__ */ new Map() };
1820
- entry.sessions.add(event.sessionId);
2222
+ entry.sessions.add(eventKey);
1821
2223
  entry.messages.set(issue.message, (entry.messages.get(issue.message) ?? 0) + 1);
1822
2224
  ruleSessions.set(issue.rule, entry);
1823
2225
  }
@@ -1859,22 +2261,24 @@ var PatternAnalyzer = class _PatternAnalyzer {
1859
2261
  telemetryDir;
1860
2262
  outputDir;
1861
2263
  _cachedEvents = null;
2264
+ _cachedPreviousPatterns = null;
1862
2265
  constructor(telemetryDir) {
1863
2266
  const defaultDir = (0, import_node_path5.join)((0, import_node_os4.homedir)(), ".kairos", "telemetry");
1864
2267
  this.telemetryDir = telemetryDir ?? defaultDir;
1865
2268
  this.outputDir = telemetryDir ? (0, import_node_path5.join)(telemetryDir, "..") : (0, import_node_path5.join)((0, import_node_os4.homedir)(), ".kairos");
1866
2269
  }
1867
2270
  async loadPreviousPatterns() {
2271
+ if (this._cachedPreviousPatterns !== null) return this._cachedPreviousPatterns;
1868
2272
  try {
1869
2273
  const raw = await (0, import_promises3.readFile)((0, import_node_path5.join)(this.outputDir, "patterns.json"), "utf-8");
1870
2274
  const prev = JSON.parse(raw);
1871
2275
  const version = prev.schemaVersion ?? 0;
1872
2276
  const patterns = prev.topFailureRules ?? [];
1873
- if (version === PATTERN_SCHEMA_VERSION) return patterns;
1874
- return this.migratePatterns(patterns, version);
2277
+ this._cachedPreviousPatterns = version === PATTERN_SCHEMA_VERSION ? patterns : this.migratePatterns(patterns, version);
1875
2278
  } catch {
1876
- return [];
2279
+ this._cachedPreviousPatterns = [];
1877
2280
  }
2281
+ return this._cachedPreviousPatterns;
1878
2282
  }
1879
2283
  migratePatterns(patterns, fromVersion) {
1880
2284
  let migrated = patterns;
@@ -2174,6 +2578,7 @@ var PatternAnalyzer = class _PatternAnalyzer {
2174
2578
  const tmpPath = `${outputPath}.tmp`;
2175
2579
  await (0, import_promises3.writeFile)(tmpPath, JSON.stringify(analysis, null, 2), "utf-8");
2176
2580
  await (0, import_promises3.rename)(tmpPath, outputPath);
2581
+ this._cachedPreviousPatterns = null;
2177
2582
  const historySummary = {
2178
2583
  timestamp: analysis.generatedAt,
2179
2584
  totalBuilds: analysis.summary.totalBuilds,
@@ -2222,7 +2627,7 @@ var PatternAnalyzer = class _PatternAnalyzer {
2222
2627
  })
2223
2628
  ));
2224
2629
  return {
2225
- sessionId: bc.sessionId,
2630
+ sessionId: bc.runId ?? bc.sessionId,
2226
2631
  date: bc.fileDate,
2227
2632
  description: data.description ?? "",
2228
2633
  workflowType: data.workflowType ?? null,
@@ -2255,7 +2660,7 @@ var PatternAnalyzer = class _PatternAnalyzer {
2255
2660
  alerts.push({
2256
2661
  type: "stale_pattern",
2257
2662
  rule: p.rule,
2258
- message: `Pattern references Rule ${p.rule} which does not exist in the current validator (rules 1-26)`
2663
+ message: `Pattern references Rule ${p.rule} which does not exist in the current validator (rules 1-34)`
2259
2664
  });
2260
2665
  }
2261
2666
  }
@@ -2395,7 +2800,7 @@ function inferWorkflowType(description) {
2395
2800
  // src/client.ts
2396
2801
  var import_node_os5 = require("os");
2397
2802
  var import_node_path6 = require("path");
2398
- var DEFAULT_MODEL = "claude-sonnet-4-6";
2803
+ var DEFAULT_MODEL = process.env["KAIROS_MODEL"] ?? "claude-sonnet-4-6";
2399
2804
  var Kairos = class {
2400
2805
  provider;
2401
2806
  designer;
@@ -2553,10 +2958,19 @@ var Kairos = class {
2553
2958
  }
2554
2959
  const provider = this.requireProvider();
2555
2960
  const deployed = await provider.deploy(workflow);
2556
- this.recordDeploy();
2961
+ this.logger.info("Workflow deployed to n8n", { workflowId: deployed.workflowId, name: deployed.name });
2962
+ this.recordDeploy(deployed.workflowId);
2557
2963
  if (options?.activate) {
2558
2964
  await provider.activate(deployed.workflowId);
2559
2965
  }
2966
+ let smokeTestResult;
2967
+ if (options?.smokeTest) {
2968
+ smokeTestResult = await provider.smokeTest(deployed.workflowId, workflow).catch((err) => {
2969
+ this.logger.warn("Smoke test threw unexpectedly", { err: String(err) });
2970
+ return { status: "error", triggerType: "manual", error: String(err) };
2971
+ });
2972
+ this.logger.info("Smoke test complete", { status: smokeTestResult.status, triggerType: smokeTestResult.triggerType });
2973
+ }
2560
2974
  const totalTokensInput = designResult.attemptMetadata.reduce((s, m) => s + m.tokensInput, 0);
2561
2975
  const totalTokensOutput = designResult.attemptMetadata.reduce((s, m) => s + m.tokensOutput, 0);
2562
2976
  await this.telemetry?.emit("build_complete", {
@@ -2581,7 +2995,8 @@ var Kairos = class {
2581
2995
  credentialsNeeded: designResult.credentialsNeeded,
2582
2996
  activationRequired: !options?.activate,
2583
2997
  generationAttempts: designResult.attempts,
2584
- dryRun: false
2998
+ dryRun: false,
2999
+ ...smokeTestResult !== void 0 ? { smokeTest: smokeTestResult } : {}
2585
3000
  };
2586
3001
  }
2587
3002
  async replace(id, description) {
@@ -2638,7 +3053,8 @@ var Kairos = class {
2638
3053
  await this.emitAttemptTelemetry(description, designResult, workflowType, runId);
2639
3054
  const provider = this.requireProvider();
2640
3055
  const deployed = await provider.update(id, designResult.workflow);
2641
- this.saveToLibrary(designResult.workflow, description, designResult, matches);
3056
+ this.logger.info("Workflow updated in n8n", { workflowId: deployed.workflowId, name: deployed.name });
3057
+ this.saveToLibrary(designResult.workflow, description, designResult, matches, deployed.workflowId);
2642
3058
  this.recordDeploy();
2643
3059
  const totalTokensInput = designResult.attemptMetadata.reduce((s, m) => s + m.tokensInput, 0);
2644
3060
  const totalTokensOutput = designResult.attemptMetadata.reduce((s, m) => s + m.tokensOutput, 0);
@@ -2694,10 +3110,10 @@ var Kairos = class {
2694
3110
  }, runId);
2695
3111
  }
2696
3112
  }
2697
- recordDeploy() {
3113
+ recordDeploy(n8nWorkflowId) {
2698
3114
  this.saveQueue = this.saveQueue.then(async (savedId) => {
2699
3115
  if (savedId) {
2700
- await this.library.recordDeployment(savedId);
3116
+ await this.library.recordDeployment(savedId, n8nWorkflowId);
2701
3117
  }
2702
3118
  return savedId;
2703
3119
  }).catch((err) => {
@@ -2705,7 +3121,7 @@ var Kairos = class {
2705
3121
  return null;
2706
3122
  });
2707
3123
  }
2708
- saveToLibrary(workflow, description, designResult, matches) {
3124
+ saveToLibrary(workflow, description, designResult, matches, n8nWorkflowId) {
2709
3125
  const failedAttempts = designResult.attemptMetadata.filter((m) => !m.validationPassed);
2710
3126
  const failurePatterns = failedAttempts.flatMap(
2711
3127
  (m) => m.issues.map((i) => ({ rule: i.rule, message: i.message }))
@@ -2731,6 +3147,7 @@ var Kairos = class {
2731
3147
  if (matches.length > 0) metadata.sourceWorkflowIds = matches.map((m) => m.workflow.id);
2732
3148
  if (topMatch) metadata.topMatchScore = topMatch.score;
2733
3149
  if (designResult.credentialsNeeded.length > 0) metadata.credentialsNeeded = designResult.credentialsNeeded;
3150
+ if (n8nWorkflowId) metadata.n8nWorkflowId = n8nWorkflowId;
2734
3151
  const firstTryPass = designResult.attemptMetadata.length > 0 && designResult.attemptMetadata[0].validationPassed;
2735
3152
  const failedRules = Array.from(new Set(
2736
3153
  designResult.attemptMetadata.filter((m) => !m.validationPassed).flatMap((m) => m.issues.map((i) => i.rule))
@@ -2794,12 +3211,32 @@ var import_node_path7 = require("path");
2794
3211
  var import_node_os6 = require("os");
2795
3212
 
2796
3213
  // src/library/scorer.ts
2797
- var WEIGHTS = {
2798
- tfidf: 0.35,
2799
- nodeFingerprint: 0.3,
2800
- outcome: 0.2,
2801
- deploy: 0.15
2802
- };
3214
+ function loadWeights() {
3215
+ const raw = {
3216
+ tfidf: parseFloat(process.env["KAIROS_WEIGHT_TFIDF"] ?? ""),
3217
+ nodeFingerprint: parseFloat(process.env["KAIROS_WEIGHT_JACCARD"] ?? ""),
3218
+ outcome: parseFloat(process.env["KAIROS_WEIGHT_OUTCOME"] ?? ""),
3219
+ deploy: parseFloat(process.env["KAIROS_WEIGHT_DEPLOY"] ?? "")
3220
+ };
3221
+ const defaults = { tfidf: 0.35, nodeFingerprint: 0.3, outcome: 0.2, deploy: 0.15 };
3222
+ const anySet = Object.values(raw).some((v) => !isNaN(v) && v >= 0);
3223
+ if (!anySet) return defaults;
3224
+ const w = {
3225
+ tfidf: !isNaN(raw.tfidf) && raw.tfidf >= 0 ? raw.tfidf : defaults.tfidf,
3226
+ nodeFingerprint: !isNaN(raw.nodeFingerprint) && raw.nodeFingerprint >= 0 ? raw.nodeFingerprint : defaults.nodeFingerprint,
3227
+ outcome: !isNaN(raw.outcome) && raw.outcome >= 0 ? raw.outcome : defaults.outcome,
3228
+ deploy: !isNaN(raw.deploy) && raw.deploy >= 0 ? raw.deploy : defaults.deploy
3229
+ };
3230
+ const total = w.tfidf + w.nodeFingerprint + w.outcome + w.deploy;
3231
+ if (total <= 0) return defaults;
3232
+ return {
3233
+ tfidf: w.tfidf / total,
3234
+ nodeFingerprint: w.nodeFingerprint / total,
3235
+ outcome: w.outcome / total,
3236
+ deploy: w.deploy / total
3237
+ };
3238
+ }
3239
+ var WEIGHTS = loadWeights();
2803
3240
  var NODE_KEYWORDS = {
2804
3241
  slack: ["slack", "slackApi"],
2805
3242
  email: ["gmail", "sendEmail", "emailSend", "emailReadImap"],
@@ -2984,6 +3421,8 @@ function clusterWorkflows(workflows) {
2984
3421
  }
2985
3422
  return clusters.sort((a, b) => b.members.length - a.members.length);
2986
3423
  }
3424
+ var NOVELTY_BOOST = 0.05;
3425
+ var NOVELTY_PENALTY = 0.03;
2987
3426
  function rerank(candidates, clusters) {
2988
3427
  const clusterMap = /* @__PURE__ */ new Map();
2989
3428
  for (const cluster of clusters) {
@@ -2991,7 +3430,7 @@ function rerank(candidates, clusters) {
2991
3430
  clusterMap.set(member.id, cluster);
2992
3431
  }
2993
3432
  }
2994
- return candidates.map((c) => {
3433
+ const pass1 = candidates.map((c) => {
2995
3434
  const cluster = clusterMap.get(c.workflow.id);
2996
3435
  let boost = 0;
2997
3436
  if (cluster && cluster.avgFirstTryPassRate > 0) {
@@ -3003,7 +3442,25 @@ function rerank(candidates, clusters) {
3003
3442
  return {
3004
3443
  workflow: c.workflow,
3005
3444
  score: Math.max(0, Math.min(1, c.score + boost)),
3006
- ...cluster ? { clusterPattern: cluster.pattern } : {}
3445
+ cluster
3446
+ };
3447
+ }).sort((a, b) => b.score - a.score);
3448
+ const seenFingerprints = /* @__PURE__ */ new Set();
3449
+ return pass1.map((c) => {
3450
+ const fpKey = c.cluster ? fingerprintKey(c.cluster.fingerprint) : null;
3451
+ let noveltyAdjust = 0;
3452
+ if (fpKey !== null) {
3453
+ if (!seenFingerprints.has(fpKey)) {
3454
+ seenFingerprints.add(fpKey);
3455
+ noveltyAdjust = NOVELTY_BOOST;
3456
+ } else {
3457
+ noveltyAdjust = -NOVELTY_PENALTY;
3458
+ }
3459
+ }
3460
+ return {
3461
+ workflow: c.workflow,
3462
+ score: Math.max(0, Math.min(1, c.score + noveltyAdjust)),
3463
+ ...c.cluster ? { clusterPattern: c.cluster.pattern } : {}
3007
3464
  };
3008
3465
  }).sort((a, b) => b.score - a.score);
3009
3466
  }
@@ -3020,7 +3477,11 @@ function buildSearchCorpus(w) {
3020
3477
  });
3021
3478
  return `${w.description} ${w.workflow.name} ${w.tags.join(" ")} ${nodeTokens.join(" ")}`;
3022
3479
  }
3023
- var MAX_LIBRARY_SIZE = 500;
3480
+ var _rawSize = parseInt(process.env["KAIROS_LIBRARY_SIZE"] ?? "500", 10);
3481
+ var MAX_LIBRARY_SIZE = Number.isFinite(_rawSize) && _rawSize >= 10 ? _rawSize : 500;
3482
+ function evictionScore(m) {
3483
+ return (m.deployCount ?? 0) * 3 + (m.timesRetrieved ?? 0) + (m.outcomeStats?.totalUses ?? 0);
3484
+ }
3024
3485
  function isValidMeta(item) {
3025
3486
  return typeof item === "object" && item !== null && typeof item.id === "string" && typeof item.description === "string" && typeof item.workflowName === "string" && Array.isArray(item.cachedNodeTypes);
3026
3487
  }
@@ -3068,6 +3529,7 @@ var FileLibrary = class {
3068
3529
  } catch {
3069
3530
  this.meta = [];
3070
3531
  }
3532
+ await this.scanForOrphansAndCleanup();
3071
3533
  } else {
3072
3534
  try {
3073
3535
  const raw = await (0, import_promises4.readFile)(indexPath, "utf-8");
@@ -3082,6 +3544,31 @@ var FileLibrary = class {
3082
3544
  await (0, import_promises4.mkdir)(this.workflowsDir, { recursive: true });
3083
3545
  }
3084
3546
  }
3547
+ async scanForOrphansAndCleanup() {
3548
+ let entries;
3549
+ try {
3550
+ entries = await (0, import_promises4.readdir)(this.workflowsDir);
3551
+ } catch {
3552
+ return;
3553
+ }
3554
+ const indexedIds = new Set(this.meta.map((m) => m.id));
3555
+ const orphanIds = [];
3556
+ for (const filename of entries) {
3557
+ if (filename.endsWith(".tmp")) {
3558
+ await (0, import_promises4.unlink)((0, import_node_path7.join)(this.workflowsDir, filename)).catch(() => {
3559
+ });
3560
+ continue;
3561
+ }
3562
+ if (!filename.endsWith(".json")) continue;
3563
+ const id = filename.slice(0, -5);
3564
+ if (!indexedIds.has(id)) {
3565
+ orphanIds.push(id);
3566
+ }
3567
+ }
3568
+ if (orphanIds.length > 0) {
3569
+ console.warn(`[FileLibrary] Found ${orphanIds.length} orphaned workflow file(s) not in index: ${orphanIds.join(", ")}`);
3570
+ }
3571
+ }
3085
3572
  /**
3086
3573
  * One-time transparent migration from v0.4.x monolithic index.json.
3087
3574
  * Splits each stored workflow into a per-file workflow JSON and a lightweight
@@ -3152,10 +3639,12 @@ var FileLibrary = class {
3152
3639
  const docTokenSets = docTokenArrays.map((tokens) => new Set(tokens));
3153
3640
  const docCount = shells.length;
3154
3641
  const idf = /* @__PURE__ */ new Map();
3642
+ const idfCeiling = Math.log(docCount + 1) + 1;
3155
3643
  const allTokens = new Set(queryTokens);
3156
3644
  for (const token of allTokens) {
3157
3645
  const docsWithToken = docTokenSets.filter((d) => d.has(token)).length;
3158
- idf.set(token, Math.log((docCount + 1) / (docsWithToken + 1)) + 1);
3646
+ const rawIdf = Math.log((docCount + 1) / (docsWithToken + 1)) + 1;
3647
+ idf.set(token, rawIdf / idfCeiling);
3159
3648
  }
3160
3649
  const scored = hybridScore(queryTokens, description, shells, docTokenArrays, idf).filter((m) => m.score > 0).sort((a, b) => b.score - a.score);
3161
3650
  const clusters = clusterWorkflows(shells);
@@ -3181,6 +3670,27 @@ var FileLibrary = class {
3181
3670
  return results.filter((r) => r !== null);
3182
3671
  }
3183
3672
  async save(workflow, metadata) {
3673
+ const existingByN8nId = metadata.n8nWorkflowId ? this.meta.find((m) => m.n8nWorkflowId === metadata.n8nWorkflowId) : void 0;
3674
+ const normalizedDesc = metadata.description.trim().toLowerCase();
3675
+ const existing = existingByN8nId ?? this.meta.find((m) => m.description.trim().toLowerCase() === normalizedDesc);
3676
+ if (existing) {
3677
+ existing.description = metadata.description;
3678
+ existing.workflowName = workflow.name;
3679
+ existing.cachedNodeTypes = workflow.nodes.map((n) => n.type);
3680
+ if (metadata.n8nWorkflowId) existing.n8nWorkflowId = metadata.n8nWorkflowId;
3681
+ if (metadata.generationAttempts != null) {
3682
+ existing.generationAttempts = metadata.generationAttempts;
3683
+ }
3684
+ if (metadata.failurePatterns?.length) {
3685
+ existing.failurePatterns = this.deduplicateFailurePatterns(metadata.failurePatterns);
3686
+ }
3687
+ if (metadata.tags?.length) {
3688
+ existing.tags = [.../* @__PURE__ */ new Set([...existing.tags, ...metadata.tags])];
3689
+ }
3690
+ await this.writeWorkflowFile(existing.id, workflow);
3691
+ await this.persist();
3692
+ return existing.id;
3693
+ }
3184
3694
  const id = generateUUID();
3185
3695
  await this.writeWorkflowFile(id, workflow);
3186
3696
  const failurePatterns = this.deduplicateFailurePatterns(metadata.failurePatterns);
@@ -3202,25 +3712,27 @@ var FileLibrary = class {
3202
3712
  ...metadata.sourceKind ? { sourceKind: metadata.sourceKind } : {},
3203
3713
  ...metadata.sourceId ? { sourceId: metadata.sourceId } : {},
3204
3714
  ...metadata.sourceUrl ? { sourceUrl: metadata.sourceUrl } : {},
3205
- ...metadata.trustLevel ? { trustLevel: metadata.trustLevel } : {}
3715
+ ...metadata.trustLevel ? { trustLevel: metadata.trustLevel } : {},
3716
+ ...metadata.n8nWorkflowId ? { n8nWorkflowId: metadata.n8nWorkflowId } : {}
3206
3717
  };
3207
3718
  this.meta.push(meta);
3208
3719
  if (this.meta.length > MAX_LIBRARY_SIZE) {
3209
3720
  this.meta.sort((a, b) => {
3210
3721
  if (a.id === id) return -1;
3211
3722
  if (b.id === id) return 1;
3212
- return (b.deployCount ?? 0) - (a.deployCount ?? 0);
3723
+ return evictionScore(b) - evictionScore(a);
3213
3724
  });
3214
3725
  this.meta = this.meta.slice(0, MAX_LIBRARY_SIZE);
3215
3726
  }
3216
3727
  await this.persist();
3217
3728
  return id;
3218
3729
  }
3219
- async recordDeployment(id) {
3730
+ async recordDeployment(id, n8nWorkflowId) {
3220
3731
  const m = this.meta.find((m2) => m2.id === id);
3221
3732
  if (m) {
3222
3733
  m.deployCount++;
3223
3734
  m.lastDeployedAt = (/* @__PURE__ */ new Date()).toISOString();
3735
+ if (n8nWorkflowId) m.n8nWorkflowId = n8nWorkflowId;
3224
3736
  await this.persist();
3225
3737
  }
3226
3738
  }
@@ -3283,37 +3795,98 @@ var FileLibrary = class {
3283
3795
  }
3284
3796
  return [...map.values()];
3285
3797
  }
3798
+ // ── Cross-process file locking ────────────────────────────────────────────
3799
+ // Uses O_EXCL (exclusive create) which is atomic on POSIX and Windows NTFS.
3800
+ // Protects the read-modify-write cycle in persist() from concurrent writers
3801
+ // in separate OS processes (e.g. MCP server + CLI running simultaneously).
3802
+ get lockPath() {
3803
+ return (0, import_node_path7.join)(this.dir, ".index.lock");
3804
+ }
3805
+ async acquireLock(timeoutMs = 3e3) {
3806
+ const deadline = Date.now() + timeoutMs;
3807
+ let delayMs = 10;
3808
+ while (true) {
3809
+ try {
3810
+ const fh = await (0, import_promises4.open)(this.lockPath, "wx");
3811
+ await fh.writeFile(String(process.pid));
3812
+ await fh.close();
3813
+ return async () => {
3814
+ await (0, import_promises4.unlink)(this.lockPath).catch(() => {
3815
+ });
3816
+ };
3817
+ } catch {
3818
+ try {
3819
+ const content = await (0, import_promises4.readFile)(this.lockPath, "utf-8");
3820
+ const lockPid = parseInt(content.trim(), 10);
3821
+ const fileStat = await (0, import_promises4.stat)(this.lockPath);
3822
+ const ageMs = Date.now() - fileStat.mtimeMs;
3823
+ if (ageMs > 1e4) {
3824
+ await (0, import_promises4.unlink)(this.lockPath).catch(() => {
3825
+ });
3826
+ continue;
3827
+ }
3828
+ if (!isNaN(lockPid)) {
3829
+ try {
3830
+ process.kill(lockPid, 0);
3831
+ } catch {
3832
+ await (0, import_promises4.unlink)(this.lockPath).catch(() => {
3833
+ });
3834
+ continue;
3835
+ }
3836
+ }
3837
+ } catch {
3838
+ continue;
3839
+ }
3840
+ if (Date.now() > deadline) {
3841
+ return async () => {
3842
+ };
3843
+ }
3844
+ await new Promise((r) => setTimeout(r, delayMs));
3845
+ delayMs = Math.min(delayMs * 1.5, 200);
3846
+ }
3847
+ }
3848
+ }
3286
3849
  /**
3287
3850
  * Direct write used only during migration (before writeQueue is needed).
3288
3851
  */
3289
3852
  async persistNow() {
3290
- const indexPath = (0, import_node_path7.join)(this.dir, "index.json");
3291
- const tmpPath = `${indexPath}.tmp`;
3292
- await (0, import_promises4.writeFile)(tmpPath, JSON.stringify(this.meta, null, 2), "utf-8");
3293
- await (0, import_promises4.rename)(tmpPath, indexPath);
3853
+ const releaseLock = await this.acquireLock();
3854
+ try {
3855
+ const indexPath = (0, import_node_path7.join)(this.dir, "index.json");
3856
+ const tmpPath = `${indexPath}.tmp`;
3857
+ await (0, import_promises4.writeFile)(tmpPath, JSON.stringify(this.meta, null, 2), "utf-8");
3858
+ await (0, import_promises4.rename)(tmpPath, indexPath);
3859
+ } finally {
3860
+ await releaseLock();
3861
+ }
3294
3862
  }
3295
3863
  persist() {
3296
3864
  this.writeQueue = this.writeQueue.then(async () => {
3297
- const indexPath = (0, import_node_path7.join)(this.dir, "index.json");
3298
- let onDisk = [];
3865
+ const releaseLock = await this.acquireLock();
3299
3866
  try {
3300
- const raw = await (0, import_promises4.readFile)(indexPath, "utf-8");
3301
- const parsed = JSON.parse(raw);
3302
- if (Array.isArray(parsed)) {
3303
- onDisk = parsed.filter(isValidMeta);
3867
+ const indexPath = (0, import_node_path7.join)(this.dir, "index.json");
3868
+ let onDisk = [];
3869
+ try {
3870
+ const raw = await (0, import_promises4.readFile)(indexPath, "utf-8");
3871
+ const parsed = JSON.parse(raw);
3872
+ if (Array.isArray(parsed)) {
3873
+ onDisk = parsed.filter(isValidMeta);
3874
+ }
3875
+ } catch {
3304
3876
  }
3305
- } catch {
3306
- }
3307
- const ourIds = new Set(this.meta.map((m) => m.id));
3308
- const external = onDisk.filter((m) => !ourIds.has(m.id));
3309
- let merged = [...this.meta, ...external];
3310
- if (merged.length > MAX_LIBRARY_SIZE) {
3311
- merged.sort((a, b) => (b.deployCount ?? 0) - (a.deployCount ?? 0));
3312
- merged = merged.slice(0, MAX_LIBRARY_SIZE);
3877
+ const ourIds = new Set(this.meta.map((m) => m.id));
3878
+ const external = onDisk.filter((m) => !ourIds.has(m.id));
3879
+ let merged = [...this.meta, ...external];
3880
+ if (merged.length > MAX_LIBRARY_SIZE) {
3881
+ merged.sort((a, b) => evictionScore(b) - evictionScore(a));
3882
+ merged = merged.slice(0, MAX_LIBRARY_SIZE);
3883
+ }
3884
+ const tmpPath = `${indexPath}.tmp`;
3885
+ await (0, import_promises4.writeFile)(tmpPath, JSON.stringify(merged, null, 2), "utf-8");
3886
+ await (0, import_promises4.rename)(tmpPath, indexPath);
3887
+ } finally {
3888
+ await releaseLock();
3313
3889
  }
3314
- const tmpPath = `${indexPath}.tmp`;
3315
- await (0, import_promises4.writeFile)(tmpPath, JSON.stringify(merged, null, 2), "utf-8");
3316
- await (0, import_promises4.rename)(tmpPath, indexPath);
3317
3890
  });
3318
3891
  return this.writeQueue;
3319
3892
  }
@@ -3335,6 +3908,19 @@ var SECRET_PATTERNS = [
3335
3908
  /AIza[a-zA-Z0-9_-]{35}/,
3336
3909
  /AKIA[A-Z0-9]{16}/
3337
3910
  ];
3911
+ var SECRET_PREFIXES = ["sk-", "ghp_", "xoxb-", "AIza", "AKIA"];
3912
+ function collectExpressionStrings(obj, out = []) {
3913
+ if (typeof obj === "string") {
3914
+ if (obj.includes("={{")) out.push(obj);
3915
+ } else if (Array.isArray(obj)) {
3916
+ for (const item of obj) collectExpressionStrings(item, out);
3917
+ } else if (obj !== null && typeof obj === "object") {
3918
+ for (const val of Object.values(obj)) {
3919
+ collectExpressionStrings(val, out);
3920
+ }
3921
+ }
3922
+ return out;
3923
+ }
3338
3924
  function assessTemplateSafety(workflow) {
3339
3925
  const reasons = [];
3340
3926
  let worst = "safe";
@@ -3357,6 +3943,15 @@ function assessTemplateSafety(workflow) {
3357
3943
  break;
3358
3944
  }
3359
3945
  }
3946
+ const expressions = collectExpressionStrings(node.parameters);
3947
+ for (const expr of expressions) {
3948
+ for (const prefix of SECRET_PREFIXES) {
3949
+ if (expr.includes(prefix)) {
3950
+ escalate("review", `Node "${node.name}" has an expression containing credential-like prefix "${prefix}"`);
3951
+ break;
3952
+ }
3953
+ }
3954
+ }
3360
3955
  }
3361
3956
  return { trustLevel: worst, reasons };
3362
3957
  }
@@ -3414,12 +4009,26 @@ var TemplateSyncer = class {
3414
4009
  }
3415
4010
  return progress;
3416
4011
  }
4012
+ async fetchWithBackoff(url, maxRetries = 3) {
4013
+ let delayMs = DELAY_BETWEEN_FETCHES_MS;
4014
+ let lastResponse;
4015
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
4016
+ lastResponse = await fetch(url);
4017
+ if (lastResponse.status !== 429 && lastResponse.status !== 503) return lastResponse;
4018
+ if (attempt === maxRetries) break;
4019
+ const retryAfterHeader = lastResponse.headers.get("Retry-After");
4020
+ const waitMs = retryAfterHeader ? parseInt(retryAfterHeader, 10) * 1e3 : delayMs * Math.pow(2, attempt);
4021
+ this.logger.warn(`HTTP ${lastResponse.status} from template API, retrying in ${waitMs}ms`, { url, attempt });
4022
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
4023
+ }
4024
+ return lastResponse;
4025
+ }
3417
4026
  async fetchTemplateIds(max, progress) {
3418
4027
  const ids = [];
3419
4028
  let page = 1;
3420
4029
  while (ids.length < max) {
3421
4030
  const url = `${N8N_TEMPLATE_API}/search?page=${page}&rows=${PAGE_SIZE}`;
3422
- const response = await fetch(url);
4031
+ const response = await this.fetchWithBackoff(url);
3423
4032
  if (!response.ok) break;
3424
4033
  const data = await response.json();
3425
4034
  progress.total = Math.min(data.totalWorkflows, max);
@@ -3439,7 +4048,7 @@ var TemplateSyncer = class {
3439
4048
  }
3440
4049
  async processTemplate(id, progress) {
3441
4050
  const url = `${N8N_TEMPLATE_API}/workflows/${id}`;
3442
- const response = await fetch(url);
4051
+ const response = await this.fetchWithBackoff(url);
3443
4052
  if (!response.ok) return;
3444
4053
  const data = await response.json();
3445
4054
  const templateMeta = data.workflow;
@@ -3501,6 +4110,7 @@ Kairos SDK \u2014 LLM-powered n8n workflow generation
3501
4110
  Usage:
3502
4111
  kairos init First-time setup wizard
3503
4112
  kairos build <description> [options]
4113
+ kairos replace <n8n-id> <description>
3504
4114
  kairos patterns [options]
3505
4115
  kairos sessions [options]
3506
4116
  kairos list
@@ -3633,6 +4243,27 @@ async function handleBuild(positional, flags) {
3633
4243
  ...result.dryRun ? { workflow: result.workflow } : {}
3634
4244
  }, null, 2));
3635
4245
  }
4246
+ async function handleReplace(positional) {
4247
+ const id = positional[0];
4248
+ const description = positional.slice(1).join(" ");
4249
+ if (!id || !description) {
4250
+ console.error("Usage: kairos replace <n8n-workflow-id> <description>");
4251
+ process.exit(1);
4252
+ }
4253
+ const kairos = createClient();
4254
+ const start = Date.now();
4255
+ console.error(`Replacing workflow ${id}...`);
4256
+ const result = await kairos.replace(id, description);
4257
+ await kairos.drain();
4258
+ const elapsed = ((Date.now() - start) / 1e3).toFixed(1);
4259
+ console.error(`Done in ${elapsed}s (${result.generationAttempts} attempt${result.generationAttempts > 1 ? "s" : ""})`);
4260
+ console.error("");
4261
+ console.log(JSON.stringify({
4262
+ workflowId: result.workflowId,
4263
+ name: result.name,
4264
+ generationAttempts: result.generationAttempts
4265
+ }, null, 2));
4266
+ }
3636
4267
  async function handleList() {
3637
4268
  const kairos = createClient();
3638
4269
  const workflows = await kairos.list();
@@ -3700,14 +4331,7 @@ async function handleSyncTemplates(flags) {
3700
4331
  const maxRaw = typeof flags["max"] === "string" ? parseInt(flags["max"], 10) : NaN;
3701
4332
  const max = Number.isNaN(maxRaw) ? 500 : maxRaw;
3702
4333
  const library = new FileLibrary();
3703
- const logger = {
3704
- debug: () => {
3705
- },
3706
- info: (msg, meta) => console.error(meta ? `${msg} ${JSON.stringify(meta)}` : msg),
3707
- warn: (msg, meta) => console.error(meta ? `[warn] ${msg} ${JSON.stringify(meta)}` : `[warn] ${msg}`),
3708
- error: (msg, meta) => console.error(meta ? `[error] ${msg} ${JSON.stringify(meta)}` : `[error] ${msg}`)
3709
- };
3710
- const syncer = new TemplateSyncer(library, logger);
4334
+ const syncer = new TemplateSyncer(library, CLI_LOGGER);
3711
4335
  console.error(`Syncing up to ${max} templates from n8n community library...`);
3712
4336
  const result = await syncer.sync({
3713
4337
  maxTemplates: max,
@@ -3915,7 +4539,7 @@ async function handleInit() {
3915
4539
  }
3916
4540
  async function main() {
3917
4541
  const { command, positional, flags } = parseArgs(process.argv);
3918
- if (!command || command === "help" || flags["help"] === true) {
4542
+ if (!command || command === "help" || command === "--help" || flags["help"] === true) {
3919
4543
  console.log(HELP);
3920
4544
  return;
3921
4545
  }
@@ -3926,6 +4550,9 @@ async function main() {
3926
4550
  case "build":
3927
4551
  await handleBuild(positional, flags);
3928
4552
  break;
4553
+ case "replace":
4554
+ await handleReplace(positional);
4555
+ break;
3929
4556
  case "patterns":
3930
4557
  await handlePatterns(flags);
3931
4558
  break;