@manny-est/node-red-flowpilot 0.3.0 → 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,
@@ -482,7 +495,7 @@ module.exports = function flowPilotRuntime(RED) {
482
495
 
483
496
  const described = describeSelectionContext(context, settings.redactionEnabled);
484
497
  const messages = buildMessages(
485
- settings.systemPrompt || "You are FlowPilot, a Node-RED development assistant.",
498
+ buildChatSystemPrompt(settings),
486
499
  history, historyTruncated, described, prompt
487
500
  );
488
501
 
@@ -519,7 +532,7 @@ module.exports = function flowPilotRuntime(RED) {
519
532
 
520
533
  const described = describeSelectionContext(context, settings.redactionEnabled);
521
534
  const messages = buildMessages(
522
- settings.systemPrompt || "You are FlowPilot, a Node-RED development assistant.",
535
+ buildChatSystemPrompt(settings),
523
536
  history, historyTruncated, described, prompt
524
537
  );
525
538
 
@@ -604,6 +617,27 @@ module.exports = function flowPilotRuntime(RED) {
604
617
  }
605
618
  });
606
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
+
607
641
  // ---- Settings: write -------------------------------------------------
608
642
 
609
643
  RED.httpAdmin.post("/flowpilot/settings", RED.auth.needsPermission("settings.write"), function (req, res) {
@@ -917,6 +951,33 @@ module.exports = function flowPilotRuntime(RED) {
917
951
  // types or wire integrity yet (that's the next chunk) — it returns the parsed
918
952
  // envelope so the frontend can display it for review.
919
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
+
920
981
  function extractJsonObject(text) {
921
982
  if (!text) { throw new Error("Empty response from provider."); }
922
983
  let s = String(text).trim();
@@ -946,10 +1007,8 @@ module.exports = function flowPilotRuntime(RED) {
946
1007
  }
947
1008
  }
948
1009
 
949
- // If there's leading/trailing prose, grab the outermost {...}.
950
- const first = s.indexOf("{");
951
- const last = s.lastIndexOf("}");
952
- if (first === -1 || last === -1 || last < first) {
1010
+ const firstObjIdx = s.indexOf("{");
1011
+ if (firstObjIdx === -1) {
953
1012
  // No JSON object found at all — flagged separately from a found-
954
1013
  // but-unparseable ({...} present, JSON.parse failed) "garbled" error,
955
1014
  // so callers can distinguish "model just answered in prose" (tolerate)
@@ -958,7 +1017,57 @@ module.exports = function flowPilotRuntime(RED) {
958
1017
  err.noJsonFound = true;
959
1018
  throw err;
960
1019
  }
961
- 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;
962
1071
  }
963
1072
 
964
1073
  // ---------------------------------------------------------------------
@@ -971,7 +1080,12 @@ module.exports = function flowPilotRuntime(RED) {
971
1080
  const settings = storage.getSettings();
972
1081
  const activeProvider = storage.getActiveProvider(settings);
973
1082
  const described = describeSelectionContext(context, settings.redactionEnabled);
974
- const messages = buildMessages(systemPrompt, history, historyTruncated, described, userPrompt);
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);
975
1089
  return { activeProvider, described, messages };
976
1090
  }
977
1091
 
@@ -1196,7 +1310,7 @@ module.exports = function flowPilotRuntime(RED) {
1196
1310
  // otherwise it's not recognizable as a modify response at all.
1197
1311
  if (auditAction === "modify") {
1198
1312
  const hasModifyShape = ("changes" in parsed) || ("newNodes" in parsed) ||
1199
- ("newWires" in parsed) || ("removeNodes" in parsed) ||
1313
+ ("newWires" in parsed) || ("removeNodes" in parsed) || ("newGroups" in parsed) ||
1200
1314
  (typeof parsed.explanation === "string" && parsed.explanation.trim());
1201
1315
  if (!hasModifyShape) {
1202
1316
  const err = new Error("The response did not contain any recognizable modify fields.");
@@ -1209,6 +1323,14 @@ module.exports = function flowPilotRuntime(RED) {
1209
1323
  const newNodes = Array.isArray(parsed.newNodes) ? parsed.newNodes : [];
1210
1324
  const newWires = Array.isArray(parsed.newWires) ? parsed.newWires : [];
1211
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 : [];
1212
1334
 
1213
1335
  storage.appendAudit(Object.assign({
1214
1336
  action: auditAction,
@@ -1219,6 +1341,7 @@ module.exports = function flowPilotRuntime(RED) {
1219
1341
  newNodeCount: newNodes.length,
1220
1342
  newWireCount: newWires.length,
1221
1343
  removeNodeCount: removeNodes.length,
1344
+ newGroupCount: newGroups.length,
1222
1345
  contextNodeCount: described ? described.nodeCount : 0,
1223
1346
  contextConnectionCount: described ? described.connectionCount : 0
1224
1347
  }, perf));
@@ -1228,7 +1351,8 @@ module.exports = function flowPilotRuntime(RED) {
1228
1351
  changes: changes,
1229
1352
  newNodes: newNodes,
1230
1353
  newWires: newWires,
1231
- removeNodes: removeNodes
1354
+ removeNodes: removeNodes,
1355
+ newGroups: newGroups
1232
1356
  };
1233
1357
  const modifyAction = extractSuggestedAction(parsed);
1234
1358
  if (modifyAction) { modifyResult.suggestedAction = modifyAction; }
@@ -1363,6 +1487,16 @@ module.exports = function flowPilotRuntime(RED) {
1363
1487
 
1364
1488
  const originalIds = new Set(originalNodes.map(function (n) { return n.id; }));
1365
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
+
1366
1500
  // Validate removeNodes: all ids must be in the original selection.
1367
1501
  const removeNodes = Array.isArray(result.removeNodes) ? result.removeNodes : [];
1368
1502
  if (removeNodes.length > 0) {
@@ -1390,8 +1524,9 @@ module.exports = function flowPilotRuntime(RED) {
1390
1524
  .filter(function (id) { return id !== undefined && id !== null; });
1391
1525
 
1392
1526
  // Validate that changes contains no hallucinated ids, and that no id is
1393
- // both patched and marked for removal.
1394
- 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)); });
1395
1530
  const wronglyRemovedIds = changeIds.filter(function (id) { return removeSet.has(String(id)); });
1396
1531
 
1397
1532
  const idProblems = [];
@@ -1430,27 +1565,95 @@ module.exports = function flowPilotRuntime(RED) {
1430
1565
  return patch ? Object.assign({}, n, patch) : n;
1431
1566
  });
1432
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
+
1433
1585
  const finalRemoveNodes = Array.from(removeSet);
1434
1586
 
1435
- // "group" nodes aren't supported yet (the editor's group API needs
1436
- // bounding-box computation + group-aware undo that applyInsertions
1437
- // doesn't implement). The system prompt tells the model not to propose
1438
- // them, but strip any that slip through anyway, and drop any newWires
1439
- // 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.
1440
1603
  const allNewNodes = result.newNodes || [];
1441
- const groupNodes = allNewNodes.filter(function (n) { return n && n.type === "group"; });
1604
+ const strayGroupNodes = allNewNodes.filter(function (n) { return n && n.type === "group"; });
1442
1605
  const newNodes = allNewNodes.filter(function (n) { return !(n && n.type === "group"); });
1443
1606
  const newNodeIdSet = new Set(newNodes.map(function (n) { return n && n.id; }).filter(Boolean));
1444
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
+
1445
1616
  // Validate newWires references: each from/to must be either an existing
1446
- // 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.
1447
1620
  let newWires = result.newWires || [];
1448
- if (groupNodes.length > 0) {
1449
- 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));
1450
1623
  newWires = newWires.filter(function (wire) {
1451
1624
  return !groupIdSet.has(String(wire.from)) && !groupIdSet.has(String(wire.to));
1452
1625
  });
1453
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
+ }
1454
1657
  if (newWires.length > 0) {
1455
1658
  const wireProblems = [];
1456
1659
  newWires.forEach(function (wire, i) {
@@ -1473,19 +1676,15 @@ module.exports = function flowPilotRuntime(RED) {
1473
1676
  }
1474
1677
  }
1475
1678
 
1476
- let explanation = result.explanation;
1477
- if (groupNodes.length > 0) {
1478
- storage.appendAudit({ action: "modify_group_stripped", count: groupNodes.length });
1479
- explanation = (explanation ? explanation + "\n\n" : "") +
1480
- "Note: grouping nodes into a visual group isn't supported yet, so that part of the request was skipped.";
1481
- }
1679
+ if (newGroups.length > 0) { storage.appendAudit({ action: "modify_groups", count: newGroups.length }); }
1482
1680
 
1483
1681
  const body = {
1484
- explanation: explanation,
1682
+ explanation: result.explanation,
1485
1683
  flow: flow,
1486
1684
  newNodes: newNodes,
1487
1685
  newWires: newWires,
1488
- removeNodes: finalRemoveNodes
1686
+ removeNodes: finalRemoveNodes,
1687
+ newGroups: newGroups
1489
1688
  };
1490
1689
  if (result.suggestedAction) { body.suggestedAction = result.suggestedAction; }
1491
1690
 
@@ -1572,6 +1771,46 @@ module.exports = function flowPilotRuntime(RED) {
1572
1771
  }
1573
1772
  });
1574
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
+
1575
1814
  RED.httpAdmin.post("/flowpilot/document", RED.auth.needsPermission("settings.write"), async function (req, res) {
1576
1815
  const context = req.body && req.body.context;
1577
1816
  const described = describeSelectionContext(context, storage.getSettings().redactionEnabled);
@@ -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"):
@@ -46,8 +46,12 @@ Rules for the "flow" array:
46
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"]}
51
55
 
52
56
  Example — three nodes chained inject -> function -> debug, showing "wires" on every single node including the first and last:
53
57
  {
@@ -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
@@ -0,0 +1,74 @@
1
+ // Builds the dynamic "Personality" instruction, scaled by
2
+ // settings.personaIntensity (1-10). Kept separate from default-system-
3
+ // prompt.js (the user-editable base prompt) so the persona always reflects
4
+ // the CURRENT slider value, rather than being baked into the persisted,
5
+ // freeform systemPrompt text where it could drift out of sync.
6
+ //
7
+ // Two scopes, since Chat and Generate/Document/Modify have different shapes:
8
+ // - "chat" (default): framing applies to ordinary chat replies — greetings,
9
+ // capability questions, brief transitions.
10
+ // - "explanation": framing applies to the natural-language "explanation"
11
+ // field of a generate/document/modify envelope ONLY — never to node
12
+ // names, ids, or any other JSON field, which the model must still produce
13
+ // exactly as instructed by that mode's own system prompt.
14
+ //
15
+ // Reference-point anchors (not just an abstract 1-10 rule) because smaller/
16
+ // local models follow concrete worked examples far more reliably than prose
17
+ // instructions alone — the same lesson learned fixing Generate's "wires" bug.
18
+ function buildPersonaInstruction(intensity, options) {
19
+ const n = Math.max(1, Math.min(10, Math.round(Number(intensity) || 3)));
20
+ const scope = (options && options.scope === "explanation")
21
+ ? "in the natural-language \"explanation\" text of your response only — " +
22
+ "never in node names, ids, or any other field, which must follow this " +
23
+ "mode's own format rules exactly"
24
+ : "at greetings, \"what can you do?\"-style capability questions, and " +
25
+ "brief transition moments only";
26
+
27
+ // The "hold back" instruction must NOT be blanket — at high intensity the
28
+ // whole point is to NOT hold back. Scaling this by n keeps "go all out at
29
+ // 10" from being undercut by a one-size-fits-all caution at the end.
30
+ const restraint = n >= 8
31
+ ? "At this intensity, go all the way in: every qualifying moment gets " +
32
+ "the FULL treatment — multiple sentences of in-character captain-" +
33
+ "speak, not one sprinkled word. Do not hold back, downplay it, or " +
34
+ "soften it to seem tasteful — \"a little goes a long way\" does NOT " +
35
+ "apply at this intensity; lean all the way in, every time."
36
+ : (n >= 5
37
+ ? "Use it often enough to be a clearly recognizable voice, but " +
38
+ "don't overdo it — a sentence or two of flavor per qualifying " +
39
+ "moment is plenty."
40
+ : "A little goes a long way: a short phrase, or nothing at all, is " +
41
+ "usually enough — don't repeat it in every single reply.");
42
+
43
+ return "Personality (intensity " + n + "/10, where 1 is a plain, no-frills " +
44
+ "Node-RED engineer and 10 is a comically over-the-top airline captain " +
45
+ "who happens to be a Node-RED expert): scale your voice " + scope + " " +
46
+ "to this intensity. NEVER let it touch the substance — explanations, " +
47
+ "troubleshooting, diffs, technical detail, and errors always stay " +
48
+ "plain, direct, and accurate no matter the intensity — a confused or " +
49
+ "stuck user gets a straight answer, never a bit.\n\n" +
50
+ "Reference points to interpolate between:\n" +
51
+ "- 1 (plain engineer): \"Hi, I'm FlowPilot. I can generate, modify, " +
52
+ "document, or chat about your flows — what do you need?\" No aviation " +
53
+ "language anywhere, ever.\n" +
54
+ "- 3 (subtle co-pilot): \"You pick the destination, I help you get " +
55
+ "there.\" A light \"wheels up\" / \"touchdown\" nod at a transition, " +
56
+ "used sparingly — most replies have no aviation language at all.\n" +
57
+ "- 7 (noticeable captain energy): \"Welcome aboard — I'm FlowPilot, " +
58
+ "your co-pilot for this flow. Let's get you cleared for takeoff.\" " +
59
+ "Aviation framing shows up more often and more colorfully, but still " +
60
+ "backs off completely once things turn technical.\n" +
61
+ "- 10 (full captain, comic — GO ALL OUT): \"Ladies and gentlemen, this " +
62
+ "is your captain speaking. I've just illuminated the fasten seatbelt " +
63
+ "sign — please take your seats, because I've finished building the " +
64
+ "Node-RED flow you requested. We are cleared for takeoff: fully wired, " +
65
+ "deployed, and ready for your review. Enjoy the flight, and thank you " +
66
+ "for choosing FlowPilot Airlines.\" At 10, EVERY qualifying moment gets " +
67
+ "a full announcement like this one, with callsigns, runway/altitude " +
68
+ "metaphors, and flight-crew theatrics throughout — not a passing " +
69
+ "reference — but the instant things turn technical, drop the act " +
70
+ "entirely and answer like the expert engineer underneath it.\n\n" +
71
+ restraint;
72
+ }
73
+
74
+ module.exports = { buildPersonaInstruction };