@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/CHANGELOG.md +35 -0
- package/PROJECT-OVERVIEW.md +42 -7
- package/README.md +15 -0
- package/USER-GUIDE.md +45 -1
- package/flowpilot-core.css +1189 -0
- package/flowpilot-core.js +6427 -0
- package/flowpilot.html +9 -5896
- package/flowpilot.js +280 -29
- package/lib/build-system-prompt.js +32 -0
- package/lib/default-system-prompt.js +0 -2
- package/lib/generation-system-prompt.js +5 -1
- package/lib/modify-system-prompt.js +16 -6
- package/lib/persona-prompt.js +74 -0
- package/lib/popout/view.html +33 -0
- package/lib/storage.js +26 -0
- package/package.json +3 -1
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
|
|
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
|
|
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
|
-
|
|
950
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1436
|
-
//
|
|
1437
|
-
//
|
|
1438
|
-
//
|
|
1439
|
-
//
|
|
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
|
|
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 (
|
|
1449
|
-
const groupIdSet = new Set(
|
|
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
|
-
|
|
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
|
|
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 "
|
|
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
|
|
175
|
-
|
|
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 };
|