@manny-est/node-red-flowpilot 0.5.1 → 0.6.0-beta.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.
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+
3
+ // ---------------------------------------------------------------------
4
+ // The agent-strategy contract: a "strategy":"agent" turn is only ever
5
+ // allowed to mutate the flow via a WRITE tool call, never via the classic
6
+ // JSON-envelope mutation fields (changes/newNodes/newWires/removeNodes/
7
+ // newGroups). If a model still emits those fields on a turn that made no
8
+ // tool calls, strip them before the response reaches the client and log
9
+ // what was stripped — the two mutation code paths (classic envelope vs.
10
+ // agentic WRITE tools) must stay mutually exclusive per agent turn.
11
+ // Classic-strategy turns and turns that DID make tool calls are untouched
12
+ // by design (guard clause below) — this only fires on the one contract-
13
+ // violating shape it exists to catch.
14
+ // ---------------------------------------------------------------------
15
+
16
+ const AGENT_MUTATION_FIELDS = ["changes", "newNodes", "newWires", "removeNodes", "newGroups"];
17
+
18
+ function enforceAgentContract(result, execution, hasToolCalls) {
19
+ if (!result || !execution || execution.strategy !== "agent" || hasToolCalls) {
20
+ return result;
21
+ }
22
+
23
+ const strippedFields = [];
24
+ const counts = {};
25
+ AGENT_MUTATION_FIELDS.forEach(function (field) {
26
+ if (!Object.prototype.hasOwnProperty.call(result, field)) { return; }
27
+ strippedFields.push(field);
28
+ counts[field] = Array.isArray(result[field]) ? result[field].length : 1;
29
+ delete result[field];
30
+ });
31
+
32
+ if (strippedFields.length) {
33
+ result.strippedFields = strippedFields;
34
+ console.warn(
35
+ "[FlowPilot] agent contract stripped mutation fields strategy=%s entry=%s conversationId=%s counts=%s",
36
+ execution.strategy,
37
+ execution.entry,
38
+ execution.conversationId || "none",
39
+ JSON.stringify(counts)
40
+ );
41
+ }
42
+ return result;
43
+ }
44
+
45
+ module.exports = { enforceAgentContract, AGENT_MUTATION_FIELDS };
@@ -22,6 +22,7 @@ const FRAGMENT_ORDER = [
22
22
  "markdown.js",
23
23
  "selection-context.js",
24
24
  "apply-review.js",
25
+ "graph-truth.js",
25
26
  "modes.js",
26
27
  "main.js",
27
28
  "init.js"
@@ -5,8 +5,9 @@
5
5
  // is the framing: this is the FIRST step of a build -> deploy -> test -> fix
6
6
  // loop, not a one-shot generation, so the model should plan ahead briefly.
7
7
  const generationPrompt = require("./generation-system-prompt");
8
+ const { composePromptSections } = require("./prompt-fragments");
8
9
 
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
+ const buildFraming = `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
 
11
12
  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
 
@@ -31,8 +32,29 @@ ALWAYS include at least one debug node in your proposed flow so the test-and-rev
31
32
  - Any other flow shape: add a debug node at the last meaningful output point.
32
33
  Never generate a build flow without a debug node. The loop cannot review what it cannot see.
33
34
 
34
- 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.
35
+ FIRST-STEP ENVELOPE RULES (criticalread before writing your response):
35
36
 
36
- ---
37
+ 1. Your response in this first step must always use the "flow" array for new nodes. Never include "changes", "removeNodes", "newNodes", or "newWires" in a first-step response — those fields are only valid in fix iterations after the user has applied and tested. The "flow" key is the ONLY way to add nodes in this step.
37
38
 
38
- ` + generationPrompt;
39
+ 2. Context nodes (the user's existing selected nodes) are provided for reference only — to show what already exists so your new flow can complement it. They are NOT nodes you should remove. Never plan to delete context nodes in a first-step response. If you believe a context node is wrong or misplaced, note that in "explanation" and suggest the user fix it manually before applying your proposal.
40
+
41
+ 3. If the user's intent is ambiguous about whether to ADD new nodes alongside the existing canvas or to CHANGE/REPLACE existing nodes, ask a clarifying question (see below) rather than guessing. In particular: if the request could mean "add a test harness" OR "restructure the existing flow," always ask first.
42
+
43
+ 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.`;
44
+
45
+ const fpUidTapsFragment = `FP-UID checkpoint taps (temporary FlowPilot scaffolding):
46
+
47
+ For every external-call node in this build step — including http request, mqtt out, exec, file write, and any other node that reaches outside the flow — add a debug checkpoint tap that observes the message at that boundary.
48
+
49
+ - Name the taps exactly "FP-UID001", "FP-UID002", and so on, sequentially in flow order. Reset numbering to 001 for every new build response.
50
+ - Configure each tap for the complete message object using the Node-RED serialized form: "complete": "true". Also set "active": true, "tosidebar": true, and "wires": [].
51
+ - Wire each tap as a PARALLEL branch; never insert it inline or replace the main downstream connection. An external node with an output must wire to both its normal downstream target(s) and its FP-UID tap.
52
+ - If an external sink has no output port, branch the tap from the node feeding that sink so it observes the message being sent.
53
+ - These nodes are FlowPilot-owned scaffolding. Include them even when the user did not request debug nodes; FlowPilot will remove them when the task finishes.
54
+ - If this step has no external-call nodes, do not add any FP-UID tap.`;
55
+
56
+ module.exports = composePromptSections([
57
+ buildFraming,
58
+ generationPrompt,
59
+ fpUidTapsFragment
60
+ ]);
@@ -0,0 +1,106 @@
1
+ "use strict";
2
+
3
+ const { findMatchingBrace } = require("./envelope");
4
+
5
+ const CHAT_DATA_MARKER = "<<<FLOWPILOT_DATA>>>";
6
+ const CHAT_DATA_MARKER_RE = /<<<\s{0,8}FLOWPILOT_DATA\s{0,8}>>>/i;
7
+ const CHAT_DATA_MARKER_MAX_SCAN = 40;
8
+
9
+ function stripTrailingDataFence(text) {
10
+ return String(text || "")
11
+ .replace(/(?:\r?\n)?```(?:json)?[ \t]*(?:\r?\n)?$/i, "")
12
+ .replace(/\s+$/, "");
13
+ }
14
+
15
+ function stripLeadingDataFence(text) {
16
+ return String(text || "")
17
+ .replace(/^\s*```(?:json)?[ \t]*\r?\n?/i, "")
18
+ .replace(/\s*```[\s\r\n]*$/i, "")
19
+ .trim();
20
+ }
21
+
22
+ function findChatDataMarker(text) {
23
+ const source = String(text || "");
24
+ const match = CHAT_DATA_MARKER_RE.exec(source);
25
+ if (!match) { return null; }
26
+ return {
27
+ index: match.index,
28
+ marker: match[0]
29
+ };
30
+ }
31
+
32
+ function parseChatDataObject(text) {
33
+ const source = stripLeadingDataFence(text);
34
+ const firstBrace = source.indexOf("{");
35
+ if (firstBrace === -1) { return null; }
36
+ const end = findMatchingBrace(source, firstBrace);
37
+ if (end === -1) { return null; }
38
+ try {
39
+ return JSON.parse(source.slice(firstBrace, end + 1));
40
+ } catch (e) {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ function stripChatDataFromVisibleText(content) {
46
+ const text = String(content || "");
47
+ const found = findChatDataMarker(text);
48
+ if (!found) { return text; }
49
+ return stripTrailingDataFence(text.slice(0, found.index));
50
+ }
51
+
52
+ function splitChatDataBlock(content) {
53
+ const text = String(content || "");
54
+ const found = findChatDataMarker(text);
55
+ if (!found) { return { message: text, data: null }; }
56
+
57
+ const message = stripTrailingDataFence(text.slice(0, found.index));
58
+ const data = parseChatDataObject(text.slice(found.index + found.marker.length));
59
+ return { message: message, data: data };
60
+ }
61
+
62
+ function createChatDataStreamSplitter() {
63
+ let held = "";
64
+ let inData = false;
65
+ let dataBuf = "";
66
+
67
+ function push(delta) {
68
+ if (inData) { dataBuf += delta; return ""; }
69
+
70
+ const combined = held + String(delta || "");
71
+ const found = findChatDataMarker(combined);
72
+ if (found) {
73
+ inData = true;
74
+ dataBuf = combined.slice(found.index + found.marker.length);
75
+ held = "";
76
+ return stripTrailingDataFence(combined.slice(0, found.index));
77
+ }
78
+
79
+ if (combined.length <= CHAT_DATA_MARKER_MAX_SCAN) {
80
+ held = combined;
81
+ return "";
82
+ }
83
+ held = combined.slice(-CHAT_DATA_MARKER_MAX_SCAN);
84
+ return combined.slice(0, -CHAT_DATA_MARKER_MAX_SCAN);
85
+ }
86
+
87
+ function finish() {
88
+ const tail = inData ? "" : held;
89
+ held = "";
90
+ const data = inData ? parseChatDataObject(dataBuf) : null;
91
+ return {
92
+ tail: inData ? "" : stripChatDataFromVisibleText(tail),
93
+ data: data
94
+ };
95
+ }
96
+
97
+ return { push: push, finish: finish };
98
+ }
99
+
100
+ module.exports = {
101
+ CHAT_DATA_MARKER,
102
+ createChatDataStreamSplitter,
103
+ findChatDataMarker,
104
+ splitChatDataBlock,
105
+ stripChatDataFromVisibleText
106
+ };