@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.
@@ -13,7 +13,13 @@ var import_node_os = require("os");
13
13
 
14
14
  // src/utils/uuid.ts
15
15
  function generateUUID() {
16
- return crypto.randomUUID();
16
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
17
+ return crypto.randomUUID();
18
+ }
19
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
20
+ const r = Math.random() * 16 | 0;
21
+ return (c === "x" ? r : r & 3 | 8).toString(16);
22
+ });
17
23
  }
18
24
 
19
25
  // src/utils/thresholds.ts
@@ -26,12 +32,32 @@ function scoreToMode(score) {
26
32
  }
27
33
 
28
34
  // src/library/scorer.ts
29
- var WEIGHTS = {
30
- tfidf: 0.35,
31
- nodeFingerprint: 0.3,
32
- outcome: 0.2,
33
- deploy: 0.15
34
- };
35
+ function loadWeights() {
36
+ const raw = {
37
+ tfidf: parseFloat(process.env["KAIROS_WEIGHT_TFIDF"] ?? ""),
38
+ nodeFingerprint: parseFloat(process.env["KAIROS_WEIGHT_JACCARD"] ?? ""),
39
+ outcome: parseFloat(process.env["KAIROS_WEIGHT_OUTCOME"] ?? ""),
40
+ deploy: parseFloat(process.env["KAIROS_WEIGHT_DEPLOY"] ?? "")
41
+ };
42
+ const defaults = { tfidf: 0.35, nodeFingerprint: 0.3, outcome: 0.2, deploy: 0.15 };
43
+ const anySet = Object.values(raw).some((v) => !isNaN(v) && v >= 0);
44
+ if (!anySet) return defaults;
45
+ const w = {
46
+ tfidf: !isNaN(raw.tfidf) && raw.tfidf >= 0 ? raw.tfidf : defaults.tfidf,
47
+ nodeFingerprint: !isNaN(raw.nodeFingerprint) && raw.nodeFingerprint >= 0 ? raw.nodeFingerprint : defaults.nodeFingerprint,
48
+ outcome: !isNaN(raw.outcome) && raw.outcome >= 0 ? raw.outcome : defaults.outcome,
49
+ deploy: !isNaN(raw.deploy) && raw.deploy >= 0 ? raw.deploy : defaults.deploy
50
+ };
51
+ const total = w.tfidf + w.nodeFingerprint + w.outcome + w.deploy;
52
+ if (total <= 0) return defaults;
53
+ return {
54
+ tfidf: w.tfidf / total,
55
+ nodeFingerprint: w.nodeFingerprint / total,
56
+ outcome: w.outcome / total,
57
+ deploy: w.deploy / total
58
+ };
59
+ }
60
+ var WEIGHTS = loadWeights();
35
61
  var NODE_KEYWORDS = {
36
62
  slack: ["slack", "slackApi"],
37
63
  email: ["gmail", "sendEmail", "emailSend", "emailReadImap"],
@@ -216,6 +242,8 @@ function clusterWorkflows(workflows) {
216
242
  }
217
243
  return clusters.sort((a, b) => b.members.length - a.members.length);
218
244
  }
245
+ var NOVELTY_BOOST = 0.05;
246
+ var NOVELTY_PENALTY = 0.03;
219
247
  function rerank(candidates, clusters) {
220
248
  const clusterMap = /* @__PURE__ */ new Map();
221
249
  for (const cluster of clusters) {
@@ -223,7 +251,7 @@ function rerank(candidates, clusters) {
223
251
  clusterMap.set(member.id, cluster);
224
252
  }
225
253
  }
226
- return candidates.map((c) => {
254
+ const pass1 = candidates.map((c) => {
227
255
  const cluster = clusterMap.get(c.workflow.id);
228
256
  let boost = 0;
229
257
  if (cluster && cluster.avgFirstTryPassRate > 0) {
@@ -235,7 +263,25 @@ function rerank(candidates, clusters) {
235
263
  return {
236
264
  workflow: c.workflow,
237
265
  score: Math.max(0, Math.min(1, c.score + boost)),
238
- ...cluster ? { clusterPattern: cluster.pattern } : {}
266
+ cluster
267
+ };
268
+ }).sort((a, b) => b.score - a.score);
269
+ const seenFingerprints = /* @__PURE__ */ new Set();
270
+ return pass1.map((c) => {
271
+ const fpKey = c.cluster ? fingerprintKey(c.cluster.fingerprint) : null;
272
+ let noveltyAdjust = 0;
273
+ if (fpKey !== null) {
274
+ if (!seenFingerprints.has(fpKey)) {
275
+ seenFingerprints.add(fpKey);
276
+ noveltyAdjust = NOVELTY_BOOST;
277
+ } else {
278
+ noveltyAdjust = -NOVELTY_PENALTY;
279
+ }
280
+ }
281
+ return {
282
+ workflow: c.workflow,
283
+ score: Math.max(0, Math.min(1, c.score + noveltyAdjust)),
284
+ ...c.cluster ? { clusterPattern: c.cluster.pattern } : {}
239
285
  };
240
286
  }).sort((a, b) => b.score - a.score);
241
287
  }
@@ -252,7 +298,11 @@ function buildSearchCorpus(w) {
252
298
  });
253
299
  return `${w.description} ${w.workflow.name} ${w.tags.join(" ")} ${nodeTokens.join(" ")}`;
254
300
  }
255
- var MAX_LIBRARY_SIZE = 500;
301
+ var _rawSize = parseInt(process.env["KAIROS_LIBRARY_SIZE"] ?? "500", 10);
302
+ var MAX_LIBRARY_SIZE = Number.isFinite(_rawSize) && _rawSize >= 10 ? _rawSize : 500;
303
+ function evictionScore(m) {
304
+ return (m.deployCount ?? 0) * 3 + (m.timesRetrieved ?? 0) + (m.outcomeStats?.totalUses ?? 0);
305
+ }
256
306
  function isValidMeta(item) {
257
307
  return typeof item === "object" && item !== null && typeof item.id === "string" && typeof item.description === "string" && typeof item.workflowName === "string" && Array.isArray(item.cachedNodeTypes);
258
308
  }
@@ -300,6 +350,7 @@ var FileLibrary = class {
300
350
  } catch {
301
351
  this.meta = [];
302
352
  }
353
+ await this.scanForOrphansAndCleanup();
303
354
  } else {
304
355
  try {
305
356
  const raw = await (0, import_promises.readFile)(indexPath, "utf-8");
@@ -314,6 +365,31 @@ var FileLibrary = class {
314
365
  await (0, import_promises.mkdir)(this.workflowsDir, { recursive: true });
315
366
  }
316
367
  }
368
+ async scanForOrphansAndCleanup() {
369
+ let entries;
370
+ try {
371
+ entries = await (0, import_promises.readdir)(this.workflowsDir);
372
+ } catch {
373
+ return;
374
+ }
375
+ const indexedIds = new Set(this.meta.map((m) => m.id));
376
+ const orphanIds = [];
377
+ for (const filename of entries) {
378
+ if (filename.endsWith(".tmp")) {
379
+ await (0, import_promises.unlink)((0, import_node_path.join)(this.workflowsDir, filename)).catch(() => {
380
+ });
381
+ continue;
382
+ }
383
+ if (!filename.endsWith(".json")) continue;
384
+ const id = filename.slice(0, -5);
385
+ if (!indexedIds.has(id)) {
386
+ orphanIds.push(id);
387
+ }
388
+ }
389
+ if (orphanIds.length > 0) {
390
+ console.warn(`[FileLibrary] Found ${orphanIds.length} orphaned workflow file(s) not in index: ${orphanIds.join(", ")}`);
391
+ }
392
+ }
317
393
  /**
318
394
  * One-time transparent migration from v0.4.x monolithic index.json.
319
395
  * Splits each stored workflow into a per-file workflow JSON and a lightweight
@@ -384,10 +460,12 @@ var FileLibrary = class {
384
460
  const docTokenSets = docTokenArrays.map((tokens) => new Set(tokens));
385
461
  const docCount = shells.length;
386
462
  const idf = /* @__PURE__ */ new Map();
463
+ const idfCeiling = Math.log(docCount + 1) + 1;
387
464
  const allTokens = new Set(queryTokens);
388
465
  for (const token of allTokens) {
389
466
  const docsWithToken = docTokenSets.filter((d) => d.has(token)).length;
390
- idf.set(token, Math.log((docCount + 1) / (docsWithToken + 1)) + 1);
467
+ const rawIdf = Math.log((docCount + 1) / (docsWithToken + 1)) + 1;
468
+ idf.set(token, rawIdf / idfCeiling);
391
469
  }
392
470
  const scored = hybridScore(queryTokens, description, shells, docTokenArrays, idf).filter((m) => m.score > 0).sort((a, b) => b.score - a.score);
393
471
  const clusters = clusterWorkflows(shells);
@@ -413,6 +491,27 @@ var FileLibrary = class {
413
491
  return results.filter((r) => r !== null);
414
492
  }
415
493
  async save(workflow, metadata) {
494
+ const existingByN8nId = metadata.n8nWorkflowId ? this.meta.find((m) => m.n8nWorkflowId === metadata.n8nWorkflowId) : void 0;
495
+ const normalizedDesc = metadata.description.trim().toLowerCase();
496
+ const existing = existingByN8nId ?? this.meta.find((m) => m.description.trim().toLowerCase() === normalizedDesc);
497
+ if (existing) {
498
+ existing.description = metadata.description;
499
+ existing.workflowName = workflow.name;
500
+ existing.cachedNodeTypes = workflow.nodes.map((n) => n.type);
501
+ if (metadata.n8nWorkflowId) existing.n8nWorkflowId = metadata.n8nWorkflowId;
502
+ if (metadata.generationAttempts != null) {
503
+ existing.generationAttempts = metadata.generationAttempts;
504
+ }
505
+ if (metadata.failurePatterns?.length) {
506
+ existing.failurePatterns = this.deduplicateFailurePatterns(metadata.failurePatterns);
507
+ }
508
+ if (metadata.tags?.length) {
509
+ existing.tags = [.../* @__PURE__ */ new Set([...existing.tags, ...metadata.tags])];
510
+ }
511
+ await this.writeWorkflowFile(existing.id, workflow);
512
+ await this.persist();
513
+ return existing.id;
514
+ }
416
515
  const id = generateUUID();
417
516
  await this.writeWorkflowFile(id, workflow);
418
517
  const failurePatterns = this.deduplicateFailurePatterns(metadata.failurePatterns);
@@ -434,25 +533,27 @@ var FileLibrary = class {
434
533
  ...metadata.sourceKind ? { sourceKind: metadata.sourceKind } : {},
435
534
  ...metadata.sourceId ? { sourceId: metadata.sourceId } : {},
436
535
  ...metadata.sourceUrl ? { sourceUrl: metadata.sourceUrl } : {},
437
- ...metadata.trustLevel ? { trustLevel: metadata.trustLevel } : {}
536
+ ...metadata.trustLevel ? { trustLevel: metadata.trustLevel } : {},
537
+ ...metadata.n8nWorkflowId ? { n8nWorkflowId: metadata.n8nWorkflowId } : {}
438
538
  };
439
539
  this.meta.push(meta);
440
540
  if (this.meta.length > MAX_LIBRARY_SIZE) {
441
541
  this.meta.sort((a, b) => {
442
542
  if (a.id === id) return -1;
443
543
  if (b.id === id) return 1;
444
- return (b.deployCount ?? 0) - (a.deployCount ?? 0);
544
+ return evictionScore(b) - evictionScore(a);
445
545
  });
446
546
  this.meta = this.meta.slice(0, MAX_LIBRARY_SIZE);
447
547
  }
448
548
  await this.persist();
449
549
  return id;
450
550
  }
451
- async recordDeployment(id) {
551
+ async recordDeployment(id, n8nWorkflowId) {
452
552
  const m = this.meta.find((m2) => m2.id === id);
453
553
  if (m) {
454
554
  m.deployCount++;
455
555
  m.lastDeployedAt = (/* @__PURE__ */ new Date()).toISOString();
556
+ if (n8nWorkflowId) m.n8nWorkflowId = n8nWorkflowId;
456
557
  await this.persist();
457
558
  }
458
559
  }
@@ -515,37 +616,98 @@ var FileLibrary = class {
515
616
  }
516
617
  return [...map.values()];
517
618
  }
619
+ // ── Cross-process file locking ────────────────────────────────────────────
620
+ // Uses O_EXCL (exclusive create) which is atomic on POSIX and Windows NTFS.
621
+ // Protects the read-modify-write cycle in persist() from concurrent writers
622
+ // in separate OS processes (e.g. MCP server + CLI running simultaneously).
623
+ get lockPath() {
624
+ return (0, import_node_path.join)(this.dir, ".index.lock");
625
+ }
626
+ async acquireLock(timeoutMs = 3e3) {
627
+ const deadline = Date.now() + timeoutMs;
628
+ let delayMs = 10;
629
+ while (true) {
630
+ try {
631
+ const fh = await (0, import_promises.open)(this.lockPath, "wx");
632
+ await fh.writeFile(String(process.pid));
633
+ await fh.close();
634
+ return async () => {
635
+ await (0, import_promises.unlink)(this.lockPath).catch(() => {
636
+ });
637
+ };
638
+ } catch {
639
+ try {
640
+ const content = await (0, import_promises.readFile)(this.lockPath, "utf-8");
641
+ const lockPid = parseInt(content.trim(), 10);
642
+ const fileStat = await (0, import_promises.stat)(this.lockPath);
643
+ const ageMs = Date.now() - fileStat.mtimeMs;
644
+ if (ageMs > 1e4) {
645
+ await (0, import_promises.unlink)(this.lockPath).catch(() => {
646
+ });
647
+ continue;
648
+ }
649
+ if (!isNaN(lockPid)) {
650
+ try {
651
+ process.kill(lockPid, 0);
652
+ } catch {
653
+ await (0, import_promises.unlink)(this.lockPath).catch(() => {
654
+ });
655
+ continue;
656
+ }
657
+ }
658
+ } catch {
659
+ continue;
660
+ }
661
+ if (Date.now() > deadline) {
662
+ return async () => {
663
+ };
664
+ }
665
+ await new Promise((r) => setTimeout(r, delayMs));
666
+ delayMs = Math.min(delayMs * 1.5, 200);
667
+ }
668
+ }
669
+ }
518
670
  /**
519
671
  * Direct write used only during migration (before writeQueue is needed).
520
672
  */
521
673
  async persistNow() {
522
- const indexPath = (0, import_node_path.join)(this.dir, "index.json");
523
- const tmpPath = `${indexPath}.tmp`;
524
- await (0, import_promises.writeFile)(tmpPath, JSON.stringify(this.meta, null, 2), "utf-8");
525
- await (0, import_promises.rename)(tmpPath, indexPath);
674
+ const releaseLock = await this.acquireLock();
675
+ try {
676
+ const indexPath = (0, import_node_path.join)(this.dir, "index.json");
677
+ const tmpPath = `${indexPath}.tmp`;
678
+ await (0, import_promises.writeFile)(tmpPath, JSON.stringify(this.meta, null, 2), "utf-8");
679
+ await (0, import_promises.rename)(tmpPath, indexPath);
680
+ } finally {
681
+ await releaseLock();
682
+ }
526
683
  }
527
684
  persist() {
528
685
  this.writeQueue = this.writeQueue.then(async () => {
529
- const indexPath = (0, import_node_path.join)(this.dir, "index.json");
530
- let onDisk = [];
686
+ const releaseLock = await this.acquireLock();
531
687
  try {
532
- const raw = await (0, import_promises.readFile)(indexPath, "utf-8");
533
- const parsed = JSON.parse(raw);
534
- if (Array.isArray(parsed)) {
535
- onDisk = parsed.filter(isValidMeta);
688
+ const indexPath = (0, import_node_path.join)(this.dir, "index.json");
689
+ let onDisk = [];
690
+ try {
691
+ const raw = await (0, import_promises.readFile)(indexPath, "utf-8");
692
+ const parsed = JSON.parse(raw);
693
+ if (Array.isArray(parsed)) {
694
+ onDisk = parsed.filter(isValidMeta);
695
+ }
696
+ } catch {
536
697
  }
537
- } catch {
538
- }
539
- const ourIds = new Set(this.meta.map((m) => m.id));
540
- const external = onDisk.filter((m) => !ourIds.has(m.id));
541
- let merged = [...this.meta, ...external];
542
- if (merged.length > MAX_LIBRARY_SIZE) {
543
- merged.sort((a, b) => (b.deployCount ?? 0) - (a.deployCount ?? 0));
544
- merged = merged.slice(0, MAX_LIBRARY_SIZE);
698
+ const ourIds = new Set(this.meta.map((m) => m.id));
699
+ const external = onDisk.filter((m) => !ourIds.has(m.id));
700
+ let merged = [...this.meta, ...external];
701
+ if (merged.length > MAX_LIBRARY_SIZE) {
702
+ merged.sort((a, b) => evictionScore(b) - evictionScore(a));
703
+ merged = merged.slice(0, MAX_LIBRARY_SIZE);
704
+ }
705
+ const tmpPath = `${indexPath}.tmp`;
706
+ await (0, import_promises.writeFile)(tmpPath, JSON.stringify(merged, null, 2), "utf-8");
707
+ await (0, import_promises.rename)(tmpPath, indexPath);
708
+ } finally {
709
+ await releaseLock();
545
710
  }
546
- const tmpPath = `${indexPath}.tmp`;
547
- await (0, import_promises.writeFile)(tmpPath, JSON.stringify(merged, null, 2), "utf-8");
548
- await (0, import_promises.rename)(tmpPath, indexPath);
549
711
  });
550
712
  return this.writeQueue;
551
713
  }
@@ -651,6 +813,14 @@ var NodeRegistry = class {
651
813
  if (!def) return true;
652
814
  return def.safeTypeVersions.includes(version);
653
815
  }
816
+ // Returns true when the version is a positive integer greater than the highest
817
+ // known safe version — indicates a newer release rather than a bad value.
818
+ isVersionNewer(type, version) {
819
+ const def = this.byType.get(type);
820
+ if (!def || def.safeTypeVersions.length === 0) return false;
821
+ const max = Math.max(...def.safeTypeVersions);
822
+ return Number.isInteger(version) && version > max;
823
+ }
654
824
  getRequiredParams(type) {
655
825
  return this.byType.get(type)?.requiredParams ?? [];
656
826
  }
@@ -721,6 +891,14 @@ var N8nValidator = class {
721
891
  this.checkRule24(workflow, issues);
722
892
  this.checkRule25(workflow, issues);
723
893
  this.checkRule26(workflow, issues);
894
+ this.checkRule27(workflow, issues);
895
+ this.checkRule28(workflow, issues);
896
+ this.checkRule29(workflow, issues);
897
+ this.checkRule30(workflow, issues);
898
+ this.checkRule31(workflow, issues);
899
+ this.checkRule32(workflow, issues);
900
+ this.checkRule33(workflow, issues);
901
+ this.checkRule34(workflow, issues);
724
902
  if (Array.isArray(workflow.nodes)) {
725
903
  const nodeById = new Map(workflow.nodes.map((n) => [n.id, n.type]));
726
904
  for (const issue of issues) {
@@ -972,19 +1150,22 @@ var N8nValidator = class {
972
1150
  }
973
1151
  }
974
1152
  }
975
- // Rule 19 (WARN): typeVersion is within known safe range for registered node types
1153
+ // Rule 19 (WARN): typeVersion is within known safe range for registered node types.
1154
+ // In lenient mode (KAIROS_REGISTRY_STRICT != 'true'), versions higher than the known
1155
+ // max are allowed — they likely represent newer n8n releases Kairos hasn't catalogued yet.
976
1156
  checkRule19(w, issues) {
977
1157
  if (!Array.isArray(w.nodes)) return;
1158
+ const strict = process.env["KAIROS_REGISTRY_STRICT"] === "true";
978
1159
  for (const node of w.nodes) {
979
1160
  if (typeof node.type !== "string" || typeof node.typeVersion !== "number") continue;
980
- if (!this.registry.isVersionSafe(node.type, node.typeVersion)) {
981
- this.warn(
982
- issues,
983
- 19,
984
- `Node "${node.name}" uses typeVersion ${node.typeVersion} for type "${node.type}" which is not in the known safe list`,
985
- node.id
986
- );
987
- }
1161
+ if (this.registry.isVersionSafe(node.type, node.typeVersion)) continue;
1162
+ if (!strict && this.registry.isVersionNewer(node.type, node.typeVersion)) continue;
1163
+ this.warn(
1164
+ issues,
1165
+ 19,
1166
+ `Node "${node.name}" uses typeVersion ${node.typeVersion} for type "${node.type}" which is not in the known safe list`,
1167
+ node.id
1168
+ );
988
1169
  }
989
1170
  }
990
1171
  // Rule 20 (WARN): cycle detection — no node should be reachable from itself
@@ -1033,6 +1214,27 @@ var N8nValidator = class {
1033
1214
  }
1034
1215
  }
1035
1216
  }
1217
+ // Rule 21 (WARN): webhook with responseMode="responseNode" must have respondToWebhook node
1218
+ checkRule21(w, issues) {
1219
+ if (!Array.isArray(w.nodes)) return;
1220
+ const webhooksNeedingResponse = w.nodes.filter((n) => {
1221
+ if (!n.type.includes("webhook")) return false;
1222
+ const params = n.parameters;
1223
+ return params?.responseMode === "responseNode";
1224
+ });
1225
+ if (webhooksNeedingResponse.length === 0) return;
1226
+ const hasRespondNode = w.nodes.some((n) => n.type.includes("respondToWebhook"));
1227
+ if (!hasRespondNode) {
1228
+ for (const wh of webhooksNeedingResponse) {
1229
+ this.warn(
1230
+ issues,
1231
+ 21,
1232
+ `Webhook "${wh.name}" uses responseMode "responseNode" but no respondToWebhook node exists in the workflow`,
1233
+ wh.id
1234
+ );
1235
+ }
1236
+ }
1237
+ }
1036
1238
  // Rule 22 (WARN): check requiredParams from registry
1037
1239
  checkRule22(w, issues) {
1038
1240
  if (!Array.isArray(w.nodes)) return;
@@ -1141,23 +1343,162 @@ var N8nValidator = class {
1141
1343
  walk(params);
1142
1344
  return expressions;
1143
1345
  }
1144
- // Rule 21 (WARN): webhook with responseMode="responseNode" must have respondToWebhook node
1145
- checkRule21(w, issues) {
1346
+ // Rule 27 (WARN): httpRequest URL is a placeholder
1347
+ checkRule27(w, issues) {
1146
1348
  if (!Array.isArray(w.nodes)) return;
1147
- const webhooksNeedingResponse = w.nodes.filter((n) => {
1148
- if (!n.type.includes("webhook")) return false;
1149
- const params = n.parameters;
1150
- return params?.responseMode === "responseNode";
1151
- });
1152
- if (webhooksNeedingResponse.length === 0) return;
1153
- const hasRespondNode = w.nodes.some((n) => n.type.includes("respondToWebhook"));
1154
- if (!hasRespondNode) {
1155
- for (const wh of webhooksNeedingResponse) {
1349
+ const PLACEHOLDER_RE = [
1350
+ /^https?:\/\/example\.com/i,
1351
+ /your[-_]?(api[-_]?)?url/i,
1352
+ /^https?:\/\/$/,
1353
+ /^<.+>$/,
1354
+ /placeholder/i
1355
+ ];
1356
+ for (const node of w.nodes) {
1357
+ if (node.type !== "n8n-nodes-base.httpRequest") continue;
1358
+ const params = node.parameters;
1359
+ const url = params?.["url"];
1360
+ if (typeof url !== "string" || url.trim() === "") continue;
1361
+ if (PLACEHOLDER_RE.some((re) => re.test(url.trim()))) {
1156
1362
  this.warn(
1157
1363
  issues,
1158
- 21,
1159
- `Webhook "${wh.name}" uses responseMode "responseNode" but no respondToWebhook node exists in the workflow`,
1160
- wh.id
1364
+ 27,
1365
+ `Node "${node.name}" httpRequest URL appears to be a placeholder: "${url}" \u2014 replace with your actual endpoint`,
1366
+ node.id
1367
+ );
1368
+ }
1369
+ }
1370
+ }
1371
+ // Rule 28 (WARN): code node with empty or comment-only code
1372
+ checkRule28(w, issues) {
1373
+ if (!Array.isArray(w.nodes)) return;
1374
+ for (const node of w.nodes) {
1375
+ if (node.type !== "n8n-nodes-base.code") continue;
1376
+ const params = node.parameters;
1377
+ const jsCode = typeof params?.["jsCode"] === "string" ? params["jsCode"] : "";
1378
+ const pythonCode = typeof params?.["pythonCode"] === "string" ? params["pythonCode"] : "";
1379
+ const code = jsCode || pythonCode;
1380
+ const stripped = code.replace(/\/\/[^\n]*/g, "").replace(/\/\*[\s\S]*?\*\//g, "").replace(/#[^\n]*/g, "").trim();
1381
+ if (!stripped) {
1382
+ this.warn(issues, 28, `Node "${node.name}" code node has no executable code`, node.id);
1383
+ }
1384
+ }
1385
+ }
1386
+ // Rule 29 (WARN): slack node message operation missing channel
1387
+ checkRule29(w, issues) {
1388
+ if (!Array.isArray(w.nodes)) return;
1389
+ for (const node of w.nodes) {
1390
+ if (node.type !== "n8n-nodes-base.slack") continue;
1391
+ const params = node.parameters;
1392
+ const resource = params?.["resource"];
1393
+ const operation = params?.["operation"];
1394
+ const isMessageOp = resource === "message" || operation === "sendMessage" || operation === "post";
1395
+ if (!isMessageOp) continue;
1396
+ const channel = params?.["channel"] ?? params?.["channelId"];
1397
+ const rlValue = typeof channel === "object" && channel !== null ? channel["value"] : void 0;
1398
+ const isEmpty = channel === void 0 || channel === null || typeof channel === "string" && channel.trim() === "" || typeof channel === "object" && (!rlValue || typeof rlValue === "string" && rlValue.trim() === "");
1399
+ if (isEmpty) {
1400
+ this.warn(issues, 29, `Node "${node.name}" Slack message has no channel specified`, node.id);
1401
+ }
1402
+ }
1403
+ }
1404
+ // Rule 30 (WARN): gmail node send operation missing recipient
1405
+ checkRule30(w, issues) {
1406
+ if (!Array.isArray(w.nodes)) return;
1407
+ for (const node of w.nodes) {
1408
+ if (node.type !== "n8n-nodes-base.gmail") continue;
1409
+ const params = node.parameters;
1410
+ const operation = params?.["operation"];
1411
+ if (operation !== "send") continue;
1412
+ const to = params?.["to"] ?? params?.["toList"];
1413
+ const isEmpty = to === void 0 || to === null || typeof to === "string" && to.trim() === "" || Array.isArray(to) && to.length === 0;
1414
+ if (isEmpty) {
1415
+ this.warn(issues, 30, `Node "${node.name}" gmail send has no recipient (to) specified`, node.id);
1416
+ }
1417
+ }
1418
+ }
1419
+ // Rule 31 (WARN): if node with empty conditions
1420
+ checkRule31(w, issues) {
1421
+ if (!Array.isArray(w.nodes)) return;
1422
+ for (const node of w.nodes) {
1423
+ if (node.type !== "n8n-nodes-base.if") continue;
1424
+ const params = node.parameters;
1425
+ const conditions = params?.["conditions"];
1426
+ if (conditions === void 0 || conditions === null) {
1427
+ this.warn(issues, 31, `Node "${node.name}" if node has no conditions defined`, node.id);
1428
+ continue;
1429
+ }
1430
+ if (typeof conditions === "object" && !Array.isArray(conditions)) {
1431
+ const conds = conditions["conditions"];
1432
+ if (!Array.isArray(conds) || conds.length === 0) {
1433
+ this.warn(issues, 31, `Node "${node.name}" if node conditions array is empty`, node.id);
1434
+ }
1435
+ } else if (Array.isArray(conditions) && conditions.length === 0) {
1436
+ this.warn(issues, 31, `Node "${node.name}" if node conditions array is empty`, node.id);
1437
+ }
1438
+ }
1439
+ }
1440
+ // Rule 32 (WARN): set node with no assignments
1441
+ checkRule32(w, issues) {
1442
+ if (!Array.isArray(w.nodes)) return;
1443
+ for (const node of w.nodes) {
1444
+ if (node.type !== "n8n-nodes-base.set") continue;
1445
+ const params = node.parameters;
1446
+ const assignmentsObj = params?.["assignments"];
1447
+ const assignmentsArr = assignmentsObj?.["assignments"];
1448
+ const valuesObj = params?.["values"];
1449
+ const hasV1 = valuesObj && Object.values(valuesObj).some((v) => Array.isArray(v) && v.length > 0);
1450
+ const hasV3 = Array.isArray(assignmentsArr) && assignmentsArr.length > 0;
1451
+ if (!hasV1 && !hasV3) {
1452
+ this.warn(
1453
+ issues,
1454
+ 32,
1455
+ `Node "${node.name}" set node has no fields defined \u2014 it will pass data through unchanged`,
1456
+ node.id
1457
+ );
1458
+ }
1459
+ }
1460
+ }
1461
+ // Rule 33 (WARN): scheduleTrigger with no schedule rules
1462
+ checkRule33(w, issues) {
1463
+ if (!Array.isArray(w.nodes)) return;
1464
+ for (const node of w.nodes) {
1465
+ if (node.type !== "n8n-nodes-base.scheduleTrigger") continue;
1466
+ const params = node.parameters;
1467
+ const rule = params?.["rule"];
1468
+ const intervals = rule?.["interval"];
1469
+ if (!Array.isArray(intervals) || intervals.length === 0) {
1470
+ this.warn(issues, 33, `Node "${node.name}" scheduleTrigger has no schedule rules defined`, node.id);
1471
+ }
1472
+ }
1473
+ }
1474
+ // Rule 34 (WARN): webhook path contains spaces, starts with slash, or looks like a full URL
1475
+ checkRule34(w, issues) {
1476
+ if (!Array.isArray(w.nodes)) return;
1477
+ for (const node of w.nodes) {
1478
+ if (node.type !== "n8n-nodes-base.webhook") continue;
1479
+ const params = node.parameters;
1480
+ const path = params?.["path"];
1481
+ if (typeof path !== "string") continue;
1482
+ if (/\s/.test(path)) {
1483
+ this.warn(
1484
+ issues,
1485
+ 34,
1486
+ `Node "${node.name}" webhook path contains spaces: "${path}" \u2014 use hyphens or underscores instead`,
1487
+ node.id
1488
+ );
1489
+ } else if (/^https?:\/\//i.test(path)) {
1490
+ this.warn(
1491
+ issues,
1492
+ 34,
1493
+ `Node "${node.name}" webhook path looks like a full URL \u2014 it should be a relative path (e.g. "my-hook")`,
1494
+ node.id
1495
+ );
1496
+ } else if (path.startsWith("/")) {
1497
+ this.warn(
1498
+ issues,
1499
+ 34,
1500
+ `Node "${node.name}" webhook path starts with "/" \u2014 n8n adds the leading slash automatically`,
1501
+ node.id
1161
1502
  );
1162
1503
  }
1163
1504
  }
@@ -1212,7 +1553,26 @@ var ProviderError = class extends KairosError {
1212
1553
  }
1213
1554
  };
1214
1555
 
1556
+ // src/errors/guard-error.ts
1557
+ var GuardError = class extends KairosError {
1558
+ constructor(message) {
1559
+ super(message);
1560
+ this.name = "GuardError";
1561
+ }
1562
+ };
1563
+
1215
1564
  // src/utils/retry.ts
1565
+ function isTransientNetworkError(err) {
1566
+ const TRANSIENT_CODES = /* @__PURE__ */ new Set(["ECONNRESET", "ETIMEDOUT", "ECONNREFUSED", "ENOTFOUND", "ECONNABORTED"]);
1567
+ let current = err;
1568
+ for (let i = 0; i < 4; i++) {
1569
+ if (current === null || typeof current !== "object") break;
1570
+ const code = current.code;
1571
+ if (typeof code === "string" && TRANSIENT_CODES.has(code)) return true;
1572
+ current = current.cause;
1573
+ }
1574
+ return false;
1575
+ }
1216
1576
  async function withRetry(fn, maxAttempts, delayMs, shouldRetry) {
1217
1577
  let lastError;
1218
1578
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
@@ -1237,6 +1597,7 @@ function fetchWithTimeout(url, init, timeoutMs) {
1237
1597
 
1238
1598
  // src/providers/n8n/api-client.ts
1239
1599
  var EXECUTION_LIMIT_CAP = 100;
1600
+ var N8N_API_PAGE_SIZE = 250;
1240
1601
  var REQUEST_TIMEOUT_MS = 3e4;
1241
1602
  var RETRY_ATTEMPTS = 3;
1242
1603
  var RETRY_DELAY_MS = 1e3;
@@ -1245,6 +1606,17 @@ var N8nApiClient = class {
1245
1606
  this.baseUrl = baseUrl;
1246
1607
  this.apiKey = apiKey;
1247
1608
  this.logger = logger;
1609
+ if (!baseUrl || typeof baseUrl !== "string") {
1610
+ throw new GuardError("N8nApiClient: baseUrl must be a non-empty string");
1611
+ }
1612
+ try {
1613
+ new URL(baseUrl);
1614
+ } catch {
1615
+ throw new GuardError(`N8nApiClient: baseUrl is not a valid URL: "${baseUrl}"`);
1616
+ }
1617
+ if (!apiKey || typeof apiKey !== "string") {
1618
+ throw new GuardError("N8nApiClient: apiKey must be a non-empty string");
1619
+ }
1248
1620
  }
1249
1621
  baseUrl;
1250
1622
  apiKey;
@@ -1254,7 +1626,12 @@ var N8nApiClient = class {
1254
1626
  this.logger.debug(`n8n ${method} ${path}`);
1255
1627
  const isSafe = method === "GET";
1256
1628
  if (!isSafe) {
1257
- return this.singleRequest(url, method, path, body);
1629
+ return withRetry(
1630
+ () => this.singleRequest(url, method, path, body),
1631
+ 2,
1632
+ RETRY_DELAY_MS,
1633
+ isTransientNetworkError
1634
+ );
1258
1635
  }
1259
1636
  return withRetry(
1260
1637
  () => this.singleRequest(url, method, path, body),
@@ -1309,7 +1686,7 @@ var N8nApiClient = class {
1309
1686
  }
1310
1687
  async listWorkflows() {
1311
1688
  const all = [];
1312
- let path = "/workflows?limit=250";
1689
+ let path = `/workflows?limit=${N8N_API_PAGE_SIZE}`;
1313
1690
  for (; ; ) {
1314
1691
  const response = await this.request("GET", path);
1315
1692
  for (const w of response.data) {
@@ -1323,7 +1700,7 @@ var N8nApiClient = class {
1323
1700
  });
1324
1701
  }
1325
1702
  if (!response.nextCursor) break;
1326
- path = `/workflows?limit=250&cursor=${response.nextCursor}`;
1703
+ path = `/workflows?limit=${N8N_API_PAGE_SIZE}&cursor=${response.nextCursor}`;
1327
1704
  }
1328
1705
  return all;
1329
1706
  }
@@ -1353,14 +1730,14 @@ var N8nApiClient = class {
1353
1730
  }
1354
1731
  async listTags() {
1355
1732
  const all = [];
1356
- let path = "/tags?limit=250";
1733
+ let path = `/tags?limit=${N8N_API_PAGE_SIZE}`;
1357
1734
  for (; ; ) {
1358
1735
  const response = await this.request("GET", path);
1359
1736
  for (const t of response.data) {
1360
1737
  all.push({ id: t.id, name: t.name });
1361
1738
  }
1362
1739
  if (!response.nextCursor) break;
1363
- path = `/tags?limit=250&cursor=${response.nextCursor}`;
1740
+ path = `/tags?limit=${N8N_API_PAGE_SIZE}&cursor=${response.nextCursor}`;
1364
1741
  }
1365
1742
  return all;
1366
1743
  }
@@ -1384,6 +1761,32 @@ var N8nApiClient = class {
1384
1761
  return [];
1385
1762
  }
1386
1763
  }
1764
+ async triggerManual(workflowId) {
1765
+ const raw = await this.request("POST", `/workflows/${workflowId}/run`);
1766
+ const inner = raw["data"];
1767
+ const execId = inner?.["executionId"] ?? raw["executionId"];
1768
+ if (execId === void 0 || execId === null) {
1769
+ throw new ProviderError(
1770
+ `n8n trigger response missing executionId \u2014 got: ${JSON.stringify(raw)}`
1771
+ );
1772
+ }
1773
+ return String(execId);
1774
+ }
1775
+ async triggerWebhookTest(path) {
1776
+ const cleanPath = path.startsWith("/") ? path : `/${path}`;
1777
+ const url = `${this.baseUrl.replace(/\/$/, "")}/webhook-test${cleanPath}`;
1778
+ this.logger.debug(`n8n POST webhook-test ${cleanPath}`);
1779
+ try {
1780
+ const response = await fetchWithTimeout(
1781
+ url,
1782
+ { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}) },
1783
+ REQUEST_TIMEOUT_MS
1784
+ );
1785
+ return response.status;
1786
+ } catch (err) {
1787
+ throw new ProviderError(`Webhook test request failed for path "${path}"`, err);
1788
+ }
1789
+ }
1387
1790
  mapExecution(e) {
1388
1791
  return {
1389
1792
  id: e.id,
@@ -1600,6 +2003,14 @@ Cron: { "rule": { "interval": [{ "field": "cronExpression", "expression": "0 9 *
1600
2003
  8. No deprecated $node["NodeName"].json \u2014 use $('NodeName').item.json.field
1601
2004
  9. No $json.items[0] array indexing \u2014 access fields directly as $json.field
1602
2005
  10. No bare $('NodeName').json \u2014 always use .first().json.field or .all()
2006
+ 11. httpRequest URL is a real endpoint (not "example.com" or "YOUR_URL")
2007
+ 12. code nodes contain actual logic \u2014 not empty or comment-only
2008
+ 13. Slack message nodes have a channel specified (channelId or channel)
2009
+ 14. Gmail send nodes have a recipient (to field non-empty)
2010
+ 15. if nodes have at least one condition in conditions.conditions[]
2011
+ 16. set nodes have at least one entry in assignments.assignments[]
2012
+ 17. scheduleTrigger has at least one rule in rule.interval[]
2013
+ 18. webhook path is relative (no spaces, no leading slash, no http://)
1603
2014
 
1604
2015
  ---
1605
2016
 
@@ -1607,7 +2018,7 @@ Respond ONLY with a generate_workflow tool call. No prose. No markdown outside t
1607
2018
  If the request is impossible or unclear, set the error field instead of generating a workflow.`;
1608
2019
 
1609
2020
  // src/validation/rule-metadata.ts
1610
- var VALIDATOR_RULE_IDS = Array.from({ length: 26 }, (_, i) => i + 1);
2021
+ var VALIDATOR_RULE_IDS = Array.from({ length: 34 }, (_, i) => i + 1);
1611
2022
  var RULE_PIPELINE_STAGES = {
1612
2023
  1: "node_generation",
1613
2024
  2: "node_generation",
@@ -1634,7 +2045,15 @@ var RULE_PIPELINE_STAGES = {
1634
2045
  23: "node_generation",
1635
2046
  24: "expression_syntax",
1636
2047
  25: "expression_syntax",
1637
- 26: "expression_syntax"
2048
+ 26: "expression_syntax",
2049
+ 27: "node_generation",
2050
+ 28: "node_generation",
2051
+ 29: "node_generation",
2052
+ 30: "node_generation",
2053
+ 31: "node_generation",
2054
+ 32: "node_generation",
2055
+ 33: "node_generation",
2056
+ 34: "node_generation"
1638
2057
  };
1639
2058
  var RULE_EXAMPLES = {
1640
2059
  17: {
@@ -1652,6 +2071,38 @@ var RULE_EXAMPLES = {
1652
2071
  26: {
1653
2072
  bad: "$('Fetch Data').json.email",
1654
2073
  good: "$('Fetch Data').first().json.email"
2074
+ },
2075
+ 27: {
2076
+ bad: '"url": "https://example.com/api/data"',
2077
+ good: '"url": "https://api.yourservice.com/v1/endpoint"'
2078
+ },
2079
+ 28: {
2080
+ bad: '"jsCode": "// TODO: implement this"',
2081
+ good: '"jsCode": "return items.map(item => ({ json: { result: item.json.value * 2 } }))"'
2082
+ },
2083
+ 29: {
2084
+ bad: '"channelId": ""',
2085
+ good: '"channelId": { "__rl": true, "value": "C0123456789", "mode": "id" }'
2086
+ },
2087
+ 30: {
2088
+ bad: '"operation": "send", "to": ""',
2089
+ good: '"operation": "send", "to": "recipient@example.com"'
2090
+ },
2091
+ 31: {
2092
+ bad: '"conditions": { "combinator": "and", "conditions": [] }',
2093
+ good: '"conditions": { "combinator": "and", "conditions": [{ "leftValue": "={{ $json.status }}", "rightValue": "active", "operator": { "type": "string", "operation": "equals" } }] }'
2094
+ },
2095
+ 32: {
2096
+ bad: '"assignments": { "assignments": [] }',
2097
+ good: '"assignments": { "assignments": [{ "id": "f1", "name": "status", "value": "processed", "type": "string" }] }'
2098
+ },
2099
+ 33: {
2100
+ bad: '"rule": { "interval": [] }',
2101
+ good: '"rule": { "interval": [{ "field": "cronExpression", "expression": "0 9 * * 1-5" }] }'
2102
+ },
2103
+ 34: {
2104
+ bad: '"path": "/my webhook"',
2105
+ good: '"path": "my-webhook"'
1655
2106
  }
1656
2107
  };
1657
2108
  var RULE_MITIGATIONS = {
@@ -1680,7 +2131,15 @@ var RULE_MITIGATIONS = {
1680
2131
  23: "Use node types that exist in the n8n registry \u2014 check with kairos_sync",
1681
2132
  24: 'Use modern accessor syntax: $("NodeName").item.json.field instead of deprecated $node["NodeName"].json.field',
1682
2133
  25: "Access item fields directly with $json.field \u2014 n8n flattens items automatically, do not use $json.items[0]",
1683
- 26: 'Use $("NodeName").first().json.field or $("NodeName").all() \u2014 bare $("NodeName").json without .first() or .all() throws at runtime'
2134
+ 26: 'Use $("NodeName").first().json.field or $("NodeName").all() \u2014 bare $("NodeName").json without .first() or .all() throws at runtime',
2135
+ 27: 'Replace placeholder URLs with your actual API endpoint \u2014 do not use "example.com" or "YOUR_URL" patterns',
2136
+ 28: "Add executable code to the code node \u2014 empty or comment-only code nodes do nothing at runtime",
2137
+ 29: "Set the channel parameter for Slack message operations (channelId with __rl object, or channel as string)",
2138
+ 30: "Set the to parameter for Gmail send operations with at least one recipient email address",
2139
+ 31: "Add at least one condition to the if node \u2014 conditions.conditions array must be non-empty",
2140
+ 32: "Add field assignments to the set node \u2014 assignments.assignments array must be non-empty for typeVersion 3.x",
2141
+ 33: "Add at least one schedule rule to scheduleTrigger \u2014 rule.interval array must have at least one entry",
2142
+ 34: 'Webhook path must be a relative path without spaces, leading slashes, or protocol prefixes (e.g. "my-hook")'
1684
2143
  };
1685
2144
 
1686
2145
  // src/generation/prompt-builder.ts
@@ -1712,18 +2171,37 @@ var PromptBuilder = class {
1712
2171
  }
1713
2172
  build(request, matches, globalFailureRates = [], dynamicCatalog) {
1714
2173
  const mode = this.resolveMode(matches);
1715
- const system = this.buildSystem(matches, mode, globalFailureRates, dynamicCatalog);
2174
+ const system = this.buildSystem(matches, mode, globalFailureRates, dynamicCatalog, request.description);
1716
2175
  const userMessage = this.buildUserMessage(request, matches, mode);
1717
2176
  return { system, userMessage, mode, matches };
1718
2177
  }
1719
- buildCorrectionMessage(request, matches, allIssues, attempt) {
2178
+ buildCorrectionMessage(request, matches, allIssues, attempt, failingRuleIds) {
1720
2179
  const base = this.buildUserMessage(request, matches, this.resolveMode(matches));
2180
+ let examplesSection = "";
2181
+ if (failingRuleIds && failingRuleIds.length > 0) {
2182
+ const uniqueRules = [...new Set(failingRuleIds)];
2183
+ const exampleLines = [];
2184
+ for (const rule of uniqueRules) {
2185
+ const ex = RULE_EXAMPLES[rule];
2186
+ if (ex) {
2187
+ exampleLines.push(`Rule ${rule}:
2188
+ Bad: ${ex.bad}
2189
+ Good: ${ex.good}`);
2190
+ }
2191
+ }
2192
+ if (exampleLines.length > 0) {
2193
+ examplesSection = `
2194
+
2195
+ ## Concrete Fix Examples
2196
+ ${exampleLines.join("\n\n")}`;
2197
+ }
2198
+ }
1721
2199
  return `${base}
1722
2200
 
1723
2201
  IMPORTANT: A previous generation attempt (attempt ${attempt}) failed validation with these issues:
1724
2202
  ${allIssues.join("\n")}
1725
2203
 
1726
- Fix ALL of the above issues in your new response. Do not repeat any of these mistakes.`;
2204
+ Fix ALL of the above issues in your new response. Do not repeat any of these mistakes.${examplesSection}`;
1727
2205
  }
1728
2206
  resolveMode(matches) {
1729
2207
  if (matches.length === 0) return "scratch";
@@ -1731,7 +2209,7 @@ Fix ALL of the above issues in your new response. Do not repeat any of these mis
1731
2209
  if (!top) return "scratch";
1732
2210
  return scoreToMode(top.score);
1733
2211
  }
1734
- buildSystem(matches, mode, globalFailureRates = [], dynamicCatalog) {
2212
+ buildSystem(matches, mode, globalFailureRates = [], dynamicCatalog, description) {
1735
2213
  let basePrompt = SYSTEM_PROMPT_V1;
1736
2214
  if (dynamicCatalog) {
1737
2215
  basePrompt = basePrompt.replace(
@@ -1791,7 +2269,7 @@ A loosely similar workflow (score: ${hint.score.toFixed(2)}) used these node typ
1791
2269
  });
1792
2270
  }
1793
2271
  }
1794
- const warnings = this.buildFailureWarnings(matches, globalFailureRates);
2272
+ const warnings = this.buildFailureWarnings(matches, globalFailureRates, description);
1795
2273
  if (warnings) {
1796
2274
  blocks.push({ type: "text", text: warnings });
1797
2275
  }
@@ -1818,15 +2296,34 @@ A loosely similar workflow (score: ${hint.score.toFixed(2)}) used these node typ
1818
2296
  const patterns = this._lastActivePatterns ?? this.getActivePatterns(this.resolveMaxPatterns());
1819
2297
  return patterns.map((p) => p.rule);
1820
2298
  }
1821
- getActivePatterns(maxCount = 10) {
2299
+ getActivePatterns(maxCount = 10, description) {
1822
2300
  const all = this.loadPatterns().filter((p) => p.state !== "resolved" && p.confidence > 0);
1823
2301
  const regressed = all.filter((p) => p.regressed).sort((a, b) => b.compositeScore - a.compositeScore);
1824
2302
  const confirmed = all.filter((p) => !p.regressed && p.state === "confirmed").sort((a, b) => b.compositeScore - a.compositeScore);
1825
2303
  const drafts = all.filter((p) => !p.regressed && p.state !== "confirmed").sort((a, b) => b.compositeScore - a.compositeScore);
1826
- return [...regressed, ...confirmed, ...drafts].slice(0, maxCount);
1827
- }
1828
- buildFailureWarnings(matches, globalFailureRates) {
1829
- const richPatterns = this.getActivePatterns(this.resolveMaxPatterns());
2304
+ const ordered = [...regressed, ...confirmed, ...drafts];
2305
+ if (this.profile === "minimal" && description) {
2306
+ return this.rankByRelevance(ordered, description).slice(0, maxCount);
2307
+ }
2308
+ return ordered.slice(0, maxCount);
2309
+ }
2310
+ rankByRelevance(patterns, description) {
2311
+ const lower = description.toLowerCase();
2312
+ const STAGE_KEYWORDS = {
2313
+ credential_injection: ["credential", "auth", "api key", "token", "oauth", "smtp", "imap", "password", "secret"],
2314
+ connection_wiring: ["connect", "link", "wire", "chain", "merge", "branch", "join"],
2315
+ expression_syntax: ["expression", "variable", "json", "field", "data", "$json", "item"],
2316
+ workflow_structure: ["trigger", "webhook", "schedule", "structure", "workflow"],
2317
+ node_generation: ["node", "generate", "create", "build", "send", "fetch", "email", "slack", "http"]
2318
+ };
2319
+ return patterns.map((p) => {
2320
+ const keywords = STAGE_KEYWORDS[p.pipelineStage] ?? [];
2321
+ const relevanceBoost = keywords.some((kw) => lower.includes(kw)) ? 1 : 0;
2322
+ return { pattern: p, sort: relevanceBoost * 10 + p.compositeScore };
2323
+ }).sort((a, b) => b.sort - a.sort).map((x) => x.pattern);
2324
+ }
2325
+ buildFailureWarnings(matches, globalFailureRates, description) {
2326
+ const richPatterns = this.getActivePatterns(this.resolveMaxPatterns(), description);
1830
2327
  this._lastActivePatterns = richPatterns;
1831
2328
  if (richPatterns.length > 0) {
1832
2329
  return this.buildStageGroupedWarnings(richPatterns, matches);
@@ -1993,19 +2490,20 @@ var TelemetryReader = class {
1993
2490
  }
1994
2491
  const events = await this.readRecentEvents(days);
1995
2492
  const buildSessions = new Set(
1996
- events.filter((e) => e.eventType === "build_complete").map((e) => e.sessionId)
2493
+ events.filter((e) => e.eventType === "build_complete").map((e) => e.runId ?? e.sessionId)
1997
2494
  );
1998
2495
  const MIN_BUILDS_FOR_RATES = 3;
1999
2496
  if (buildSessions.size < MIN_BUILDS_FOR_RATES) return [];
2000
2497
  const ruleSessions = /* @__PURE__ */ new Map();
2001
2498
  for (const event of events) {
2002
2499
  if (event.eventType !== "generation_attempt") continue;
2003
- if (!buildSessions.has(event.sessionId)) continue;
2500
+ const eventKey = event.runId ?? event.sessionId;
2501
+ if (!buildSessions.has(eventKey)) continue;
2004
2502
  const data = event.data;
2005
2503
  if (data.validationPassed || !data.issues) continue;
2006
2504
  for (const issue of data.issues) {
2007
2505
  const entry = ruleSessions.get(issue.rule) ?? { sessions: /* @__PURE__ */ new Set(), messages: /* @__PURE__ */ new Map() };
2008
- entry.sessions.add(event.sessionId);
2506
+ entry.sessions.add(eventKey);
2009
2507
  entry.messages.set(issue.message, (entry.messages.get(issue.message) ?? 0) + 1);
2010
2508
  ruleSessions.set(issue.rule, entry);
2011
2509
  }
@@ -2047,22 +2545,24 @@ var PatternAnalyzer = class _PatternAnalyzer {
2047
2545
  telemetryDir;
2048
2546
  outputDir;
2049
2547
  _cachedEvents = null;
2548
+ _cachedPreviousPatterns = null;
2050
2549
  constructor(telemetryDir) {
2051
2550
  const defaultDir = (0, import_node_path5.join)((0, import_node_os4.homedir)(), ".kairos", "telemetry");
2052
2551
  this.telemetryDir = telemetryDir ?? defaultDir;
2053
2552
  this.outputDir = telemetryDir ? (0, import_node_path5.join)(telemetryDir, "..") : (0, import_node_path5.join)((0, import_node_os4.homedir)(), ".kairos");
2054
2553
  }
2055
2554
  async loadPreviousPatterns() {
2555
+ if (this._cachedPreviousPatterns !== null) return this._cachedPreviousPatterns;
2056
2556
  try {
2057
2557
  const raw = await (0, import_promises3.readFile)((0, import_node_path5.join)(this.outputDir, "patterns.json"), "utf-8");
2058
2558
  const prev = JSON.parse(raw);
2059
2559
  const version = prev.schemaVersion ?? 0;
2060
2560
  const patterns = prev.topFailureRules ?? [];
2061
- if (version === PATTERN_SCHEMA_VERSION) return patterns;
2062
- return this.migratePatterns(patterns, version);
2561
+ this._cachedPreviousPatterns = version === PATTERN_SCHEMA_VERSION ? patterns : this.migratePatterns(patterns, version);
2063
2562
  } catch {
2064
- return [];
2563
+ this._cachedPreviousPatterns = [];
2065
2564
  }
2565
+ return this._cachedPreviousPatterns;
2066
2566
  }
2067
2567
  migratePatterns(patterns, fromVersion) {
2068
2568
  let migrated = patterns;
@@ -2362,6 +2862,7 @@ var PatternAnalyzer = class _PatternAnalyzer {
2362
2862
  const tmpPath = `${outputPath}.tmp`;
2363
2863
  await (0, import_promises3.writeFile)(tmpPath, JSON.stringify(analysis, null, 2), "utf-8");
2364
2864
  await (0, import_promises3.rename)(tmpPath, outputPath);
2865
+ this._cachedPreviousPatterns = null;
2365
2866
  const historySummary = {
2366
2867
  timestamp: analysis.generatedAt,
2367
2868
  totalBuilds: analysis.summary.totalBuilds,
@@ -2410,7 +2911,7 @@ var PatternAnalyzer = class _PatternAnalyzer {
2410
2911
  })
2411
2912
  ));
2412
2913
  return {
2413
- sessionId: bc.sessionId,
2914
+ sessionId: bc.runId ?? bc.sessionId,
2414
2915
  date: bc.fileDate,
2415
2916
  description: data.description ?? "",
2416
2917
  workflowType: data.workflowType ?? null,
@@ -2443,7 +2944,7 @@ var PatternAnalyzer = class _PatternAnalyzer {
2443
2944
  alerts.push({
2444
2945
  type: "stale_pattern",
2445
2946
  rule: p.rule,
2446
- message: `Pattern references Rule ${p.rule} which does not exist in the current validator (rules 1-26)`
2947
+ message: `Pattern references Rule ${p.rule} which does not exist in the current validator (rules 1-34)`
2447
2948
  });
2448
2949
  }
2449
2950
  }
@@ -2683,9 +3184,13 @@ var import_meta = {};
2683
3184
  var __dirname = (0, import_node_path7.dirname)((0, import_node_url.fileURLToPath)(import_meta.url));
2684
3185
  var pkg = JSON.parse((0, import_node_fs3.readFileSync)((0, import_node_path7.join)(__dirname, "..", "package.json"), "utf-8"));
2685
3186
  var library = new FileLibrary();
2686
- var validator = new N8nValidator();
3187
+ var _validator = new N8nValidator();
3188
+ function getValidator() {
3189
+ return _validator;
3190
+ }
2687
3191
  var nodeSyncer = new NodeSyncer();
2688
3192
  var lastSync = null;
3193
+ var AUTO_SYNC_TIMEOUT_MS = 5e3;
2689
3194
  var stripper = new N8nFieldStripper();
2690
3195
  var promptBuilder = new PromptBuilder(getMcpPatternsPath());
2691
3196
  function getMcpTelemetry() {
@@ -2720,11 +3225,23 @@ function isAllowed(action) {
2720
3225
  const key = `KAIROS_MCP_ALLOW_${action.toUpperCase()}`;
2721
3226
  return process.env[key] === "true";
2722
3227
  }
3228
+ function mcpText(text) {
3229
+ return { content: [{ type: "text", text }] };
3230
+ }
3231
+ function mcpError(text) {
3232
+ return { content: [{ type: "text", text }], isError: true };
3233
+ }
3234
+ function checkMcpAuth(provided) {
3235
+ const expected = process.env["KAIROS_MCP_SECRET"];
3236
+ if (!expected) return null;
3237
+ if (provided === expected) return null;
3238
+ return mcpError(JSON.stringify({ error: "Unauthorized: missing or incorrect kairos_secret" }));
3239
+ }
2723
3240
  function getApiClient() {
2724
3241
  const baseUrl = process.env["N8N_BASE_URL"];
2725
3242
  const apiKey = process.env["N8N_API_KEY"];
2726
3243
  if (!baseUrl || !apiKey) {
2727
- throw new Error("N8N_BASE_URL and N8N_API_KEY environment variables are required for n8n operations");
3244
+ throw new GuardError("N8N_BASE_URL and N8N_API_KEY environment variables are required for n8n operations");
2728
3245
  }
2729
3246
  return new N8nApiClient(baseUrl, apiKey, nullLogger);
2730
3247
  }
@@ -2738,7 +3255,7 @@ async function autoSync() {
2738
3255
  const nodeTypes = await client.getNodeTypes();
2739
3256
  if (nodeTypes.length === 0) return null;
2740
3257
  lastSync = nodeSyncer.sync(nodeTypes);
2741
- validator = new N8nValidator(lastSync.registry);
3258
+ _validator = new N8nValidator(lastSync.registry);
2742
3259
  return lastSync;
2743
3260
  } catch {
2744
3261
  return null;
@@ -2760,21 +3277,21 @@ server.tool(
2760
3277
  const baseUrl = process.env["N8N_BASE_URL"];
2761
3278
  const apiKey = process.env["N8N_API_KEY"];
2762
3279
  if (!baseUrl || !apiKey) {
2763
- return {
2764
- content: [{
2765
- type: "text",
2766
- text: JSON.stringify({ error: "N8N_BASE_URL and N8N_API_KEY are required. Kairos needs to sync your n8n instance's node types to generate accurate workflows." })
2767
- }],
2768
- isError: true
2769
- };
3280
+ return mcpError(JSON.stringify({ error: "N8N_BASE_URL and N8N_API_KEY are required. Kairos needs to sync your n8n instance's node types to generate accurate workflows." }));
2770
3281
  }
2771
3282
  const runId = generateUUID();
2772
3283
  const workflowType = inferWorkflowType(description);
3284
+ const syncPromise = autoSync();
3285
+ const syncTimeout = new Promise((resolve) => setTimeout(() => resolve(null), AUTO_SYNC_TIMEOUT_MS));
2773
3286
  await library.initialize();
2774
- const syncResult = await autoSync();
2775
- const matches = await library.search(description);
2776
- const telemetryReader = getTelemetryReader();
2777
- const failureRates = await telemetryReader?.getFailureRates() ?? [];
3287
+ const [syncResult, matches, failureRates] = await Promise.all([
3288
+ Promise.race([syncPromise, syncTimeout]),
3289
+ library.search(description),
3290
+ (async () => {
3291
+ const reader = getTelemetryReader();
3292
+ return reader ? reader.getFailureRates() : [];
3293
+ })()
3294
+ ]);
2778
3295
  const request = { description, ...name ? { name } : {} };
2779
3296
  const built = promptBuilder.build(request, matches, failureRates, syncResult?.catalogText);
2780
3297
  if (mcpTelemetry) {
@@ -2788,43 +3305,38 @@ server.tool(
2788
3305
  await mcpTelemetry.emit("build_start", { description, model: "mcp-decomposed", dryRun: false }, runId);
2789
3306
  }
2790
3307
  const systemText = built.system.map((block) => block.text).join("\n\n---\n\n");
2791
- return {
2792
- content: [{
2793
- type: "text",
2794
- text: JSON.stringify({
2795
- kairos_run_id: runId,
2796
- mode: built.mode,
2797
- matchCount: matches.length,
2798
- topMatchScore: matches[0]?.score ?? null,
2799
- nodeCatalog: syncResult ? "synced" : "static",
2800
- nodeCount: syncResult?.nodeCount ?? null,
2801
- ...syncResult ? {} : { syncWarning: "Could not sync node types from your n8n instance. Using static fallback catalog \u2014 generated workflows may not match your exact n8n setup." },
2802
- systemPrompt: systemText,
2803
- userMessage: built.userMessage,
2804
- outputFormat: {
2805
- description: "Generate a JSON object with this exact structure. The workflow field contains the n8n workflow. credentialsNeeded lists services requiring credentials.",
2806
- schema: {
2807
- workflow: {
2808
- name: "string \u2014 descriptive workflow name",
2809
- nodes: "array \u2014 n8n node objects with id (UUID v4), type, typeVersion, name, position, parameters",
2810
- connections: "object \u2014 keyed by source node NAME, maps to target nodes",
2811
- settings: 'object \u2014 include executionOrder: "v1"'
2812
- },
2813
- credentialsNeeded: [{
2814
- service: 'string \u2014 e.g. "Slack"',
2815
- credentialType: 'string \u2014 e.g. "slackOAuth2Api"',
2816
- description: "string \u2014 what the user needs to set up"
2817
- }]
2818
- }
2819
- }
2820
- }, null, 2)
2821
- }]
2822
- };
3308
+ return mcpText(JSON.stringify({
3309
+ kairos_run_id: runId,
3310
+ mode: built.mode,
3311
+ matchCount: matches.length,
3312
+ topMatchScore: matches[0]?.score ?? null,
3313
+ nodeCatalog: syncResult ? "synced" : "static",
3314
+ nodeCount: syncResult?.nodeCount ?? null,
3315
+ ...syncResult ? {} : { syncWarning: "Could not sync node types from your n8n instance. Using static fallback catalog \u2014 generated workflows may not match your exact n8n setup." },
3316
+ systemPrompt: systemText,
3317
+ userMessage: built.userMessage,
3318
+ outputFormat: {
3319
+ description: "Generate a JSON object with this exact structure. The workflow field contains the n8n workflow. credentialsNeeded lists services requiring credentials.",
3320
+ schema: {
3321
+ workflow: {
3322
+ name: "string \u2014 descriptive workflow name",
3323
+ nodes: "array \u2014 n8n node objects with id (UUID v4), type, typeVersion, name, position, parameters",
3324
+ connections: "object \u2014 keyed by source node NAME, maps to target nodes",
3325
+ settings: 'object \u2014 include executionOrder: "v1"'
3326
+ },
3327
+ credentialsNeeded: [{
3328
+ service: 'string \u2014 e.g. "Slack"',
3329
+ credentialType: 'string \u2014 e.g. "slackOAuth2Api"',
3330
+ description: "string \u2014 what the user needs to set up"
3331
+ }]
3332
+ }
3333
+ }
3334
+ }, null, 2));
2823
3335
  }
2824
3336
  );
2825
3337
  server.tool(
2826
3338
  "kairos_validate",
2827
- "Validate n8n workflow JSON against 26 structural rules. Returns pass/fail with specific issues. If validation fails, fix the issues and call this again. Errors block deployment; warnings are advisory.",
3339
+ "Validate n8n workflow JSON against 34 structural rules. Returns pass/fail with specific issues. If validation fails, fix the issues and call this again. Errors block deployment; warnings are advisory.",
2828
3340
  {
2829
3341
  workflow: import_zod.z.string().describe("The workflow JSON string to validate"),
2830
3342
  kairos_run_id: import_zod.z.string().optional().describe("Run ID from kairos_prompt \u2014 enables telemetry correlation")
@@ -2834,17 +3346,9 @@ server.tool(
2834
3346
  try {
2835
3347
  parsed = JSON.parse(workflowStr);
2836
3348
  } catch (e) {
2837
- return {
2838
- content: [{
2839
- type: "text",
2840
- text: JSON.stringify({
2841
- valid: false,
2842
- error: `Invalid JSON: ${e instanceof Error ? e.message : String(e)}`
2843
- }, null, 2)
2844
- }]
2845
- };
3349
+ return mcpText(JSON.stringify({ valid: false, error: `Invalid JSON: ${e instanceof Error ? e.message : String(e)}` }, null, 2));
2846
3350
  }
2847
- const result = validator.validate(parsed);
3351
+ const result = getValidator().validate(parsed);
2848
3352
  const errors = result.issues.filter((i) => i.severity === "error");
2849
3353
  const warnings = result.issues.filter((i) => i.severity === "warn");
2850
3354
  if (mcpTelemetry && kairos_run_id) {
@@ -2865,27 +3369,14 @@ server.tool(
2865
3369
  }, kairos_run_id);
2866
3370
  }
2867
3371
  }
2868
- return {
2869
- content: [{
2870
- type: "text",
2871
- text: JSON.stringify({
2872
- valid: result.valid,
2873
- errorCount: errors.length,
2874
- warningCount: warnings.length,
2875
- errors: errors.map((i) => ({
2876
- rule: i.rule,
2877
- message: i.message,
2878
- nodeId: i.nodeId ?? null
2879
- })),
2880
- warnings: warnings.map((i) => ({
2881
- rule: i.rule,
2882
- message: i.message,
2883
- nodeId: i.nodeId ?? null
2884
- })),
2885
- deployable: errors.length === 0
2886
- }, null, 2)
2887
- }]
2888
- };
3372
+ return mcpText(JSON.stringify({
3373
+ valid: result.valid,
3374
+ errorCount: errors.length,
3375
+ warningCount: warnings.length,
3376
+ errors: errors.map((i) => ({ rule: i.rule, message: i.message, nodeId: i.nodeId ?? null })),
3377
+ warnings: warnings.map((i) => ({ rule: i.rule, message: i.message, nodeId: i.nodeId ?? null })),
3378
+ deployable: errors.length === 0
3379
+ }, null, 2));
2889
3380
  }
2890
3381
  );
2891
3382
  server.tool(
@@ -2894,101 +3385,146 @@ server.tool(
2894
3385
  {
2895
3386
  workflow: import_zod.z.string().describe("The validated workflow JSON string to deploy"),
2896
3387
  activate: import_zod.z.boolean().default(false).describe("Activate the workflow immediately after deployment"),
2897
- kairos_run_id: import_zod.z.string().optional().describe("Run ID from kairos_prompt \u2014 enables telemetry correlation")
3388
+ kairos_run_id: import_zod.z.string().optional().describe("Run ID from kairos_prompt \u2014 enables telemetry correlation"),
3389
+ kairos_secret: import_zod.z.string().optional().describe("Required when KAIROS_MCP_SECRET env var is set")
2898
3390
  },
2899
- async ({ workflow: workflowStr, activate, kairos_run_id }) => {
3391
+ async ({ workflow: workflowStr, activate, kairos_run_id, kairos_secret }) => {
3392
+ const authError = checkMcpAuth(kairos_secret);
3393
+ if (authError) return authError;
2900
3394
  if (!isAllowed("deploy")) {
2901
- return {
2902
- content: [{
2903
- type: "text",
2904
- text: JSON.stringify({ error: "Deploy is disabled. Set KAIROS_MCP_ALLOW_DEPLOY=true to enable." })
2905
- }],
2906
- isError: true
2907
- };
3395
+ return mcpError(JSON.stringify({ error: "Deploy is disabled. Set KAIROS_MCP_ALLOW_DEPLOY=true to enable." }));
2908
3396
  }
2909
3397
  let parsed;
2910
3398
  try {
2911
3399
  parsed = JSON.parse(workflowStr);
2912
3400
  } catch (e) {
2913
- return {
2914
- content: [{
2915
- type: "text",
2916
- text: JSON.stringify({ error: `Invalid JSON: ${e instanceof Error ? e.message : String(e)}` })
2917
- }]
2918
- };
3401
+ return mcpError(JSON.stringify({ error: `Invalid JSON: ${e instanceof Error ? e.message : String(e)}` }));
2919
3402
  }
2920
- const validation = validator.validate(parsed);
3403
+ const validation = getValidator().validate(parsed);
2921
3404
  const errors = validation.issues.filter((i) => i.severity === "error");
2922
3405
  if (errors.length > 0) {
2923
- return {
2924
- content: [{
2925
- type: "text",
2926
- text: JSON.stringify({
2927
- error: "Workflow has validation errors \u2014 fix them before deploying",
2928
- errors: errors.map((i) => ({ rule: i.rule, message: i.message }))
2929
- }, null, 2)
2930
- }]
2931
- };
3406
+ return mcpError(JSON.stringify({
3407
+ error: "Workflow has validation errors \u2014 fix them before deploying",
3408
+ errors: errors.map((i) => ({ rule: i.rule, message: i.message }))
3409
+ }, null, 2));
2932
3410
  }
2933
3411
  const client = getApiClient();
2934
3412
  const stripped = stripper.stripForCreate(parsed);
2935
3413
  const response = await client.createWorkflow(stripped);
2936
3414
  if (activate) {
2937
3415
  if (!isAllowed("activate")) {
2938
- return {
2939
- content: [{
2940
- type: "text",
2941
- text: JSON.stringify({
2942
- workflowId: response.id,
2943
- name: response.name,
2944
- activated: false,
2945
- warning: "Workflow deployed but activation is disabled. Set KAIROS_MCP_ALLOW_ACTIVATE=true to enable.",
2946
- url: `${process.env["N8N_BASE_URL"]}/workflow/${response.id}`
2947
- }, null, 2)
2948
- }]
2949
- };
3416
+ return mcpText(JSON.stringify({
3417
+ workflowId: response.id,
3418
+ name: response.name,
3419
+ activated: false,
3420
+ warning: "Workflow deployed but activation is disabled. Set KAIROS_MCP_ALLOW_ACTIVATE=true to enable.",
3421
+ url: `${process.env["N8N_BASE_URL"]}/workflow/${response.id}`
3422
+ }, null, 2));
2950
3423
  }
2951
3424
  await client.activateWorkflow(response.id);
2952
3425
  }
3426
+ const session = kairos_run_id ? mcpSessions.get(kairos_run_id) : void 0;
3427
+ const missingSessionWarning = kairos_run_id && !session ? `
3428
+
3429
+ Note: kairos_run_id "${kairos_run_id}" was provided but no active session was found. This usually means kairos_deploy was called without a prior kairos_prompt call, or the session expired. Telemetry and pattern learning for this build were skipped.` : "";
2953
3430
  await library.initialize();
2954
3431
  await library.save(parsed, {
2955
- description: parsed.name,
3432
+ description: session?.description ?? parsed.name,
2956
3433
  generationMode: "scratch",
2957
- generationAttempts: 1
3434
+ generationAttempts: session?.validateAttempts ?? 1,
3435
+ n8nWorkflowId: response.id
2958
3436
  });
2959
- if (mcpTelemetry && kairos_run_id) {
2960
- const session = mcpSessions.get(kairos_run_id);
2961
- if (session) {
2962
- await mcpTelemetry.emit("build_complete", {
2963
- description: session.description,
2964
- success: true,
2965
- totalAttempts: session.validateAttempts,
2966
- totalDurationMs: Date.now() - session.startTime,
2967
- totalTokensInput: 0,
2968
- totalTokensOutput: 0,
2969
- workflowName: response.name,
2970
- workflowId: response.id,
2971
- dryRun: false,
2972
- credentialsNeeded: 0,
2973
- warnedRules: session.warnedRules,
2974
- workflowType: session.workflowType
2975
- }, kairos_run_id);
2976
- mcpSessions.delete(kairos_run_id);
2977
- PatternAnalyzer.fromEnv().analyzeAndSave().catch(() => {
2978
- });
2979
- }
3437
+ if (mcpTelemetry && kairos_run_id && session) {
3438
+ await mcpTelemetry.emit("build_complete", {
3439
+ description: session.description,
3440
+ success: true,
3441
+ totalAttempts: session.validateAttempts,
3442
+ totalDurationMs: Date.now() - session.startTime,
3443
+ totalTokensInput: 0,
3444
+ totalTokensOutput: 0,
3445
+ workflowName: response.name,
3446
+ workflowId: response.id,
3447
+ dryRun: false,
3448
+ credentialsNeeded: 0,
3449
+ warnedRules: session.warnedRules,
3450
+ workflowType: session.workflowType
3451
+ }, kairos_run_id);
3452
+ mcpSessions.delete(kairos_run_id);
3453
+ PatternAnalyzer.fromEnv().analyzeAndSave().catch(() => {
3454
+ });
2980
3455
  }
2981
- return {
2982
- content: [{
2983
- type: "text",
2984
- text: JSON.stringify({
2985
- workflowId: response.id,
2986
- name: response.name,
2987
- activated: activate,
2988
- url: `${process.env["N8N_BASE_URL"]}/workflow/${response.id}`
2989
- }, null, 2)
2990
- }]
2991
- };
3456
+ return mcpText(JSON.stringify({
3457
+ workflowId: response.id,
3458
+ name: response.name,
3459
+ activated: activate,
3460
+ url: `${process.env["N8N_BASE_URL"]}/workflow/${response.id}`
3461
+ }, null, 2) + missingSessionWarning);
3462
+ }
3463
+ );
3464
+ server.tool(
3465
+ "kairos_replace",
3466
+ "Replace an existing n8n workflow with a new version. Validates before updating. Use kairos_prompt \u2192 kairos_validate \u2192 kairos_replace for iteration on existing workflows.",
3467
+ {
3468
+ workflow_id: import_zod.z.string().describe("The n8n workflow ID to replace"),
3469
+ workflow: import_zod.z.string().describe("The validated workflow JSON string"),
3470
+ kairos_run_id: import_zod.z.string().optional().describe("Run ID from kairos_prompt \u2014 enables telemetry correlation"),
3471
+ kairos_secret: import_zod.z.string().optional().describe("Required when KAIROS_MCP_SECRET env var is set")
3472
+ },
3473
+ async ({ workflow_id, workflow: workflowStr, kairos_run_id, kairos_secret }) => {
3474
+ const authError = checkMcpAuth(kairos_secret);
3475
+ if (authError) return authError;
3476
+ let parsed;
3477
+ try {
3478
+ parsed = JSON.parse(workflowStr);
3479
+ } catch (e) {
3480
+ return mcpError(JSON.stringify({ error: `Invalid JSON: ${e instanceof Error ? e.message : String(e)}` }));
3481
+ }
3482
+ const validation = getValidator().validate(parsed);
3483
+ const errors = validation.issues.filter((i) => i.severity === "error");
3484
+ if (errors.length > 0) {
3485
+ return mcpError(JSON.stringify({
3486
+ error: "Workflow has validation errors \u2014 fix them before replacing",
3487
+ errors: errors.map((i) => ({ rule: i.rule, message: i.message }))
3488
+ }, null, 2));
3489
+ }
3490
+ const client = getApiClient();
3491
+ const stripped = stripper.stripForUpdate(parsed);
3492
+ const response = await client.updateWorkflow(workflow_id, stripped);
3493
+ const session = kairos_run_id ? mcpSessions.get(kairos_run_id) : void 0;
3494
+ const missingSessionWarning = kairos_run_id && !session ? `
3495
+
3496
+ Note: kairos_run_id "${kairos_run_id}" was provided but no active session was found.` : "";
3497
+ await library.initialize();
3498
+ await library.save(parsed, {
3499
+ description: session?.description ?? parsed.name,
3500
+ generationMode: "scratch",
3501
+ generationAttempts: session?.validateAttempts ?? 1,
3502
+ n8nWorkflowId: workflow_id
3503
+ });
3504
+ if (mcpTelemetry && kairos_run_id && session) {
3505
+ await mcpTelemetry.emit("build_complete", {
3506
+ description: session.description,
3507
+ success: true,
3508
+ totalAttempts: session.validateAttempts,
3509
+ totalDurationMs: Date.now() - session.startTime,
3510
+ totalTokensInput: 0,
3511
+ totalTokensOutput: 0,
3512
+ workflowName: response.name,
3513
+ workflowId: response.id,
3514
+ dryRun: false,
3515
+ credentialsNeeded: 0,
3516
+ warnedRules: session.warnedRules,
3517
+ workflowType: session.workflowType
3518
+ }, kairos_run_id);
3519
+ mcpSessions.delete(kairos_run_id);
3520
+ PatternAnalyzer.fromEnv().analyzeAndSave().catch(() => {
3521
+ });
3522
+ }
3523
+ return mcpText(JSON.stringify({
3524
+ workflowId: response.id,
3525
+ name: response.name,
3526
+ url: `${process.env["N8N_BASE_URL"]}/workflow/${response.id}`
3527
+ }, null, 2) + missingSessionWarning);
2992
3528
  }
2993
3529
  );
2994
3530
  server.tool(
@@ -3001,23 +3537,20 @@ server.tool(
3001
3537
  async ({ query, limit }) => {
3002
3538
  await library.initialize();
3003
3539
  const matches = await library.search(query);
3004
- return {
3005
- content: [{
3006
- type: "text",
3007
- text: JSON.stringify(
3008
- matches.slice(0, limit).map((m) => ({
3009
- score: Number(m.score.toFixed(3)),
3010
- mode: m.mode,
3011
- description: m.workflow.description,
3012
- nodeCount: m.workflow.workflow.nodes.length,
3013
- nodes: m.workflow.workflow.nodes.map((n) => n.name),
3014
- failurePatterns: m.workflow.failurePatterns ?? []
3015
- })),
3016
- null,
3017
- 2
3018
- )
3019
- }]
3020
- };
3540
+ return mcpText(JSON.stringify(
3541
+ matches.slice(0, limit).map((m) => ({
3542
+ id: m.workflow.id,
3543
+ score: Number(m.score.toFixed(3)),
3544
+ mode: m.mode,
3545
+ description: m.workflow.description,
3546
+ nodeCount: m.workflow.workflow.nodes.length,
3547
+ nodes: m.workflow.workflow.nodes.map((n) => n.name),
3548
+ n8nWorkflowId: m.workflow.n8nWorkflowId ?? null,
3549
+ failurePatterns: m.workflow.failurePatterns ?? []
3550
+ })),
3551
+ null,
3552
+ 2
3553
+ ));
3021
3554
  }
3022
3555
  );
3023
3556
  server.tool(
@@ -3028,36 +3561,19 @@ server.tool(
3028
3561
  const baseUrl = process.env["N8N_BASE_URL"];
3029
3562
  const apiKey = process.env["N8N_API_KEY"];
3030
3563
  if (!baseUrl || !apiKey) {
3031
- return {
3032
- content: [{
3033
- type: "text",
3034
- text: JSON.stringify({ error: "N8N_BASE_URL and N8N_API_KEY are required for sync." })
3035
- }],
3036
- isError: true
3037
- };
3564
+ return mcpError(JSON.stringify({ error: "N8N_BASE_URL and N8N_API_KEY are required for sync." }));
3038
3565
  }
3039
3566
  lastSync = null;
3040
3567
  const result = await autoSync();
3041
3568
  if (!result) {
3042
- return {
3043
- content: [{
3044
- type: "text",
3045
- text: JSON.stringify({ error: "Failed to fetch node types from n8n. Check your credentials and that your instance is running." })
3046
- }],
3047
- isError: true
3048
- };
3569
+ return mcpError(JSON.stringify({ error: "Failed to fetch node types from n8n. Check your credentials and that your instance is running." }));
3049
3570
  }
3050
- return {
3051
- content: [{
3052
- type: "text",
3053
- text: JSON.stringify({
3054
- synced: true,
3055
- nodeCount: result.nodeCount,
3056
- newNodes: result.newNodes,
3057
- message: `Synced ${result.nodeCount} node types from your n8n instance (${result.newNodes} not in default catalog).`
3058
- }, null, 2)
3059
- }]
3060
- };
3571
+ return mcpText(JSON.stringify({
3572
+ synced: true,
3573
+ nodeCount: result.nodeCount,
3574
+ newNodes: result.newNodes,
3575
+ message: `Synced ${result.nodeCount} node types from your n8n instance (${result.newNodes} not in default catalog).`
3576
+ }, null, 2));
3061
3577
  }
3062
3578
  );
3063
3579
  server.tool(
@@ -3073,12 +3589,72 @@ server.tool(
3073
3589
  if (limit !== void 0 && limit > 0) {
3074
3590
  analysis.topFailureRules = analysis.topFailureRules.slice(0, limit);
3075
3591
  }
3076
- return {
3077
- content: [{
3078
- type: "text",
3079
- text: JSON.stringify(analysis, null, 2)
3080
- }]
3081
- };
3592
+ return mcpText(JSON.stringify(analysis, null, 2));
3593
+ }
3594
+ );
3595
+ server.tool(
3596
+ "kairos_library",
3597
+ "Browse the local Kairos workflow library. Returns saved workflow metadata. Use the optional query to search, or omit it to list all entries.",
3598
+ {
3599
+ query: import_zod.z.string().optional().describe("Optional search query \u2014 omit to list all entries"),
3600
+ limit: import_zod.z.number().default(20).describe("Maximum entries to return")
3601
+ },
3602
+ async ({ query, limit }) => {
3603
+ await library.initialize();
3604
+ if (query) {
3605
+ const matches = await library.search(query);
3606
+ return mcpText(JSON.stringify(
3607
+ matches.slice(0, limit).map((m) => ({
3608
+ id: m.workflow.id,
3609
+ description: m.workflow.description,
3610
+ score: Number(m.score.toFixed(3)),
3611
+ mode: m.mode,
3612
+ nodeCount: m.workflow.workflow.nodes.length,
3613
+ nodes: m.workflow.workflow.nodes.map((n) => n.name),
3614
+ deployCount: m.workflow.deployCount,
3615
+ n8nWorkflowId: m.workflow.n8nWorkflowId ?? null,
3616
+ createdAt: m.workflow.createdAt
3617
+ })),
3618
+ null,
3619
+ 2
3620
+ ));
3621
+ }
3622
+ const all = await library.list();
3623
+ return mcpText(JSON.stringify(
3624
+ all.slice(0, limit).map((w) => ({
3625
+ id: w.id,
3626
+ description: w.description,
3627
+ nodeCount: w.workflow.nodes.length,
3628
+ nodes: w.workflow.nodes.map((n) => n.name),
3629
+ deployCount: w.deployCount,
3630
+ n8nWorkflowId: w.n8nWorkflowId ?? null,
3631
+ timesRetrieved: w.timesRetrieved ?? 0,
3632
+ createdAt: w.createdAt
3633
+ })),
3634
+ null,
3635
+ 2
3636
+ ));
3637
+ }
3638
+ );
3639
+ server.tool(
3640
+ "kairos_outcome",
3641
+ "Record the outcome of a workflow build against a library entry. Trains the pattern learning system to know what works and what fails over time.",
3642
+ {
3643
+ library_id: import_zod.z.string().describe("The Kairos library entry ID (returned by kairos_deploy, kairos_replace, or kairos_library)"),
3644
+ attempts: import_zod.z.number().describe("Number of generation+validation attempts before success"),
3645
+ first_try_pass: import_zod.z.boolean().describe("Whether the first attempt passed validation"),
3646
+ failed_rules: import_zod.z.array(import_zod.z.number()).describe("Validation rule IDs that failed during generation"),
3647
+ mode: import_zod.z.enum(["direct", "reference"]).describe("How the library entry was used during generation")
3648
+ },
3649
+ async ({ library_id, attempts, first_try_pass, failed_rules, mode }) => {
3650
+ await library.initialize();
3651
+ await library.recordOutcome(library_id, {
3652
+ attempts,
3653
+ firstTryPass: first_try_pass,
3654
+ failedRules: failed_rules,
3655
+ mode
3656
+ });
3657
+ return mcpText(JSON.stringify({ recorded: true, libraryId: library_id }));
3082
3658
  }
3083
3659
  );
3084
3660
  server.tool(
@@ -3088,12 +3664,7 @@ server.tool(
3088
3664
  async () => {
3089
3665
  const client = getApiClient();
3090
3666
  const workflows = await client.listWorkflows();
3091
- return {
3092
- content: [{
3093
- type: "text",
3094
- text: JSON.stringify(workflows, null, 2)
3095
- }]
3096
- };
3667
+ return mcpText(JSON.stringify(workflows, null, 2));
3097
3668
  }
3098
3669
  );
3099
3670
  server.tool(
@@ -3105,12 +3676,7 @@ server.tool(
3105
3676
  async ({ workflow_id }) => {
3106
3677
  const client = getApiClient();
3107
3678
  const workflow = await client.getWorkflow(workflow_id);
3108
- return {
3109
- content: [{
3110
- type: "text",
3111
- text: JSON.stringify(workflow, null, 2)
3112
- }]
3113
- };
3679
+ return mcpText(JSON.stringify(workflow, null, 2));
3114
3680
  }
3115
3681
  );
3116
3682
  server.tool(
@@ -3121,22 +3687,11 @@ server.tool(
3121
3687
  },
3122
3688
  async ({ workflow_id }) => {
3123
3689
  if (!isAllowed("activate")) {
3124
- return {
3125
- content: [{
3126
- type: "text",
3127
- text: JSON.stringify({ error: "Activate is disabled. Set KAIROS_MCP_ALLOW_ACTIVATE=true to enable." })
3128
- }],
3129
- isError: true
3130
- };
3690
+ return mcpError(JSON.stringify({ error: "Activate is disabled. Set KAIROS_MCP_ALLOW_ACTIVATE=true to enable." }));
3131
3691
  }
3132
3692
  const client = getApiClient();
3133
3693
  await client.activateWorkflow(workflow_id);
3134
- return {
3135
- content: [{
3136
- type: "text",
3137
- text: `Activated workflow ${workflow_id}`
3138
- }]
3139
- };
3694
+ return mcpText(`Activated workflow ${workflow_id}`);
3140
3695
  }
3141
3696
  );
3142
3697
  server.tool(
@@ -3148,38 +3703,25 @@ server.tool(
3148
3703
  async ({ workflow_id }) => {
3149
3704
  const client = getApiClient();
3150
3705
  await client.deactivateWorkflow(workflow_id);
3151
- return {
3152
- content: [{
3153
- type: "text",
3154
- text: `Deactivated workflow ${workflow_id}`
3155
- }]
3156
- };
3706
+ return mcpText(`Deactivated workflow ${workflow_id}`);
3157
3707
  }
3158
3708
  );
3159
3709
  server.tool(
3160
3710
  "kairos_delete",
3161
3711
  "Delete a workflow from n8n. This is irreversible.",
3162
3712
  {
3163
- workflow_id: import_zod.z.string().describe("The n8n workflow ID to delete")
3713
+ workflow_id: import_zod.z.string().describe("The n8n workflow ID to delete"),
3714
+ kairos_secret: import_zod.z.string().optional().describe("Required when KAIROS_MCP_SECRET env var is set")
3164
3715
  },
3165
- async ({ workflow_id }) => {
3716
+ async ({ workflow_id, kairos_secret }) => {
3717
+ const authError = checkMcpAuth(kairos_secret);
3718
+ if (authError) return authError;
3166
3719
  if (!isAllowed("delete")) {
3167
- return {
3168
- content: [{
3169
- type: "text",
3170
- text: JSON.stringify({ error: "Delete is disabled. Set KAIROS_MCP_ALLOW_DELETE=true to enable." })
3171
- }],
3172
- isError: true
3173
- };
3720
+ return mcpError(JSON.stringify({ error: "Delete is disabled. Set KAIROS_MCP_ALLOW_DELETE=true to enable." }));
3174
3721
  }
3175
3722
  const client = getApiClient();
3176
3723
  await client.deleteWorkflow(workflow_id);
3177
- return {
3178
- content: [{
3179
- type: "text",
3180
- text: `Deleted workflow ${workflow_id}`
3181
- }]
3182
- };
3724
+ return mcpText(`Deleted workflow ${workflow_id}`);
3183
3725
  }
3184
3726
  );
3185
3727
  server.tool(
@@ -3192,15 +3734,15 @@ server.tool(
3192
3734
  async ({ workflow_id, limit }) => {
3193
3735
  const client = getApiClient();
3194
3736
  const executions = await client.getExecutions(workflow_id, { limit });
3195
- return {
3196
- content: [{
3197
- type: "text",
3198
- text: JSON.stringify(executions, null, 2)
3199
- }]
3200
- };
3737
+ return mcpText(JSON.stringify(executions, null, 2));
3201
3738
  }
3202
3739
  );
3203
3740
  async function main() {
3741
+ if (!process.env["ANTHROPIC_API_KEY"]) {
3742
+ process.stderr.write(
3743
+ "[kairos-mcp] WARNING: ANTHROPIC_API_KEY is not set \u2014 kairos_prompt will fail. Set it before using workflow generation tools.\n"
3744
+ );
3745
+ }
3204
3746
  const transport = new import_stdio.StdioServerTransport();
3205
3747
  await server.connect(transport);
3206
3748
  }