@kairos-sdk/core 0.4.5 → 0.5.1

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