@manny-est/node-red-flowpilot 0.5.0 → 0.5.2

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/flowpilot.js CHANGED
@@ -1,7 +1,71 @@
1
1
  const http = require("http");
2
2
  const path = require("path");
3
3
  const createStorage = require("./lib/storage");
4
- const provider = require("./lib/provider-openai-compatible");
4
+ const openaiProvider = require("./lib/provider-openai-compatible");
5
+ const anthropicProvider = require("./lib/provider-anthropic");
6
+ function getProvider(ap) {
7
+ return (ap && ap.type === "anthropic") ? anthropicProvider : openaiProvider;
8
+ }
9
+
10
+ const DIRECT_COMPLETION_SCHEMA = {
11
+ type: "object",
12
+ properties: {
13
+ explanation: { type: "string" },
14
+ flow: { type: "array" },
15
+ changes: { type: "array" },
16
+ newNodes: { type: "array" },
17
+ newWires: { type: "array" },
18
+ removeNodes: { type: "array" },
19
+ question: { type: "string" },
20
+ mode: { type: "string" }
21
+ },
22
+ additionalProperties: true
23
+ };
24
+
25
+ const SAFE_NODE_TYPES = new Set([
26
+ "inject", "function", "change", "switch", "filter", "json", "xml", "csv",
27
+ "base64", "html", "split", "join", "sort", "batch", "debug", "status",
28
+ "comment", "link in", "link out", "link call", "junction"
29
+ ]);
30
+
31
+ const MODIFY_VERIFY_SKIP_PROPS = new Set([
32
+ "wires", "x", "y", "z", "g", "outputLabels", "inputLabels", "links"
33
+ ]);
34
+
35
+ function classifyFlowNodes(nodes) {
36
+ const classes = { safe: [], sideEffecting: [] };
37
+ if (!Array.isArray(nodes)) { return classes; }
38
+
39
+ nodes.forEach(function (node) {
40
+ if (!node || typeof node !== "object") { return; }
41
+ const summary = {
42
+ id: node.id,
43
+ type: node.type,
44
+ name: node.name || ""
45
+ };
46
+ if (SAFE_NODE_TYPES.has(node.type)) {
47
+ classes.safe.push(summary);
48
+ } else {
49
+ classes.sideEffecting.push(summary);
50
+ }
51
+ });
52
+ return classes;
53
+ }
54
+
55
+ function directCompletionResponseFormat(activeProvider, auditAction, useTools) {
56
+ if (useTools || (activeProvider && activeProvider.type === "anthropic") ||
57
+ (auditAction !== "generate" && auditAction !== "modify")) {
58
+ return null;
59
+ }
60
+ return {
61
+ type: "json_schema",
62
+ json_schema: {
63
+ name: "flowpilot_" + auditAction + "_response",
64
+ strict: false,
65
+ schema: DIRECT_COMPLETION_SCHEMA
66
+ }
67
+ };
68
+ }
5
69
  const generationSystemPrompt = require("./lib/generation-system-prompt");
6
70
  const documentSystemPrompt = require("./lib/document-system-prompt");
7
71
  const modifySystemPrompt = require("./lib/modify-system-prompt");
@@ -9,6 +73,7 @@ const buildSystemPrompt = require("./lib/build-system-prompt");
9
73
  const personaPrompt = require("./lib/persona-prompt");
10
74
  const { buildCoreScript } = require("./lib/build-core-script");
11
75
  const { extractJsonObject } = require("./lib/envelope");
76
+ const { repairEnvelope } = require("./lib/validator");
12
77
 
13
78
  module.exports = function flowPilotRuntime(RED) {
14
79
  const storage = createStorage(RED.settings.userDir);
@@ -404,10 +469,12 @@ module.exports = function flowPilotRuntime(RED) {
404
469
  // entry reports the same shape.
405
470
  // ---------------------------------------------------------------------
406
471
  function performanceAuditFields(messages, content, providerResult) {
472
+ const promptChars = (messages || []).reduce(function (sum, m) {
473
+ return sum + (m && typeof m.content === "string" ? m.content.length : 0);
474
+ }, 0);
407
475
  const fields = {
408
- promptChars: (messages || []).reduce(function (sum, m) {
409
- return sum + (m && typeof m.content === "string" ? m.content.length : 0);
410
- }, 0),
476
+ promptChars: promptChars,
477
+ promptTokenEst: Math.round(promptChars / 4),
411
478
  completionChars: (content || "").length
412
479
  };
413
480
  if (providerResult && providerResult.timing) { fields.timing = providerResult.timing; }
@@ -415,6 +482,113 @@ module.exports = function flowPilotRuntime(RED) {
415
482
  return fields;
416
483
  }
417
484
 
485
+ // W0.2: server-side redaction-placeholder validator. If a model echoes a
486
+ // [redacted:...] sentinel as a proposed value in changes[].set, drop that
487
+ // field before it reaches the client. This replaces the client-side
488
+ // CRITICAL rule that previously tried to prompt the model out of this.
489
+ // Returns { cleanedChanges, skippedNote } — skippedNote is null when nothing
490
+ // was dropped.
491
+ function isRedactionSentinel(v) {
492
+ if (typeof v === "string") {
493
+ return v === "[unserializable]" || v === "[redacted]" || v.indexOf("[redacted:") === 0;
494
+ }
495
+ if (Array.isArray(v)) { return v.some(isRedactionSentinel); }
496
+ if (v !== null && typeof v === "object") {
497
+ return Object.keys(v).some(function (k) { return isRedactionSentinel(v[k]); });
498
+ }
499
+ return false;
500
+ }
501
+
502
+ function stripRedactionPlaceholders(changes) {
503
+ const dropped = [];
504
+ const cleanedChanges = (Array.isArray(changes) ? changes : []).map(function (entry) {
505
+ if (!entry || typeof entry !== "object" || !entry.set) { return entry; }
506
+ const cleanSet = {};
507
+ const droppedKeys = [];
508
+ Object.keys(entry.set).forEach(function (k) {
509
+ if (isRedactionSentinel(entry.set[k])) {
510
+ droppedKeys.push(k);
511
+ } else {
512
+ cleanSet[k] = entry.set[k];
513
+ }
514
+ });
515
+ if (droppedKeys.length) {
516
+ dropped.push({ id: entry.id, keys: droppedKeys });
517
+ }
518
+ return Object.assign({}, entry, { set: cleanSet });
519
+ });
520
+
521
+ let skippedNote = null;
522
+ if (dropped.length) {
523
+ const parts = dropped.map(function (d) {
524
+ return d.keys.join(", ") + (d.id ? " on " + d.id : "");
525
+ });
526
+ skippedNote = "Dropped redacted field(s) — these are credentials or secrets " +
527
+ "that FlowPilot cannot write. They are unchanged on the canvas. " +
528
+ "Update them directly in the Node-RED node editor if needed. " +
529
+ "(" + parts.join("; ") + ")";
530
+ }
531
+ return { cleanedChanges: cleanedChanges, skippedNote: skippedNote };
532
+ }
533
+
534
+ // B2: sanitized context can contain "[unserializable]" for opaque
535
+ // node-internal values. Models can echo those fields back as empty values,
536
+ // false, zero, objects, or other guessed defaults. No proposed value is
537
+ // safely diffable against an unknown original, so drop the field before
538
+ // reconstructing the full flow. Visible originals remain legitimate Modify
539
+ // targets because they do not carry the sentinel.
540
+ function stripUnserializableEchoes(set, originalNode) {
541
+ const clean = Object.assign({}, (set && typeof set === "object") ? set : {});
542
+ if (!originalNode) { return clean; }
543
+ Object.keys(clean).forEach(function (k) {
544
+ if (originalNode[k] === "[unserializable]") {
545
+ delete clean[k];
546
+ }
547
+ });
548
+ return clean;
549
+ }
550
+
551
+ // W0.4: when logAssembledPrompts is enabled, append one JSON-lines entry to
552
+ // assembled-prompts.log. Called after each provider round-trip with both
553
+ // the outgoing messages and the raw response content. Auth keys are never
554
+ // included — only baseUrl + model from the provider profile.
555
+ function maybeLogAssembledPrompt(mode, messages, responseContent, parseOutcome, activeProvider) {
556
+ const settings = storage.getSettings();
557
+ if (!settings.logAssembledPrompts) { return; }
558
+ const promptChars = (messages || []).reduce(function (sum, m) {
559
+ return sum + (m && typeof m.content === "string" ? m.content.length : 0);
560
+ }, 0);
561
+ storage.appendAssembledPromptLog({
562
+ mode: mode,
563
+ providerBaseUrl: activeProvider && activeProvider.baseUrl,
564
+ model: activeProvider && activeProvider.model,
565
+ promptTokenEst: Math.round(promptChars / 4),
566
+ messageCount: (messages || []).length,
567
+ messages: messages,
568
+ responseChars: typeof responseContent === "string" ? responseContent.length : 0,
569
+ responseContent: responseContent,
570
+ parseOutcome: parseOutcome
571
+ });
572
+ }
573
+
574
+ // W0.1: warn when estimated prompt token count approaches the provider's
575
+ // configured context window (numCtx). Overflow is silent — instructions
576
+ // vanish with no error, which is exactly the "model ignores my rules"
577
+ // signature. 32k tokens is the practical floor for prompts this size.
578
+ // Called after buildMessages; numCtx=0 means unknown/unset, skip check.
579
+ function warnNumCtxOverflow(messages, activeProvider, mode) {
580
+ const numCtx = (activeProvider && activeProvider.numCtx) ? activeProvider.numCtx : 0;
581
+ const promptChars = (messages || []).reduce(function (sum, m) {
582
+ return sum + (m && typeof m.content === "string" ? m.content.length : 0);
583
+ }, 0);
584
+ const promptTokenEst = Math.round(promptChars / 4);
585
+ if (numCtx > 0 && promptTokenEst > numCtx * 0.9) {
586
+ console.warn("[FlowPilot] num_ctx overflow risk: mode=%s estimated=%d tokens numCtx=%d (%.0f%% full)",
587
+ mode, promptTokenEst, numCtx, (promptTokenEst / numCtx) * 100);
588
+ }
589
+ return promptTokenEst;
590
+ }
591
+
418
592
  // ---------------------------------------------------------------------
419
593
  // Shared helper: format selected-node context (sanitized by the frontend)
420
594
  // into a system-message string for the model, plus counts for audit logs.
@@ -470,6 +644,16 @@ module.exports = function flowPilotRuntime(RED) {
470
644
  }
471
645
  }
472
646
 
647
+ const configNodes = (context && Array.isArray(context.configNodes)) ? context.configNodes : [];
648
+ if (configNodes.length > 0) {
649
+ content += (content ? "\n\n" : "") +
650
+ "Config nodes referenced by the selection (shared configuration " +
651
+ "objects not shown on the canvas; credentials are redacted). " +
652
+ "Use a config node's \"id\" to point an existing node at it via " +
653
+ "a \"changes\" patch, or create a new one via \"newNodes\":\n```json\n" +
654
+ JSON.stringify(configNodes) + "\n```";
655
+ }
656
+
473
657
  if (debugMessages.length > 0) {
474
658
  content += (content ? "\n\n" : "") +
475
659
  "The user attached recent Node-RED Debug sidebar output for " +
@@ -500,9 +684,10 @@ module.exports = function flowPilotRuntime(RED) {
500
684
  buildChatSystemPrompt(settings),
501
685
  history, historyTruncated, described, prompt
502
686
  );
687
+ warnNumCtxOverflow(messages, activeProvider, "chat");
503
688
 
504
689
  const chatOptions = useTools ? { tools: AGENT_READ_TOOLS, toolChoice: "auto" } : undefined;
505
- const result = await provider.chat(activeProvider, messages, chatOptions);
690
+ const result = await getProvider(activeProvider).chat(activeProvider, messages, chatOptions);
506
691
 
507
692
  if (result.toolCalls) {
508
693
  const perf = performanceAuditFields(messages, result.content, result);
@@ -537,6 +722,7 @@ module.exports = function flowPilotRuntime(RED) {
537
722
  buildChatSystemPrompt(settings),
538
723
  history, historyTruncated, described, prompt
539
724
  );
725
+ warnNumCtxOverflow(messages, activeProvider, "chat-stream");
540
726
 
541
727
  res.writeHead(200, {
542
728
  "Content-Type": "text/event-stream; charset=utf-8",
@@ -556,13 +742,18 @@ module.exports = function flowPilotRuntime(RED) {
556
742
 
557
743
  let streamResult;
558
744
  try {
559
- streamResult = await provider.chatStream(activeProvider, messages, function (delta) {
560
- const visible = splitter.push(delta);
561
- if (visible) {
562
- visibleText += visible;
563
- res.write("data: " + JSON.stringify({ delta: visible }) + "\n\n");
745
+ streamResult = await getProvider(activeProvider).chatStream(activeProvider, messages,
746
+ function (delta) {
747
+ const visible = splitter.push(delta);
748
+ if (visible) {
749
+ visibleText += visible;
750
+ res.write("data: " + JSON.stringify({ delta: visible }) + "\n\n");
751
+ }
752
+ },
753
+ function (reasoningDelta) {
754
+ res.write("data: " + JSON.stringify({ reasoningDelta: reasoningDelta }) + "\n\n");
564
755
  }
565
- });
756
+ );
566
757
  } catch (err) {
567
758
  res.write("data: " + JSON.stringify({ error: err.message }) + "\n\n");
568
759
  res.end();
@@ -683,7 +874,7 @@ module.exports = function flowPilotRuntime(RED) {
683
874
  try {
684
875
  const settings = storage.getSettings();
685
876
  const activeProvider = storage.getActiveProvider(settings);
686
- const result = await provider.listModels(activeProvider);
877
+ const result = await getProvider(activeProvider).listModels(activeProvider);
687
878
  storage.appendAudit({
688
879
  action: "list_models",
689
880
  providerName: activeProvider.providerName,
@@ -751,6 +942,10 @@ module.exports = function flowPilotRuntime(RED) {
751
942
  if (suggestedAction) { body.suggestedAction = suggestedAction; }
752
943
  const questionOptions = extractQuestionOptions(chatData);
753
944
  if (questionOptions) { body.questionOptions = questionOptions; }
945
+ // Pass reasoning_content through so the frontend can render a thinking
946
+ // block on the non-streaming (agent-loop) path too.
947
+ const rawMsg = result.raw && result.raw.choices && result.raw.choices[0] && result.raw.choices[0].message;
948
+ if (rawMsg && rawMsg.reasoning_content) { body.reasoningContent = rawMsg.reasoning_content; }
754
949
  res.json(body);
755
950
  } catch (err) {
756
951
  storage.appendAudit({ action: "chat_error", error: err.message });
@@ -787,7 +982,7 @@ module.exports = function flowPilotRuntime(RED) {
787
982
  try {
788
983
  const settings = storage.getSettings();
789
984
  const activeProvider = storage.getActiveProvider(settings);
790
- const result = await provider.chat(activeProvider, messages, { tools: AGENT_READ_TOOLS, toolChoice: "auto" });
985
+ const result = await getProvider(activeProvider).chat(activeProvider, messages, { tools: AGENT_READ_TOOLS, toolChoice: "auto" });
791
986
 
792
987
  storage.appendAudit(Object.assign({
793
988
  action: "agent_step",
@@ -932,30 +1127,43 @@ module.exports = function flowPilotRuntime(RED) {
932
1127
  // Capability probe — connectivity already succeeded above, so a
933
1128
  // probe failure here just means "no tool support", not a /test failure.
934
1129
  // Persist the result on the provider profile for the agentic tool-calling path.
935
- const probe = await provider.probeTools(activeProvider);
1130
+ const probe = await getProvider(activeProvider).probeTools(activeProvider);
1131
+ const reasoning = getProvider(activeProvider).detectReasoning(result.raw);
936
1132
  storage.appendAudit({
937
1133
  action: "capability_probe",
938
1134
  providerName: activeProvider.providerName,
939
1135
  baseUrl: activeProvider.baseUrl,
940
1136
  model: activeProvider.model,
941
- supportsTools: probe.supportsTools
1137
+ supportsTools: probe.supportsTools,
1138
+ isReasoningModel: reasoning.isReasoningModel
942
1139
  });
943
1140
 
944
1141
  const updatedProviders = (settings.providers || []).map(function (p) {
945
1142
  return p.id === activeProvider.id
946
- ? Object.assign({}, p, { supportsTools: probe.supportsTools, toolsProbedAt: new Date().toISOString() })
1143
+ ? Object.assign({}, p, {
1144
+ supportsTools: probe.supportsTools,
1145
+ toolsProbedAt: new Date().toISOString(),
1146
+ isReasoningModel: reasoning.isReasoningModel,
1147
+ reasoningProbedAt: new Date().toISOString(),
1148
+ probedModel: activeProvider.model
1149
+ })
947
1150
  : p;
948
1151
  });
949
1152
  storage.saveSettings(Object.assign({}, settings, { providers: updatedProviders }));
950
1153
 
1154
+ const toolLabel = probe.supportsTools
1155
+ ? "✓ Connected · ✓ Supports tools"
1156
+ : "✓ Connected · ⚠ No tool support — compatibility mode";
1157
+ const reasoningLabel = reasoning.isReasoningModel ? " · Reasoning model" : "";
1158
+
951
1159
  res.json({
952
1160
  message: chatMessage || "[No assistant message returned by provider]",
953
1161
  raw: result.raw ? "[raw response captured]" : null,
954
1162
  capability: {
955
1163
  supportsTools: probe.supportsTools,
956
- label: probe.supportsTools
957
- ? "✓ Connected · ✓ Supports tools"
958
- : "✓ Connected · ⚠ No tool support — compatibility mode"
1164
+ isReasoningModel: reasoning.isReasoningModel,
1165
+ probedModel: activeProvider.model,
1166
+ label: toolLabel + reasoningLabel
959
1167
  }
960
1168
  });
961
1169
  } catch (err) {
@@ -964,6 +1172,58 @@ module.exports = function flowPilotRuntime(RED) {
964
1172
  }
965
1173
  });
966
1174
 
1175
+ // ---- Probe: silent capability re-check after model change -----------
1176
+ // Called by the frontend when it detects that the active provider's model
1177
+ // changed since the last full pre-flight — stale supportsTools silently
1178
+ // misroutes chat (agent-loop vs streaming/non-streaming). Runs probeTools
1179
+ // + a minimal chat for reasoning detection, saves all results including
1180
+ // probedModel, and returns { supportsTools, isReasoningModel, probedModel }.
1181
+
1182
+ RED.httpAdmin.post("/flowpilot/probe", RED.auth.needsPermission("settings.write"), async function (req, res) {
1183
+ try {
1184
+ const settings = storage.getSettings();
1185
+ const activeProvider = storage.getActiveProvider(settings);
1186
+
1187
+ const probe = await getProvider(activeProvider).probeTools(activeProvider);
1188
+ const chatResult = await getProvider(activeProvider).chat(activeProvider, [
1189
+ { role: "system", content: "You are a helpful assistant." },
1190
+ { role: "user", content: "Say hello." }
1191
+ ]);
1192
+ const reasoning = getProvider(activeProvider).detectReasoning(chatResult.raw);
1193
+
1194
+ const updatedProviders = (settings.providers || []).map(function (p) {
1195
+ return p.id === activeProvider.id
1196
+ ? Object.assign({}, p, {
1197
+ supportsTools: probe.supportsTools,
1198
+ toolsProbedAt: new Date().toISOString(),
1199
+ isReasoningModel: reasoning.isReasoningModel,
1200
+ reasoningProbedAt: new Date().toISOString(),
1201
+ probedModel: activeProvider.model
1202
+ })
1203
+ : p;
1204
+ });
1205
+ storage.saveSettings(Object.assign({}, settings, { providers: updatedProviders }));
1206
+
1207
+ storage.appendAudit({
1208
+ action: "auto_probe",
1209
+ providerName: activeProvider.providerName,
1210
+ baseUrl: activeProvider.baseUrl,
1211
+ model: activeProvider.model,
1212
+ supportsTools: probe.supportsTools,
1213
+ isReasoningModel: reasoning.isReasoningModel
1214
+ });
1215
+
1216
+ res.json({
1217
+ supportsTools: probe.supportsTools,
1218
+ isReasoningModel: reasoning.isReasoningModel,
1219
+ probedModel: activeProvider.model
1220
+ });
1221
+ } catch (err) {
1222
+ storage.appendAudit({ action: "auto_probe_error", error: err.message });
1223
+ res.status(500).json({ error: err.message });
1224
+ }
1225
+ });
1226
+
967
1227
  // ---- Generate: produce an importable flow fragment --------------------
968
1228
  // Uses the generation system prompt and expects the model to return a single
969
1229
  // JSON object { explanation, flow }. This first cut does NOT validate node
@@ -976,7 +1236,7 @@ module.exports = function flowPilotRuntime(RED) {
976
1236
  // out from runFlowGeneration so the streaming variant can build the
977
1237
  // same request and swap provider.chat for provider.chatStream.
978
1238
  // ---------------------------------------------------------------------
979
- function buildGenerationContext(systemPrompt, userPrompt, context, history, historyTruncated) {
1239
+ function buildGenerationContext(systemPrompt, userPrompt, context, history, historyTruncated, auditAction) {
980
1240
  const settings = storage.getSettings();
981
1241
  const activeProvider = storage.getActiveProvider(settings);
982
1242
  const described = describeSelectionContext(context, settings.redactionEnabled);
@@ -986,6 +1246,7 @@ module.exports = function flowPilotRuntime(RED) {
986
1246
  // mode's own system prompt above already specifies.
987
1247
  const personaInstruction = personaPrompt.buildPersonaInstruction(settings.personaIntensity, { scope: "explanation" });
988
1248
  const messages = buildMessages(systemPrompt + "\n\n" + personaInstruction, history, historyTruncated, described, userPrompt);
1249
+ warnNumCtxOverflow(messages, activeProvider, auditAction);
989
1250
  return { activeProvider, described, messages };
990
1251
  }
991
1252
 
@@ -1111,7 +1372,40 @@ module.exports = function flowPilotRuntime(RED) {
1111
1372
  // provider.chatStream). Throws an Error with .status and (when applicable)
1112
1373
  // .raw for the route to relay.
1113
1374
  // ---------------------------------------------------------------------
1114
- function processGenerationContent(content, providerResult, messages, auditAction, described, activeProvider) {
1375
+ function buildFpUidManifest(flow) {
1376
+ if (!Array.isArray(flow)) { return []; }
1377
+
1378
+ return flow
1379
+ .filter(function (node) {
1380
+ return node && node.type === "debug" &&
1381
+ typeof node.name === "string" && /^FP-UID\d+$/.test(node.name);
1382
+ })
1383
+ .sort(function (a, b) {
1384
+ const bySequence = Number(a.name.slice(6)) - Number(b.name.slice(6));
1385
+ if (bySequence !== 0) { return bySequence; }
1386
+ const byName = a.name.localeCompare(b.name);
1387
+ if (byName !== 0) { return byName; }
1388
+ return String(a.id || "").localeCompare(String(b.id || ""));
1389
+ })
1390
+ .map(function (tap) {
1391
+ const upstream = flow.find(function (node) {
1392
+ return node && Array.isArray(node.wires) && node.wires.some(function (port) {
1393
+ return Array.isArray(port) && port.indexOf(tap.id) !== -1;
1394
+ });
1395
+ });
1396
+ const upstreamPort = upstream ? upstream.wires.findIndex(function (port) {
1397
+ return Array.isArray(port) && port.indexOf(tap.id) !== -1;
1398
+ }) : -1;
1399
+ return {
1400
+ name: tap.name,
1401
+ id: tap.id,
1402
+ wiredFrom: upstream ? upstream.id : null,
1403
+ wiredFromPort: upstreamPort >= 0 ? upstreamPort : null
1404
+ };
1405
+ });
1406
+ }
1407
+
1408
+ function processGenerationContent(content, providerResult, messages, auditAction, described, activeProvider, userPrompt) {
1115
1409
  const perf = performanceAuditFields(messages, content, providerResult);
1116
1410
 
1117
1411
  // Mode-mismatch redirect: the model may respond in plain prose —
@@ -1187,6 +1481,58 @@ module.exports = function flowPilotRuntime(RED) {
1187
1481
  }
1188
1482
  }
1189
1483
 
1484
+ // Constrained-decoding providers cannot emit the legacy plain-prose
1485
+ // redirect + hidden data block because response_format requires a JSON
1486
+ // object. Accept the equivalent top-level {"mode":"..."} envelope and
1487
+ // translate it back into the existing prose/suggestedAction result shape.
1488
+ // Reuse the original request as the chip prompt when the model omits one.
1489
+ if (typeof parsed.mode === "string" &&
1490
+ ["generate", "document", "modify", "chat"].indexOf(parsed.mode) !== -1 &&
1491
+ parsed.mode !== auditAction) {
1492
+ const redirectPrompt = (typeof parsed.prompt === "string" && parsed.prompt.trim())
1493
+ ? parsed.prompt.trim()
1494
+ : String(userPrompt || "").trim();
1495
+ const redirect = {
1496
+ mode: parsed.mode,
1497
+ prompt: redirectPrompt
1498
+ };
1499
+ if (typeof parsed.selectionHint === "string" && parsed.selectionHint.trim()) {
1500
+ redirect.selectionHint = parsed.selectionHint.trim();
1501
+ }
1502
+ const redirectProse = (typeof parsed.explanation === "string" && parsed.explanation.trim())
1503
+ ? parsed.explanation.trim()
1504
+ : "This request belongs in " + parsed.mode + " mode.";
1505
+ storage.appendAudit(Object.assign({ action: auditAction + "_prose" }, perf));
1506
+ return { prose: redirectProse, suggestedAction: redirect };
1507
+ }
1508
+
1509
+ // W2 — Class A repair pass. Run on every successfully-parsed envelope
1510
+ // before the mode-specific branches below. Repairs that don't apply
1511
+ // to a given mode's envelope shape are no-ops (e.g. repairFlowNodes
1512
+ // on an empty/absent flow array). Switch mismatches are surfaced as a
1513
+ // skippedNote on the modify result rather than a 422 — a targeted
1514
+ // message the model can act on, without discarding the rest of the
1515
+ // response.
1516
+ {
1517
+ const repaired = repairEnvelope(parsed);
1518
+ parsed = repaired.envelope;
1519
+ if (repaired.repairs.length) {
1520
+ console.info("[FlowPilot] W2 validator repaired %d field(s): %s",
1521
+ repaired.repairs.length,
1522
+ repaired.repairs.map(function (r) { return r.rule + ":" + r.detail; }).join("; "));
1523
+ }
1524
+ if (repaired.switchMismatches.length) {
1525
+ const detail = repaired.switchMismatches.map(function (m) {
1526
+ return "node " + m.id + " has " + m.rulesLen + " rule(s) but " + m.wiresLen + " wire port(s)";
1527
+ }).join("; ");
1528
+ // Surface as a validation warning on the parsed envelope — the
1529
+ // modify path will pick it up below and add it as a skippedNote.
1530
+ parsed._switchMismatchNote = "Switch rules/wires mismatch — " + detail +
1531
+ ". The number of wires[] entries must equal the number of rules[]. " +
1532
+ "Please resend with the corrected switch node.";
1533
+ }
1534
+ }
1535
+
1190
1536
  // Clarifying-question envelope. The model may ask ONE
1191
1537
  // follow-up question instead of producing a flow when the request is too
1192
1538
  // ambiguous to act on. The frontend renders the question as a normal
@@ -1232,12 +1578,18 @@ module.exports = function flowPilotRuntime(RED) {
1232
1578
  // since newNodes itself is copied through.
1233
1579
  const newGroups = Array.isArray(parsed.newGroups) ? parsed.newGroups : [];
1234
1580
 
1581
+ // W0.2: strip any redaction-placeholder values from changes[].set before
1582
+ // they reach the client. Code owns the user notification now — the CRITICAL
1583
+ // block in the Modify prompt that tried to prevent this via instruction is
1584
+ // deleted. skippedNote is surfaced in modifyResult for the frontend to show.
1585
+ const { cleanedChanges, skippedNote: redactionSkippedNote } = stripRedactionPlaceholders(changes);
1586
+
1235
1587
  storage.appendAudit(Object.assign({
1236
1588
  action: auditAction,
1237
1589
  providerName: activeProvider.providerName,
1238
1590
  baseUrl: activeProvider.baseUrl,
1239
1591
  model: activeProvider.model,
1240
- changeCount: changes.length,
1592
+ changeCount: cleanedChanges.length,
1241
1593
  newNodeCount: newNodes.length,
1242
1594
  newWireCount: newWires.length,
1243
1595
  removeNodeCount: removeNodes.length,
@@ -1248,12 +1600,15 @@ module.exports = function flowPilotRuntime(RED) {
1248
1600
 
1249
1601
  const modifyResult = {
1250
1602
  explanation: parsed.explanation || "",
1251
- changes: changes,
1603
+ changes: cleanedChanges,
1252
1604
  newNodes: newNodes,
1253
1605
  newWires: newWires,
1254
1606
  removeNodes: removeNodes,
1255
1607
  newGroups: newGroups
1256
1608
  };
1609
+ // Combine skipped-note sources: redaction (W0.2) and switch mismatch (W2).
1610
+ const skippedNotes = [redactionSkippedNote, parsed._switchMismatchNote].filter(Boolean);
1611
+ if (skippedNotes.length) { modifyResult.skippedNote = skippedNotes.join(" "); }
1257
1612
  const modifyAction = extractSuggestedAction(parsed);
1258
1613
  if (modifyAction) { modifyResult.suggestedAction = modifyAction; }
1259
1614
  return modifyResult;
@@ -1283,6 +1638,11 @@ module.exports = function flowPilotRuntime(RED) {
1283
1638
  newNodes: Array.isArray(parsed.newNodes) ? parsed.newNodes : [],
1284
1639
  newWires: Array.isArray(parsed.newWires) ? parsed.newWires : []
1285
1640
  };
1641
+ if (auditAction === "build") {
1642
+ const fpUidManifest = buildFpUidManifest(flow);
1643
+ if (fpUidManifest.length) { flowResult.fpUidManifest = fpUidManifest; }
1644
+ if (flow.length) { flowResult.stepNodeClasses = classifyFlowNodes(flow); }
1645
+ }
1286
1646
  const flowAction = extractSuggestedAction(parsed);
1287
1647
  if (flowAction) { flowResult.suggestedAction = flowAction; }
1288
1648
  return flowResult;
@@ -1302,13 +1662,27 @@ module.exports = function flowPilotRuntime(RED) {
1302
1662
  // runChat's early return — so the route can hand it to the frontend
1303
1663
  // without running processGenerationContent yet.
1304
1664
  async function runFlowGeneration(systemPrompt, auditAction, userPrompt, context, history, historyTruncated, useTools) {
1305
- const { activeProvider, described, messages } = buildGenerationContext(systemPrompt, userPrompt, context, history, historyTruncated);
1306
- const chatOptions = useTools ? { tools: AGENT_READ_TOOLS, toolChoice: "auto" } : undefined;
1307
- const result = await provider.chat(activeProvider, messages, chatOptions);
1665
+ const { activeProvider, described, messages } = buildGenerationContext(systemPrompt, userPrompt, context, history, historyTruncated, auditAction);
1666
+ const responseFormat = directCompletionResponseFormat(activeProvider, auditAction, useTools);
1667
+ const chatOptions = useTools
1668
+ ? { tools: AGENT_READ_TOOLS, toolChoice: "auto" }
1669
+ : (responseFormat ? { responseFormat: responseFormat } : undefined);
1670
+ const result = await getProvider(activeProvider).chat(activeProvider, messages, chatOptions);
1308
1671
  if (result.toolCalls) {
1309
1672
  return { toolCalls: result.toolCalls, messages: messages, content: result.content || null, usage: result.usage || null };
1310
1673
  }
1311
- return processGenerationContent(result.content || "", result, messages, auditAction, described, activeProvider);
1674
+ const content = result.content || "";
1675
+ let parseOutcome = "unknown";
1676
+ try {
1677
+ const generated = processGenerationContent(content, result, messages, auditAction, described, activeProvider, userPrompt);
1678
+ parseOutcome = generated.prose ? "prose" : generated.question ? "question" : "success";
1679
+ return generated;
1680
+ } catch (err) {
1681
+ parseOutcome = "parse_error:" + (err.message || "");
1682
+ throw err;
1683
+ } finally {
1684
+ maybeLogAssembledPrompt(auditAction, messages, content, parseOutcome, activeProvider);
1685
+ }
1312
1686
  }
1313
1687
 
1314
1688
  // ---------------------------------------------------------------------
@@ -1320,9 +1694,24 @@ module.exports = function flowPilotRuntime(RED) {
1320
1694
  // this resolves.
1321
1695
  // ---------------------------------------------------------------------
1322
1696
  async function runFlowGenerationStream(systemPrompt, auditAction, userPrompt, context, history, historyTruncated, onDelta) {
1323
- const { activeProvider, described, messages } = buildGenerationContext(systemPrompt, userPrompt, context, history, historyTruncated);
1324
- const result = await provider.chatStream(activeProvider, messages, onDelta);
1325
- return processGenerationContent(result.content || "", result, messages, auditAction, described, activeProvider);
1697
+ const { activeProvider, described, messages } = buildGenerationContext(systemPrompt, userPrompt, context, history, historyTruncated, auditAction);
1698
+ const responseFormat = directCompletionResponseFormat(activeProvider, auditAction, false);
1699
+ const streamOptions = responseFormat ? { responseFormat: responseFormat } : undefined;
1700
+ const result = await getProvider(activeProvider).chatStream(
1701
+ activeProvider, messages, onDelta, undefined, streamOptions
1702
+ );
1703
+ const content = result.content || "";
1704
+ let parseOutcome = "unknown";
1705
+ try {
1706
+ const generated = processGenerationContent(content, result, messages, auditAction, described, activeProvider, userPrompt);
1707
+ parseOutcome = generated.prose ? "prose" : generated.question ? "question" : "success";
1708
+ return generated;
1709
+ } catch (err) {
1710
+ parseOutcome = "parse_error:" + (err.message || "");
1711
+ throw err;
1712
+ } finally {
1713
+ maybeLogAssembledPrompt(auditAction, messages, content, parseOutcome, activeProvider);
1714
+ }
1326
1715
  }
1327
1716
 
1328
1717
  // Relays a runFlowGeneration error to the client with the right status,
@@ -1460,10 +1849,17 @@ module.exports = function flowPilotRuntime(RED) {
1460
1849
  // Each patch's "set" is shallow-merged onto a copy of the original node.
1461
1850
  // "id", "x", "y", "z" can never move via a patch — strip them
1462
1851
  // defensively even though the prompt already forbids them.
1852
+ const originalById = {};
1853
+ originalNodes.forEach(function (n) {
1854
+ if (n && n.id !== undefined && n.id !== null) {
1855
+ originalById[String(n.id)] = n;
1856
+ }
1857
+ });
1858
+
1463
1859
  const patchById = {};
1464
1860
  validChanges.forEach(function (c) {
1465
1861
  const set = (c.set && typeof c.set === "object") ? c.set : {};
1466
- const clean = Object.assign({}, set);
1862
+ const clean = stripUnserializableEchoes(set, originalById[String(c.id)]);
1467
1863
  delete clean.id;
1468
1864
  delete clean.x;
1469
1865
  delete clean.y;
@@ -1592,6 +1988,35 @@ module.exports = function flowPilotRuntime(RED) {
1592
1988
 
1593
1989
  if (newGroups.length > 0) { storage.appendAudit({ action: "modify_groups", count: newGroups.length }); }
1594
1990
 
1991
+ const verifySteps = [];
1992
+ validChanges.forEach(function (change) {
1993
+ const set = patchById[String(change.id)] || {};
1994
+ Object.keys(set).forEach(function (prop) {
1995
+ if (MODIFY_VERIFY_SKIP_PROPS.has(prop)) { return; }
1996
+ verifySteps.push({
1997
+ nodeId: change.id,
1998
+ check: "property",
1999
+ prop: prop,
2000
+ expected: set[prop]
2001
+ });
2002
+ });
2003
+ });
2004
+ newNodes.forEach(function (node) {
2005
+ if (!node || node.id === undefined || node.id === null) { return; }
2006
+ verifySteps.push({ nodeId: node.id, check: "exists" });
2007
+ });
2008
+ finalRemoveNodes.forEach(function (id) {
2009
+ verifySteps.push({ nodeId: id, check: "absent" });
2010
+ });
2011
+ newWires.forEach(function (wire) {
2012
+ verifySteps.push({
2013
+ fromId: wire.from,
2014
+ fromPort: wire.fromPort || 0,
2015
+ toId: wire.to,
2016
+ check: "wire"
2017
+ });
2018
+ });
2019
+
1595
2020
  const body = {
1596
2021
  explanation: result.explanation,
1597
2022
  flow: flow,
@@ -1600,6 +2025,7 @@ module.exports = function flowPilotRuntime(RED) {
1600
2025
  removeNodes: finalRemoveNodes,
1601
2026
  newGroups: newGroups
1602
2027
  };
2028
+ if (verifySteps.length) { body.verifySteps = verifySteps; }
1603
2029
  if (skippedDescriptions.length > 0) { body.skippedNote = skippedDescriptions.join(". ") + "."; }
1604
2030
  if (result.suggestedAction) { body.suggestedAction = result.suggestedAction; }
1605
2031
 
@@ -1787,10 +2213,13 @@ module.exports = function flowPilotRuntime(RED) {
1787
2213
  const historyTruncated = !!req.body.historyTruncated;
1788
2214
 
1789
2215
  const finalize = function (result) { return finalizeModifyResult(result, originalNodes); };
2216
+ const hasSwitch = Array.isArray(context && context.nodes) &&
2217
+ context.nodes.some(function (node) { return node && node.type === "switch"; });
2218
+ const modifyPrompt = modifySystemPrompt({ hasSwitch: hasSwitch });
1790
2219
 
1791
2220
  if (req.body.stream) {
1792
2221
  return runExecuteStream(
1793
- req, res, modifySystemPrompt, "modify", String(prompt).trim(), context,
2222
+ req, res, modifyPrompt, "modify", String(prompt).trim(), context,
1794
2223
  history, historyTruncated, finalize, req.body.conversationId
1795
2224
  );
1796
2225
  }
@@ -1798,7 +2227,7 @@ module.exports = function flowPilotRuntime(RED) {
1798
2227
  try {
1799
2228
  const useTools = !!req.body.tools;
1800
2229
  const result = await runFlowGeneration(
1801
- modifySystemPrompt, "modify", String(prompt).trim(), context,
2230
+ modifyPrompt, "modify", String(prompt).trim(), context,
1802
2231
  history, historyTruncated, useTools
1803
2232
  );
1804
2233
  if (result.toolCalls) {