@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/index.cjs CHANGED
@@ -63,7 +63,13 @@ var import_sdk = __toESM(require("@anthropic-ai/sdk"), 1);
63
63
 
64
64
  // src/utils/uuid.ts
65
65
  function generateUUID() {
66
- return crypto.randomUUID();
66
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
67
+ return crypto.randomUUID();
68
+ }
69
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
70
+ const r = Math.random() * 16 | 0;
71
+ return (c === "x" ? r : r & 3 | 8).toString(16);
72
+ });
67
73
  }
68
74
 
69
75
  // src/library/null-library.ts
@@ -119,7 +125,26 @@ var ProviderError = class extends KairosError {
119
125
  }
120
126
  };
121
127
 
128
+ // src/errors/guard-error.ts
129
+ var GuardError = class extends KairosError {
130
+ constructor(message) {
131
+ super(message);
132
+ this.name = "GuardError";
133
+ }
134
+ };
135
+
122
136
  // src/utils/retry.ts
137
+ function isTransientNetworkError(err) {
138
+ const TRANSIENT_CODES = /* @__PURE__ */ new Set(["ECONNRESET", "ETIMEDOUT", "ECONNREFUSED", "ENOTFOUND", "ECONNABORTED"]);
139
+ let current = err;
140
+ for (let i = 0; i < 4; i++) {
141
+ if (current === null || typeof current !== "object") break;
142
+ const code = current.code;
143
+ if (typeof code === "string" && TRANSIENT_CODES.has(code)) return true;
144
+ current = current.cause;
145
+ }
146
+ return false;
147
+ }
123
148
  async function withRetry(fn, maxAttempts, delayMs, shouldRetry) {
124
149
  let lastError;
125
150
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
@@ -144,6 +169,7 @@ function fetchWithTimeout(url, init, timeoutMs) {
144
169
 
145
170
  // src/providers/n8n/api-client.ts
146
171
  var EXECUTION_LIMIT_CAP = 100;
172
+ var N8N_API_PAGE_SIZE = 250;
147
173
  var REQUEST_TIMEOUT_MS = 3e4;
148
174
  var RETRY_ATTEMPTS = 3;
149
175
  var RETRY_DELAY_MS = 1e3;
@@ -152,6 +178,17 @@ var N8nApiClient = class {
152
178
  this.baseUrl = baseUrl;
153
179
  this.apiKey = apiKey;
154
180
  this.logger = logger;
181
+ if (!baseUrl || typeof baseUrl !== "string") {
182
+ throw new GuardError("N8nApiClient: baseUrl must be a non-empty string");
183
+ }
184
+ try {
185
+ new URL(baseUrl);
186
+ } catch {
187
+ throw new GuardError(`N8nApiClient: baseUrl is not a valid URL: "${baseUrl}"`);
188
+ }
189
+ if (!apiKey || typeof apiKey !== "string") {
190
+ throw new GuardError("N8nApiClient: apiKey must be a non-empty string");
191
+ }
155
192
  }
156
193
  baseUrl;
157
194
  apiKey;
@@ -161,7 +198,12 @@ var N8nApiClient = class {
161
198
  this.logger.debug(`n8n ${method} ${path}`);
162
199
  const isSafe = method === "GET";
163
200
  if (!isSafe) {
164
- return this.singleRequest(url, method, path, body);
201
+ return withRetry(
202
+ () => this.singleRequest(url, method, path, body),
203
+ 2,
204
+ RETRY_DELAY_MS,
205
+ isTransientNetworkError
206
+ );
165
207
  }
166
208
  return withRetry(
167
209
  () => this.singleRequest(url, method, path, body),
@@ -216,7 +258,7 @@ var N8nApiClient = class {
216
258
  }
217
259
  async listWorkflows() {
218
260
  const all = [];
219
- let path = "/workflows?limit=250";
261
+ let path = `/workflows?limit=${N8N_API_PAGE_SIZE}`;
220
262
  for (; ; ) {
221
263
  const response = await this.request("GET", path);
222
264
  for (const w of response.data) {
@@ -230,7 +272,7 @@ var N8nApiClient = class {
230
272
  });
231
273
  }
232
274
  if (!response.nextCursor) break;
233
- path = `/workflows?limit=250&cursor=${response.nextCursor}`;
275
+ path = `/workflows?limit=${N8N_API_PAGE_SIZE}&cursor=${response.nextCursor}`;
234
276
  }
235
277
  return all;
236
278
  }
@@ -260,14 +302,14 @@ var N8nApiClient = class {
260
302
  }
261
303
  async listTags() {
262
304
  const all = [];
263
- let path = "/tags?limit=250";
305
+ let path = `/tags?limit=${N8N_API_PAGE_SIZE}`;
264
306
  for (; ; ) {
265
307
  const response = await this.request("GET", path);
266
308
  for (const t of response.data) {
267
309
  all.push({ id: t.id, name: t.name });
268
310
  }
269
311
  if (!response.nextCursor) break;
270
- path = `/tags?limit=250&cursor=${response.nextCursor}`;
312
+ path = `/tags?limit=${N8N_API_PAGE_SIZE}&cursor=${response.nextCursor}`;
271
313
  }
272
314
  return all;
273
315
  }
@@ -291,6 +333,32 @@ var N8nApiClient = class {
291
333
  return [];
292
334
  }
293
335
  }
336
+ async triggerManual(workflowId) {
337
+ const raw = await this.request("POST", `/workflows/${workflowId}/run`);
338
+ const inner = raw["data"];
339
+ const execId = inner?.["executionId"] ?? raw["executionId"];
340
+ if (execId === void 0 || execId === null) {
341
+ throw new ProviderError(
342
+ `n8n trigger response missing executionId \u2014 got: ${JSON.stringify(raw)}`
343
+ );
344
+ }
345
+ return String(execId);
346
+ }
347
+ async triggerWebhookTest(path) {
348
+ const cleanPath = path.startsWith("/") ? path : `/${path}`;
349
+ const url = `${this.baseUrl.replace(/\/$/, "")}/webhook-test${cleanPath}`;
350
+ this.logger.debug(`n8n POST webhook-test ${cleanPath}`);
351
+ try {
352
+ const response = await fetchWithTimeout(
353
+ url,
354
+ { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}) },
355
+ REQUEST_TIMEOUT_MS
356
+ );
357
+ return response.status;
358
+ } catch (err) {
359
+ throw new ProviderError(`Webhook test request failed for path "${path}"`, err);
360
+ }
361
+ }
294
362
  mapExecution(e) {
295
363
  return {
296
364
  id: e.id,
@@ -338,15 +406,9 @@ var N8nFieldStripper = class {
338
406
  }
339
407
  };
340
408
 
341
- // src/errors/guard-error.ts
342
- var GuardError = class extends KairosError {
343
- constructor(message) {
344
- super(message);
345
- this.name = "GuardError";
346
- }
347
- };
348
-
349
409
  // src/providers/n8n/provider.ts
410
+ var SMOKE_TEST_TIMEOUT_MS = 3e4;
411
+ var SMOKE_TEST_POLL_INTERVAL_MS = 1e3;
350
412
  var N8nProvider = class {
351
413
  constructor(client, stripper) {
352
414
  this.client = client;
@@ -408,6 +470,71 @@ var N8nProvider = class {
408
470
  async untag(workflowId, tagIds) {
409
471
  await this.client.untagWorkflow(workflowId, tagIds);
410
472
  }
473
+ async smokeTest(workflowId, workflow) {
474
+ const start = Date.now();
475
+ const trigger = this.detectTrigger(workflow);
476
+ if (trigger.type === "unsupported") {
477
+ return { status: "not-applicable", triggerType: "not-applicable" };
478
+ }
479
+ if (trigger.type === "manual") {
480
+ let executionId;
481
+ try {
482
+ executionId = await this.client.triggerManual(workflowId);
483
+ } catch (err) {
484
+ return { status: "error", triggerType: "manual", durationMs: Date.now() - start, error: String(err) };
485
+ }
486
+ try {
487
+ const execution = await this.pollExecution(executionId);
488
+ const durationMs = Date.now() - start;
489
+ if (execution.status === "success") {
490
+ return { status: "passed", triggerType: "manual", executionId, durationMs };
491
+ }
492
+ return {
493
+ status: "failed",
494
+ triggerType: "manual",
495
+ executionId,
496
+ durationMs,
497
+ error: `Execution ended with status: ${execution.status}`
498
+ };
499
+ } catch (err) {
500
+ return { status: "error", triggerType: "manual", executionId, durationMs: Date.now() - start, error: String(err) };
501
+ }
502
+ }
503
+ try {
504
+ const statusCode = await this.client.triggerWebhookTest(trigger.path);
505
+ const durationMs = Date.now() - start;
506
+ if (statusCode >= 200 && statusCode < 300) {
507
+ return { status: "passed", triggerType: "webhook", durationMs };
508
+ }
509
+ return { status: "failed", triggerType: "webhook", durationMs, error: `Webhook returned HTTP ${statusCode}` };
510
+ } catch (err) {
511
+ return { status: "error", triggerType: "webhook", durationMs: Date.now() - start, error: String(err) };
512
+ }
513
+ }
514
+ detectTrigger(workflow) {
515
+ for (const node of workflow.nodes) {
516
+ if (node.type === "n8n-nodes-base.manualTrigger") return { type: "manual" };
517
+ if (node.type === "n8n-nodes-base.webhook") {
518
+ const params = node.parameters;
519
+ const path = typeof params?.["path"] === "string" ? params["path"] : "webhook";
520
+ return { type: "webhook", path };
521
+ }
522
+ }
523
+ return { type: "unsupported" };
524
+ }
525
+ async pollExecution(executionId) {
526
+ const deadline = Date.now() + SMOKE_TEST_TIMEOUT_MS;
527
+ for (; ; ) {
528
+ const execution = await this.client.getExecution(executionId);
529
+ if (execution.status !== "running" && execution.status !== "waiting") {
530
+ return execution;
531
+ }
532
+ const remaining = deadline - Date.now();
533
+ if (remaining <= 0) break;
534
+ await new Promise((resolve) => setTimeout(resolve, Math.min(SMOKE_TEST_POLL_INTERVAL_MS, remaining)));
535
+ }
536
+ throw new ProviderError(`Smoke test: execution ${executionId} did not complete within ${SMOKE_TEST_TIMEOUT_MS}ms`);
537
+ }
411
538
  };
412
539
 
413
540
  // src/validation/registry.ts
@@ -510,6 +637,14 @@ var NodeRegistry = class {
510
637
  if (!def) return true;
511
638
  return def.safeTypeVersions.includes(version);
512
639
  }
640
+ // Returns true when the version is a positive integer greater than the highest
641
+ // known safe version — indicates a newer release rather than a bad value.
642
+ isVersionNewer(type, version) {
643
+ const def = this.byType.get(type);
644
+ if (!def || def.safeTypeVersions.length === 0) return false;
645
+ const max = Math.max(...def.safeTypeVersions);
646
+ return Number.isInteger(version) && version > max;
647
+ }
513
648
  getRequiredParams(type) {
514
649
  return this.byType.get(type)?.requiredParams ?? [];
515
650
  }
@@ -562,6 +697,14 @@ var N8nValidator = class {
562
697
  this.checkRule24(workflow, issues);
563
698
  this.checkRule25(workflow, issues);
564
699
  this.checkRule26(workflow, issues);
700
+ this.checkRule27(workflow, issues);
701
+ this.checkRule28(workflow, issues);
702
+ this.checkRule29(workflow, issues);
703
+ this.checkRule30(workflow, issues);
704
+ this.checkRule31(workflow, issues);
705
+ this.checkRule32(workflow, issues);
706
+ this.checkRule33(workflow, issues);
707
+ this.checkRule34(workflow, issues);
565
708
  if (Array.isArray(workflow.nodes)) {
566
709
  const nodeById = new Map(workflow.nodes.map((n) => [n.id, n.type]));
567
710
  for (const issue of issues) {
@@ -813,19 +956,22 @@ var N8nValidator = class {
813
956
  }
814
957
  }
815
958
  }
816
- // Rule 19 (WARN): typeVersion is within known safe range for registered node types
959
+ // Rule 19 (WARN): typeVersion is within known safe range for registered node types.
960
+ // In lenient mode (KAIROS_REGISTRY_STRICT != 'true'), versions higher than the known
961
+ // max are allowed — they likely represent newer n8n releases Kairos hasn't catalogued yet.
817
962
  checkRule19(w, issues) {
818
963
  if (!Array.isArray(w.nodes)) return;
964
+ const strict = process.env["KAIROS_REGISTRY_STRICT"] === "true";
819
965
  for (const node of w.nodes) {
820
966
  if (typeof node.type !== "string" || typeof node.typeVersion !== "number") continue;
821
- if (!this.registry.isVersionSafe(node.type, node.typeVersion)) {
822
- this.warn(
823
- issues,
824
- 19,
825
- `Node "${node.name}" uses typeVersion ${node.typeVersion} for type "${node.type}" which is not in the known safe list`,
826
- node.id
827
- );
828
- }
967
+ if (this.registry.isVersionSafe(node.type, node.typeVersion)) continue;
968
+ if (!strict && this.registry.isVersionNewer(node.type, node.typeVersion)) continue;
969
+ this.warn(
970
+ issues,
971
+ 19,
972
+ `Node "${node.name}" uses typeVersion ${node.typeVersion} for type "${node.type}" which is not in the known safe list`,
973
+ node.id
974
+ );
829
975
  }
830
976
  }
831
977
  // Rule 20 (WARN): cycle detection — no node should be reachable from itself
@@ -874,6 +1020,27 @@ var N8nValidator = class {
874
1020
  }
875
1021
  }
876
1022
  }
1023
+ // Rule 21 (WARN): webhook with responseMode="responseNode" must have respondToWebhook node
1024
+ checkRule21(w, issues) {
1025
+ if (!Array.isArray(w.nodes)) return;
1026
+ const webhooksNeedingResponse = w.nodes.filter((n) => {
1027
+ if (!n.type.includes("webhook")) return false;
1028
+ const params = n.parameters;
1029
+ return params?.responseMode === "responseNode";
1030
+ });
1031
+ if (webhooksNeedingResponse.length === 0) return;
1032
+ const hasRespondNode = w.nodes.some((n) => n.type.includes("respondToWebhook"));
1033
+ if (!hasRespondNode) {
1034
+ for (const wh of webhooksNeedingResponse) {
1035
+ this.warn(
1036
+ issues,
1037
+ 21,
1038
+ `Webhook "${wh.name}" uses responseMode "responseNode" but no respondToWebhook node exists in the workflow`,
1039
+ wh.id
1040
+ );
1041
+ }
1042
+ }
1043
+ }
877
1044
  // Rule 22 (WARN): check requiredParams from registry
878
1045
  checkRule22(w, issues) {
879
1046
  if (!Array.isArray(w.nodes)) return;
@@ -982,23 +1149,162 @@ var N8nValidator = class {
982
1149
  walk(params);
983
1150
  return expressions;
984
1151
  }
985
- // Rule 21 (WARN): webhook with responseMode="responseNode" must have respondToWebhook node
986
- checkRule21(w, issues) {
1152
+ // Rule 27 (WARN): httpRequest URL is a placeholder
1153
+ checkRule27(w, issues) {
987
1154
  if (!Array.isArray(w.nodes)) return;
988
- const webhooksNeedingResponse = w.nodes.filter((n) => {
989
- if (!n.type.includes("webhook")) return false;
990
- const params = n.parameters;
991
- return params?.responseMode === "responseNode";
992
- });
993
- if (webhooksNeedingResponse.length === 0) return;
994
- const hasRespondNode = w.nodes.some((n) => n.type.includes("respondToWebhook"));
995
- if (!hasRespondNode) {
996
- for (const wh of webhooksNeedingResponse) {
1155
+ const PLACEHOLDER_RE = [
1156
+ /^https?:\/\/example\.com/i,
1157
+ /your[-_]?(api[-_]?)?url/i,
1158
+ /^https?:\/\/$/,
1159
+ /^<.+>$/,
1160
+ /placeholder/i
1161
+ ];
1162
+ for (const node of w.nodes) {
1163
+ if (node.type !== "n8n-nodes-base.httpRequest") continue;
1164
+ const params = node.parameters;
1165
+ const url = params?.["url"];
1166
+ if (typeof url !== "string" || url.trim() === "") continue;
1167
+ if (PLACEHOLDER_RE.some((re) => re.test(url.trim()))) {
997
1168
  this.warn(
998
1169
  issues,
999
- 21,
1000
- `Webhook "${wh.name}" uses responseMode "responseNode" but no respondToWebhook node exists in the workflow`,
1001
- wh.id
1170
+ 27,
1171
+ `Node "${node.name}" httpRequest URL appears to be a placeholder: "${url}" \u2014 replace with your actual endpoint`,
1172
+ node.id
1173
+ );
1174
+ }
1175
+ }
1176
+ }
1177
+ // Rule 28 (WARN): code node with empty or comment-only code
1178
+ checkRule28(w, issues) {
1179
+ if (!Array.isArray(w.nodes)) return;
1180
+ for (const node of w.nodes) {
1181
+ if (node.type !== "n8n-nodes-base.code") continue;
1182
+ const params = node.parameters;
1183
+ const jsCode = typeof params?.["jsCode"] === "string" ? params["jsCode"] : "";
1184
+ const pythonCode = typeof params?.["pythonCode"] === "string" ? params["pythonCode"] : "";
1185
+ const code = jsCode || pythonCode;
1186
+ const stripped = code.replace(/\/\/[^\n]*/g, "").replace(/\/\*[\s\S]*?\*\//g, "").replace(/#[^\n]*/g, "").trim();
1187
+ if (!stripped) {
1188
+ this.warn(issues, 28, `Node "${node.name}" code node has no executable code`, node.id);
1189
+ }
1190
+ }
1191
+ }
1192
+ // Rule 29 (WARN): slack node message operation missing channel
1193
+ checkRule29(w, issues) {
1194
+ if (!Array.isArray(w.nodes)) return;
1195
+ for (const node of w.nodes) {
1196
+ if (node.type !== "n8n-nodes-base.slack") continue;
1197
+ const params = node.parameters;
1198
+ const resource = params?.["resource"];
1199
+ const operation = params?.["operation"];
1200
+ const isMessageOp = resource === "message" || operation === "sendMessage" || operation === "post";
1201
+ if (!isMessageOp) continue;
1202
+ const channel = params?.["channel"] ?? params?.["channelId"];
1203
+ const rlValue = typeof channel === "object" && channel !== null ? channel["value"] : void 0;
1204
+ const isEmpty = channel === void 0 || channel === null || typeof channel === "string" && channel.trim() === "" || typeof channel === "object" && (!rlValue || typeof rlValue === "string" && rlValue.trim() === "");
1205
+ if (isEmpty) {
1206
+ this.warn(issues, 29, `Node "${node.name}" Slack message has no channel specified`, node.id);
1207
+ }
1208
+ }
1209
+ }
1210
+ // Rule 30 (WARN): gmail node send operation missing recipient
1211
+ checkRule30(w, issues) {
1212
+ if (!Array.isArray(w.nodes)) return;
1213
+ for (const node of w.nodes) {
1214
+ if (node.type !== "n8n-nodes-base.gmail") continue;
1215
+ const params = node.parameters;
1216
+ const operation = params?.["operation"];
1217
+ if (operation !== "send") continue;
1218
+ const to = params?.["to"] ?? params?.["toList"];
1219
+ const isEmpty = to === void 0 || to === null || typeof to === "string" && to.trim() === "" || Array.isArray(to) && to.length === 0;
1220
+ if (isEmpty) {
1221
+ this.warn(issues, 30, `Node "${node.name}" gmail send has no recipient (to) specified`, node.id);
1222
+ }
1223
+ }
1224
+ }
1225
+ // Rule 31 (WARN): if node with empty conditions
1226
+ checkRule31(w, issues) {
1227
+ if (!Array.isArray(w.nodes)) return;
1228
+ for (const node of w.nodes) {
1229
+ if (node.type !== "n8n-nodes-base.if") continue;
1230
+ const params = node.parameters;
1231
+ const conditions = params?.["conditions"];
1232
+ if (conditions === void 0 || conditions === null) {
1233
+ this.warn(issues, 31, `Node "${node.name}" if node has no conditions defined`, node.id);
1234
+ continue;
1235
+ }
1236
+ if (typeof conditions === "object" && !Array.isArray(conditions)) {
1237
+ const conds = conditions["conditions"];
1238
+ if (!Array.isArray(conds) || conds.length === 0) {
1239
+ this.warn(issues, 31, `Node "${node.name}" if node conditions array is empty`, node.id);
1240
+ }
1241
+ } else if (Array.isArray(conditions) && conditions.length === 0) {
1242
+ this.warn(issues, 31, `Node "${node.name}" if node conditions array is empty`, node.id);
1243
+ }
1244
+ }
1245
+ }
1246
+ // Rule 32 (WARN): set node with no assignments
1247
+ checkRule32(w, issues) {
1248
+ if (!Array.isArray(w.nodes)) return;
1249
+ for (const node of w.nodes) {
1250
+ if (node.type !== "n8n-nodes-base.set") continue;
1251
+ const params = node.parameters;
1252
+ const assignmentsObj = params?.["assignments"];
1253
+ const assignmentsArr = assignmentsObj?.["assignments"];
1254
+ const valuesObj = params?.["values"];
1255
+ const hasV1 = valuesObj && Object.values(valuesObj).some((v) => Array.isArray(v) && v.length > 0);
1256
+ const hasV3 = Array.isArray(assignmentsArr) && assignmentsArr.length > 0;
1257
+ if (!hasV1 && !hasV3) {
1258
+ this.warn(
1259
+ issues,
1260
+ 32,
1261
+ `Node "${node.name}" set node has no fields defined \u2014 it will pass data through unchanged`,
1262
+ node.id
1263
+ );
1264
+ }
1265
+ }
1266
+ }
1267
+ // Rule 33 (WARN): scheduleTrigger with no schedule rules
1268
+ checkRule33(w, issues) {
1269
+ if (!Array.isArray(w.nodes)) return;
1270
+ for (const node of w.nodes) {
1271
+ if (node.type !== "n8n-nodes-base.scheduleTrigger") continue;
1272
+ const params = node.parameters;
1273
+ const rule = params?.["rule"];
1274
+ const intervals = rule?.["interval"];
1275
+ if (!Array.isArray(intervals) || intervals.length === 0) {
1276
+ this.warn(issues, 33, `Node "${node.name}" scheduleTrigger has no schedule rules defined`, node.id);
1277
+ }
1278
+ }
1279
+ }
1280
+ // Rule 34 (WARN): webhook path contains spaces, starts with slash, or looks like a full URL
1281
+ checkRule34(w, issues) {
1282
+ if (!Array.isArray(w.nodes)) return;
1283
+ for (const node of w.nodes) {
1284
+ if (node.type !== "n8n-nodes-base.webhook") continue;
1285
+ const params = node.parameters;
1286
+ const path = params?.["path"];
1287
+ if (typeof path !== "string") continue;
1288
+ if (/\s/.test(path)) {
1289
+ this.warn(
1290
+ issues,
1291
+ 34,
1292
+ `Node "${node.name}" webhook path contains spaces: "${path}" \u2014 use hyphens or underscores instead`,
1293
+ node.id
1294
+ );
1295
+ } else if (/^https?:\/\//i.test(path)) {
1296
+ this.warn(
1297
+ issues,
1298
+ 34,
1299
+ `Node "${node.name}" webhook path looks like a full URL \u2014 it should be a relative path (e.g. "my-hook")`,
1300
+ node.id
1301
+ );
1302
+ } else if (path.startsWith("/")) {
1303
+ this.warn(
1304
+ issues,
1305
+ 34,
1306
+ `Node "${node.name}" webhook path starts with "/" \u2014 n8n adds the leading slash automatically`,
1307
+ node.id
1002
1308
  );
1003
1309
  }
1004
1310
  }
@@ -1239,6 +1545,14 @@ Cron: { "rule": { "interval": [{ "field": "cronExpression", "expression": "0 9 *
1239
1545
  8. No deprecated $node["NodeName"].json \u2014 use $('NodeName').item.json.field
1240
1546
  9. No $json.items[0] array indexing \u2014 access fields directly as $json.field
1241
1547
  10. No bare $('NodeName').json \u2014 always use .first().json.field or .all()
1548
+ 11. httpRequest URL is a real endpoint (not "example.com" or "YOUR_URL")
1549
+ 12. code nodes contain actual logic \u2014 not empty or comment-only
1550
+ 13. Slack message nodes have a channel specified (channelId or channel)
1551
+ 14. Gmail send nodes have a recipient (to field non-empty)
1552
+ 15. if nodes have at least one condition in conditions.conditions[]
1553
+ 16. set nodes have at least one entry in assignments.assignments[]
1554
+ 17. scheduleTrigger has at least one rule in rule.interval[]
1555
+ 18. webhook path is relative (no spaces, no leading slash, no http://)
1242
1556
 
1243
1557
  ---
1244
1558
 
@@ -1255,7 +1569,7 @@ function scoreToMode(score) {
1255
1569
  }
1256
1570
 
1257
1571
  // src/validation/rule-metadata.ts
1258
- var VALIDATOR_RULE_IDS = Array.from({ length: 26 }, (_, i) => i + 1);
1572
+ var VALIDATOR_RULE_IDS = Array.from({ length: 34 }, (_, i) => i + 1);
1259
1573
  var RULE_PIPELINE_STAGES = {
1260
1574
  1: "node_generation",
1261
1575
  2: "node_generation",
@@ -1282,7 +1596,15 @@ var RULE_PIPELINE_STAGES = {
1282
1596
  23: "node_generation",
1283
1597
  24: "expression_syntax",
1284
1598
  25: "expression_syntax",
1285
- 26: "expression_syntax"
1599
+ 26: "expression_syntax",
1600
+ 27: "node_generation",
1601
+ 28: "node_generation",
1602
+ 29: "node_generation",
1603
+ 30: "node_generation",
1604
+ 31: "node_generation",
1605
+ 32: "node_generation",
1606
+ 33: "node_generation",
1607
+ 34: "node_generation"
1286
1608
  };
1287
1609
  var RULE_EXAMPLES = {
1288
1610
  17: {
@@ -1300,6 +1622,38 @@ var RULE_EXAMPLES = {
1300
1622
  26: {
1301
1623
  bad: "$('Fetch Data').json.email",
1302
1624
  good: "$('Fetch Data').first().json.email"
1625
+ },
1626
+ 27: {
1627
+ bad: '"url": "https://example.com/api/data"',
1628
+ good: '"url": "https://api.yourservice.com/v1/endpoint"'
1629
+ },
1630
+ 28: {
1631
+ bad: '"jsCode": "// TODO: implement this"',
1632
+ good: '"jsCode": "return items.map(item => ({ json: { result: item.json.value * 2 } }))"'
1633
+ },
1634
+ 29: {
1635
+ bad: '"channelId": ""',
1636
+ good: '"channelId": { "__rl": true, "value": "C0123456789", "mode": "id" }'
1637
+ },
1638
+ 30: {
1639
+ bad: '"operation": "send", "to": ""',
1640
+ good: '"operation": "send", "to": "recipient@example.com"'
1641
+ },
1642
+ 31: {
1643
+ bad: '"conditions": { "combinator": "and", "conditions": [] }',
1644
+ good: '"conditions": { "combinator": "and", "conditions": [{ "leftValue": "={{ $json.status }}", "rightValue": "active", "operator": { "type": "string", "operation": "equals" } }] }'
1645
+ },
1646
+ 32: {
1647
+ bad: '"assignments": { "assignments": [] }',
1648
+ good: '"assignments": { "assignments": [{ "id": "f1", "name": "status", "value": "processed", "type": "string" }] }'
1649
+ },
1650
+ 33: {
1651
+ bad: '"rule": { "interval": [] }',
1652
+ good: '"rule": { "interval": [{ "field": "cronExpression", "expression": "0 9 * * 1-5" }] }'
1653
+ },
1654
+ 34: {
1655
+ bad: '"path": "/my webhook"',
1656
+ good: '"path": "my-webhook"'
1303
1657
  }
1304
1658
  };
1305
1659
  var RULE_MITIGATIONS = {
@@ -1328,7 +1682,15 @@ var RULE_MITIGATIONS = {
1328
1682
  23: "Use node types that exist in the n8n registry \u2014 check with kairos_sync",
1329
1683
  24: 'Use modern accessor syntax: $("NodeName").item.json.field instead of deprecated $node["NodeName"].json.field',
1330
1684
  25: "Access item fields directly with $json.field \u2014 n8n flattens items automatically, do not use $json.items[0]",
1331
- 26: 'Use $("NodeName").first().json.field or $("NodeName").all() \u2014 bare $("NodeName").json without .first() or .all() throws at runtime'
1685
+ 26: 'Use $("NodeName").first().json.field or $("NodeName").all() \u2014 bare $("NodeName").json without .first() or .all() throws at runtime',
1686
+ 27: 'Replace placeholder URLs with your actual API endpoint \u2014 do not use "example.com" or "YOUR_URL" patterns',
1687
+ 28: "Add executable code to the code node \u2014 empty or comment-only code nodes do nothing at runtime",
1688
+ 29: "Set the channel parameter for Slack message operations (channelId with __rl object, or channel as string)",
1689
+ 30: "Set the to parameter for Gmail send operations with at least one recipient email address",
1690
+ 31: "Add at least one condition to the if node \u2014 conditions.conditions array must be non-empty",
1691
+ 32: "Add field assignments to the set node \u2014 assignments.assignments array must be non-empty for typeVersion 3.x",
1692
+ 33: "Add at least one schedule rule to scheduleTrigger \u2014 rule.interval array must have at least one entry",
1693
+ 34: 'Webhook path must be a relative path without spaces, leading slashes, or protocol prefixes (e.g. "my-hook")'
1332
1694
  };
1333
1695
 
1334
1696
  // src/generation/prompt-builder.ts
@@ -1360,18 +1722,37 @@ var PromptBuilder = class {
1360
1722
  }
1361
1723
  build(request, matches, globalFailureRates = [], dynamicCatalog) {
1362
1724
  const mode = this.resolveMode(matches);
1363
- const system = this.buildSystem(matches, mode, globalFailureRates, dynamicCatalog);
1725
+ const system = this.buildSystem(matches, mode, globalFailureRates, dynamicCatalog, request.description);
1364
1726
  const userMessage = this.buildUserMessage(request, matches, mode);
1365
1727
  return { system, userMessage, mode, matches };
1366
1728
  }
1367
- buildCorrectionMessage(request, matches, allIssues, attempt) {
1729
+ buildCorrectionMessage(request, matches, allIssues, attempt, failingRuleIds) {
1368
1730
  const base = this.buildUserMessage(request, matches, this.resolveMode(matches));
1731
+ let examplesSection = "";
1732
+ if (failingRuleIds && failingRuleIds.length > 0) {
1733
+ const uniqueRules = [...new Set(failingRuleIds)];
1734
+ const exampleLines = [];
1735
+ for (const rule of uniqueRules) {
1736
+ const ex = RULE_EXAMPLES[rule];
1737
+ if (ex) {
1738
+ exampleLines.push(`Rule ${rule}:
1739
+ Bad: ${ex.bad}
1740
+ Good: ${ex.good}`);
1741
+ }
1742
+ }
1743
+ if (exampleLines.length > 0) {
1744
+ examplesSection = `
1745
+
1746
+ ## Concrete Fix Examples
1747
+ ${exampleLines.join("\n\n")}`;
1748
+ }
1749
+ }
1369
1750
  return `${base}
1370
1751
 
1371
1752
  IMPORTANT: A previous generation attempt (attempt ${attempt}) failed validation with these issues:
1372
1753
  ${allIssues.join("\n")}
1373
1754
 
1374
- Fix ALL of the above issues in your new response. Do not repeat any of these mistakes.`;
1755
+ Fix ALL of the above issues in your new response. Do not repeat any of these mistakes.${examplesSection}`;
1375
1756
  }
1376
1757
  resolveMode(matches) {
1377
1758
  if (matches.length === 0) return "scratch";
@@ -1379,7 +1760,7 @@ Fix ALL of the above issues in your new response. Do not repeat any of these mis
1379
1760
  if (!top) return "scratch";
1380
1761
  return scoreToMode(top.score);
1381
1762
  }
1382
- buildSystem(matches, mode, globalFailureRates = [], dynamicCatalog) {
1763
+ buildSystem(matches, mode, globalFailureRates = [], dynamicCatalog, description) {
1383
1764
  let basePrompt = SYSTEM_PROMPT_V1;
1384
1765
  if (dynamicCatalog) {
1385
1766
  basePrompt = basePrompt.replace(
@@ -1439,7 +1820,7 @@ A loosely similar workflow (score: ${hint.score.toFixed(2)}) used these node typ
1439
1820
  });
1440
1821
  }
1441
1822
  }
1442
- const warnings = this.buildFailureWarnings(matches, globalFailureRates);
1823
+ const warnings = this.buildFailureWarnings(matches, globalFailureRates, description);
1443
1824
  if (warnings) {
1444
1825
  blocks.push({ type: "text", text: warnings });
1445
1826
  }
@@ -1466,15 +1847,34 @@ A loosely similar workflow (score: ${hint.score.toFixed(2)}) used these node typ
1466
1847
  const patterns = this._lastActivePatterns ?? this.getActivePatterns(this.resolveMaxPatterns());
1467
1848
  return patterns.map((p) => p.rule);
1468
1849
  }
1469
- getActivePatterns(maxCount = 10) {
1850
+ getActivePatterns(maxCount = 10, description) {
1470
1851
  const all = this.loadPatterns().filter((p) => p.state !== "resolved" && p.confidence > 0);
1471
1852
  const regressed = all.filter((p) => p.regressed).sort((a, b) => b.compositeScore - a.compositeScore);
1472
1853
  const confirmed = all.filter((p) => !p.regressed && p.state === "confirmed").sort((a, b) => b.compositeScore - a.compositeScore);
1473
1854
  const drafts = all.filter((p) => !p.regressed && p.state !== "confirmed").sort((a, b) => b.compositeScore - a.compositeScore);
1474
- return [...regressed, ...confirmed, ...drafts].slice(0, maxCount);
1475
- }
1476
- buildFailureWarnings(matches, globalFailureRates) {
1477
- const richPatterns = this.getActivePatterns(this.resolveMaxPatterns());
1855
+ const ordered = [...regressed, ...confirmed, ...drafts];
1856
+ if (this.profile === "minimal" && description) {
1857
+ return this.rankByRelevance(ordered, description).slice(0, maxCount);
1858
+ }
1859
+ return ordered.slice(0, maxCount);
1860
+ }
1861
+ rankByRelevance(patterns, description) {
1862
+ const lower = description.toLowerCase();
1863
+ const STAGE_KEYWORDS = {
1864
+ credential_injection: ["credential", "auth", "api key", "token", "oauth", "smtp", "imap", "password", "secret"],
1865
+ connection_wiring: ["connect", "link", "wire", "chain", "merge", "branch", "join"],
1866
+ expression_syntax: ["expression", "variable", "json", "field", "data", "$json", "item"],
1867
+ workflow_structure: ["trigger", "webhook", "schedule", "structure", "workflow"],
1868
+ node_generation: ["node", "generate", "create", "build", "send", "fetch", "email", "slack", "http"]
1869
+ };
1870
+ return patterns.map((p) => {
1871
+ const keywords = STAGE_KEYWORDS[p.pipelineStage] ?? [];
1872
+ const relevanceBoost = keywords.some((kw) => lower.includes(kw)) ? 1 : 0;
1873
+ return { pattern: p, sort: relevanceBoost * 10 + p.compositeScore };
1874
+ }).sort((a, b) => b.sort - a.sort).map((x) => x.pattern);
1875
+ }
1876
+ buildFailureWarnings(matches, globalFailureRates, description) {
1877
+ const richPatterns = this.getActivePatterns(this.resolveMaxPatterns(), description);
1478
1878
  this._lastActivePatterns = richPatterns;
1479
1879
  if (richPatterns.length > 0) {
1480
1880
  return this.buildStageGroupedWarnings(richPatterns, matches);
@@ -1654,7 +2054,8 @@ var WorkflowDesigner = class {
1654
2054
  const issueLines = lastErrors.map(
1655
2055
  (i) => `- [Rule ${i.rule}] ${i.message}${i.nodeId ? ` (node: ${i.nodeId})` : ""}`
1656
2056
  );
1657
- userMessage = this.promptBuilder.buildCorrectionMessage(request, matches, issueLines, attempt - 1);
2057
+ const failingRuleIds = lastErrors.map((i) => i.rule);
2058
+ userMessage = this.promptBuilder.buildCorrectionMessage(request, matches, issueLines, attempt - 1, failingRuleIds);
1658
2059
  this.logger.debug(`WorkflowDesigner: correction attempt ${attempt}`, { issueCount: lastErrors.length });
1659
2060
  }
1660
2061
  const start = Date.now();
@@ -1840,19 +2241,20 @@ var TelemetryReader = class {
1840
2241
  }
1841
2242
  const events = await this.readRecentEvents(days);
1842
2243
  const buildSessions = new Set(
1843
- events.filter((e) => e.eventType === "build_complete").map((e) => e.sessionId)
2244
+ events.filter((e) => e.eventType === "build_complete").map((e) => e.runId ?? e.sessionId)
1844
2245
  );
1845
2246
  const MIN_BUILDS_FOR_RATES = 3;
1846
2247
  if (buildSessions.size < MIN_BUILDS_FOR_RATES) return [];
1847
2248
  const ruleSessions = /* @__PURE__ */ new Map();
1848
2249
  for (const event of events) {
1849
2250
  if (event.eventType !== "generation_attempt") continue;
1850
- if (!buildSessions.has(event.sessionId)) continue;
2251
+ const eventKey = event.runId ?? event.sessionId;
2252
+ if (!buildSessions.has(eventKey)) continue;
1851
2253
  const data = event.data;
1852
2254
  if (data.validationPassed || !data.issues) continue;
1853
2255
  for (const issue of data.issues) {
1854
2256
  const entry = ruleSessions.get(issue.rule) ?? { sessions: /* @__PURE__ */ new Set(), messages: /* @__PURE__ */ new Map() };
1855
- entry.sessions.add(event.sessionId);
2257
+ entry.sessions.add(eventKey);
1856
2258
  entry.messages.set(issue.message, (entry.messages.get(issue.message) ?? 0) + 1);
1857
2259
  ruleSessions.set(issue.rule, entry);
1858
2260
  }
@@ -1894,22 +2296,24 @@ var PatternAnalyzer = class _PatternAnalyzer {
1894
2296
  telemetryDir;
1895
2297
  outputDir;
1896
2298
  _cachedEvents = null;
2299
+ _cachedPreviousPatterns = null;
1897
2300
  constructor(telemetryDir) {
1898
2301
  const defaultDir = (0, import_node_path5.join)((0, import_node_os4.homedir)(), ".kairos", "telemetry");
1899
2302
  this.telemetryDir = telemetryDir ?? defaultDir;
1900
2303
  this.outputDir = telemetryDir ? (0, import_node_path5.join)(telemetryDir, "..") : (0, import_node_path5.join)((0, import_node_os4.homedir)(), ".kairos");
1901
2304
  }
1902
2305
  async loadPreviousPatterns() {
2306
+ if (this._cachedPreviousPatterns !== null) return this._cachedPreviousPatterns;
1903
2307
  try {
1904
2308
  const raw = await (0, import_promises3.readFile)((0, import_node_path5.join)(this.outputDir, "patterns.json"), "utf-8");
1905
2309
  const prev = JSON.parse(raw);
1906
2310
  const version = prev.schemaVersion ?? 0;
1907
2311
  const patterns = prev.topFailureRules ?? [];
1908
- if (version === PATTERN_SCHEMA_VERSION) return patterns;
1909
- return this.migratePatterns(patterns, version);
2312
+ this._cachedPreviousPatterns = version === PATTERN_SCHEMA_VERSION ? patterns : this.migratePatterns(patterns, version);
1910
2313
  } catch {
1911
- return [];
2314
+ this._cachedPreviousPatterns = [];
1912
2315
  }
2316
+ return this._cachedPreviousPatterns;
1913
2317
  }
1914
2318
  migratePatterns(patterns, fromVersion) {
1915
2319
  let migrated = patterns;
@@ -2209,6 +2613,7 @@ var PatternAnalyzer = class _PatternAnalyzer {
2209
2613
  const tmpPath = `${outputPath}.tmp`;
2210
2614
  await (0, import_promises3.writeFile)(tmpPath, JSON.stringify(analysis, null, 2), "utf-8");
2211
2615
  await (0, import_promises3.rename)(tmpPath, outputPath);
2616
+ this._cachedPreviousPatterns = null;
2212
2617
  const historySummary = {
2213
2618
  timestamp: analysis.generatedAt,
2214
2619
  totalBuilds: analysis.summary.totalBuilds,
@@ -2257,7 +2662,7 @@ var PatternAnalyzer = class _PatternAnalyzer {
2257
2662
  })
2258
2663
  ));
2259
2664
  return {
2260
- sessionId: bc.sessionId,
2665
+ sessionId: bc.runId ?? bc.sessionId,
2261
2666
  date: bc.fileDate,
2262
2667
  description: data.description ?? "",
2263
2668
  workflowType: data.workflowType ?? null,
@@ -2290,7 +2695,7 @@ var PatternAnalyzer = class _PatternAnalyzer {
2290
2695
  alerts.push({
2291
2696
  type: "stale_pattern",
2292
2697
  rule: p.rule,
2293
- message: `Pattern references Rule ${p.rule} which does not exist in the current validator (rules 1-26)`
2698
+ message: `Pattern references Rule ${p.rule} which does not exist in the current validator (rules 1-34)`
2294
2699
  });
2295
2700
  }
2296
2701
  }
@@ -2430,7 +2835,7 @@ function inferWorkflowType(description) {
2430
2835
  // src/client.ts
2431
2836
  var import_node_os5 = require("os");
2432
2837
  var import_node_path6 = require("path");
2433
- var DEFAULT_MODEL = "claude-sonnet-4-6";
2838
+ var DEFAULT_MODEL = process.env["KAIROS_MODEL"] ?? "claude-sonnet-4-6";
2434
2839
  var Kairos = class {
2435
2840
  provider;
2436
2841
  designer;
@@ -2588,10 +2993,19 @@ var Kairos = class {
2588
2993
  }
2589
2994
  const provider = this.requireProvider();
2590
2995
  const deployed = await provider.deploy(workflow);
2591
- this.recordDeploy();
2996
+ this.logger.info("Workflow deployed to n8n", { workflowId: deployed.workflowId, name: deployed.name });
2997
+ this.recordDeploy(deployed.workflowId);
2592
2998
  if (options?.activate) {
2593
2999
  await provider.activate(deployed.workflowId);
2594
3000
  }
3001
+ let smokeTestResult;
3002
+ if (options?.smokeTest) {
3003
+ smokeTestResult = await provider.smokeTest(deployed.workflowId, workflow).catch((err) => {
3004
+ this.logger.warn("Smoke test threw unexpectedly", { err: String(err) });
3005
+ return { status: "error", triggerType: "manual", error: String(err) };
3006
+ });
3007
+ this.logger.info("Smoke test complete", { status: smokeTestResult.status, triggerType: smokeTestResult.triggerType });
3008
+ }
2595
3009
  const totalTokensInput = designResult.attemptMetadata.reduce((s, m) => s + m.tokensInput, 0);
2596
3010
  const totalTokensOutput = designResult.attemptMetadata.reduce((s, m) => s + m.tokensOutput, 0);
2597
3011
  await this.telemetry?.emit("build_complete", {
@@ -2616,7 +3030,8 @@ var Kairos = class {
2616
3030
  credentialsNeeded: designResult.credentialsNeeded,
2617
3031
  activationRequired: !options?.activate,
2618
3032
  generationAttempts: designResult.attempts,
2619
- dryRun: false
3033
+ dryRun: false,
3034
+ ...smokeTestResult !== void 0 ? { smokeTest: smokeTestResult } : {}
2620
3035
  };
2621
3036
  }
2622
3037
  async replace(id, description) {
@@ -2673,7 +3088,8 @@ var Kairos = class {
2673
3088
  await this.emitAttemptTelemetry(description, designResult, workflowType, runId);
2674
3089
  const provider = this.requireProvider();
2675
3090
  const deployed = await provider.update(id, designResult.workflow);
2676
- this.saveToLibrary(designResult.workflow, description, designResult, matches);
3091
+ this.logger.info("Workflow updated in n8n", { workflowId: deployed.workflowId, name: deployed.name });
3092
+ this.saveToLibrary(designResult.workflow, description, designResult, matches, deployed.workflowId);
2677
3093
  this.recordDeploy();
2678
3094
  const totalTokensInput = designResult.attemptMetadata.reduce((s, m) => s + m.tokensInput, 0);
2679
3095
  const totalTokensOutput = designResult.attemptMetadata.reduce((s, m) => s + m.tokensOutput, 0);
@@ -2729,10 +3145,10 @@ var Kairos = class {
2729
3145
  }, runId);
2730
3146
  }
2731
3147
  }
2732
- recordDeploy() {
3148
+ recordDeploy(n8nWorkflowId) {
2733
3149
  this.saveQueue = this.saveQueue.then(async (savedId) => {
2734
3150
  if (savedId) {
2735
- await this.library.recordDeployment(savedId);
3151
+ await this.library.recordDeployment(savedId, n8nWorkflowId);
2736
3152
  }
2737
3153
  return savedId;
2738
3154
  }).catch((err) => {
@@ -2740,7 +3156,7 @@ var Kairos = class {
2740
3156
  return null;
2741
3157
  });
2742
3158
  }
2743
- saveToLibrary(workflow, description, designResult, matches) {
3159
+ saveToLibrary(workflow, description, designResult, matches, n8nWorkflowId) {
2744
3160
  const failedAttempts = designResult.attemptMetadata.filter((m) => !m.validationPassed);
2745
3161
  const failurePatterns = failedAttempts.flatMap(
2746
3162
  (m) => m.issues.map((i) => ({ rule: i.rule, message: i.message }))
@@ -2766,6 +3182,7 @@ var Kairos = class {
2766
3182
  if (matches.length > 0) metadata.sourceWorkflowIds = matches.map((m) => m.workflow.id);
2767
3183
  if (topMatch) metadata.topMatchScore = topMatch.score;
2768
3184
  if (designResult.credentialsNeeded.length > 0) metadata.credentialsNeeded = designResult.credentialsNeeded;
3185
+ if (n8nWorkflowId) metadata.n8nWorkflowId = n8nWorkflowId;
2769
3186
  const firstTryPass = designResult.attemptMetadata.length > 0 && designResult.attemptMetadata[0].validationPassed;
2770
3187
  const failedRules = Array.from(new Set(
2771
3188
  designResult.attemptMetadata.filter((m) => !m.validationPassed).flatMap((m) => m.issues.map((i) => i.rule))
@@ -2829,12 +3246,32 @@ var import_node_path7 = require("path");
2829
3246
  var import_node_os6 = require("os");
2830
3247
 
2831
3248
  // src/library/scorer.ts
2832
- var WEIGHTS = {
2833
- tfidf: 0.35,
2834
- nodeFingerprint: 0.3,
2835
- outcome: 0.2,
2836
- deploy: 0.15
2837
- };
3249
+ function loadWeights() {
3250
+ const raw = {
3251
+ tfidf: parseFloat(process.env["KAIROS_WEIGHT_TFIDF"] ?? ""),
3252
+ nodeFingerprint: parseFloat(process.env["KAIROS_WEIGHT_JACCARD"] ?? ""),
3253
+ outcome: parseFloat(process.env["KAIROS_WEIGHT_OUTCOME"] ?? ""),
3254
+ deploy: parseFloat(process.env["KAIROS_WEIGHT_DEPLOY"] ?? "")
3255
+ };
3256
+ const defaults = { tfidf: 0.35, nodeFingerprint: 0.3, outcome: 0.2, deploy: 0.15 };
3257
+ const anySet = Object.values(raw).some((v) => !isNaN(v) && v >= 0);
3258
+ if (!anySet) return defaults;
3259
+ const w = {
3260
+ tfidf: !isNaN(raw.tfidf) && raw.tfidf >= 0 ? raw.tfidf : defaults.tfidf,
3261
+ nodeFingerprint: !isNaN(raw.nodeFingerprint) && raw.nodeFingerprint >= 0 ? raw.nodeFingerprint : defaults.nodeFingerprint,
3262
+ outcome: !isNaN(raw.outcome) && raw.outcome >= 0 ? raw.outcome : defaults.outcome,
3263
+ deploy: !isNaN(raw.deploy) && raw.deploy >= 0 ? raw.deploy : defaults.deploy
3264
+ };
3265
+ const total = w.tfidf + w.nodeFingerprint + w.outcome + w.deploy;
3266
+ if (total <= 0) return defaults;
3267
+ return {
3268
+ tfidf: w.tfidf / total,
3269
+ nodeFingerprint: w.nodeFingerprint / total,
3270
+ outcome: w.outcome / total,
3271
+ deploy: w.deploy / total
3272
+ };
3273
+ }
3274
+ var WEIGHTS = loadWeights();
2838
3275
  var NODE_KEYWORDS = {
2839
3276
  slack: ["slack", "slackApi"],
2840
3277
  email: ["gmail", "sendEmail", "emailSend", "emailReadImap"],
@@ -3019,6 +3456,8 @@ function clusterWorkflows(workflows) {
3019
3456
  }
3020
3457
  return clusters.sort((a, b) => b.members.length - a.members.length);
3021
3458
  }
3459
+ var NOVELTY_BOOST = 0.05;
3460
+ var NOVELTY_PENALTY = 0.03;
3022
3461
  function rerank(candidates, clusters) {
3023
3462
  const clusterMap = /* @__PURE__ */ new Map();
3024
3463
  for (const cluster of clusters) {
@@ -3026,7 +3465,7 @@ function rerank(candidates, clusters) {
3026
3465
  clusterMap.set(member.id, cluster);
3027
3466
  }
3028
3467
  }
3029
- return candidates.map((c) => {
3468
+ const pass1 = candidates.map((c) => {
3030
3469
  const cluster = clusterMap.get(c.workflow.id);
3031
3470
  let boost = 0;
3032
3471
  if (cluster && cluster.avgFirstTryPassRate > 0) {
@@ -3038,7 +3477,25 @@ function rerank(candidates, clusters) {
3038
3477
  return {
3039
3478
  workflow: c.workflow,
3040
3479
  score: Math.max(0, Math.min(1, c.score + boost)),
3041
- ...cluster ? { clusterPattern: cluster.pattern } : {}
3480
+ cluster
3481
+ };
3482
+ }).sort((a, b) => b.score - a.score);
3483
+ const seenFingerprints = /* @__PURE__ */ new Set();
3484
+ return pass1.map((c) => {
3485
+ const fpKey = c.cluster ? fingerprintKey(c.cluster.fingerprint) : null;
3486
+ let noveltyAdjust = 0;
3487
+ if (fpKey !== null) {
3488
+ if (!seenFingerprints.has(fpKey)) {
3489
+ seenFingerprints.add(fpKey);
3490
+ noveltyAdjust = NOVELTY_BOOST;
3491
+ } else {
3492
+ noveltyAdjust = -NOVELTY_PENALTY;
3493
+ }
3494
+ }
3495
+ return {
3496
+ workflow: c.workflow,
3497
+ score: Math.max(0, Math.min(1, c.score + noveltyAdjust)),
3498
+ ...c.cluster ? { clusterPattern: c.cluster.pattern } : {}
3042
3499
  };
3043
3500
  }).sort((a, b) => b.score - a.score);
3044
3501
  }
@@ -3055,7 +3512,11 @@ function buildSearchCorpus(w) {
3055
3512
  });
3056
3513
  return `${w.description} ${w.workflow.name} ${w.tags.join(" ")} ${nodeTokens.join(" ")}`;
3057
3514
  }
3058
- var MAX_LIBRARY_SIZE = 500;
3515
+ var _rawSize = parseInt(process.env["KAIROS_LIBRARY_SIZE"] ?? "500", 10);
3516
+ var MAX_LIBRARY_SIZE = Number.isFinite(_rawSize) && _rawSize >= 10 ? _rawSize : 500;
3517
+ function evictionScore(m) {
3518
+ return (m.deployCount ?? 0) * 3 + (m.timesRetrieved ?? 0) + (m.outcomeStats?.totalUses ?? 0);
3519
+ }
3059
3520
  function isValidMeta(item) {
3060
3521
  return typeof item === "object" && item !== null && typeof item.id === "string" && typeof item.description === "string" && typeof item.workflowName === "string" && Array.isArray(item.cachedNodeTypes);
3061
3522
  }
@@ -3103,6 +3564,7 @@ var FileLibrary = class {
3103
3564
  } catch {
3104
3565
  this.meta = [];
3105
3566
  }
3567
+ await this.scanForOrphansAndCleanup();
3106
3568
  } else {
3107
3569
  try {
3108
3570
  const raw = await (0, import_promises4.readFile)(indexPath, "utf-8");
@@ -3117,6 +3579,31 @@ var FileLibrary = class {
3117
3579
  await (0, import_promises4.mkdir)(this.workflowsDir, { recursive: true });
3118
3580
  }
3119
3581
  }
3582
+ async scanForOrphansAndCleanup() {
3583
+ let entries;
3584
+ try {
3585
+ entries = await (0, import_promises4.readdir)(this.workflowsDir);
3586
+ } catch {
3587
+ return;
3588
+ }
3589
+ const indexedIds = new Set(this.meta.map((m) => m.id));
3590
+ const orphanIds = [];
3591
+ for (const filename of entries) {
3592
+ if (filename.endsWith(".tmp")) {
3593
+ await (0, import_promises4.unlink)((0, import_node_path7.join)(this.workflowsDir, filename)).catch(() => {
3594
+ });
3595
+ continue;
3596
+ }
3597
+ if (!filename.endsWith(".json")) continue;
3598
+ const id = filename.slice(0, -5);
3599
+ if (!indexedIds.has(id)) {
3600
+ orphanIds.push(id);
3601
+ }
3602
+ }
3603
+ if (orphanIds.length > 0) {
3604
+ console.warn(`[FileLibrary] Found ${orphanIds.length} orphaned workflow file(s) not in index: ${orphanIds.join(", ")}`);
3605
+ }
3606
+ }
3120
3607
  /**
3121
3608
  * One-time transparent migration from v0.4.x monolithic index.json.
3122
3609
  * Splits each stored workflow into a per-file workflow JSON and a lightweight
@@ -3187,10 +3674,12 @@ var FileLibrary = class {
3187
3674
  const docTokenSets = docTokenArrays.map((tokens) => new Set(tokens));
3188
3675
  const docCount = shells.length;
3189
3676
  const idf = /* @__PURE__ */ new Map();
3677
+ const idfCeiling = Math.log(docCount + 1) + 1;
3190
3678
  const allTokens = new Set(queryTokens);
3191
3679
  for (const token of allTokens) {
3192
3680
  const docsWithToken = docTokenSets.filter((d) => d.has(token)).length;
3193
- idf.set(token, Math.log((docCount + 1) / (docsWithToken + 1)) + 1);
3681
+ const rawIdf = Math.log((docCount + 1) / (docsWithToken + 1)) + 1;
3682
+ idf.set(token, rawIdf / idfCeiling);
3194
3683
  }
3195
3684
  const scored = hybridScore(queryTokens, description, shells, docTokenArrays, idf).filter((m) => m.score > 0).sort((a, b) => b.score - a.score);
3196
3685
  const clusters = clusterWorkflows(shells);
@@ -3216,6 +3705,27 @@ var FileLibrary = class {
3216
3705
  return results.filter((r) => r !== null);
3217
3706
  }
3218
3707
  async save(workflow, metadata) {
3708
+ const existingByN8nId = metadata.n8nWorkflowId ? this.meta.find((m) => m.n8nWorkflowId === metadata.n8nWorkflowId) : void 0;
3709
+ const normalizedDesc = metadata.description.trim().toLowerCase();
3710
+ const existing = existingByN8nId ?? this.meta.find((m) => m.description.trim().toLowerCase() === normalizedDesc);
3711
+ if (existing) {
3712
+ existing.description = metadata.description;
3713
+ existing.workflowName = workflow.name;
3714
+ existing.cachedNodeTypes = workflow.nodes.map((n) => n.type);
3715
+ if (metadata.n8nWorkflowId) existing.n8nWorkflowId = metadata.n8nWorkflowId;
3716
+ if (metadata.generationAttempts != null) {
3717
+ existing.generationAttempts = metadata.generationAttempts;
3718
+ }
3719
+ if (metadata.failurePatterns?.length) {
3720
+ existing.failurePatterns = this.deduplicateFailurePatterns(metadata.failurePatterns);
3721
+ }
3722
+ if (metadata.tags?.length) {
3723
+ existing.tags = [.../* @__PURE__ */ new Set([...existing.tags, ...metadata.tags])];
3724
+ }
3725
+ await this.writeWorkflowFile(existing.id, workflow);
3726
+ await this.persist();
3727
+ return existing.id;
3728
+ }
3219
3729
  const id = generateUUID();
3220
3730
  await this.writeWorkflowFile(id, workflow);
3221
3731
  const failurePatterns = this.deduplicateFailurePatterns(metadata.failurePatterns);
@@ -3237,25 +3747,27 @@ var FileLibrary = class {
3237
3747
  ...metadata.sourceKind ? { sourceKind: metadata.sourceKind } : {},
3238
3748
  ...metadata.sourceId ? { sourceId: metadata.sourceId } : {},
3239
3749
  ...metadata.sourceUrl ? { sourceUrl: metadata.sourceUrl } : {},
3240
- ...metadata.trustLevel ? { trustLevel: metadata.trustLevel } : {}
3750
+ ...metadata.trustLevel ? { trustLevel: metadata.trustLevel } : {},
3751
+ ...metadata.n8nWorkflowId ? { n8nWorkflowId: metadata.n8nWorkflowId } : {}
3241
3752
  };
3242
3753
  this.meta.push(meta);
3243
3754
  if (this.meta.length > MAX_LIBRARY_SIZE) {
3244
3755
  this.meta.sort((a, b) => {
3245
3756
  if (a.id === id) return -1;
3246
3757
  if (b.id === id) return 1;
3247
- return (b.deployCount ?? 0) - (a.deployCount ?? 0);
3758
+ return evictionScore(b) - evictionScore(a);
3248
3759
  });
3249
3760
  this.meta = this.meta.slice(0, MAX_LIBRARY_SIZE);
3250
3761
  }
3251
3762
  await this.persist();
3252
3763
  return id;
3253
3764
  }
3254
- async recordDeployment(id) {
3765
+ async recordDeployment(id, n8nWorkflowId) {
3255
3766
  const m = this.meta.find((m2) => m2.id === id);
3256
3767
  if (m) {
3257
3768
  m.deployCount++;
3258
3769
  m.lastDeployedAt = (/* @__PURE__ */ new Date()).toISOString();
3770
+ if (n8nWorkflowId) m.n8nWorkflowId = n8nWorkflowId;
3259
3771
  await this.persist();
3260
3772
  }
3261
3773
  }
@@ -3318,37 +3830,98 @@ var FileLibrary = class {
3318
3830
  }
3319
3831
  return [...map.values()];
3320
3832
  }
3833
+ // ── Cross-process file locking ────────────────────────────────────────────
3834
+ // Uses O_EXCL (exclusive create) which is atomic on POSIX and Windows NTFS.
3835
+ // Protects the read-modify-write cycle in persist() from concurrent writers
3836
+ // in separate OS processes (e.g. MCP server + CLI running simultaneously).
3837
+ get lockPath() {
3838
+ return (0, import_node_path7.join)(this.dir, ".index.lock");
3839
+ }
3840
+ async acquireLock(timeoutMs = 3e3) {
3841
+ const deadline = Date.now() + timeoutMs;
3842
+ let delayMs = 10;
3843
+ while (true) {
3844
+ try {
3845
+ const fh = await (0, import_promises4.open)(this.lockPath, "wx");
3846
+ await fh.writeFile(String(process.pid));
3847
+ await fh.close();
3848
+ return async () => {
3849
+ await (0, import_promises4.unlink)(this.lockPath).catch(() => {
3850
+ });
3851
+ };
3852
+ } catch {
3853
+ try {
3854
+ const content = await (0, import_promises4.readFile)(this.lockPath, "utf-8");
3855
+ const lockPid = parseInt(content.trim(), 10);
3856
+ const fileStat = await (0, import_promises4.stat)(this.lockPath);
3857
+ const ageMs = Date.now() - fileStat.mtimeMs;
3858
+ if (ageMs > 1e4) {
3859
+ await (0, import_promises4.unlink)(this.lockPath).catch(() => {
3860
+ });
3861
+ continue;
3862
+ }
3863
+ if (!isNaN(lockPid)) {
3864
+ try {
3865
+ process.kill(lockPid, 0);
3866
+ } catch {
3867
+ await (0, import_promises4.unlink)(this.lockPath).catch(() => {
3868
+ });
3869
+ continue;
3870
+ }
3871
+ }
3872
+ } catch {
3873
+ continue;
3874
+ }
3875
+ if (Date.now() > deadline) {
3876
+ return async () => {
3877
+ };
3878
+ }
3879
+ await new Promise((r) => setTimeout(r, delayMs));
3880
+ delayMs = Math.min(delayMs * 1.5, 200);
3881
+ }
3882
+ }
3883
+ }
3321
3884
  /**
3322
3885
  * Direct write used only during migration (before writeQueue is needed).
3323
3886
  */
3324
3887
  async persistNow() {
3325
- const indexPath = (0, import_node_path7.join)(this.dir, "index.json");
3326
- const tmpPath = `${indexPath}.tmp`;
3327
- await (0, import_promises4.writeFile)(tmpPath, JSON.stringify(this.meta, null, 2), "utf-8");
3328
- await (0, import_promises4.rename)(tmpPath, indexPath);
3888
+ const releaseLock = await this.acquireLock();
3889
+ try {
3890
+ const indexPath = (0, import_node_path7.join)(this.dir, "index.json");
3891
+ const tmpPath = `${indexPath}.tmp`;
3892
+ await (0, import_promises4.writeFile)(tmpPath, JSON.stringify(this.meta, null, 2), "utf-8");
3893
+ await (0, import_promises4.rename)(tmpPath, indexPath);
3894
+ } finally {
3895
+ await releaseLock();
3896
+ }
3329
3897
  }
3330
3898
  persist() {
3331
3899
  this.writeQueue = this.writeQueue.then(async () => {
3332
- const indexPath = (0, import_node_path7.join)(this.dir, "index.json");
3333
- let onDisk = [];
3900
+ const releaseLock = await this.acquireLock();
3334
3901
  try {
3335
- const raw = await (0, import_promises4.readFile)(indexPath, "utf-8");
3336
- const parsed = JSON.parse(raw);
3337
- if (Array.isArray(parsed)) {
3338
- onDisk = parsed.filter(isValidMeta);
3902
+ const indexPath = (0, import_node_path7.join)(this.dir, "index.json");
3903
+ let onDisk = [];
3904
+ try {
3905
+ const raw = await (0, import_promises4.readFile)(indexPath, "utf-8");
3906
+ const parsed = JSON.parse(raw);
3907
+ if (Array.isArray(parsed)) {
3908
+ onDisk = parsed.filter(isValidMeta);
3909
+ }
3910
+ } catch {
3339
3911
  }
3340
- } catch {
3341
- }
3342
- const ourIds = new Set(this.meta.map((m) => m.id));
3343
- const external = onDisk.filter((m) => !ourIds.has(m.id));
3344
- let merged = [...this.meta, ...external];
3345
- if (merged.length > MAX_LIBRARY_SIZE) {
3346
- merged.sort((a, b) => (b.deployCount ?? 0) - (a.deployCount ?? 0));
3347
- merged = merged.slice(0, MAX_LIBRARY_SIZE);
3912
+ const ourIds = new Set(this.meta.map((m) => m.id));
3913
+ const external = onDisk.filter((m) => !ourIds.has(m.id));
3914
+ let merged = [...this.meta, ...external];
3915
+ if (merged.length > MAX_LIBRARY_SIZE) {
3916
+ merged.sort((a, b) => evictionScore(b) - evictionScore(a));
3917
+ merged = merged.slice(0, MAX_LIBRARY_SIZE);
3918
+ }
3919
+ const tmpPath = `${indexPath}.tmp`;
3920
+ await (0, import_promises4.writeFile)(tmpPath, JSON.stringify(merged, null, 2), "utf-8");
3921
+ await (0, import_promises4.rename)(tmpPath, indexPath);
3922
+ } finally {
3923
+ await releaseLock();
3348
3924
  }
3349
- const tmpPath = `${indexPath}.tmp`;
3350
- await (0, import_promises4.writeFile)(tmpPath, JSON.stringify(merged, null, 2), "utf-8");
3351
- await (0, import_promises4.rename)(tmpPath, indexPath);
3352
3925
  });
3353
3926
  return this.writeQueue;
3354
3927
  }
@@ -3370,6 +3943,19 @@ var SECRET_PATTERNS = [
3370
3943
  /AIza[a-zA-Z0-9_-]{35}/,
3371
3944
  /AKIA[A-Z0-9]{16}/
3372
3945
  ];
3946
+ var SECRET_PREFIXES = ["sk-", "ghp_", "xoxb-", "AIza", "AKIA"];
3947
+ function collectExpressionStrings(obj, out = []) {
3948
+ if (typeof obj === "string") {
3949
+ if (obj.includes("={{")) out.push(obj);
3950
+ } else if (Array.isArray(obj)) {
3951
+ for (const item of obj) collectExpressionStrings(item, out);
3952
+ } else if (obj !== null && typeof obj === "object") {
3953
+ for (const val of Object.values(obj)) {
3954
+ collectExpressionStrings(val, out);
3955
+ }
3956
+ }
3957
+ return out;
3958
+ }
3373
3959
  function assessTemplateSafety(workflow) {
3374
3960
  const reasons = [];
3375
3961
  let worst = "safe";
@@ -3392,6 +3978,15 @@ function assessTemplateSafety(workflow) {
3392
3978
  break;
3393
3979
  }
3394
3980
  }
3981
+ const expressions = collectExpressionStrings(node.parameters);
3982
+ for (const expr of expressions) {
3983
+ for (const prefix of SECRET_PREFIXES) {
3984
+ if (expr.includes(prefix)) {
3985
+ escalate("review", `Node "${node.name}" has an expression containing credential-like prefix "${prefix}"`);
3986
+ break;
3987
+ }
3988
+ }
3989
+ }
3395
3990
  }
3396
3991
  return { trustLevel: worst, reasons };
3397
3992
  }
@@ -3449,12 +4044,26 @@ var TemplateSyncer = class {
3449
4044
  }
3450
4045
  return progress;
3451
4046
  }
4047
+ async fetchWithBackoff(url, maxRetries = 3) {
4048
+ let delayMs = DELAY_BETWEEN_FETCHES_MS;
4049
+ let lastResponse;
4050
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
4051
+ lastResponse = await fetch(url);
4052
+ if (lastResponse.status !== 429 && lastResponse.status !== 503) return lastResponse;
4053
+ if (attempt === maxRetries) break;
4054
+ const retryAfterHeader = lastResponse.headers.get("Retry-After");
4055
+ const waitMs = retryAfterHeader ? parseInt(retryAfterHeader, 10) * 1e3 : delayMs * Math.pow(2, attempt);
4056
+ this.logger.warn(`HTTP ${lastResponse.status} from template API, retrying in ${waitMs}ms`, { url, attempt });
4057
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
4058
+ }
4059
+ return lastResponse;
4060
+ }
3452
4061
  async fetchTemplateIds(max, progress) {
3453
4062
  const ids = [];
3454
4063
  let page = 1;
3455
4064
  while (ids.length < max) {
3456
4065
  const url = `${N8N_TEMPLATE_API}/search?page=${page}&rows=${PAGE_SIZE}`;
3457
- const response = await fetch(url);
4066
+ const response = await this.fetchWithBackoff(url);
3458
4067
  if (!response.ok) break;
3459
4068
  const data = await response.json();
3460
4069
  progress.total = Math.min(data.totalWorkflows, max);
@@ -3474,7 +4083,7 @@ var TemplateSyncer = class {
3474
4083
  }
3475
4084
  async processTemplate(id, progress) {
3476
4085
  const url = `${N8N_TEMPLATE_API}/workflows/${id}`;
3477
- const response = await fetch(url);
4086
+ const response = await this.fetchWithBackoff(url);
3478
4087
  if (!response.ok) return;
3479
4088
  const data = await response.json();
3480
4089
  const templateMeta = data.workflow;