@manny-est/node-red-flowpilot 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,50 @@
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 for Modify; flow for Generate/Document/Build — Generate's own
8
+ // envelope shape uses "flow", not "newNodes"/"newWires", found live during
9
+ // the 0.6.0 FINISH-list pass: the field was missing from this list entirely,
10
+ // so an agent-strategy Generate final turn with no tool calls could emit a
11
+ // full flow array completely unprotected). If a model still emits those
12
+ // fields on a turn that made no tool calls, strip them before the response
13
+ // reaches the client and log
14
+ // what was stripped — the two mutation code paths (classic envelope vs.
15
+ // agentic WRITE tools) must stay mutually exclusive per agent turn.
16
+ // Classic-strategy turns and turns that DID make tool calls are untouched
17
+ // by design (guard clause below) — this only fires on the one contract-
18
+ // violating shape it exists to catch.
19
+ // ---------------------------------------------------------------------
20
+
21
+ const AGENT_MUTATION_FIELDS = ["changes", "newNodes", "newWires", "removeNodes", "newGroups", "flow"];
22
+
23
+ function enforceAgentContract(result, execution, hasToolCalls) {
24
+ if (!result || !execution || execution.strategy !== "agent" || hasToolCalls) {
25
+ return result;
26
+ }
27
+
28
+ const strippedFields = [];
29
+ const counts = {};
30
+ AGENT_MUTATION_FIELDS.forEach(function (field) {
31
+ if (!Object.prototype.hasOwnProperty.call(result, field)) { return; }
32
+ strippedFields.push(field);
33
+ counts[field] = Array.isArray(result[field]) ? result[field].length : 1;
34
+ delete result[field];
35
+ });
36
+
37
+ if (strippedFields.length) {
38
+ result.strippedFields = strippedFields;
39
+ console.warn(
40
+ "[FlowPilot] agent contract stripped mutation fields strategy=%s entry=%s conversationId=%s counts=%s",
41
+ execution.strategy,
42
+ execution.entry,
43
+ execution.conversationId || "none",
44
+ JSON.stringify(counts)
45
+ );
46
+ }
47
+ return result;
48
+ }
49
+
50
+ 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"
@@ -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
+ };
@@ -126,7 +126,18 @@
126
126
  // existing nodes. Uses RED.nodes.add (the same path Node-RED's undo uses
127
127
  // internally for t:"add" events) and pushes one compound history entry so
128
128
  // a single Ctrl+Z removes both the new nodes and their wires together.
129
- function applyInsertions(newNodes, newWires, contextNodeIds) {
129
+ //
130
+ // historyEvents (optional, CLAUDE-027): when the caller passes a shared
131
+ // array here (an agentic run's own accumulator — see runAgentLoop in
132
+ // modes.js), the compound event below is collected into it INSTEAD of
133
+ // being pushed to RED.history directly, so the run's own flush can fold
134
+ // it together with every other WRITE-tool call this same run made into
135
+ // ONE RED.history entry (RED.history's native t:"multi" wrapper —
136
+ // confirmed via @node-red/editor-client's red.js, e.g. its
137
+ // deleteSelection()). Omitted (undefined), this behaves exactly as
138
+ // before — every other caller (classic Modify/Generate apply, the build
139
+ // loop) still gets its own immediate, standalone push.
140
+ function applyInsertions(newNodes, newWires, contextNodeIds, historyEvents) {
130
141
  if ((!newNodes || !newNodes.length) && (!newWires || !newWires.length)) { return; }
131
142
 
132
143
  // Determine z (flow-tab id) from the active workspace.
@@ -537,7 +548,7 @@
537
548
  // NB: for t:"add", ev.nodes must be an array of ID STRINGS — NR's undo
538
549
  // does RED.nodes.node(ev.nodes[i]) then reads .z, which throws (and
539
550
  // silently breaks Ctrl+Z) if given node objects instead of ids.
540
- RED.history.push({
551
+ var insertHistoryEvent = {
541
552
  t: "add",
542
553
  nodes: addedNodes.map(function (n) { return n.id; }),
543
554
  links: addedLinks,
@@ -547,7 +558,8 @@
547
558
  subflowInputs: [],
548
559
  subflowOutputs: [],
549
560
  dirty: RED.nodes.dirty()
550
- });
561
+ };
562
+ if (historyEvents) { historyEvents.push(insertHistoryEvent); } else { RED.history.push(insertHistoryEvent); }
551
563
 
552
564
  RED.nodes.dirty(true);
553
565
  RED.view.redraw(true);
@@ -1044,8 +1056,16 @@
1044
1056
  // Tier 1 — property changes: mutate live node + {t:"edit"} history entry
1045
1057
  // Tier 3 — wire changes: removeLink/addLink + {t:"add", removedLinks} entry
1046
1058
  // Tier 4 — node removals: collect links, removeLink, remove node + {t:"delete"} entry
1047
- // One history entry per node per type so Ctrl+Z steps back through them cleanly.
1048
- function applyModifications(nodeDiffs, removeNodes, $applyBtn, idMap) {
1059
+ // One history entry per node per type so Ctrl+Z steps back through them cleanly
1060
+ // UNLESS historyEvents (optional, CLAUDE-027) is passed: an agentic run's
1061
+ // shared accumulator, into which every event below is collected instead of
1062
+ // pushed immediately, so the run's own flush (runAgentLoop, modes.js) can
1063
+ // fold ALL of this run's WRITE-tool calls together into ONE RED.history
1064
+ // entry via t:"multi" — see applyInsertions' historyEvents doc above for
1065
+ // the same mechanism. Every other caller (classic Modify/Generate apply,
1066
+ // build loop) omits it and keeps this function's original per-node/per-
1067
+ // type granularity unchanged.
1068
+ function applyModifications(nodeDiffs, removeNodes, $applyBtn, idMap, historyEvents) {
1049
1069
  idMap = idMap || {};
1050
1070
  var propApplied = 0;
1051
1071
  var wireNodesApplied = 0;
@@ -1090,12 +1110,13 @@
1090
1110
 
1091
1111
  liveNode.changed = true;
1092
1112
  liveNode.dirty = true;
1093
- RED.history.push({
1113
+ var editHistoryEvent = {
1094
1114
  t: "edit",
1095
1115
  node: liveNode,
1096
1116
  changes: oldValues,
1097
1117
  dirty: RED.nodes.dirty()
1098
- });
1118
+ };
1119
+ if (historyEvents) { historyEvents.push(editHistoryEvent); } else { RED.history.push(editHistoryEvent); }
1099
1120
  propApplied++;
1100
1121
  });
1101
1122
 
@@ -1151,12 +1172,13 @@
1151
1172
  });
1152
1173
 
1153
1174
  if (removedLinks.length || addedLinks.length) {
1154
- RED.history.push({
1175
+ var wireHistoryEvent = {
1155
1176
  t: "add",
1156
1177
  links: addedLinks,
1157
1178
  removedLinks: removedLinks,
1158
1179
  dirty: RED.nodes.dirty()
1159
- });
1180
+ };
1181
+ if (historyEvents) { historyEvents.push(wireHistoryEvent); } else { RED.history.push(wireHistoryEvent); }
1160
1182
  wireNodesApplied++;
1161
1183
  }
1162
1184
  });
@@ -1216,7 +1238,7 @@
1216
1238
  }
1217
1239
  }
1218
1240
 
1219
- RED.history.push({
1241
+ var removeHistoryEvent = {
1220
1242
  t: "delete",
1221
1243
  nodes: isJunction ? [] : [liveNode],
1222
1244
  links: connectedLinks,
@@ -1226,7 +1248,8 @@
1226
1248
  subflowInputs: [],
1227
1249
  subflowOutputs: [],
1228
1250
  dirty: RED.nodes.dirty()
1229
- });
1251
+ };
1252
+ if (historyEvents) { historyEvents.push(removeHistoryEvent); } else { RED.history.push(removeHistoryEvent); }
1230
1253
  nodesRemoved++;
1231
1254
  });
1232
1255
 
@@ -0,0 +1,63 @@
1
+ // P10-E (ADR-005): the single implementation of graph truth. Every
2
+ // verification consumer — runSingleVerifyCheck's per-check-type
3
+ // dispatch (modes.js), the WRITE-tool tool_result checks
4
+ // (runChecksForToolResult, main.js), verifyImportedNodes (modes.js),
5
+ // and group_nodes's post-check (main.js) — delegates here instead of
6
+ // re-reading RED.nodes/RED.nodes.eachLink locally. Wires are read
7
+ // exclusively via RED.nodes.eachLink, never node.wires: addLink/
8
+ // removeLink never re-sync a live node's own .wires array mid-session
9
+ // (CLAUDE-010 — the drift this module exists to prevent from
10
+ // recurring). Diff computation (apply-review.js's computeWireDiff and
11
+ // its own eachLink scans) is a different concern — "what changed" vs.
12
+ // "is this true right now" — and stays out of scope per ADR-005.
13
+
14
+ function nodeExists(id) {
15
+ return !!RED.nodes.node(id);
16
+ }
17
+
18
+ function nodeAbsent(id) {
19
+ return !RED.nodes.node(id);
20
+ }
21
+
22
+ function propertyEquals(id, key, want) {
23
+ var node = RED.nodes.node(id);
24
+ return !!node && node[key] === want;
25
+ }
26
+
27
+ // Read-only counterpart to propertyEquals, for diagnostics that need to
28
+ // show the actual current value alongside the expected one (CLAUDE-018)
29
+ // rather than just a boolean match. Returns { exists, value } — value is
30
+ // undefined when the node doesn't exist so callers can tell "no node"
31
+ // apart from "property is actually undefined".
32
+ function readProperty(id, key) {
33
+ var node = RED.nodes.node(id);
34
+ return { exists: !!node, value: node ? node[key] : undefined };
35
+ }
36
+
37
+ function wireExists(fromId, port, toId) {
38
+ var found = false;
39
+ RED.nodes.eachLink(function (l) {
40
+ if (found) { return; }
41
+ if (l.source && l.source.id === fromId &&
42
+ (l.sourcePort || 0) === (port || 0) &&
43
+ l.target && l.target.id === toId) {
44
+ found = true;
45
+ }
46
+ });
47
+ return found;
48
+ }
49
+
50
+ function wireAbsent(fromId, port, toId) {
51
+ return !wireExists(fromId, port, toId);
52
+ }
53
+
54
+ // ids: a single node id or an array of ids — true only if every one of
55
+ // them currently belongs to groupId (per its live .g ref).
56
+ function groupContains(groupId, ids) {
57
+ var list = Array.isArray(ids) ? ids : [ids];
58
+ if (!list.length) { return false; }
59
+ return list.every(function (id) {
60
+ var node = RED.nodes.node(id);
61
+ return !!node && node.g === groupId;
62
+ });
63
+ }
@@ -4,6 +4,85 @@
4
4
  // a page reload continues the same transcript; reset by clearChat()
5
5
  // ("start a fresh conversation" gets a fresh transcript file too).
6
6
  // ---------------------------------------------------------------------
7
+ var FP_CONVERSATION_ID_KEY = "fp-conversation-id";
8
+ var FP_RUN_MARKER_KEY = "fp-run-marker";
9
+
10
+ function flowpilotStorageLog(level, event, data) {
11
+ var logger = console[level] || console.log;
12
+ try {
13
+ logger.call(console, "[FlowPilot][storage] " + event, data || {});
14
+ } catch (e) { /* console unavailable */ }
15
+ }
16
+
17
+ function flowpilotSessionStorage() {
18
+ return window.sessionStorage;
19
+ }
20
+
21
+ function flowpilotStorageGet(key, reason) {
22
+ try {
23
+ var value = flowpilotSessionStorage().getItem(key);
24
+ flowpilotStorageLog("log", "get", {
25
+ key: key,
26
+ reason: reason || "",
27
+ value: value,
28
+ href: location.href
29
+ });
30
+ return value;
31
+ } catch (e) {
32
+ flowpilotStorageLog("warn", "get-failed", {
33
+ key: key,
34
+ reason: reason || "",
35
+ error: e && e.message ? e.message : String(e),
36
+ href: location.href
37
+ });
38
+ return null;
39
+ }
40
+ }
41
+
42
+ function flowpilotStorageSet(key, value, reason) {
43
+ try {
44
+ flowpilotSessionStorage().setItem(key, value);
45
+ var readBack = flowpilotSessionStorage().getItem(key);
46
+ flowpilotStorageLog("log", "set", {
47
+ key: key,
48
+ reason: reason || "",
49
+ value: value,
50
+ readBack: readBack,
51
+ href: location.href
52
+ });
53
+ return true;
54
+ } catch (e) {
55
+ flowpilotStorageLog("warn", "set-failed", {
56
+ key: key,
57
+ reason: reason || "",
58
+ value: value,
59
+ error: e && e.message ? e.message : String(e),
60
+ href: location.href
61
+ });
62
+ return false;
63
+ }
64
+ }
65
+
66
+ function flowpilotStorageRemove(key, reason) {
67
+ try {
68
+ flowpilotSessionStorage().removeItem(key);
69
+ flowpilotStorageLog("log", "remove", {
70
+ key: key,
71
+ reason: reason || "",
72
+ href: location.href
73
+ });
74
+ return true;
75
+ } catch (e) {
76
+ flowpilotStorageLog("warn", "remove-failed", {
77
+ key: key,
78
+ reason: reason || "",
79
+ error: e && e.message ? e.message : String(e),
80
+ href: location.href
81
+ });
82
+ return false;
83
+ }
84
+ }
85
+
7
86
  function makeConversationId() {
8
87
  if (window.crypto && typeof window.crypto.randomUUID === "function") {
9
88
  return window.crypto.randomUUID();
@@ -13,16 +92,88 @@
13
92
 
14
93
  function newConversationId() {
15
94
  var id = makeConversationId();
16
- try { sessionStorage.setItem("fp-conversation-id", id); } catch (e) { /* storage unavailable */ }
95
+ flowpilotStorageSet(FP_CONVERSATION_ID_KEY, id, "newConversationId");
17
96
  return id;
18
97
  }
19
98
 
20
- var conversationId = (function () {
99
+ function persistConversationId(id, reason) {
100
+ if (!id) { return false; }
101
+ return flowpilotStorageSet(FP_CONVERSATION_ID_KEY, String(id), reason || "persistConversationId");
102
+ }
103
+
104
+ function clearConversationId(reason) {
105
+ return flowpilotStorageRemove(FP_CONVERSATION_ID_KEY, reason || "clearConversationId");
106
+ }
107
+
108
+ function readRunMarker() {
109
+ var raw = flowpilotStorageGet(FP_RUN_MARKER_KEY, "readRunMarker");
110
+ if (!raw) { return null; }
21
111
  try {
22
- var existing = sessionStorage.getItem("fp-conversation-id");
23
- if (existing) { return existing; }
24
- } catch (e) { /* storage unavailable */ }
25
- return newConversationId();
112
+ return JSON.parse(raw);
113
+ } catch (e) {
114
+ flowpilotStorageLog("warn", "run-marker-parse-failed", {
115
+ raw: raw,
116
+ error: e && e.message ? e.message : String(e)
117
+ });
118
+ flowpilotStorageRemove(FP_RUN_MARKER_KEY, "invalid run marker json");
119
+ return null;
120
+ }
121
+ }
122
+
123
+ function writeRunMarker(marker, reason) {
124
+ if (!marker) { return false; }
125
+ return flowpilotStorageSet(FP_RUN_MARKER_KEY, JSON.stringify(marker), reason || "writeRunMarker");
126
+ }
127
+
128
+ function clearRunMarker(reason) {
129
+ return flowpilotStorageRemove(FP_RUN_MARKER_KEY, reason || "clearRunMarker");
130
+ }
131
+
132
+ function renderInterruptedRunMessage(appliedCount) {
133
+ addMessage("assistant",
134
+ "⚠ This run was interrupted after step " + appliedCount +
135
+ " — completed steps are applied (Ctrl+Z to undo). Re-send to continue from here.");
136
+ }
137
+
138
+ function restoreInterruptedRunMarker(expectedConversationId) {
139
+ var marker = readRunMarker();
140
+ if (!marker) { return; }
141
+ flowpilotStorageLog("log", "restore-run-marker", {
142
+ marker: marker,
143
+ expectedConversationId: expectedConversationId || null
144
+ });
145
+ if (expectedConversationId && marker.conversationId && marker.conversationId !== expectedConversationId) {
146
+ flowpilotStorageLog("warn", "run-marker-conversation-mismatch", {
147
+ markerConversationId: marker.conversationId,
148
+ expectedConversationId: expectedConversationId
149
+ });
150
+ clearRunMarker("run marker conversation mismatch");
151
+ return;
152
+ }
153
+ renderInterruptedRunMessage(Number(marker.appliedCount) || 0);
154
+ clearRunMarker("restored interrupted run banner");
155
+ }
156
+
157
+ // CLAUDE-029: whether conversationId above came from an existing
158
+ // sessionStorage entry (a page reload continuing a prior conversation)
159
+ // rather than being freshly minted — drives whether page init rehydrates
160
+ // the Chat panel from the server. See rehydrateConversationOnLoad().
161
+ var conversationIdWasRestored = false;
162
+
163
+ var conversationId = (function () {
164
+ flowpilotStorageLog("log", "conversation-id-init-enter", {
165
+ href: location.href,
166
+ readyState: document.readyState
167
+ });
168
+ var existing = flowpilotStorageGet(FP_CONVERSATION_ID_KEY, "conversationId init");
169
+ if (existing) {
170
+ conversationIdWasRestored = true;
171
+ flowpilotStorageLog("log", "conversation-id-restored", { conversationId: existing });
172
+ return existing;
173
+ }
174
+ var fresh = newConversationId();
175
+ flowpilotStorageLog("log", "conversation-id-created", { conversationId: fresh });
176
+ return fresh;
26
177
  })();
27
178
 
28
179
  // ---------------------------------------------------------------------