@manny-est/node-red-flowpilot 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/flowpilot.js CHANGED
@@ -1,9 +1,12 @@
1
1
  const http = require("http");
2
+ const path = require("path");
2
3
  const createStorage = require("./lib/storage");
3
4
  const provider = require("./lib/provider-openai-compatible");
4
5
  const generationSystemPrompt = require("./lib/generation-system-prompt");
5
6
  const documentSystemPrompt = require("./lib/document-system-prompt");
6
7
  const modifySystemPrompt = require("./lib/modify-system-prompt");
8
+ const buildSystemPrompt = require("./lib/build-system-prompt");
9
+ const personaPrompt = require("./lib/persona-prompt");
7
10
 
8
11
  module.exports = function flowPilotRuntime(RED) {
9
12
  const storage = createStorage(RED.settings.userDir);
@@ -360,6 +363,16 @@ module.exports = function flowPilotRuntime(RED) {
360
363
  return installedNodesCache;
361
364
  }
362
365
 
366
+ // Chat-only: the user's base system prompt plus a freshly-generated
367
+ // persona instruction (never baked into the persisted prompt itself, so
368
+ // it always reflects the current personaIntensity slider value).
369
+ // Generate/Document/Modify use their own mode-specific prompts and don't
370
+ // call this — aviation flavor has no place in a structured JSON envelope.
371
+ function buildChatSystemPrompt(settings) {
372
+ const base = settings.systemPrompt || "You are FlowPilot, a Node-RED development assistant.";
373
+ return base + "\n\n" + personaPrompt.buildPersonaInstruction(settings.personaIntensity);
374
+ }
375
+
363
376
  // Assemble the final messages array in the one place both /chat and the
364
377
  // generate/modify/document endpoints use: system prompt, optional
365
378
  // installed-node-package note, optional truncation notice, history,
@@ -406,7 +419,7 @@ module.exports = function flowPilotRuntime(RED) {
406
419
  // Returns null when there's no selection — used by both /chat and
407
420
  // /generate so the two describe context identically and never drift.
408
421
  // ---------------------------------------------------------------------
409
- function describeSelectionContext(context) {
422
+ function describeSelectionContext(context, redactionEnabled) {
410
423
  const nodes = context && Array.isArray(context.nodes) ? context.nodes : [];
411
424
  const debugMessages = context && Array.isArray(context.debugMessages) ? context.debugMessages : [];
412
425
  if (nodes.length === 0 && debugMessages.length === 0) { return null; }
@@ -416,10 +429,26 @@ module.exports = function flowPilotRuntime(RED) {
416
429
  const perNode = Array.isArray(connections.perNode) ? connections.perNode : [];
417
430
  const subFlowCount = (typeof connections.subFlowCount === "number") ? connections.subFlowCount : 0;
418
431
 
432
+ // Node-RED's own credential store (config node "credentials" fields) is
433
+ // dropped by the frontend's sanitizer unconditionally — that part never
434
+ // changes. redactionEnabled only controls the SEPARATE secret-shaped-value
435
+ // scrubbing (password/token/apiKey-looking fields elsewhere in a node's
436
+ // config) — tell the model the truth about which protection is active.
437
+ const credentialNote = redactionEnabled === false
438
+ ? "Redaction is OFF for this session — context may contain sensitive " +
439
+ "values the user chose to share (e.g. embedded API keys or tokens); " +
440
+ "handle carefully and never volunteer them. Node-RED's separate " +
441
+ "credential store is still never included. This is a setting in the " +
442
+ "editor's FlowPilot Settings panel (Context & Safety section) — you " +
443
+ "have no ability to read, change, or report on it beyond this note; " +
444
+ "if the user wants to turn it back on, tell them to uncheck it there " +
445
+ "(it requires re-confirming a type-to-confirm phrase, by design)."
446
+ : "This is sanitized configuration; credentials are redacted.";
447
+
419
448
  let content = "";
420
449
  if (nodes.length > 0) {
421
450
  content += "The user has selected the following Node-RED nodes as context. " +
422
- "This is sanitized configuration; credentials are redacted.\n\n" +
451
+ credentialNote + "\n\n" +
423
452
  "Nodes:\n```json\n" + JSON.stringify(nodes) + "\n```";
424
453
  if (edges.length > 0) {
425
454
  content += "\n\nConnections — directed edges by node id (a node's wires " +
@@ -464,9 +493,9 @@ module.exports = function flowPilotRuntime(RED) {
464
493
  const settings = storage.getSettings();
465
494
  const activeProvider = storage.getActiveProvider(settings);
466
495
 
467
- const described = describeSelectionContext(context);
496
+ const described = describeSelectionContext(context, settings.redactionEnabled);
468
497
  const messages = buildMessages(
469
- settings.systemPrompt || "You are FlowPilot, a Node-RED development assistant.",
498
+ buildChatSystemPrompt(settings),
470
499
  history, historyTruncated, described, prompt
471
500
  );
472
501
 
@@ -501,9 +530,9 @@ module.exports = function flowPilotRuntime(RED) {
501
530
  const settings = storage.getSettings();
502
531
  const activeProvider = storage.getActiveProvider(settings);
503
532
 
504
- const described = describeSelectionContext(context);
533
+ const described = describeSelectionContext(context, settings.redactionEnabled);
505
534
  const messages = buildMessages(
506
- settings.systemPrompt || "You are FlowPilot, a Node-RED development assistant.",
535
+ buildChatSystemPrompt(settings),
507
536
  history, historyTruncated, described, prompt
508
537
  );
509
538
 
@@ -588,6 +617,27 @@ module.exports = function flowPilotRuntime(RED) {
588
617
  }
589
618
  });
590
619
 
620
+ // ---- Pop-out window (Phase 8.5 C1, v1 review-only) -------------------
621
+ // Serves the shared renderer (flowpilot-core.js, the same script
622
+ // flowpilot.html loads for the sidebar) plus its stylesheet and the
623
+ // pop-out's own minimal page — mirroring core Node-RED's debug-node
624
+ // pattern (RED.httpAdmin.get("/debug/view/view.html", ...) serving a
625
+ // static lib/debug/view.html that loads the SAME debug-utils.js the
626
+ // sidebar uses). Gated the same as every other FlowPilot route, unlike
627
+ // NR5's own debug view route, which has no permission check at all.
628
+
629
+ RED.httpAdmin.get("/flowpilot/core.js", RED.auth.needsPermission("settings.read"), function (req, res) {
630
+ res.sendFile(path.join(__dirname, "flowpilot-core.js"));
631
+ });
632
+
633
+ RED.httpAdmin.get("/flowpilot/core.css", RED.auth.needsPermission("settings.read"), function (req, res) {
634
+ res.sendFile(path.join(__dirname, "flowpilot-core.css"));
635
+ });
636
+
637
+ RED.httpAdmin.get("/flowpilot/popout/view.html", RED.auth.needsPermission("settings.read"), function (req, res) {
638
+ res.sendFile(path.join(__dirname, "lib", "popout", "view.html"));
639
+ });
640
+
591
641
  // ---- Settings: write -------------------------------------------------
592
642
 
593
643
  RED.httpAdmin.post("/flowpilot/settings", RED.auth.needsPermission("settings.write"), function (req, res) {
@@ -735,7 +785,7 @@ module.exports = function flowPilotRuntime(RED) {
735
785
 
736
786
  if (mode !== "chat") {
737
787
  const context = req.body.context;
738
- const described = describeSelectionContext(context);
788
+ const described = describeSelectionContext(context, settings.redactionEnabled);
739
789
  const generated = processGenerationContent(result.content || "", result, messages, mode, described, activeProvider);
740
790
  recordTranscriptTurn(req.body.conversationId, mode, req.body.prompt || null, transcriptTextFromGenerationResult(generated));
741
791
  const finalize = (mode === "modify")
@@ -901,6 +951,33 @@ module.exports = function flowPilotRuntime(RED) {
901
951
  // types or wire integrity yet (that's the next chunk) — it returns the parsed
902
952
  // envelope so the frontend can display it for review.
903
953
 
954
+ // Given s[startIdx] === "{", scans forward with brace-depth counting that
955
+ // ignores braces inside string literals (so a value like "{{payload}}"
956
+ // can't be mistaken for structure) to find the index of the MATCHING
957
+ // closing "}". Returns -1 if the braces never balance before the string
958
+ // ends (truncated/malformed input).
959
+ function findMatchingBrace(s, startIdx) {
960
+ let depth = 0;
961
+ let inString = false;
962
+ let escaped = false;
963
+ for (let i = startIdx; i < s.length; i++) {
964
+ const ch = s[i];
965
+ if (inString) {
966
+ if (escaped) { escaped = false; }
967
+ else if (ch === "\\") { escaped = true; }
968
+ else if (ch === "\"") { inString = false; }
969
+ continue;
970
+ }
971
+ if (ch === "\"") { inString = true; }
972
+ else if (ch === "{") { depth++; }
973
+ else if (ch === "}") {
974
+ depth--;
975
+ if (depth === 0) { return i; }
976
+ }
977
+ }
978
+ return -1;
979
+ }
980
+
904
981
  function extractJsonObject(text) {
905
982
  if (!text) { throw new Error("Empty response from provider."); }
906
983
  let s = String(text).trim();
@@ -930,10 +1007,8 @@ module.exports = function flowPilotRuntime(RED) {
930
1007
  }
931
1008
  }
932
1009
 
933
- // If there's leading/trailing prose, grab the outermost {...}.
934
- const first = s.indexOf("{");
935
- const last = s.lastIndexOf("}");
936
- if (first === -1 || last === -1 || last < first) {
1010
+ const firstObjIdx = s.indexOf("{");
1011
+ if (firstObjIdx === -1) {
937
1012
  // No JSON object found at all — flagged separately from a found-
938
1013
  // but-unparseable ({...} present, JSON.parse failed) "garbled" error,
939
1014
  // so callers can distinguish "model just answered in prose" (tolerate)
@@ -942,7 +1017,57 @@ module.exports = function flowPilotRuntime(RED) {
942
1017
  err.noJsonFound = true;
943
1018
  throw err;
944
1019
  }
945
- return JSON.parse(s.slice(first, last + 1));
1020
+
1021
+ // There may be more than one "{" before the real envelope — e.g. prose
1022
+ // explaining a fix that mentions inline code like "{{payload}}" before
1023
+ // the actual JSON (seen live: a review response started with "The
1024
+ // template node is using `{{payload}}` with...", and slicing from THAT
1025
+ // brace to the envelope's real closing "}" produced unparseable
1026
+ // garbage). Try each candidate "{" in order with string-aware brace
1027
+ // matching (findMatchingBrace, which ignores braces inside quoted
1028
+ // strings) rather than just slicing from the first "{" to the last
1029
+ // "}".
1030
+ //
1031
+ // A candidate must not just PARSE, it must also look like one of the
1032
+ // known envelope shapes (have at least one recognized top-level key) —
1033
+ // seen live: a pure-prose advice response that mentioned structured
1034
+ // logging included the illustrative example
1035
+ // `{"level":"info","event":"trivia_answer","user":"alex","correct":true}`,
1036
+ // which IS valid standalone JSON, so the old "first candidate that
1037
+ // parses wins" rule accepted it as "the envelope" and the caller threw
1038
+ // "no recognizable modify fields" — when the right answer was to treat
1039
+ // the whole reply as prose, since there was no real envelope at all.
1040
+ const ENVELOPE_KEYS = ["explanation", "flow", "question", "changes", "newNodes", "newWires", "removeNodes", "newGroups", "prose"];
1041
+ function looksLikeEnvelope(obj) {
1042
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) { return false; }
1043
+ return ENVELOPE_KEYS.some(function (k) { return k in obj; });
1044
+ }
1045
+
1046
+ let lastError = null;
1047
+ let searchFrom = firstObjIdx;
1048
+ while (searchFrom !== -1 && searchFrom < s.length) {
1049
+ const end = findMatchingBrace(s, searchFrom);
1050
+ if (end !== -1) {
1051
+ try {
1052
+ const candidate = JSON.parse(s.slice(searchFrom, end + 1));
1053
+ if (looksLikeEnvelope(candidate)) { return candidate; }
1054
+ // Valid JSON, but not envelope-shaped (e.g. an illustrative
1055
+ // example embedded in prose) — keep searching rather than
1056
+ // accepting it.
1057
+ } catch (e) {
1058
+ lastError = e;
1059
+ }
1060
+ }
1061
+ searchFrom = s.indexOf("{", searchFrom + 1);
1062
+ }
1063
+ // No candidate both parsed AND looked like a real envelope — equivalent
1064
+ // to "the model just answered in prose," not "the envelope is broken."
1065
+ // Let callers fall back to rendering this as a normal message instead
1066
+ // of surfacing a parse error (same noJsonFound flag the "no { at all"
1067
+ // branch above uses).
1068
+ const err = lastError || new Error("Provider's JSON object could not be parsed.");
1069
+ err.noJsonFound = true;
1070
+ throw err;
946
1071
  }
947
1072
 
948
1073
  // ---------------------------------------------------------------------
@@ -954,8 +1079,13 @@ module.exports = function flowPilotRuntime(RED) {
954
1079
  function buildGenerationContext(systemPrompt, userPrompt, context, history, historyTruncated) {
955
1080
  const settings = storage.getSettings();
956
1081
  const activeProvider = storage.getActiveProvider(settings);
957
- const described = describeSelectionContext(context);
958
- const messages = buildMessages(systemPrompt, history, historyTruncated, described, userPrompt);
1082
+ const described = describeSelectionContext(context, settings.redactionEnabled);
1083
+ // Persona applies to the "explanation" field only (a real hand-off/
1084
+ // transition moment — "here's the flow I built for you") — never to
1085
+ // node names, ids, or any structural JSON, which stays exactly as each
1086
+ // mode's own system prompt above already specifies.
1087
+ const personaInstruction = personaPrompt.buildPersonaInstruction(settings.personaIntensity, { scope: "explanation" });
1088
+ const messages = buildMessages(systemPrompt + "\n\n" + personaInstruction, history, historyTruncated, described, userPrompt);
959
1089
  return { activeProvider, described, messages };
960
1090
  }
961
1091
 
@@ -1180,7 +1310,7 @@ module.exports = function flowPilotRuntime(RED) {
1180
1310
  // otherwise it's not recognizable as a modify response at all.
1181
1311
  if (auditAction === "modify") {
1182
1312
  const hasModifyShape = ("changes" in parsed) || ("newNodes" in parsed) ||
1183
- ("newWires" in parsed) || ("removeNodes" in parsed) ||
1313
+ ("newWires" in parsed) || ("removeNodes" in parsed) || ("newGroups" in parsed) ||
1184
1314
  (typeof parsed.explanation === "string" && parsed.explanation.trim());
1185
1315
  if (!hasModifyShape) {
1186
1316
  const err = new Error("The response did not contain any recognizable modify fields.");
@@ -1193,6 +1323,14 @@ module.exports = function flowPilotRuntime(RED) {
1193
1323
  const newNodes = Array.isArray(parsed.newNodes) ? parsed.newNodes : [];
1194
1324
  const newWires = Array.isArray(parsed.newWires) ? parsed.newWires : [];
1195
1325
  const removeNodes = Array.isArray(parsed.removeNodes) ? parsed.removeNodes : [];
1326
+ // Bug found live: this object is what finalizeModifyResult later reads
1327
+ // as "result" — but it never copied parsed.newGroups onto itself, so
1328
+ // even a model correctly using the top-level "newGroups" field (per
1329
+ // the prompt) had it silently dropped right here, before
1330
+ // finalizeModifyResult's own newGroups handling (fixed earlier) ever
1331
+ // saw it. Only a stray type:"group" entry inside newNodes survived,
1332
+ // since newNodes itself is copied through.
1333
+ const newGroups = Array.isArray(parsed.newGroups) ? parsed.newGroups : [];
1196
1334
 
1197
1335
  storage.appendAudit(Object.assign({
1198
1336
  action: auditAction,
@@ -1203,6 +1341,7 @@ module.exports = function flowPilotRuntime(RED) {
1203
1341
  newNodeCount: newNodes.length,
1204
1342
  newWireCount: newWires.length,
1205
1343
  removeNodeCount: removeNodes.length,
1344
+ newGroupCount: newGroups.length,
1206
1345
  contextNodeCount: described ? described.nodeCount : 0,
1207
1346
  contextConnectionCount: described ? described.connectionCount : 0
1208
1347
  }, perf));
@@ -1212,7 +1351,8 @@ module.exports = function flowPilotRuntime(RED) {
1212
1351
  changes: changes,
1213
1352
  newNodes: newNodes,
1214
1353
  newWires: newWires,
1215
- removeNodes: removeNodes
1354
+ removeNodes: removeNodes,
1355
+ newGroups: newGroups
1216
1356
  };
1217
1357
  const modifyAction = extractSuggestedAction(parsed);
1218
1358
  if (modifyAction) { modifyResult.suggestedAction = modifyAction; }
@@ -1347,6 +1487,16 @@ module.exports = function flowPilotRuntime(RED) {
1347
1487
 
1348
1488
  const originalIds = new Set(originalNodes.map(function (n) { return n.id; }));
1349
1489
 
1490
+ // Group ids the selection is actually inside (sanitizeNode resolves
1491
+ // each context node's group membership into a `group: {id, name}`
1492
+ // field — Phase 8.5 C2). A "changes" patch may target one of THESE
1493
+ // group ids too (e.g. to rename it) even though the group itself
1494
+ // isn't a member of originalIds — the user selected something
1495
+ // relevant to it, same spirit as selecting a node lets you patch it.
1496
+ const contextGroupIds = new Set(
1497
+ originalNodes.map(function (n) { return n.group && n.group.id; }).filter(Boolean)
1498
+ );
1499
+
1350
1500
  // Validate removeNodes: all ids must be in the original selection.
1351
1501
  const removeNodes = Array.isArray(result.removeNodes) ? result.removeNodes : [];
1352
1502
  if (removeNodes.length > 0) {
@@ -1374,8 +1524,9 @@ module.exports = function flowPilotRuntime(RED) {
1374
1524
  .filter(function (id) { return id !== undefined && id !== null; });
1375
1525
 
1376
1526
  // Validate that changes contains no hallucinated ids, and that no id is
1377
- // both patched and marked for removal.
1378
- const extraIds = changeIds.filter(function (id) { return !originalIds.has(String(id)); });
1527
+ // both patched and marked for removal. A group id from contextGroupIds
1528
+ // is allowed here too (see above) even though it's not in originalIds.
1529
+ const extraIds = changeIds.filter(function (id) { return !originalIds.has(String(id)) && !contextGroupIds.has(String(id)); });
1379
1530
  const wronglyRemovedIds = changeIds.filter(function (id) { return removeSet.has(String(id)); });
1380
1531
 
1381
1532
  const idProblems = [];
@@ -1414,27 +1565,95 @@ module.exports = function flowPilotRuntime(RED) {
1414
1565
  return patch ? Object.assign({}, n, patch) : n;
1415
1566
  });
1416
1567
 
1568
+ // A "changes" patch targeting a group id (contextGroupIds, not
1569
+ // originalIds — see above) has nowhere to merge onto above, since
1570
+ // originalNodes never includes the group itself, only nodes inside
1571
+ // it. Synthesize a minimal {id, type:"group", ...patch} entry for
1572
+ // each one instead, so it rides through the SAME flow array the
1573
+ // frontend's existing Tier-1 diff/apply pipeline already handles —
1574
+ // computeNodeDiff()/applyModifications() don't care what TYPE a node
1575
+ // is, and findLiveNode() already resolves a group id to the live
1576
+ // group object (Phase 8.5 C2 slice 1). This is how a group gets
1577
+ // renamed/restyled — pure property edit, no new apply-side code.
1578
+ const groupPatchIds = changeIds.filter(function (id) {
1579
+ return contextGroupIds.has(String(id)) && !originalIds.has(String(id));
1580
+ });
1581
+ groupPatchIds.forEach(function (id) {
1582
+ flow.push(Object.assign({ id: id, type: "group" }, patchById[String(id)]));
1583
+ });
1584
+
1417
1585
  const finalRemoveNodes = Array.from(removeSet);
1418
1586
 
1419
- // "group" nodes aren't supported yet (the editor's group API needs
1420
- // bounding-box computation + group-aware undo that applyInsertions
1421
- // doesn't implement). The system prompt tells the model not to propose
1422
- // them, but strip any that slip through anyway, and drop any newWires
1423
- // that reference a stripped group's placeholder id.
1587
+ // Groups describe MEMBERSHIP, not a regular node to insert — the
1588
+ // model is taught (modify-system-prompt.js) to put them in their OWN
1589
+ // top-level "newGroups" field, never inside "newNodes" (that API
1590
+ // doesn't know what a group is at all applyInsertions would try to
1591
+ // RED.nodes.add() it). Bug found live: this used to ONLY look for a
1592
+ // stray type:"group" entry INSIDE newNodes and never read
1593
+ // result.newGroups at all — a model correctly following the prompt's
1594
+ // own instructions had its groups silently dropped ("No changes
1595
+ // detected"). Read the real field now; still tolerate a stray
1596
+ // type:"group" entry left inside newNodes as a fallback, merging
1597
+ // both rather than requiring exactly one style. Each entry's "nodes"
1598
+ // is the FULL desired membership for that group id: if the id
1599
+ // matches an EXISTING live group, the frontend reconciles membership
1600
+ // to match exactly (add/remove as needed, down to zero — ungrouping
1601
+ // everyone); if not, it creates a new group with exactly that
1602
+ // membership. See applyGroupChanges() in flowpilot-core.js.
1424
1603
  const allNewNodes = result.newNodes || [];
1425
- const groupNodes = allNewNodes.filter(function (n) { return n && n.type === "group"; });
1604
+ const strayGroupNodes = allNewNodes.filter(function (n) { return n && n.type === "group"; });
1426
1605
  const newNodes = allNewNodes.filter(function (n) { return !(n && n.type === "group"); });
1427
1606
  const newNodeIdSet = new Set(newNodes.map(function (n) { return n && n.id; }).filter(Boolean));
1428
1607
 
1608
+ const declaredGroups = Array.isArray(result.newGroups) ? result.newGroups : [];
1609
+ const seenGroupIds = {};
1610
+ const newGroups = declaredGroups.concat(strayGroupNodes).filter(function (g) {
1611
+ if (!g || !g.id || seenGroupIds[g.id]) { return false; }
1612
+ seenGroupIds[g.id] = true;
1613
+ return true;
1614
+ });
1615
+
1429
1616
  // Validate newWires references: each from/to must be either an existing
1430
- // context node id or a placeholder id present in newNodes.
1617
+ // context node id or a placeholder id present in newNodes. A group id
1618
+ // is never a valid wire endpoint (groups don't pass messages) — filter
1619
+ // those out the same as before, just without discarding the group itself.
1431
1620
  let newWires = result.newWires || [];
1432
- if (groupNodes.length > 0) {
1433
- const groupIdSet = new Set(groupNodes.map(function (n) { return n && n.id; }).filter(Boolean));
1621
+ if (newGroups.length > 0) {
1622
+ const groupIdSet = new Set(newGroups.map(function (n) { return n && n.id; }).filter(Boolean));
1434
1623
  newWires = newWires.filter(function (wire) {
1435
1624
  return !groupIdSet.has(String(wire.from)) && !groupIdSet.has(String(wire.to));
1436
1625
  });
1437
1626
  }
1627
+ // Validate newGroups' own "nodes" member references: each must be an
1628
+ // existing context node id or a new-node placeholder id — explicitly
1629
+ // NOT another group's id, so nested groups-within-groups (out of scope
1630
+ // for v1) are naturally rejected rather than silently mis-imported.
1631
+ // An EMPTY "nodes" is allowed through here — meaningless for creating
1632
+ // a brand new group (the frontend already no-ops that case), but a
1633
+ // legitimate "ungroup everyone in this EXISTING group" when "id"
1634
+ // matches a live one, which only the frontend can tell apart.
1635
+ if (newGroups.length > 0) {
1636
+ const groupProblems = [];
1637
+ newGroups.forEach(function (g, i) {
1638
+ if (!g || !g.id) { groupProblems.push("group " + i + " missing id"); return; }
1639
+ const members = Array.isArray(g.nodes) ? g.nodes : [];
1640
+ members.forEach(function (ref) {
1641
+ if (!originalIds.has(String(ref)) && !newNodeIdSet.has(String(ref))) {
1642
+ groupProblems.push("group " + i + " member '" + ref + "' not in existing or new nodes");
1643
+ }
1644
+ });
1645
+ });
1646
+ if (groupProblems.length > 0) {
1647
+ storage.appendAudit({ action: "modify_group_ref_error", problems: groupProblems });
1648
+ return {
1649
+ status: 422,
1650
+ body: {
1651
+ error: "Invalid group references in newGroups: " + groupProblems.join("; "),
1652
+ raw: JSON.stringify(result)
1653
+ }
1654
+ };
1655
+ }
1656
+ }
1438
1657
  if (newWires.length > 0) {
1439
1658
  const wireProblems = [];
1440
1659
  newWires.forEach(function (wire, i) {
@@ -1457,19 +1676,15 @@ module.exports = function flowPilotRuntime(RED) {
1457
1676
  }
1458
1677
  }
1459
1678
 
1460
- let explanation = result.explanation;
1461
- if (groupNodes.length > 0) {
1462
- storage.appendAudit({ action: "modify_group_stripped", count: groupNodes.length });
1463
- explanation = (explanation ? explanation + "\n\n" : "") +
1464
- "Note: grouping nodes into a visual group isn't supported yet, so that part of the request was skipped.";
1465
- }
1679
+ if (newGroups.length > 0) { storage.appendAudit({ action: "modify_groups", count: newGroups.length }); }
1466
1680
 
1467
1681
  const body = {
1468
- explanation: explanation,
1682
+ explanation: result.explanation,
1469
1683
  flow: flow,
1470
1684
  newNodes: newNodes,
1471
1685
  newWires: newWires,
1472
- removeNodes: finalRemoveNodes
1686
+ removeNodes: finalRemoveNodes,
1687
+ newGroups: newGroups
1473
1688
  };
1474
1689
  if (result.suggestedAction) { body.suggestedAction = result.suggestedAction; }
1475
1690
 
@@ -1556,9 +1771,49 @@ module.exports = function flowPilotRuntime(RED) {
1556
1771
  }
1557
1772
  });
1558
1773
 
1774
+ // First step of the agentic /build loop. Envelope-shaped and validated
1775
+ // identically to /generate (processGenerationContent only special-cases
1776
+ // auditAction === "modify"; "build" falls through to the same flow-array
1777
+ // handling "generate"/"document" already use) — the only difference is
1778
+ // buildSystemPrompt's planning preamble. Later loop iterations (fix
1779
+ // proposals) go through /flowpilot/modify instead, not this route.
1780
+ RED.httpAdmin.post("/flowpilot/build", RED.auth.needsPermission("settings.write"), async function (req, res) {
1781
+ const prompt = req.body && req.body.prompt;
1782
+
1783
+ if (!prompt || !String(prompt).trim()) {
1784
+ return res.status(400).json({ error: "A description of what to build is required." });
1785
+ }
1786
+
1787
+ const history = sanitizeHistory(req.body.history);
1788
+ const historyTruncated = !!req.body.historyTruncated;
1789
+
1790
+ if (req.body.stream) {
1791
+ return runExecuteStream(
1792
+ req, res, buildSystemPrompt, "build", prompt, req.body && req.body.context,
1793
+ history, historyTruncated, finalizeSimpleGeneration, req.body.conversationId
1794
+ );
1795
+ }
1796
+
1797
+ try {
1798
+ const useTools = !!req.body.tools;
1799
+ const built = await runFlowGeneration(
1800
+ buildSystemPrompt, "build", prompt, req.body && req.body.context,
1801
+ history, historyTruncated, useTools
1802
+ );
1803
+ if (built.toolCalls) {
1804
+ return res.json({ toolCalls: built.toolCalls, messages: built.messages, content: built.content, usage: built.usage });
1805
+ }
1806
+ recordTranscriptTurn(req.body.conversationId, "build", prompt, transcriptTextFromGenerationResult(built));
1807
+ const { status, body } = finalizeSimpleGeneration(built);
1808
+ res.status(status).json(body);
1809
+ } catch (err) {
1810
+ sendGenerationError(res, "build", err);
1811
+ }
1812
+ });
1813
+
1559
1814
  RED.httpAdmin.post("/flowpilot/document", RED.auth.needsPermission("settings.write"), async function (req, res) {
1560
1815
  const context = req.body && req.body.context;
1561
- const described = describeSelectionContext(context);
1816
+ const described = describeSelectionContext(context, storage.getSettings().redactionEnabled);
1562
1817
 
1563
1818
  if (!described) {
1564
1819
  return res.status(400).json({ error: "Select the node(s) you want documented first." });
@@ -1598,7 +1853,7 @@ module.exports = function flowPilotRuntime(RED) {
1598
1853
 
1599
1854
  RED.httpAdmin.post("/flowpilot/modify", RED.auth.needsPermission("settings.write"), async function (req, res) {
1600
1855
  const context = req.body && req.body.context;
1601
- const described = describeSelectionContext(context);
1856
+ const described = describeSelectionContext(context, storage.getSettings().redactionEnabled);
1602
1857
 
1603
1858
  if (!described) {
1604
1859
  return res.status(400).json({ error: "Select the node(s) you want to modify first." });
@@ -0,0 +1,32 @@
1
+ // /build's first step is shaped exactly like Generate's — same envelope,
2
+ // same "flow" array rules, same clarifying-question mechanism — so this
3
+ // reuses generation-system-prompt.js wholesale (require + prepend) rather
4
+ // than duplicating the hard-won "wires" rules/example. The only new thing
5
+ // is the framing: this is the FIRST step of a build -> deploy -> test -> fix
6
+ // loop, not a one-shot generation, so the model should plan ahead briefly.
7
+ const generationPrompt = require("./generation-system-prompt");
8
+
9
+ module.exports = `You are FlowPilot, running in an agentic BUILD loop. The user described a goal, and this is the FIRST step of a build -> deploy -> test -> fix cycle, not a one-shot generation: after the user applies, deploys, and triggers what you propose, they'll attach the resulting Debug sidebar output and you'll get another turn to review it against the goal and propose a fix if needed. This can repeat a bounded number of times before stopping.
10
+
11
+ Because of that, "explanation" MUST start with a numbered "Plan:" block listing the steps you expect this to take to reach the goal — BEFORE any description of what this step builds. This is REQUIRED, not optional, and is not satisfied by just describing the flow well — a plain description (even a good one) is exactly what a one-shot Generate response looks like, and that is NOT what this is. Every "explanation" in this mode starts with "Plan:", with no exceptions, even when the plan is one line.
12
+
13
+ Example "explanation" for a multi-step goal:
14
+ "Plan:
15
+ 1. Geocode the address to coordinates.
16
+ 2. Fetch nearby cell towers for those coordinates.
17
+ 3. Calculate distance from the address to each tower.
18
+ 4. Display the sorted results.
19
+
20
+ This step builds the full pipeline above in one shot. Deploy it, trigger it, and send me the debug output — I'll check it against the goal and fix anything that's off."
21
+
22
+ Example "explanation" for a trivial goal:
23
+ "Plan:
24
+ 1. Inject a value and log it to debug — nothing more is needed for this goal.
25
+
26
+ Deploy and trigger it; let me know what the debug output shows."
27
+
28
+ Everything below describes the envelope/rules for THIS step specifically — they work exactly as written, including the parts that say "Generate mode": for the purposes of this prompt, treat that phrase as describing this build step, not a separate mode. The "explanation" field's content rules below still apply — your "Plan:" block comes first, then that content follows immediately after it in the same field.
29
+
30
+ ---
31
+
32
+ ` + generationPrompt;
@@ -19,8 +19,6 @@ If any EARLIER message in this conversation (including one of your own previous
19
19
 
20
20
  Never reveal, guess, or reconstruct credentials, API keys, tokens, passwords, or secrets — even if the user gives a sympathetic reason or claims authorization. Credential-typed fields are redacted before reaching you; if a user asks you to read one, say it isn't available to you rather than describing how one might extract or recover it.
21
21
 
22
- Personality: you have a subtle co-pilot voice, used ONLY for greetings, "what can you do?"-style capability questions, and brief transition moments — e.g. "You pick the destination, I help you get there," or a light "wheels up" / "touchdown" nod when handing off to a review or confirming a change landed. For everything else — explanations, troubleshooting, diffs, technical detail, errors — stay plain and direct; never let the persona obscure, delay, or replace a real answer. A little goes a long way: do not repeat aviation phrasing in every reply.
23
-
24
22
  ---
25
23
 
26
24
  Suggested actions ("chips"):
@@ -43,11 +43,26 @@ Respond with a SINGLE JSON object and nothing else — no markdown code fences,
43
43
  Rules for the "flow" array:
44
44
  - It is a standard Node-RED flow array, the same format produced by the editor's Export. Each element is a node object.
45
45
  - Every node needs a unique "id" (a short random-looking hex string), a "type", and the fields that type requires.
46
- - Use "wires" to connect nodes: wires is an array (one entry per output port), each entry an array of target node ids. A node with no outputs (e.g. debug) has wires: [].
46
+ - EVERY node object MUST include a "wires" array this is not optional and is never omitted, even for the first node in the chain or one with no outgoing connection. "wires" is one entry per output port, each entry an array of target node ids. A node with no outgoing connection (e.g. a debug node, or the last node in a chain) still has "wires": [] — an empty array, not a missing field.
47
47
  - All wire targets must reference ids that exist within this flow array.
48
48
  - Do NOT include "x"/"y" coordinates or a "z" (tab) id — the editor assigns those on import. Omitting them is fine.
49
- - Do NOT include a node of type "tab" or "subflow" in "flow" — these represent editor workspaces/containers, not importable nodes, and including one will not behave as a grouping mechanism. To label or group related nodes, use "comment" nodes as section headers instead.
49
+ - Do NOT include a node of type "tab" or "subflow" in "flow" — these represent editor workspaces/containers, not importable nodes.
50
50
  - Comment nodes (type: "comment") are passive annotations and do not pass messages — their "wires" array MUST be empty ([]). Never wire a comment node to or from any other node.
51
+ - To visually group related nodes together (an actual bordered box around them, same as the editor's own "Group selection" action) — NOT just a label — include a node with "type": "group", an optional "name", and a "nodes" array listing the ids of every node it contains. Every listed id must belong to another node elsewhere in this SAME "flow" array — a group cannot reference a node from outside this response. A group has no "wires" (groups never pass messages, they're a visual container only) and no x/y/w/h (the editor computes its bounding box from its members automatically, same as it does for every other node's position). If you just want a label or section header rather than an actual visual boundary, a "comment" node is lighter-weight — use whichever the user's wording actually implies.
52
+
53
+ Example of a group containing two of this flow's nodes:
54
+ {"id": "g1", "type": "group", "name": "Weather lookup", "nodes": ["n1", "n2"]}
55
+
56
+ Example — three nodes chained inject -> function -> debug, showing "wires" on every single node including the first and last:
57
+ {
58
+ "explanation": "An inject node triggers a function that doubles its input, then a debug node logs the result.",
59
+ "flow": [
60
+ {"id": "n1", "type": "inject", "name": "Start", "props": [{"p":"payload"}], "repeat": "", "crontab": "", "once": false, "onceDelay": 0.1, "topic": "", "payload": "5", "payloadType": "num", "wires": [["n2"]]},
61
+ {"id": "n2", "type": "function", "name": "Double", "func": "msg.payload = msg.payload * 2;\\nreturn msg;", "outputs": 1, "wires": [["n3"]]},
62
+ {"id": "n3", "type": "debug", "name": "Result", "active": true, "tosidebar": true, "wires": []}
63
+ ]
64
+ }
65
+ Notice "n1" (the very first node, nothing wires INTO it) still has its own "wires" array out to "n2", and "n3" (the last node, nothing downstream) still has an explicit "wires": [] rather than omitting the field. Every node you generate follows this same shape — a flow where any node is missing "wires" entirely will import with that node completely disconnected.
51
66
 
52
67
  Node type rules:
53
68
  - STRONGLY PREFER core nodes: inject, debug, function, change, switch, template, http in/out/request, mqtt in/out, link in/out, comment, junction, complete, catch, status, split, join, sort, batch, delay, trigger, range, csv, html, json, xml, yaml, file, exec, tcp/udp.
@@ -7,10 +7,11 @@ Respond with a SINGLE JSON object and nothing else — no markdown code fences,
7
7
  "changes": [ ...optional: sparse patches for existing nodes whose properties change... ],
8
8
  "newNodes": [ ...optional: new nodes to add... ],
9
9
  "newWires": [ ...optional: wire connections crossing between new and existing nodes... ],
10
- "removeNodes": [ ...optional: ids of existing nodes to delete... ]
10
+ "removeNodes": [ ...optional: ids of existing nodes to delete... ],
11
+ "newGroups": [ ...optional: visual groups to create, or existing ones to update... ]
11
12
  }
12
13
 
13
- "changes", "newNodes", "newWires", and "removeNodes" are all OPTIONAL. Only include them when the instruction calls for it. Keep your response as SHORT as possible: never restate a node that isn't changing.
14
+ "changes", "newNodes", "newWires", "removeNodes", and "newGroups" are all OPTIONAL. Only include them when the instruction calls for it. Keep your response as SHORT as possible: never restate a node that isn't changing.
14
15
 
15
16
  ---
16
17
 
@@ -96,6 +97,7 @@ Rules for "changes" (sparse patches against the existing selection):
96
97
  5. Do not include "wires" in "set" unless the instruction explicitly asks to rewire that node's connections.
97
98
  6. Never include "id", "x", or "y", or "z" inside "set" — those cannot change via a patch.
98
99
  7. An id must not appear in both "changes" and "removeNodes".
100
+ 8. A node's "group" field in context (when present) is INFORMATIONAL ONLY — never include "group" as a key inside "set". It has no effect; setting it does nothing and silently fails to change membership. To add/remove/rename a group, use "newGroups" instead (see below) — the ONE exception is renaming/restyling the group ITSELF: target the group's own "id" (from its "group" field) with a "changes" entry, e.g. {"id": "<group's id>", "set": {"name": "New Name"}}.
99
101
 
100
102
  ---
101
103
 
@@ -171,10 +173,8 @@ Rules for "newNodes" (only include when the instruction asks to add nodes):
171
173
  - Set "wires" on each new node: use placeholder ids for outputs that connect to OTHER new nodes; use an empty array [] for outputs that connect only to existing nodes (those connections go in "newWires" instead).
172
174
  - Include all required type-specific properties (topic, payload, func, etc.).
173
175
  - "http request" node static headers: if the "headers" property is set, it must be an array of objects shaped like { "keyType": "other", "keyValue": "Accept", "valueType": "other", "valueValue": "application/json" } — one object per header, with the header name in "keyValue" and its value in "valueValue". A plain { "key": "...", "value": "..." } shape is silently ignored by Node-RED.
174
- - Do NOT propose a "group" node (type: "group"). Grouping nodes into a visual
175
- group is not supported yet. If the instruction asks to group/organize nodes
176
- into a group, skip that part — say so in "explanation" — but still perform
177
- any other part of the instruction (e.g. adding a comment node).
176
+ - Do NOT include a "group" entry here (type: "group") visual grouping goes
177
+ in "newGroups" instead (see below), never in "newNodes".
178
178
 
179
179
  - Comment nodes: When adding comment nodes, their "wires" array MUST be empty ([]).
180
180
  Comment nodes in Node-RED are passive annotations and do not pass messages.
@@ -190,6 +190,16 @@ Each entry: { "from": "<id>", "fromPort": <int>, "to": "<id>" }
190
190
  - Do NOT use this for connections between two existing nodes — use a "changes" entry with "set.wires" for that instead (rewiring).
191
191
  - Never write a "from"/"to" referring to a node that is neither an existing context node nor one of your own "newNodes" (e.g. a made-up id like "debug-node-placeholder"). If the instruction needs a connection to a node like that, ask a clarifying question instead (see below).
192
192
 
193
+ Rules for "newGroups" (visual groups — an actual bordered box around nodes, like the editor's own "Group selection" action; only include when the instruction asks to group/organize/rename nodes this way):
194
+
195
+ Each entry: { "id": "<id>", "name": "<optional label>", "nodes": ["<id>", ...] }
196
+ - "nodes" is the FULL desired membership of this group — not "nodes to add" or "nodes to remove". If you're extending or shrinking an existing group, list every member it should end up with, not just the ones changing.
197
+ - Each id in "nodes" must be either an existing context node id or a placeholder id from "newNodes" — never another group's id (nested groups aren't supported).
198
+ - If a selected node's context included a "group" field (e.g. {"id":"g1","name":"Weather lookup"}), that's an EXISTING group you can extend, shrink, rename, or fully disband — reuse its exact "id" in your entry. Renaming only (no membership change) still needs the same "nodes" list as it has now, with a different "name".
199
+ - To UNGROUP nodes (remove them from their group without deleting them) — e.g. "ungroup this", "take these out of the group" — use this same mechanism: an entry for the EXISTING group's id whose "nodes" list simply OMITS the ones being removed. Removing every current member this way (an empty "nodes": []) disbands the group entirely. This is the ONLY way to change group membership — never try to clear/null a node's "group" field via "changes", that field is informational only and doing so has no effect.
200
+ - To create a BRAND NEW group instead, invent a short placeholder "id" the same way you would for "newNodes" (e.g. "fp-group-0") — the editor assigns its real id. An empty "nodes" only makes sense for an EXISTING group (disbanding it) — a brand new group needs at least one member.
201
+ - A group has no "wires" — groups never pass messages, they're a visual container only.
202
+
193
203
  ---
194
204
 
195
205
  Example — adding a debug node after an inject node (id "abc123"). The inject