@manny-est/node-red-flowpilot 0.4.0 → 0.5.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,165 @@
1
+ // ---------------------------------------------------------------------
2
+ // Secret detection — shared by the node-selection sanitizer (below)
3
+ // and the live debug-message redactor (next section). Two
4
+ // complementary checks:
5
+ // - VALUE_SECRET_PATTERNS: the VALUE looks like a credential (bearer
6
+ // token, JWT, AWS key, high-entropy blob), regardless of field name.
7
+ // - SECRET_KEY: the field NAME looks secret-bearing (password, token,
8
+ // apiKey, ...) AND the value is a string long enough to plausibly be
9
+ // a real secret — short strings like `key: "ab"` or numbers like
10
+ // `keyCount: 3` are left alone.
11
+ // Redaction is informative, not silent: placeholders carry the kind and
12
+ // original length (`[redacted: bearer token, 211 chars]`) so the AI can
13
+ // still reason about "an auth header was present" without seeing it.
14
+ // ---------------------------------------------------------------------
15
+ var SECRET_KEY = /pass|secret|token|apikey|api_key|key$|credential|auth|bearer/i;
16
+ var SECRET_NAME_MIN_LEN = 8;
17
+
18
+ var VALUE_SECRET_PATTERNS = [
19
+ // Case-insensitive and tolerant of "Bearer: <token>" /
20
+ // "bearer=<token>" variants, not just the strict HTTP-header form.
21
+ { kind: "bearer token", re: /\bbearer\s*[:=]?\s+[A-Za-z0-9._\-]{8,}/i },
22
+ // "Token <credential>" — same HTTP Authorization form used by Django REST
23
+ // Framework and many other APIs. Case-sensitive: lowercase "token" is also
24
+ // matched via SECRET_KEY's name-based check; this catches the value-shape.
25
+ { kind: "token credential", re: /\bToken\s+[A-Za-z0-9._\-]{8,}/ },
26
+ { kind: "JWT", re: /\beyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+/ },
27
+ { kind: "AWS key", re: /\bAKIA[0-9A-Z]{16}\b/ },
28
+ // Long high-entropy hex/base64-ish blob with no whitespace — catches
29
+ // raw tokens/secrets that don't match a more specific pattern above.
30
+ { kind: "token/secret", re: /^[A-Za-z0-9+/=_\-]{32,}$/ }
31
+ ];
32
+
33
+ function matchSecretValue(str) {
34
+ for (var i = 0; i < VALUE_SECRET_PATTERNS.length; i++) {
35
+ if (VALUE_SECRET_PATTERNS[i].re.test(str)) { return VALUE_SECRET_PATTERNS[i].kind; }
36
+ }
37
+ return null;
38
+ }
39
+
40
+ // Node-RED's "edit message properties" UI (Inject node's Properties list,
41
+ // Change node's rules, etc.) stores each entry as
42
+ // { p: "<property name>", v/to: "<value>", vt/tot: "<type>" } — the
43
+ // secret-indicating NAME lives in `p`, but the secret VALUE lives in a
44
+ // sibling `v`/`to` field. SECRET_KEY.test() against the object key alone
45
+ // ("v"/"to") never matches, so a property literally named "auth" or
46
+ // "apikey" sailed through untouched. Check `p` explicitly for these
47
+ // known value-holding sibling keys.
48
+ var DYNAMIC_PROPERTY_VALUE_KEYS = ["v", "to"];
49
+
50
+ // Recursively redacts secret-shaped values out of a debug message / node
51
+ // config value. `key` is the property name the value was found under
52
+ // (undefined for array elements and the top-level value) — used only for
53
+ // the name-based check. Structural data (numbers, booleans, short
54
+ // strings, object/array shape) passes through untouched, preserving
55
+ // diagnostic value.
56
+ //
57
+ // currentSettings.redactionEnabled defaults true (settings.json default,
58
+ // and an empty {} before settings ever load) — explicitly opting OUT via
59
+ // the type-to-confirm Settings toggle is required to disable this. The
60
+ // Node-RED credentials field is a SEPARATE, always-on mechanism (dropped
61
+ // entirely in sanitizeNode's INTERNAL_FIELDS) and is unaffected either way.
62
+ function redactDebugValue(value, key) {
63
+ if (currentSettings.redactionEnabled === false) { return value; }
64
+ if (typeof value === "string") {
65
+ var kind = matchSecretValue(value);
66
+ if (kind) { return "[redacted: " + kind + ", " + value.length + " chars]"; }
67
+ if (key !== undefined && SECRET_KEY.test(String(key)) && value.length > SECRET_NAME_MIN_LEN) {
68
+ return "[redacted: secret field, " + value.length + " chars]";
69
+ }
70
+ return value;
71
+ }
72
+ if (Array.isArray(value)) {
73
+ return value.map(function (v) { return redactDebugValue(v, key); });
74
+ }
75
+ if (value && typeof value === "object") {
76
+ var out = {};
77
+ var dynamicNameIsSecret = typeof value.p === "string" && SECRET_KEY.test(value.p);
78
+ Object.keys(value).forEach(function (k) {
79
+ if (dynamicNameIsSecret && DYNAMIC_PROPERTY_VALUE_KEYS.indexOf(k) !== -1 &&
80
+ typeof value[k] === "string" && value[k].length > SECRET_NAME_MIN_LEN) {
81
+ out[k] = "[redacted: secret field, " + value[k].length + " chars]";
82
+ return;
83
+ }
84
+ out[k] = redactDebugValue(value[k], k);
85
+ });
86
+ return out;
87
+ }
88
+ return value;
89
+ }
90
+
91
+ function truncateForDebug(text, max) {
92
+ text = String(text);
93
+ return text.length > max ? text.slice(0, max - 1) + "…" : text;
94
+ }
95
+
96
+ // Best-effort stringify for a debug message's value — may be any type
97
+ // (string, number, object, undefined for "complete msg" mode where the
98
+ // value lives one level down, etc.).
99
+ function stringifyDebugValue(value) {
100
+ if (typeof value === "string") { return value; }
101
+ try { return JSON.stringify(value); } catch (e) { return String(value); }
102
+ }
103
+
104
+ // ---- Node-selection sanitizer ------------------------------------------
105
+ // We read the user's current node selection and build a SANITIZED copy to
106
+ // send to the AI. Two reasons we never send raw nodes:
107
+ // 1. Node objects are proxies carrying editor-internal fields (geometry,
108
+ // validation state, i18n functions) that waste tokens and mean nothing
109
+ // to the model.
110
+ // 2. Config nodes can hold secrets (broker passwords, API keys). We drop
111
+ // anything whose field name looks secret-bearing.
112
+ // The user only ever sees how many nodes are attached, and nothing is sent
113
+ // unless they have an active selection — staying within "user-initiated"
114
+ // and "complete visibility".
115
+
116
+ var INTERNAL_FIELDS = {
117
+ _def: 1, _: 1, changed: 1, moved: 1, dirty: 1, selected: 1, valid: 1,
118
+ validationErrors: 1, _index: 1, resize: 1, x: 1, y: 1, w: 1, h: 1, l: 1,
119
+ __outputs: 1, inputs: 1, outputs: 1, g: 1, _config: 1, _orig: 1,
120
+ credentials: 1
121
+ };
122
+
123
+ function sanitizeNode(n) {
124
+ var out = {};
125
+ Object.keys(n).forEach(function (k) {
126
+ if (INTERNAL_FIELDS[k]) { return; }
127
+ var v = n[k];
128
+ if (typeof v === "function") { return; }
129
+ // A secret-shaped field NAME (password/token/apikey/auth/...) only
130
+ // means "redact" when the value is itself a string long enough to
131
+ // plausibly BE a secret. Short strings under such names are usually
132
+ // enum/selector fields (e.g. an HTTP request node's authType:
133
+ // "basic"/"bearer"/"digest"/"") — hiding those gives the model no
134
+ // way to reason about (or fix) auth configuration while protecting
135
+ // nothing. Real credential VALUES are already excluded entirely via
136
+ // INTERNAL_FIELDS.credentials.
137
+ if (currentSettings.redactionEnabled !== false &&
138
+ SECRET_KEY.test(k) && typeof v === "string" && v.length > SECRET_NAME_MIN_LEN) {
139
+ out[k] = "[redacted: secret field, " + v.length + " chars]";
140
+ return;
141
+ }
142
+ // JSON round-trip strips the proxy wrapper and drops anything
143
+ // non-serializable, leaving plain config values (incl. props).
144
+ try { v = JSON.parse(JSON.stringify(v)); }
145
+ catch (e) { out[k] = "[unserializable]"; return; }
146
+ // Defense-in-depth: catch secrets hiding in objects/arrays under a
147
+ // secret-shaped name (recurses and redacts only the actual secret
148
+ // sub-fields), or in an innocuously-named field by value shape
149
+ // (e.g. a "data" field holding a JWT).
150
+ out[k] = redactDebugValue(v, k);
151
+ });
152
+ // "g" itself (the bare group id) is skipped above like x/y/w/h —
153
+ // meaningless to the model on its own. But WHICH group a node
154
+ // belongs to, and that group's name, is real semantic information
155
+ // (Phase 8.5 C2) — resolve it via RED.nodes.group(), one level
156
+ // deep only (a node's immediate group, not its ancestor chain if
157
+ // nested groups are in play). Distinct from buildConnections()'s
158
+ // "subFlow" numbering below, which is connectivity-based ("are
159
+ // these wired together"), not an actual visual group.
160
+ if (n.g && RED.nodes.group) {
161
+ var grp = RED.nodes.group(n.g);
162
+ if (grp) { out.group = { id: grp.id, name: grp.name || "" }; }
163
+ }
164
+ return out;
165
+ }
@@ -0,0 +1,211 @@
1
+ // Resolves the node-selection context that would actually be sent right
2
+ // now: the live canvas selection, or (while an Execute action is armed
3
+ // with nothing currently selected) the pinned selection from when it was
4
+ // armed. Used by "Preview JSON" so it can never show different context
5
+ // than what Send actually uses.
6
+ function resolveCurrentSelectionContext() {
7
+ var sel = (RED.view && RED.view.selection) ? RED.view.selection() : null;
8
+ var liveCount = (sel && sel.nodes) ? sel.nodes.length : 0;
9
+ if (liveCount > 0) { return collectSelectionContext(); }
10
+ if (armedExecuteAction && pinnedSelectionIds) {
11
+ return collectSelectionContext(pinnedSelectionIds);
12
+ }
13
+ return null;
14
+ }
15
+
16
+ // Clicking a group's border/background in the editor selects the GROUP
17
+ // itself — one object, type:"group" — not its members; RED.view.selection()
18
+ // returns exactly that one entry, same as selecting a single regular
19
+ // node. For context/counting purposes the user means "everything
20
+ // inside it," so expand any group entries into their real member
21
+ // nodes via RED.group.getNodes(group, recursive, excludeGroup) — the
22
+ // editor's own public API for this (recursive: descend into nested
23
+ // sub-groups too; excludeGroup: only real nodes in the result, not
24
+ // the nested group containers themselves — nesting still isn't
25
+ // authored by FlowPilot, but a member node of one shouldn't vanish
26
+ // from context just because it's nested one level deeper).
27
+ // Returns { nodes: [...real nodes, deduped], groupCount } so callers
28
+ // needing just ids and callers needing the group count (the status
29
+ // strip) share one expansion instead of two slightly different ones.
30
+ function expandGroupSelection(rawNodes) {
31
+ var expanded = [];
32
+ var seen = {};
33
+ var groupCount = 0;
34
+ (rawNodes || []).forEach(function (n) {
35
+ if (!n) { return; }
36
+ if (n.type === "group") {
37
+ groupCount++;
38
+ var members = (RED.group && RED.group.getNodes) ? RED.group.getNodes(n, true, true) : [];
39
+ members.forEach(function (m) {
40
+ if (m && !seen[m.id]) { seen[m.id] = true; expanded.push(m); }
41
+ });
42
+ } else if (!seen[n.id]) {
43
+ seen[n.id] = true; expanded.push(n);
44
+ }
45
+ });
46
+ return { nodes: expanded, groupCount: groupCount };
47
+ }
48
+
49
+ function pinCurrentSelection() {
50
+ var sel = (RED.view && RED.view.selection) ? RED.view.selection() : null;
51
+ var ids = expandGroupSelection((sel && sel.nodes) ? sel.nodes : []).nodes
52
+ .map(function (n) { return n.id; });
53
+ if (ids.length) { pinnedSelectionIds = ids; }
54
+ }
55
+
56
+ // The node ids to use for context: the live selection if non-empty,
57
+ // else the pinned selection from earlier in this armed session (or null
58
+ // if neither — Generate works fine with no context).
59
+ function activeSelectionIds() {
60
+ var sel = (RED.view && RED.view.selection) ? RED.view.selection() : null;
61
+ var liveIds = (sel && sel.nodes && sel.nodes.length)
62
+ ? expandGroupSelection(sel.nodes).nodes.map(function (n) { return n.id; })
63
+ : null;
64
+ return (liveIds && liveIds.length ? liveIds : null) || pinnedSelectionIds;
65
+ }
66
+
67
+ // ---- Selection context -------------------------------------------------
68
+ // We read the user's current node selection and build a context payload
69
+ // to send to the AI: sanitized node configs (sanitizeNode, lib/core/
70
+ // redaction.js) plus their wiring (buildConnections below). Nothing is
71
+ // sent unless the user has an active selection — staying within
72
+ // "user-initiated" and "complete visibility".
73
+
74
+ // Build readable connections from the editor's LIVE wiring model.
75
+ // Important: in the editor, node.wires is NOT populated — that's an
76
+ // export-time artifact. Live wiring lives as separate link objects, which
77
+ // RED.view.selection() hands us in sel.links. Each link is already an
78
+ // explicit edge: { source: <node>, sourcePort: <int>, target: <node> }.
79
+ // This handles multi-output and multi-input naturally — each is just
80
+ // another link object.
81
+ function nodeLabel(n) {
82
+ if (!n) { return "(unknown node)"; }
83
+ var nm = (n.name && n.name.length) ? n.name : "(unnamed)";
84
+ return nm + " [" + n.type + "]";
85
+ }
86
+
87
+ function buildConnections(nodes, links) {
88
+ var selectedIds = nodes.map(function (n) { return n.id; });
89
+ links = Array.isArray(links) ? links : [];
90
+
91
+ var edges = links.map(function (l) {
92
+ var srcId = l.source && l.source.id;
93
+ var tgtId = l.target && l.target.id;
94
+ return {
95
+ fromId: srcId,
96
+ from: nodeLabel(l.source),
97
+ fromPort: (typeof l.sourcePort === "number") ? l.sourcePort : 0,
98
+ toId: tgtId,
99
+ to: nodeLabel(l.target),
100
+ sourceInSelection: selectedIds.indexOf(srcId) !== -1,
101
+ targetInSelection: selectedIds.indexOf(tgtId) !== -1
102
+ };
103
+ });
104
+
105
+ // Group selected nodes into connected sub-flows (undirected connected
106
+ // components over the links). This turns "are these separate flows?"
107
+ // from a reasoning task into a lookup for the model. Two nodes share a
108
+ // group if a link connects them either way; an unlinked selected node
109
+ // forms its own group.
110
+ var parent = {};
111
+ nodes.forEach(function (n) { parent[n.id] = n.id; });
112
+ function find(x) {
113
+ while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; }
114
+ return x;
115
+ }
116
+ edges.forEach(function (e) {
117
+ if (parent[e.fromId] === undefined || parent[e.toId] === undefined) { return; }
118
+ parent[find(e.fromId)] = find(e.toId);
119
+ });
120
+ var rootToGroup = {};
121
+ var nextGroup = 1;
122
+ var groupOf = {};
123
+ nodes.forEach(function (n) {
124
+ var r = find(n.id);
125
+ if (rootToGroup[r] === undefined) { rootToGroup[r] = nextGroup++; }
126
+ groupOf[n.id] = rootToGroup[r];
127
+ });
128
+ var subFlowCount = nextGroup - 1;
129
+
130
+ // Per-node summary of inputs and outputs, reconstructed from edges so
131
+ // multi-input nodes are legible without the model cross-referencing.
132
+ // Each node is tagged with its sub-flow group.
133
+ var perNode = nodes.map(function (n) {
134
+ var outs = edges.filter(function (e) { return e.fromId === n.id; })
135
+ .map(function (e) { return "port " + e.fromPort + " -> " + e.to; });
136
+ var ins = edges.filter(function (e) { return e.toId === n.id; })
137
+ .map(function (e) { return e.from + " (port " + e.fromPort + ")"; });
138
+ return { node: nodeLabel(n), subFlow: groupOf[n.id], inputs: ins, outputs: outs };
139
+ });
140
+
141
+ // B4: "edges" and "perNode" otherwise duplicate the same from/to
142
+ // labels — perNode already carries the readable "Name [type]" labels,
143
+ // so trim edges down to ids + port + selection flags (the part
144
+ // perNode doesn't have) to avoid sending the same labels twice.
145
+ var compactEdges = edges.map(function (e) {
146
+ return {
147
+ fromId: e.fromId,
148
+ fromPort: e.fromPort,
149
+ toId: e.toId,
150
+ sourceInSelection: e.sourceInSelection,
151
+ targetInSelection: e.targetInSelection
152
+ };
153
+ });
154
+
155
+ return { edges: compactEdges, perNode: perNode, subFlowCount: subFlowCount };
156
+ }
157
+
158
+ // Returns { nodes, connections } or null if nothing selected.
159
+ // With nodeIds (an array of node ids — e.g. a pinned selection),
160
+ // builds context from those live nodes via RED.nodes instead of the
161
+ // current view selection. Nodes that no longer exist are dropped; links
162
+ // are gathered from the workspace's full link list, same shape as
163
+ // RED.view.selection().links (sourceInSelection/targetInSelection in
164
+ // buildConnections still works against this nodeIds set).
165
+ // Gathers every link touching any node in the given list, scanning the
166
+ // workspace's full link set directly rather than trusting
167
+ // RED.view.selection().links — needed whenever the node list didn't
168
+ // come from a literal click-drag selection (an explicit nodeIds array,
169
+ // or a group's expanded membership, whose internal wiring was never
170
+ // part of any selection.links to begin with).
171
+ function linksTouchingNodes(nodes) {
172
+ var idSet = nodes.map(function (n) { return n.id; });
173
+ var links = [];
174
+ if (RED.nodes.eachLink) {
175
+ RED.nodes.eachLink(function (l) {
176
+ var srcId = l.source && l.source.id;
177
+ var tgtId = l.target && l.target.id;
178
+ if (idSet.indexOf(srcId) !== -1 || idSet.indexOf(tgtId) !== -1) {
179
+ links.push(l);
180
+ }
181
+ });
182
+ }
183
+ return links;
184
+ }
185
+
186
+ function collectSelectionContext(nodeIds) {
187
+ var rawNodes, rawLinks;
188
+ if (Array.isArray(nodeIds)) {
189
+ rawNodes = nodeIds.map(function (id) { return RED.nodes.node(id); })
190
+ .filter(function (n) { return !!n; });
191
+ if (!rawNodes.length) { return null; }
192
+ rawLinks = linksTouchingNodes(rawNodes);
193
+ } else {
194
+ var sel = (RED.view && RED.view.selection) ? RED.view.selection() : null;
195
+ var expandedSel = expandGroupSelection((sel && sel.nodes) ? sel.nodes : []);
196
+ rawNodes = expandedSel.nodes;
197
+ if (!rawNodes.length) { return null; }
198
+ // A literal click-drag selection's sel.links already has the
199
+ // right shape — but it never reflects a SELECTED GROUP's
200
+ // internal wiring (those nodes were never individually
201
+ // selected), so gather links directly whenever a group was
202
+ // part of the selection instead of trusting sel.links.
203
+ rawLinks = expandedSel.groupCount > 0
204
+ ? linksTouchingNodes(rawNodes)
205
+ : ((sel && sel.links) ? sel.links : []);
206
+ }
207
+ return {
208
+ nodes: rawNodes.map(sanitizeNode),
209
+ connections: buildConnections(rawNodes, rawLinks)
210
+ };
211
+ }
@@ -0,0 +1,136 @@
1
+ "use strict";
2
+
3
+ // Envelope parsing (Phase 9 refactor seam 2): pulls the model's JSON
4
+ // envelope out of a raw provider response. Shared by every mode
5
+ // (chat/generate/document/modify/build) via flowpilot.js - this was the
6
+ // source of two cross-mode bugs this project already hit (a brace
7
+ // embedded in prose like "{{payload}}" mistaken for the envelope's start,
8
+ // and a valid-but-unrelated JSON snippet in prose mistaken for the
9
+ // envelope itself), which is exactly the case for keeping this logic in
10
+ // ONE place instead of duplicated per mode.
11
+ //
12
+ // Pure functions, no dependency on anything else in this package -
13
+ // require()'d directly, no special build step needed (unlike the
14
+ // browser-side flowpilot-core.js split, this file already has a real
15
+ // module system).
16
+
17
+ // Given s[startIdx] === "{", scans forward with brace-depth counting that
18
+ // ignores braces inside string literals (so a value like "{{payload}}"
19
+ // can't be mistaken for structure) to find the index of the MATCHING
20
+ // closing "}". Returns -1 if the braces never balance before the string
21
+ // ends (truncated/malformed input).
22
+ function findMatchingBrace(s, startIdx) {
23
+ let depth = 0;
24
+ let inString = false;
25
+ let escaped = false;
26
+ for (let i = startIdx; i < s.length; i++) {
27
+ const ch = s[i];
28
+ if (inString) {
29
+ if (escaped) { escaped = false; }
30
+ else if (ch === "\\") { escaped = true; }
31
+ else if (ch === "\"") { inString = false; }
32
+ continue;
33
+ }
34
+ if (ch === "\"") { inString = true; }
35
+ else if (ch === "{") { depth++; }
36
+ else if (ch === "}") {
37
+ depth--;
38
+ if (depth === 0) { return i; }
39
+ }
40
+ }
41
+ return -1;
42
+ }
43
+
44
+ function extractJsonObject(text) {
45
+ if (!text) { throw new Error("Empty response from provider."); }
46
+ let s = String(text).trim();
47
+ // Strip markdown code fences if the model wrapped the JSON.
48
+ s = s.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "").trim();
49
+
50
+ const firstBrace = s.indexOf("{");
51
+ const firstBracket = s.indexOf("[");
52
+
53
+ // The model occasionally returns a bare top-level array (e.g.
54
+ // `[ {...node...} ]`) instead of the {explanation, flow} envelope. If we
55
+ // fell through to the {...} extraction below, indexOf("{")/lastIndexOf("}")
56
+ // would grab just the first node object — which has no "flow" key and
57
+ // fails validation. Detect this case up front and wrap it as a minimal
58
+ // envelope instead.
59
+ if (firstBracket !== -1 && (firstBrace === -1 || firstBracket < firstBrace)) {
60
+ const lastBracket = s.lastIndexOf("]");
61
+ if (lastBracket !== -1 && lastBracket > firstBracket) {
62
+ try {
63
+ const arr = JSON.parse(s.slice(firstBracket, lastBracket + 1));
64
+ if (Array.isArray(arr)) {
65
+ return { explanation: "", flow: arr };
66
+ }
67
+ } catch (e) {
68
+ // Not a parseable array — fall through to the {...} extraction.
69
+ }
70
+ }
71
+ }
72
+
73
+ const firstObjIdx = s.indexOf("{");
74
+ if (firstObjIdx === -1) {
75
+ // No JSON object found at all — flagged separately from a found-
76
+ // but-unparseable ({...} present, JSON.parse failed) "garbled" error,
77
+ // so callers can distinguish "model just answered in prose" (tolerate)
78
+ // from "model's JSON envelope is broken" (still an error).
79
+ const err = new Error("Provider did not return a JSON object.");
80
+ err.noJsonFound = true;
81
+ throw err;
82
+ }
83
+
84
+ // There may be more than one "{" before the real envelope — e.g. prose
85
+ // explaining a fix that mentions inline code like "{{payload}}" before
86
+ // the actual JSON (seen live: a review response started with "The
87
+ // template node is using `{{payload}}` with...", and slicing from THAT
88
+ // brace to the envelope's real closing "}" produced unparseable
89
+ // garbage). Try each candidate "{" in order with string-aware brace
90
+ // matching (findMatchingBrace, which ignores braces inside quoted
91
+ // strings) rather than just slicing from the first "{" to the last
92
+ // "}".
93
+ //
94
+ // A candidate must not just PARSE, it must also look like one of the
95
+ // known envelope shapes (have at least one recognized top-level key) —
96
+ // seen live: a pure-prose advice response that mentioned structured
97
+ // logging included the illustrative example
98
+ // `{"level":"info","event":"trivia_answer","user":"alex","correct":true}`,
99
+ // which IS valid standalone JSON, so the old "first candidate that
100
+ // parses wins" rule accepted it as "the envelope" and the caller threw
101
+ // "no recognizable modify fields" — when the right answer was to treat
102
+ // the whole reply as prose, since there was no real envelope at all.
103
+ const ENVELOPE_KEYS = ["explanation", "flow", "question", "changes", "newNodes", "newWires", "removeNodes", "newGroups", "prose"];
104
+ function looksLikeEnvelope(obj) {
105
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) { return false; }
106
+ return ENVELOPE_KEYS.some(function (k) { return k in obj; });
107
+ }
108
+
109
+ let lastError = null;
110
+ let searchFrom = firstObjIdx;
111
+ while (searchFrom !== -1 && searchFrom < s.length) {
112
+ const end = findMatchingBrace(s, searchFrom);
113
+ if (end !== -1) {
114
+ try {
115
+ const candidate = JSON.parse(s.slice(searchFrom, end + 1));
116
+ if (looksLikeEnvelope(candidate)) { return candidate; }
117
+ // Valid JSON, but not envelope-shaped (e.g. an illustrative
118
+ // example embedded in prose) — keep searching rather than
119
+ // accepting it.
120
+ } catch (e) {
121
+ lastError = e;
122
+ }
123
+ }
124
+ searchFrom = s.indexOf("{", searchFrom + 1);
125
+ }
126
+ // No candidate both parsed AND looked like a real envelope — equivalent
127
+ // to "the model just answered in prose," not "the envelope is broken."
128
+ // Let callers fall back to rendering this as a normal message instead
129
+ // of surfacing a parse error (same noJsonFound flag the "no { at all"
130
+ // branch above uses).
131
+ const err = lastError || new Error("Provider's JSON object could not be parsed.");
132
+ err.noJsonFound = true;
133
+ throw err;
134
+ }
135
+
136
+ module.exports = { extractJsonObject: extractJsonObject, findMatchingBrace: findMatchingBrace };
@@ -97,7 +97,7 @@ Rules for "changes" (sparse patches against the existing selection):
97
97
  5. Do not include "wires" in "set" unless the instruction explicitly asks to rewire that node's connections.
98
98
  6. Never include "id", "x", or "y", or "z" inside "set" — those cannot change via a patch.
99
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"}}.
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"}}. Never include "nodes" inside that "set" object, even when renaming — a group's membership can ONLY change via "newGroups", never via "changes".
101
101
 
102
102
  ---
103
103
 
@@ -198,6 +198,7 @@ Each entry: { "id": "<id>", "name": "<optional label>", "nodes": ["<id>", ...] }
198
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
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
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
+ - To MERGE several existing groups into one: pick ONE of the existing group ids (or a new placeholder) and give it a "nodes" entry listing the UNION of every member across all the groups being merged, then add a SEPARATE "newGroups" entry for each OTHER group being absorbed with an empty "nodes": [] (disbanding it). Always submit ALL of these entries together in the SAME "newGroups" array.
201
202
  - A group has no "wires" — groups never pass messages, they're a visual container only.
202
203
 
203
204
  ---
@@ -243,6 +244,7 @@ You MUST ask a clarifying question when the instruction is too vague to act on s
243
244
  - Vague goals like "Improve this", "Optimize this", "Better this" without describing what "better" means
244
245
  - Ambiguous requests like "Add something", "Add a node", "Add something to" without specifying what or where
245
246
  - The instruction refers to an existing node by description (e.g. "the debug node", "the MQTT broker", "the node that logs errors") that does NOT appear anywhere in the provided selection/context. Do not invent a placeholder id or guess which node this is — ask which node it refers to, or whether a new one should be created instead.
247
+ - The instruction asks you to change a field that appears in context as a \`[redacted: ...]\` placeholder. You cannot propose a meaningful change to a redacted field — any value you propose will either still contain the placeholder (producing an empty diff) or will overwrite the real credential with garbage. Do NOT attempt the change. Instead, tell the user plainly that the field is redacted and must be edited directly in the Node-RED node editor. If you already tried once and saw "No changes detected," the redacted field is the reason — do not repeat the same attempt.
246
248
 
247
249
  When in doubt, ask. It is better to ask than to make incorrect assumptions.
248
250
 
package/lib/storage.js CHANGED
@@ -58,6 +58,10 @@ function createStorage(userDir) {
58
58
  // own hardcoded AGENT_LOOP_MAX_STEPS (a different bound, for a
59
59
  // different loop).
60
60
  agentLoopMaxIterations: 5,
61
+ // When true, the build loop pauses at the "attach → review" transition
62
+ // and shows a checkpoint question ("Continue with AI review, or stop?")
63
+ // instead of auto-advancing. Default false = original auto-advance behavior.
64
+ loopHoldStep: false,
61
65
  // Lets the user silence the recurring secrets/size reminder bar after
62
66
  // typing an explicit acknowledgement in settings.
63
67
  suppressContextWarnings: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@manny-est/node-red-flowpilot",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "FlowPilot for Node-RED - an AI-powered development assistant sidebar",
5
5
  "main": "flowpilot.js",
6
6
  "keywords": [
@@ -38,7 +38,6 @@
38
38
  "files": [
39
39
  "flowpilot.js",
40
40
  "flowpilot.html",
41
- "flowpilot-core.js",
42
41
  "flowpilot-core.css",
43
42
  "lib",
44
43
  "icons",