@manny-est/node-red-flowpilot 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,39 @@ 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).
627
+ //
628
+ // INTENTIONALLY UNGATED (fixed in 0.4.1 — was needsPermission("settings.
629
+ // read") in 0.4.0, which broke the editor on every adminAuth-enabled
630
+ // instance): these are static client assets, fetched via plain <script
631
+ // src>/<link>/window.open — none of which can carry the admin auth
632
+ // bearer token (that only gets attached to FlowPilot's own ajax/fetch
633
+ // calls, via Node-RED's editor-side request wrapper). needsPermission's
634
+ // bearer/tokens/anon strategies have no fallback for a request with no
635
+ // Authorization header, so the gate 401's unconditionally for this kind
636
+ // of request — confirmed against @node-red/editor-api's auth middleware.
637
+ // This is the same reason NR5's own debug-view route has no permission
638
+ // check either. No secrets live in these files; the real data/action
639
+ // routes (settings, chat, generate, modify, etc. below) stay gated.
640
+
641
+ RED.httpAdmin.get("/flowpilot/core.js", function (req, res) {
642
+ res.sendFile(path.join(__dirname, "flowpilot-core.js"));
643
+ });
644
+
645
+ RED.httpAdmin.get("/flowpilot/core.css", function (req, res) {
646
+ res.sendFile(path.join(__dirname, "flowpilot-core.css"));
647
+ });
648
+
649
+ RED.httpAdmin.get("/flowpilot/popout/view.html", function (req, res) {
650
+ res.sendFile(path.join(__dirname, "lib", "popout", "view.html"));
651
+ });
652
+
607
653
  // ---- Settings: write -------------------------------------------------
608
654
 
609
655
  RED.httpAdmin.post("/flowpilot/settings", RED.auth.needsPermission("settings.write"), function (req, res) {
@@ -917,6 +963,33 @@ module.exports = function flowPilotRuntime(RED) {
917
963
  // types or wire integrity yet (that's the next chunk) — it returns the parsed
918
964
  // envelope so the frontend can display it for review.
919
965
 
966
+ // Given s[startIdx] === "{", scans forward with brace-depth counting that
967
+ // ignores braces inside string literals (so a value like "{{payload}}"
968
+ // can't be mistaken for structure) to find the index of the MATCHING
969
+ // closing "}". Returns -1 if the braces never balance before the string
970
+ // ends (truncated/malformed input).
971
+ function findMatchingBrace(s, startIdx) {
972
+ let depth = 0;
973
+ let inString = false;
974
+ let escaped = false;
975
+ for (let i = startIdx; i < s.length; i++) {
976
+ const ch = s[i];
977
+ if (inString) {
978
+ if (escaped) { escaped = false; }
979
+ else if (ch === "\\") { escaped = true; }
980
+ else if (ch === "\"") { inString = false; }
981
+ continue;
982
+ }
983
+ if (ch === "\"") { inString = true; }
984
+ else if (ch === "{") { depth++; }
985
+ else if (ch === "}") {
986
+ depth--;
987
+ if (depth === 0) { return i; }
988
+ }
989
+ }
990
+ return -1;
991
+ }
992
+
920
993
  function extractJsonObject(text) {
921
994
  if (!text) { throw new Error("Empty response from provider."); }
922
995
  let s = String(text).trim();
@@ -946,10 +1019,8 @@ module.exports = function flowPilotRuntime(RED) {
946
1019
  }
947
1020
  }
948
1021
 
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) {
1022
+ const firstObjIdx = s.indexOf("{");
1023
+ if (firstObjIdx === -1) {
953
1024
  // No JSON object found at all — flagged separately from a found-
954
1025
  // but-unparseable ({...} present, JSON.parse failed) "garbled" error,
955
1026
  // so callers can distinguish "model just answered in prose" (tolerate)
@@ -958,7 +1029,57 @@ module.exports = function flowPilotRuntime(RED) {
958
1029
  err.noJsonFound = true;
959
1030
  throw err;
960
1031
  }
961
- return JSON.parse(s.slice(first, last + 1));
1032
+
1033
+ // There may be more than one "{" before the real envelope — e.g. prose
1034
+ // explaining a fix that mentions inline code like "{{payload}}" before
1035
+ // the actual JSON (seen live: a review response started with "The
1036
+ // template node is using `{{payload}}` with...", and slicing from THAT
1037
+ // brace to the envelope's real closing "}" produced unparseable
1038
+ // garbage). Try each candidate "{" in order with string-aware brace
1039
+ // matching (findMatchingBrace, which ignores braces inside quoted
1040
+ // strings) rather than just slicing from the first "{" to the last
1041
+ // "}".
1042
+ //
1043
+ // A candidate must not just PARSE, it must also look like one of the
1044
+ // known envelope shapes (have at least one recognized top-level key) —
1045
+ // seen live: a pure-prose advice response that mentioned structured
1046
+ // logging included the illustrative example
1047
+ // `{"level":"info","event":"trivia_answer","user":"alex","correct":true}`,
1048
+ // which IS valid standalone JSON, so the old "first candidate that
1049
+ // parses wins" rule accepted it as "the envelope" and the caller threw
1050
+ // "no recognizable modify fields" — when the right answer was to treat
1051
+ // the whole reply as prose, since there was no real envelope at all.
1052
+ const ENVELOPE_KEYS = ["explanation", "flow", "question", "changes", "newNodes", "newWires", "removeNodes", "newGroups", "prose"];
1053
+ function looksLikeEnvelope(obj) {
1054
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) { return false; }
1055
+ return ENVELOPE_KEYS.some(function (k) { return k in obj; });
1056
+ }
1057
+
1058
+ let lastError = null;
1059
+ let searchFrom = firstObjIdx;
1060
+ while (searchFrom !== -1 && searchFrom < s.length) {
1061
+ const end = findMatchingBrace(s, searchFrom);
1062
+ if (end !== -1) {
1063
+ try {
1064
+ const candidate = JSON.parse(s.slice(searchFrom, end + 1));
1065
+ if (looksLikeEnvelope(candidate)) { return candidate; }
1066
+ // Valid JSON, but not envelope-shaped (e.g. an illustrative
1067
+ // example embedded in prose) — keep searching rather than
1068
+ // accepting it.
1069
+ } catch (e) {
1070
+ lastError = e;
1071
+ }
1072
+ }
1073
+ searchFrom = s.indexOf("{", searchFrom + 1);
1074
+ }
1075
+ // No candidate both parsed AND looked like a real envelope — equivalent
1076
+ // to "the model just answered in prose," not "the envelope is broken."
1077
+ // Let callers fall back to rendering this as a normal message instead
1078
+ // of surfacing a parse error (same noJsonFound flag the "no { at all"
1079
+ // branch above uses).
1080
+ const err = lastError || new Error("Provider's JSON object could not be parsed.");
1081
+ err.noJsonFound = true;
1082
+ throw err;
962
1083
  }
963
1084
 
964
1085
  // ---------------------------------------------------------------------
@@ -971,7 +1092,12 @@ module.exports = function flowPilotRuntime(RED) {
971
1092
  const settings = storage.getSettings();
972
1093
  const activeProvider = storage.getActiveProvider(settings);
973
1094
  const described = describeSelectionContext(context, settings.redactionEnabled);
974
- const messages = buildMessages(systemPrompt, history, historyTruncated, described, userPrompt);
1095
+ // Persona applies to the "explanation" field only (a real hand-off/
1096
+ // transition moment — "here's the flow I built for you") — never to
1097
+ // node names, ids, or any structural JSON, which stays exactly as each
1098
+ // mode's own system prompt above already specifies.
1099
+ const personaInstruction = personaPrompt.buildPersonaInstruction(settings.personaIntensity, { scope: "explanation" });
1100
+ const messages = buildMessages(systemPrompt + "\n\n" + personaInstruction, history, historyTruncated, described, userPrompt);
975
1101
  return { activeProvider, described, messages };
976
1102
  }
977
1103
 
@@ -1196,7 +1322,7 @@ module.exports = function flowPilotRuntime(RED) {
1196
1322
  // otherwise it's not recognizable as a modify response at all.
1197
1323
  if (auditAction === "modify") {
1198
1324
  const hasModifyShape = ("changes" in parsed) || ("newNodes" in parsed) ||
1199
- ("newWires" in parsed) || ("removeNodes" in parsed) ||
1325
+ ("newWires" in parsed) || ("removeNodes" in parsed) || ("newGroups" in parsed) ||
1200
1326
  (typeof parsed.explanation === "string" && parsed.explanation.trim());
1201
1327
  if (!hasModifyShape) {
1202
1328
  const err = new Error("The response did not contain any recognizable modify fields.");
@@ -1209,6 +1335,14 @@ module.exports = function flowPilotRuntime(RED) {
1209
1335
  const newNodes = Array.isArray(parsed.newNodes) ? parsed.newNodes : [];
1210
1336
  const newWires = Array.isArray(parsed.newWires) ? parsed.newWires : [];
1211
1337
  const removeNodes = Array.isArray(parsed.removeNodes) ? parsed.removeNodes : [];
1338
+ // Bug found live: this object is what finalizeModifyResult later reads
1339
+ // as "result" — but it never copied parsed.newGroups onto itself, so
1340
+ // even a model correctly using the top-level "newGroups" field (per
1341
+ // the prompt) had it silently dropped right here, before
1342
+ // finalizeModifyResult's own newGroups handling (fixed earlier) ever
1343
+ // saw it. Only a stray type:"group" entry inside newNodes survived,
1344
+ // since newNodes itself is copied through.
1345
+ const newGroups = Array.isArray(parsed.newGroups) ? parsed.newGroups : [];
1212
1346
 
1213
1347
  storage.appendAudit(Object.assign({
1214
1348
  action: auditAction,
@@ -1219,6 +1353,7 @@ module.exports = function flowPilotRuntime(RED) {
1219
1353
  newNodeCount: newNodes.length,
1220
1354
  newWireCount: newWires.length,
1221
1355
  removeNodeCount: removeNodes.length,
1356
+ newGroupCount: newGroups.length,
1222
1357
  contextNodeCount: described ? described.nodeCount : 0,
1223
1358
  contextConnectionCount: described ? described.connectionCount : 0
1224
1359
  }, perf));
@@ -1228,7 +1363,8 @@ module.exports = function flowPilotRuntime(RED) {
1228
1363
  changes: changes,
1229
1364
  newNodes: newNodes,
1230
1365
  newWires: newWires,
1231
- removeNodes: removeNodes
1366
+ removeNodes: removeNodes,
1367
+ newGroups: newGroups
1232
1368
  };
1233
1369
  const modifyAction = extractSuggestedAction(parsed);
1234
1370
  if (modifyAction) { modifyResult.suggestedAction = modifyAction; }
@@ -1363,6 +1499,16 @@ module.exports = function flowPilotRuntime(RED) {
1363
1499
 
1364
1500
  const originalIds = new Set(originalNodes.map(function (n) { return n.id; }));
1365
1501
 
1502
+ // Group ids the selection is actually inside (sanitizeNode resolves
1503
+ // each context node's group membership into a `group: {id, name}`
1504
+ // field — Phase 8.5 C2). A "changes" patch may target one of THESE
1505
+ // group ids too (e.g. to rename it) even though the group itself
1506
+ // isn't a member of originalIds — the user selected something
1507
+ // relevant to it, same spirit as selecting a node lets you patch it.
1508
+ const contextGroupIds = new Set(
1509
+ originalNodes.map(function (n) { return n.group && n.group.id; }).filter(Boolean)
1510
+ );
1511
+
1366
1512
  // Validate removeNodes: all ids must be in the original selection.
1367
1513
  const removeNodes = Array.isArray(result.removeNodes) ? result.removeNodes : [];
1368
1514
  if (removeNodes.length > 0) {
@@ -1390,8 +1536,9 @@ module.exports = function flowPilotRuntime(RED) {
1390
1536
  .filter(function (id) { return id !== undefined && id !== null; });
1391
1537
 
1392
1538
  // 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)); });
1539
+ // both patched and marked for removal. A group id from contextGroupIds
1540
+ // is allowed here too (see above) even though it's not in originalIds.
1541
+ const extraIds = changeIds.filter(function (id) { return !originalIds.has(String(id)) && !contextGroupIds.has(String(id)); });
1395
1542
  const wronglyRemovedIds = changeIds.filter(function (id) { return removeSet.has(String(id)); });
1396
1543
 
1397
1544
  const idProblems = [];
@@ -1430,27 +1577,95 @@ module.exports = function flowPilotRuntime(RED) {
1430
1577
  return patch ? Object.assign({}, n, patch) : n;
1431
1578
  });
1432
1579
 
1580
+ // A "changes" patch targeting a group id (contextGroupIds, not
1581
+ // originalIds — see above) has nowhere to merge onto above, since
1582
+ // originalNodes never includes the group itself, only nodes inside
1583
+ // it. Synthesize a minimal {id, type:"group", ...patch} entry for
1584
+ // each one instead, so it rides through the SAME flow array the
1585
+ // frontend's existing Tier-1 diff/apply pipeline already handles —
1586
+ // computeNodeDiff()/applyModifications() don't care what TYPE a node
1587
+ // is, and findLiveNode() already resolves a group id to the live
1588
+ // group object (Phase 8.5 C2 slice 1). This is how a group gets
1589
+ // renamed/restyled — pure property edit, no new apply-side code.
1590
+ const groupPatchIds = changeIds.filter(function (id) {
1591
+ return contextGroupIds.has(String(id)) && !originalIds.has(String(id));
1592
+ });
1593
+ groupPatchIds.forEach(function (id) {
1594
+ flow.push(Object.assign({ id: id, type: "group" }, patchById[String(id)]));
1595
+ });
1596
+
1433
1597
  const finalRemoveNodes = Array.from(removeSet);
1434
1598
 
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.
1599
+ // Groups describe MEMBERSHIP, not a regular node to insert — the
1600
+ // model is taught (modify-system-prompt.js) to put them in their OWN
1601
+ // top-level "newGroups" field, never inside "newNodes" (that API
1602
+ // doesn't know what a group is at all applyInsertions would try to
1603
+ // RED.nodes.add() it). Bug found live: this used to ONLY look for a
1604
+ // stray type:"group" entry INSIDE newNodes and never read
1605
+ // result.newGroups at all — a model correctly following the prompt's
1606
+ // own instructions had its groups silently dropped ("No changes
1607
+ // detected"). Read the real field now; still tolerate a stray
1608
+ // type:"group" entry left inside newNodes as a fallback, merging
1609
+ // both rather than requiring exactly one style. Each entry's "nodes"
1610
+ // is the FULL desired membership for that group id: if the id
1611
+ // matches an EXISTING live group, the frontend reconciles membership
1612
+ // to match exactly (add/remove as needed, down to zero — ungrouping
1613
+ // everyone); if not, it creates a new group with exactly that
1614
+ // membership. See applyGroupChanges() in flowpilot-core.js.
1440
1615
  const allNewNodes = result.newNodes || [];
1441
- const groupNodes = allNewNodes.filter(function (n) { return n && n.type === "group"; });
1616
+ const strayGroupNodes = allNewNodes.filter(function (n) { return n && n.type === "group"; });
1442
1617
  const newNodes = allNewNodes.filter(function (n) { return !(n && n.type === "group"); });
1443
1618
  const newNodeIdSet = new Set(newNodes.map(function (n) { return n && n.id; }).filter(Boolean));
1444
1619
 
1620
+ const declaredGroups = Array.isArray(result.newGroups) ? result.newGroups : [];
1621
+ const seenGroupIds = {};
1622
+ const newGroups = declaredGroups.concat(strayGroupNodes).filter(function (g) {
1623
+ if (!g || !g.id || seenGroupIds[g.id]) { return false; }
1624
+ seenGroupIds[g.id] = true;
1625
+ return true;
1626
+ });
1627
+
1445
1628
  // Validate newWires references: each from/to must be either an existing
1446
- // context node id or a placeholder id present in newNodes.
1629
+ // context node id or a placeholder id present in newNodes. A group id
1630
+ // is never a valid wire endpoint (groups don't pass messages) — filter
1631
+ // those out the same as before, just without discarding the group itself.
1447
1632
  let newWires = result.newWires || [];
1448
- if (groupNodes.length > 0) {
1449
- const groupIdSet = new Set(groupNodes.map(function (n) { return n && n.id; }).filter(Boolean));
1633
+ if (newGroups.length > 0) {
1634
+ const groupIdSet = new Set(newGroups.map(function (n) { return n && n.id; }).filter(Boolean));
1450
1635
  newWires = newWires.filter(function (wire) {
1451
1636
  return !groupIdSet.has(String(wire.from)) && !groupIdSet.has(String(wire.to));
1452
1637
  });
1453
1638
  }
1639
+ // Validate newGroups' own "nodes" member references: each must be an
1640
+ // existing context node id or a new-node placeholder id — explicitly
1641
+ // NOT another group's id, so nested groups-within-groups (out of scope
1642
+ // for v1) are naturally rejected rather than silently mis-imported.
1643
+ // An EMPTY "nodes" is allowed through here — meaningless for creating
1644
+ // a brand new group (the frontend already no-ops that case), but a
1645
+ // legitimate "ungroup everyone in this EXISTING group" when "id"
1646
+ // matches a live one, which only the frontend can tell apart.
1647
+ if (newGroups.length > 0) {
1648
+ const groupProblems = [];
1649
+ newGroups.forEach(function (g, i) {
1650
+ if (!g || !g.id) { groupProblems.push("group " + i + " missing id"); return; }
1651
+ const members = Array.isArray(g.nodes) ? g.nodes : [];
1652
+ members.forEach(function (ref) {
1653
+ if (!originalIds.has(String(ref)) && !newNodeIdSet.has(String(ref))) {
1654
+ groupProblems.push("group " + i + " member '" + ref + "' not in existing or new nodes");
1655
+ }
1656
+ });
1657
+ });
1658
+ if (groupProblems.length > 0) {
1659
+ storage.appendAudit({ action: "modify_group_ref_error", problems: groupProblems });
1660
+ return {
1661
+ status: 422,
1662
+ body: {
1663
+ error: "Invalid group references in newGroups: " + groupProblems.join("; "),
1664
+ raw: JSON.stringify(result)
1665
+ }
1666
+ };
1667
+ }
1668
+ }
1454
1669
  if (newWires.length > 0) {
1455
1670
  const wireProblems = [];
1456
1671
  newWires.forEach(function (wire, i) {
@@ -1473,19 +1688,15 @@ module.exports = function flowPilotRuntime(RED) {
1473
1688
  }
1474
1689
  }
1475
1690
 
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
- }
1691
+ if (newGroups.length > 0) { storage.appendAudit({ action: "modify_groups", count: newGroups.length }); }
1482
1692
 
1483
1693
  const body = {
1484
- explanation: explanation,
1694
+ explanation: result.explanation,
1485
1695
  flow: flow,
1486
1696
  newNodes: newNodes,
1487
1697
  newWires: newWires,
1488
- removeNodes: finalRemoveNodes
1698
+ removeNodes: finalRemoveNodes,
1699
+ newGroups: newGroups
1489
1700
  };
1490
1701
  if (result.suggestedAction) { body.suggestedAction = result.suggestedAction; }
1491
1702
 
@@ -1572,6 +1783,46 @@ module.exports = function flowPilotRuntime(RED) {
1572
1783
  }
1573
1784
  });
1574
1785
 
1786
+ // First step of the agentic /build loop. Envelope-shaped and validated
1787
+ // identically to /generate (processGenerationContent only special-cases
1788
+ // auditAction === "modify"; "build" falls through to the same flow-array
1789
+ // handling "generate"/"document" already use) — the only difference is
1790
+ // buildSystemPrompt's planning preamble. Later loop iterations (fix
1791
+ // proposals) go through /flowpilot/modify instead, not this route.
1792
+ RED.httpAdmin.post("/flowpilot/build", RED.auth.needsPermission("settings.write"), async function (req, res) {
1793
+ const prompt = req.body && req.body.prompt;
1794
+
1795
+ if (!prompt || !String(prompt).trim()) {
1796
+ return res.status(400).json({ error: "A description of what to build is required." });
1797
+ }
1798
+
1799
+ const history = sanitizeHistory(req.body.history);
1800
+ const historyTruncated = !!req.body.historyTruncated;
1801
+
1802
+ if (req.body.stream) {
1803
+ return runExecuteStream(
1804
+ req, res, buildSystemPrompt, "build", prompt, req.body && req.body.context,
1805
+ history, historyTruncated, finalizeSimpleGeneration, req.body.conversationId
1806
+ );
1807
+ }
1808
+
1809
+ try {
1810
+ const useTools = !!req.body.tools;
1811
+ const built = await runFlowGeneration(
1812
+ buildSystemPrompt, "build", prompt, req.body && req.body.context,
1813
+ history, historyTruncated, useTools
1814
+ );
1815
+ if (built.toolCalls) {
1816
+ return res.json({ toolCalls: built.toolCalls, messages: built.messages, content: built.content, usage: built.usage });
1817
+ }
1818
+ recordTranscriptTurn(req.body.conversationId, "build", prompt, transcriptTextFromGenerationResult(built));
1819
+ const { status, body } = finalizeSimpleGeneration(built);
1820
+ res.status(status).json(body);
1821
+ } catch (err) {
1822
+ sendGenerationError(res, "build", err);
1823
+ }
1824
+ });
1825
+
1575
1826
  RED.httpAdmin.post("/flowpilot/document", RED.auth.needsPermission("settings.write"), async function (req, res) {
1576
1827
  const context = req.body && req.body.context;
1577
1828
  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 };