@manny-est/node-red-flowpilot 0.4.1 → 0.5.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 +42 -0
- package/PROJECT-OVERVIEW.md +7 -7
- package/README.md +14 -8
- package/USER-GUIDE.md +27 -10
- package/flowpilot-core.css +103 -0
- package/flowpilot.js +123 -146
- package/lib/build-core-script.js +44 -0
- package/lib/build-system-prompt.js +6 -0
- package/lib/core/apply-review.js +1926 -0
- package/lib/core/history.js +93 -0
- package/lib/core/init.js +1435 -0
- package/lib/core/main.js +1777 -0
- package/lib/core/markdown.js +165 -0
- package/lib/core/modes.js +1690 -0
- package/lib/core/redaction.js +165 -0
- package/lib/core/selection-context.js +211 -0
- package/lib/envelope.js +136 -0
- package/lib/modify-system-prompt.js +3 -1
- package/lib/provider-openai-compatible.js +112 -15
- package/lib/storage.js +4 -0
- package/package.json +2 -3
- package/flowpilot-core.js +0 -6427
|
@@ -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
|
+
}
|
package/lib/envelope.js
ADDED
|
@@ -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
|
|