@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.
- package/CHANGELOG.md +58 -0
- package/README.md +21 -7
- package/USER-GUIDE.md +27 -14
- package/flowpilot-core.css +159 -5
- package/flowpilot.js +1353 -172
- package/lib/agent-contract.js +45 -0
- package/lib/build-core-script.js +1 -0
- package/lib/build-system-prompt.js +26 -4
- package/lib/chat-data.js +106 -0
- package/lib/core/apply-review.js +268 -64
- package/lib/core/graph-truth.js +63 -0
- package/lib/core/history.js +10 -1
- package/lib/core/init.js +148 -5
- package/lib/core/main.js +755 -21
- package/lib/core/modes.js +1523 -67
- package/lib/core/selection-context.js +31 -1
- package/lib/default-system-prompt.js +17 -12
- package/lib/document-system-prompt.js +22 -32
- package/lib/envelope.js +13 -7
- package/lib/generation-system-prompt.js +29 -44
- package/lib/modify-system-prompt.js +152 -72
- package/lib/persona-prompt.js +81 -54
- package/lib/prompt-fragments.js +56 -0
- package/lib/provider-anthropic.js +388 -0
- package/lib/provider-openai-compatible.js +49 -12
- package/lib/provider-shape-check.js +34 -0
- package/lib/storage.js +160 -12
- package/lib/validator.js +238 -0
- package/package.json +1 -1
package/lib/core/main.js
CHANGED
|
@@ -40,6 +40,29 @@
|
|
|
40
40
|
var messageRecords = [];
|
|
41
41
|
var _nextRecordId = 0;
|
|
42
42
|
|
|
43
|
+
// P10-D1 (ADR-001 R5): per-conversation map of opId -> already-applied
|
|
44
|
+
// WRITE tool result. opId = runId + ":" + call.id (runId minted per
|
|
45
|
+
// runAgentLoop run, modes.js). A repeat opId (duplicate delivery, a
|
|
46
|
+
// retry, or the model repeating a call) returns the SAME result without
|
|
47
|
+
// re-invoking the executor — the graph is mutated at most once per
|
|
48
|
+
// opId. Keyed first by conversationId so switching conversations
|
|
49
|
+
// (clearChat / loading a saved transcript) can't cross-contaminate;
|
|
50
|
+
// cleared for the outgoing conversationId at those same points. In-
|
|
51
|
+
// memory only, matching messageRecords/conversationHistory's existing
|
|
52
|
+
// volatility (ADR-001: "persistence is minimal and schema-ready this
|
|
53
|
+
// phase" — full state-machine persistence is deferred to phase close).
|
|
54
|
+
var appliedOpsByConversation = {};
|
|
55
|
+
|
|
56
|
+
function getAppliedOp(convId, opId) {
|
|
57
|
+
var ops = appliedOpsByConversation[convId];
|
|
58
|
+
return ops ? ops[opId] : undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function recordAppliedOp(convId, opId, result) {
|
|
62
|
+
if (!appliedOpsByConversation[convId]) { appliedOpsByConversation[convId] = {}; }
|
|
63
|
+
appliedOpsByConversation[convId][opId] = result;
|
|
64
|
+
}
|
|
65
|
+
|
|
43
66
|
function addRecord(kind, payload) {
|
|
44
67
|
var rec = { id: _nextRecordId++, ts: Date.now(), kind: kind };
|
|
45
68
|
if (payload) {
|
|
@@ -81,6 +104,12 @@
|
|
|
81
104
|
case "question":
|
|
82
105
|
if (rec.loopCheckpoint) {
|
|
83
106
|
renderLoopCheckpoint(activeBuildLoop);
|
|
107
|
+
} else if (rec.buildConsentGate) {
|
|
108
|
+
renderBuildConsentGate(rec);
|
|
109
|
+
} else if (rec.agentToolConsent) {
|
|
110
|
+
renderAgentToolConsentGate(rec);
|
|
111
|
+
} else if (rec.askUserTool) {
|
|
112
|
+
renderAskUserQuestion(rec);
|
|
84
113
|
} else {
|
|
85
114
|
renderClarifyingQuestion(rec.options || []);
|
|
86
115
|
}
|
|
@@ -91,6 +120,9 @@
|
|
|
91
120
|
case "buildStep":
|
|
92
121
|
rerenderBuildStepRecord(rec);
|
|
93
122
|
break;
|
|
123
|
+
case "todo":
|
|
124
|
+
rerenderTodoRecord(rec);
|
|
125
|
+
break;
|
|
94
126
|
}
|
|
95
127
|
}
|
|
96
128
|
|
|
@@ -141,6 +173,7 @@
|
|
|
141
173
|
var entry = {
|
|
142
174
|
id: nextDebugMessageId++,
|
|
143
175
|
timestamp: Date.now(),
|
|
176
|
+
sourceKind: "debug",
|
|
144
177
|
name: msg.name || msg.id || "(unnamed node)",
|
|
145
178
|
topic: redactedTopic,
|
|
146
179
|
// previewValue: short, for the scannable debug-log list only.
|
|
@@ -173,8 +206,14 @@
|
|
|
173
206
|
// message from one trigger accumulate into attachedDebugMessages
|
|
174
207
|
// before review actually runs, so the model sees the full picture
|
|
175
208
|
// instead of whichever message happened to arrive first.
|
|
209
|
+
// WS4: a Skip decision at the build consent gate excludes this tap's
|
|
210
|
+
// real id from skipCheckpointTapIds — its messages still arrive here
|
|
211
|
+
// (still shown in the debug-log list/updateDebugStatus above) but
|
|
212
|
+
// don't drive the auto-attach/auto-review transition, so the loop
|
|
213
|
+
// falls back to manual confirmation or the honest-timeout instead.
|
|
176
214
|
if (activeBuildLoop && activeBuildLoop.waypoint === "attach" &&
|
|
177
|
-
activeBuildLoop.nodeIds.indexOf(msg.id) !== -1
|
|
215
|
+
activeBuildLoop.nodeIds.indexOf(msg.id) !== -1 &&
|
|
216
|
+
(activeBuildLoop.skipCheckpointTapIds || []).indexOf(msg.id) === -1) {
|
|
178
217
|
attachedDebugMessages.push(entry);
|
|
179
218
|
updateDebugStatus();
|
|
180
219
|
if (buildLoopNoDebugTimer) { clearTimeout(buildLoopNoDebugTimer); buildLoopNoDebugTimer = null; }
|
|
@@ -193,11 +232,97 @@
|
|
|
193
232
|
}
|
|
194
233
|
}
|
|
195
234
|
|
|
235
|
+
// W3: node-status stream evidence path.
|
|
236
|
+
// Fires for every deployed node that sets a status (fill/text via
|
|
237
|
+
// node.status() in user function code, or core nodes like http-request
|
|
238
|
+
// reporting "200 OK" / error text). Topic is "status/<nodeId>".
|
|
239
|
+
// Used as fallback evidence when the loop is waiting at "attach" but no
|
|
240
|
+
// debug node exists (HTTP endpoints, background workers, etc.) — gives
|
|
241
|
+
// the model something real to review instead of the loop hanging until
|
|
242
|
+
// the 20-second honest-timeout fires.
|
|
243
|
+
// Only used when no debug payload has arrived yet; if debug output is
|
|
244
|
+
// coming the richer payload data wins — let that path take over.
|
|
245
|
+
function onNodeStatus(topic, msg) {
|
|
246
|
+
if (!activeBuildLoop || activeBuildLoop.waypoint !== "attach") { return; }
|
|
247
|
+
var nodeId = topic.split("/")[1];
|
|
248
|
+
if (!nodeId || activeBuildLoop.nodeIds.indexOf(nodeId) === -1) { return; }
|
|
249
|
+
// WS4: same Skip-decision gate as onDebugMessage, keyed on the
|
|
250
|
+
// side-effecting node's own real id here (status events report the
|
|
251
|
+
// node itself, not a debug tap wired to it).
|
|
252
|
+
if ((activeBuildLoop.skipCheckpointNodeIds || []).indexOf(nodeId) !== -1) { return; }
|
|
253
|
+
if (freshBuildLoopEvidence(activeBuildLoop).length > 0) { return; }
|
|
254
|
+
|
|
255
|
+
// Skip in-progress ("blue") statuses — e.g. http-request emits
|
|
256
|
+
// fill:"blue" text:"requesting" before any response arrives. Locking
|
|
257
|
+
// in this early status starts the debounce before the actual error or
|
|
258
|
+
// response has a chance to land, so the model reviews a placeholder
|
|
259
|
+
// rather than the real result. Only terminal states (red=error,
|
|
260
|
+
// green=success, or no fill) are useful evidence.
|
|
261
|
+
if (msg && msg.fill === "blue") { return; }
|
|
262
|
+
|
|
263
|
+
var statusText = [msg && msg.fill, msg && msg.text].filter(Boolean).join(" — ");
|
|
264
|
+
if (!statusText) { return; }
|
|
265
|
+
|
|
266
|
+
var entry = {
|
|
267
|
+
id: nextDebugMessageId++,
|
|
268
|
+
timestamp: Date.now(),
|
|
269
|
+
sourceKind: "status",
|
|
270
|
+
name: nodeId + " (node status)",
|
|
271
|
+
topic: "node-status",
|
|
272
|
+
previewValue: statusText,
|
|
273
|
+
value: "Node " + nodeId + " reported status: " + statusText
|
|
274
|
+
};
|
|
275
|
+
attachedDebugMessages.push(entry);
|
|
276
|
+
updateDebugStatus();
|
|
277
|
+
|
|
278
|
+
if (buildLoopNoDebugTimer) { clearTimeout(buildLoopNoDebugTimer); buildLoopNoDebugTimer = null; }
|
|
279
|
+
if (buildLoopAttachTimer) { clearTimeout(buildLoopAttachTimer); }
|
|
280
|
+
buildLoopAttachTimer = setTimeout(function () {
|
|
281
|
+
buildLoopAttachTimer = null;
|
|
282
|
+
if (!activeBuildLoop || activeBuildLoop.waypoint !== "attach") { return; }
|
|
283
|
+
activeBuildLoop.waypoint = "review";
|
|
284
|
+
renderLoopStepper(activeBuildLoop);
|
|
285
|
+
if (currentSettings.loopHoldStep) {
|
|
286
|
+
renderLoopCheckpoint(activeBuildLoop);
|
|
287
|
+
} else {
|
|
288
|
+
runBuildReview(activeBuildLoop);
|
|
289
|
+
}
|
|
290
|
+
}, BUILD_LOOP_ATTACH_DEBOUNCE_MS);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// CLAUDE-025: attachedDebugMessages is deliberately STICKY across turns
|
|
294
|
+
// (see the block comment above — "sticky, like conversationHistory,
|
|
295
|
+
// until removed or Clear Chat"), which is exactly right for ordinary
|
|
296
|
+
// chat/generate/modify context. But the /build loop's own verification
|
|
297
|
+
// step must NOT inherit that stickiness: reviewing against a debug
|
|
298
|
+
// message left over from an earlier Build attempt (or one attached
|
|
299
|
+
// manually via the popout, or a still-running unrelated flow) as if it
|
|
300
|
+
// were evidence for THIS attempt's own goal is how a later attempt can
|
|
301
|
+
// declare Touchdown without ever having deployed or checked its own
|
|
302
|
+
// output. loop.deployedAt is stamped (see init.js's "deploy" listener)
|
|
303
|
+
// the moment THIS attempt's own "apply"->"attach" transition fires, so
|
|
304
|
+
// filtering on it scopes evidence to messages that arrived at or after
|
|
305
|
+
// that specific redeploy — never null (build review only runs once the
|
|
306
|
+
// loop has reached "attach", which requires deployedAt to be set).
|
|
307
|
+
function freshBuildLoopEvidence(loop) {
|
|
308
|
+
if (!loop || typeof loop.deployedAt !== "number") { return []; }
|
|
309
|
+
return attachedDebugMessages.filter(function (m) {
|
|
310
|
+
return m.timestamp >= loop.deployedAt;
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
196
314
|
// The exact shape sent to the backend (and shown by "Preview debug") —
|
|
197
315
|
// excludes previewValue, which exists only for the debug-log list.
|
|
198
316
|
function buildDebugMessagesForSend() {
|
|
199
317
|
return attachedDebugMessages.map(function (m) {
|
|
200
|
-
return {
|
|
318
|
+
return {
|
|
319
|
+
id: m.id,
|
|
320
|
+
timestamp: m.timestamp,
|
|
321
|
+
sourceKind: m.sourceKind,
|
|
322
|
+
name: m.name,
|
|
323
|
+
topic: m.topic,
|
|
324
|
+
value: m.value
|
|
325
|
+
};
|
|
201
326
|
});
|
|
202
327
|
}
|
|
203
328
|
|
|
@@ -550,7 +675,21 @@
|
|
|
550
675
|
|
|
551
676
|
// ---- View switching -------------------------------------------------
|
|
552
677
|
|
|
678
|
+
// CLAUDE-024: every mode-tab switch disarms whatever Execute action
|
|
679
|
+
// (Generate/Document/Modify) or Query intent was armed before the
|
|
680
|
+
// switch. Without this, arming Modify then tabbing away and back to
|
|
681
|
+
// Chat left armedExecuteAction pointing at the stale mutating action,
|
|
682
|
+
// so a plain question typed on what LOOKS like a fresh Chat tab still
|
|
683
|
+
// dispatched through Modify/Generate/Document on Send — a real
|
|
684
|
+
// unintended-mutation bug (reproduced: an unwanted node deletion).
|
|
685
|
+
// Send must always route to whatever is actually visible.
|
|
686
|
+
function disarmForModeSwitch() {
|
|
687
|
+
disarmExecuteAction();
|
|
688
|
+
disarmQueryIntent();
|
|
689
|
+
}
|
|
690
|
+
|
|
553
691
|
function showChat() {
|
|
692
|
+
disarmForModeSwitch();
|
|
554
693
|
el("#fp-chat-panel").removeClass("fp-hidden");
|
|
555
694
|
el("#fp-settings-panel").addClass("fp-hidden");
|
|
556
695
|
el("#fp-history-panel").addClass("fp-hidden");
|
|
@@ -560,6 +699,7 @@
|
|
|
560
699
|
}
|
|
561
700
|
|
|
562
701
|
function showSettings() {
|
|
702
|
+
disarmForModeSwitch();
|
|
563
703
|
el("#fp-settings-panel").removeClass("fp-hidden");
|
|
564
704
|
el("#fp-chat-panel").addClass("fp-hidden");
|
|
565
705
|
el("#fp-history-panel").addClass("fp-hidden");
|
|
@@ -569,6 +709,7 @@
|
|
|
569
709
|
}
|
|
570
710
|
|
|
571
711
|
function showHistory() {
|
|
712
|
+
disarmForModeSwitch();
|
|
572
713
|
el("#fp-history-panel").removeClass("fp-hidden");
|
|
573
714
|
el("#fp-chat-panel").addClass("fp-hidden");
|
|
574
715
|
el("#fp-settings-panel").addClass("fp-hidden");
|
|
@@ -588,6 +729,7 @@
|
|
|
588
729
|
attachedDebugMessages = [];
|
|
589
730
|
activeBuildLoop = null;
|
|
590
731
|
disarmExecuteAction(); // also clears pinnedSelectionIds
|
|
732
|
+
delete appliedOpsByConversation[conversationId];
|
|
591
733
|
conversationId = newConversationId();
|
|
592
734
|
fpChatSnappedToBottom = true;
|
|
593
735
|
updateSelectionStatus();
|
|
@@ -701,8 +843,12 @@
|
|
|
701
843
|
// Switches to a past conversation: rebuilds conversationHistory and the
|
|
702
844
|
// visible chat from its saved transcript, and continues using its
|
|
703
845
|
// conversationId so new turns append to the same transcript file.
|
|
704
|
-
|
|
846
|
+
// onError: optional — CLAUDE-029's page-load rehydration passes one to
|
|
847
|
+
// stay quiet (no chat error bubble) and clear a stale sessionStorage
|
|
848
|
+
// entry on 404, instead of the ajaxJson default of addMessage("error", …).
|
|
849
|
+
function loadConversation(id, onError) {
|
|
705
850
|
ajaxJson("GET", "flowpilot/conversations/" + encodeURIComponent(id), null, function (data) {
|
|
851
|
+
delete appliedOpsByConversation[conversationId];
|
|
706
852
|
conversationId = id;
|
|
707
853
|
try { sessionStorage.setItem("fp-conversation-id", id); } catch (e) { /* storage unavailable */ }
|
|
708
854
|
|
|
@@ -719,7 +865,7 @@
|
|
|
719
865
|
pinnedSelectionIds = null;
|
|
720
866
|
updateSelectionStatus();
|
|
721
867
|
showChat();
|
|
722
|
-
});
|
|
868
|
+
}, onError);
|
|
723
869
|
}
|
|
724
870
|
|
|
725
871
|
// Recall — searches OTHER past conversations' transcripts for the
|
|
@@ -905,17 +1051,52 @@
|
|
|
905
1051
|
// removed in both the success and error paths so it can't get stuck.
|
|
906
1052
|
// showStop adds a "Stop" button, used by the agent loop
|
|
907
1053
|
// (runAgentChat) so the user can interrupt a multi-step tool-call run.
|
|
1054
|
+
//
|
|
1055
|
+
// fpPendingStartedAt / fpPendingElapsedInterval drive the live elapsed-time
|
|
1056
|
+
// counter (.fp-typing-elapsed). Real timing data (corpus + live browser
|
|
1057
|
+
// testing, ~150+ requests) is bimodal: the overwhelming majority finish in
|
|
1058
|
+
// 2-13s, with rare genuine outliers around 73-77s and nothing observed in
|
|
1059
|
+
// between. FP_PENDING_SLOW_MS picks 25s as the "this is a slow one"
|
|
1060
|
+
// threshold — comfortably past every normal request, comfortably before
|
|
1061
|
+
// the real outliers, so the reassurance text only ever appears when it's
|
|
1062
|
+
// actually warranted.
|
|
1063
|
+
var fpPendingStartedAt = null;
|
|
1064
|
+
var fpPendingElapsedInterval = null;
|
|
1065
|
+
var FP_PENDING_SLOW_MS = 25000;
|
|
1066
|
+
|
|
1067
|
+
function fpFormatElapsed(ms) {
|
|
1068
|
+
var totalSeconds = Math.floor(ms / 1000);
|
|
1069
|
+
var text;
|
|
1070
|
+
if (totalSeconds < 60) {
|
|
1071
|
+
text = totalSeconds + "s";
|
|
1072
|
+
} else {
|
|
1073
|
+
var mins = Math.floor(totalSeconds / 60);
|
|
1074
|
+
var secs = totalSeconds % 60;
|
|
1075
|
+
text = mins + "m " + secs + "s";
|
|
1076
|
+
}
|
|
1077
|
+
if (ms >= FP_PENDING_SLOW_MS) {
|
|
1078
|
+
text += " — still working, some requests take a minute or more";
|
|
1079
|
+
}
|
|
1080
|
+
return text;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
908
1083
|
function showPending(showStop) {
|
|
909
1084
|
var $box = el("#fp-messages");
|
|
910
1085
|
if (!$box.length) { return; }
|
|
911
1086
|
// Guard against duplicates (e.g. fast double-send).
|
|
912
1087
|
$box.find("#fp-pending").remove();
|
|
1088
|
+
if (fpPendingElapsedInterval) {
|
|
1089
|
+
clearInterval(fpPendingElapsedInterval);
|
|
1090
|
+
fpPendingElapsedInterval = null;
|
|
1091
|
+
}
|
|
913
1092
|
|
|
914
1093
|
var $msg = $("<div>").addClass("fp-message").attr("id", "fp-pending");
|
|
915
1094
|
$("<div>").addClass("fp-label").text("FLOWPILOT").appendTo($msg);
|
|
916
1095
|
var $dots = $("<div>").addClass("fp-typing").attr("title", "Working…");
|
|
917
1096
|
$dots.append($("<span>"), $("<span>"), $("<span>"));
|
|
918
1097
|
$dots.append($("<span>").addClass("fp-typing-label").text("Cruising…"));
|
|
1098
|
+
var $elapsed = $("<span>").addClass("fp-typing-elapsed");
|
|
1099
|
+
$dots.append($elapsed);
|
|
919
1100
|
if (showStop) {
|
|
920
1101
|
$dots.append($("<button>")
|
|
921
1102
|
.addClass("fp-agent-stop red-ui-button red-ui-button-small")
|
|
@@ -923,6 +1104,7 @@
|
|
|
923
1104
|
.text("Stop")
|
|
924
1105
|
.on("click", function () {
|
|
925
1106
|
fpAgentStopRequested = true;
|
|
1107
|
+
if (fpCurrentAgentRequest) { fpCurrentAgentRequest.abort(); }
|
|
926
1108
|
$(this).prop("disabled", true).text("Stopping…");
|
|
927
1109
|
}));
|
|
928
1110
|
}
|
|
@@ -930,10 +1112,28 @@
|
|
|
930
1112
|
|
|
931
1113
|
$box.append($msg);
|
|
932
1114
|
scrollMessagesToBottom();
|
|
1115
|
+
|
|
1116
|
+
fpPendingStartedAt = Date.now();
|
|
1117
|
+
fpPendingElapsedInterval = setInterval(function () {
|
|
1118
|
+
var $label = el("#fp-pending .fp-typing-elapsed");
|
|
1119
|
+
if (!$label.length) {
|
|
1120
|
+
// Panel closed / #fp-pending gone without hidePending firing;
|
|
1121
|
+
// stop polling instead of leaking the interval forever.
|
|
1122
|
+
clearInterval(fpPendingElapsedInterval);
|
|
1123
|
+
fpPendingElapsedInterval = null;
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
$label.text(" · " + fpFormatElapsed(Date.now() - fpPendingStartedAt));
|
|
1127
|
+
}, 1000);
|
|
933
1128
|
}
|
|
934
1129
|
|
|
935
1130
|
function hidePending() {
|
|
936
1131
|
el("#fp-messages").find("#fp-pending").remove();
|
|
1132
|
+
if (fpPendingElapsedInterval) {
|
|
1133
|
+
clearInterval(fpPendingElapsedInterval);
|
|
1134
|
+
fpPendingElapsedInterval = null;
|
|
1135
|
+
}
|
|
1136
|
+
fpPendingStartedAt = null;
|
|
937
1137
|
}
|
|
938
1138
|
|
|
939
1139
|
// Updates the narration text shown in the pending indicator while the
|
|
@@ -994,7 +1194,7 @@
|
|
|
994
1194
|
}
|
|
995
1195
|
|
|
996
1196
|
function ajaxJson(method, url, payload, onSuccess, onError) {
|
|
997
|
-
$.ajax({
|
|
1197
|
+
return $.ajax({
|
|
998
1198
|
url: flowpilotUrl(url),
|
|
999
1199
|
method: method,
|
|
1000
1200
|
contentType: "application/json",
|
|
@@ -1007,7 +1207,14 @@
|
|
|
1007
1207
|
},
|
|
1008
1208
|
success: onSuccess,
|
|
1009
1209
|
error: function (xhr) {
|
|
1010
|
-
|
|
1210
|
+
// Prefer .message (human-readable text) over .error (a
|
|
1211
|
+
// machine code like "provider_unconfirmed" or
|
|
1212
|
+
// "agent_strategy_unavailable") when both are present — a
|
|
1213
|
+
// plain-string-only error response (the common case,
|
|
1214
|
+
// {error:"..."} with no .message) is untouched, since
|
|
1215
|
+
// .message is simply absent there.
|
|
1216
|
+
var msg = (xhr.responseJSON && xhr.responseJSON.message) ||
|
|
1217
|
+
(xhr.responseJSON && xhr.responseJSON.error) ||
|
|
1011
1218
|
xhr.responseText || xhr.statusText || "Unknown error";
|
|
1012
1219
|
if (onError) { onError(msg, xhr); }
|
|
1013
1220
|
else { addMessage("error", msg); }
|
|
@@ -1042,26 +1249,42 @@
|
|
|
1042
1249
|
if (active) { $sel.val(active.id); }
|
|
1043
1250
|
}
|
|
1044
1251
|
|
|
1045
|
-
|
|
1252
|
+
function toggleAnthropicHint(type) {
|
|
1253
|
+
var isAnthropic = type === "anthropic";
|
|
1254
|
+
el("#fp-base-url-hint").toggleClass("fp-hidden", !isAnthropic);
|
|
1255
|
+
el("#fp-base-url").attr("placeholder", isAnthropic ? "Leave blank for api.anthropic.com" : "http://localhost:8080");
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// Write the form fields from a given provider profile. p.apiKey is
|
|
1259
|
+
// never a real key here — the server masks it to a sentinel/"" on every
|
|
1260
|
+
// response (see maskProviderSecrets, flowpilot.js) — so leaving the
|
|
1261
|
+
// field untouched on save round-trips harmlessly (reconcileProviderSecrets,
|
|
1262
|
+
// lib/storage.js, keeps the real stored key).
|
|
1046
1263
|
function fillProviderFields(p) {
|
|
1047
1264
|
p = p || {};
|
|
1048
1265
|
el("#fp-provider-name").val(p.providerName || "");
|
|
1266
|
+
var type = p.type || "openai-compatible";
|
|
1267
|
+
el("#fp-provider-type").val(type);
|
|
1268
|
+
toggleAnthropicHint(type);
|
|
1049
1269
|
el("#fp-base-url").val(p.baseUrl || "");
|
|
1050
1270
|
el("#fp-api-key").val(p.apiKey || "");
|
|
1271
|
+
el("#fp-api-key").attr("placeholder", p.hasApiKey ? "Saved — leave to keep, retype to change" : "Optional");
|
|
1051
1272
|
el("#fp-model").val(p.model || "");
|
|
1052
1273
|
el("#fp-temperature").val(p.temperature !== undefined ? p.temperature : 0.2);
|
|
1053
1274
|
// Test provider is disabled until this provider has a model.
|
|
1054
1275
|
el("#fp-test-provider").prop("disabled", !(p.model && String(p.model).trim()));
|
|
1055
1276
|
}
|
|
1056
1277
|
|
|
1057
|
-
//
|
|
1058
|
-
//
|
|
1278
|
+
// CLAUDE-032: 5 discrete levels (was 1-10 with sparse interpolated
|
|
1279
|
+
// anchors — live testing found no discernible voice difference across
|
|
1280
|
+
// the old scale). Labels match lib/persona-prompt.js's PERSONA_LEVELS
|
|
1281
|
+
// exactly, one label per slider position, no interpolation.
|
|
1059
1282
|
function personaLabelFor(n) {
|
|
1060
1283
|
n = Number(n);
|
|
1061
1284
|
if (n <= 1) { return "Plain engineer — no aviation language at all."; }
|
|
1062
|
-
if (n
|
|
1063
|
-
if (n
|
|
1064
|
-
if (n
|
|
1285
|
+
if (n === 2) { return "Subtle co-pilot (default) — light, occasional flavor."; }
|
|
1286
|
+
if (n === 3) { return "Noticeable captain energy — a sentence or two, every time."; }
|
|
1287
|
+
if (n === 4) { return "Heavy captain energy — leans hard into the bit."; }
|
|
1065
1288
|
return "Full captain — comically over-the-top.";
|
|
1066
1289
|
}
|
|
1067
1290
|
|
|
@@ -1079,17 +1302,19 @@
|
|
|
1079
1302
|
fillProviderFields(activeProvider());
|
|
1080
1303
|
|
|
1081
1304
|
el("#fp-system-prompt").val(settings.systemPrompt || "");
|
|
1082
|
-
el("#fp-persona-intensity").val(settings.personaIntensity !== undefined ? settings.personaIntensity :
|
|
1305
|
+
el("#fp-persona-intensity").val(settings.personaIntensity !== undefined ? settings.personaIntensity : 2);
|
|
1083
1306
|
updatePersonaLabel();
|
|
1084
1307
|
el("#fp-warn-tokens").val(settings.contextWarnTokens || 4000);
|
|
1085
1308
|
el("#fp-high-tokens").val(settings.contextHighTokens || 8000);
|
|
1086
1309
|
el("#fp-history-max").val(settings.historyMaxExchanges !== undefined ? settings.historyMaxExchanges : 10);
|
|
1087
1310
|
el("#fp-streaming-enabled").prop("checked", !!settings.streamingEnabled);
|
|
1088
|
-
el("#fp-request-timeout").val(
|
|
1311
|
+
el("#fp-request-timeout").val(settings.requestTimeoutMs !== undefined ? (settings.requestTimeoutMs / 1000) : 180);
|
|
1312
|
+
el("#fp-agent-turn-max-tokens").val(settings.agentTurnMaxTokens !== undefined ? settings.agentTurnMaxTokens : 4096);
|
|
1089
1313
|
el("#fp-agent-loop-max-iterations").val(settings.agentLoopMaxIterations !== undefined ? settings.agentLoopMaxIterations : 5);
|
|
1090
1314
|
el("#fp-loop-hold-step").prop("checked", !!settings.loopHoldStep);
|
|
1091
1315
|
el("#fp-suppress-warnings").prop("checked", !!settings.suppressContextWarnings);
|
|
1092
1316
|
el("#fp-redaction-disabled").prop("checked", settings.redactionEnabled === false);
|
|
1317
|
+
el("#fp-debug-logging").prop("checked", !!settings.debugLogging);
|
|
1093
1318
|
|
|
1094
1319
|
// The dev/test banner is part of the warning set the user can silence
|
|
1095
1320
|
// via the type-to-confirm acknowledgement.
|
|
@@ -1134,6 +1359,7 @@
|
|
|
1134
1359
|
var ap = activeProvider();
|
|
1135
1360
|
if (!ap) { return; }
|
|
1136
1361
|
ap.providerName = el("#fp-provider-name").val() || "Provider";
|
|
1362
|
+
ap.type = el("#fp-provider-type").val() || "openai-compatible";
|
|
1137
1363
|
ap.baseUrl = el("#fp-base-url").val() || "";
|
|
1138
1364
|
ap.apiKey = el("#fp-api-key").val() || "";
|
|
1139
1365
|
ap.model = el("#fp-model").val() || "";
|
|
@@ -1163,14 +1389,15 @@
|
|
|
1163
1389
|
if (!isFinite(historyMax) || historyMax < 0) { historyMax = 10; }
|
|
1164
1390
|
|
|
1165
1391
|
var requestTimeoutSec = Number(el("#fp-request-timeout").val());
|
|
1166
|
-
if (!isFinite(requestTimeoutSec) || requestTimeoutSec < 5) { requestTimeoutSec = 180; }
|
|
1167
1392
|
|
|
1168
1393
|
var personaIntensity = Number(el("#fp-persona-intensity").val());
|
|
1169
|
-
if (!isFinite(personaIntensity) || personaIntensity < 1 || personaIntensity >
|
|
1394
|
+
if (!isFinite(personaIntensity) || personaIntensity < 1 || personaIntensity > 5) { personaIntensity = 2; }
|
|
1170
1395
|
|
|
1171
1396
|
var agentLoopMaxIterations = Number(el("#fp-agent-loop-max-iterations").val());
|
|
1172
1397
|
if (!isFinite(agentLoopMaxIterations) || agentLoopMaxIterations < 1) { agentLoopMaxIterations = 5; }
|
|
1173
1398
|
|
|
1399
|
+
var agentTurnMaxTokens = Number(el("#fp-agent-turn-max-tokens").val());
|
|
1400
|
+
|
|
1174
1401
|
return {
|
|
1175
1402
|
providers: providersList(),
|
|
1176
1403
|
activeProviderId: currentSettings.activeProviderId,
|
|
@@ -1181,10 +1408,12 @@
|
|
|
1181
1408
|
historyMaxExchanges: historyMax,
|
|
1182
1409
|
streamingEnabled: el("#fp-streaming-enabled").prop("checked"),
|
|
1183
1410
|
requestTimeoutMs: Math.round(requestTimeoutSec * 1000),
|
|
1411
|
+
agentTurnMaxTokens: agentTurnMaxTokens,
|
|
1184
1412
|
agentLoopMaxIterations: agentLoopMaxIterations,
|
|
1185
1413
|
loopHoldStep: el("#fp-loop-hold-step").prop("checked"),
|
|
1186
1414
|
suppressContextWarnings: suppress,
|
|
1187
1415
|
redactionEnabled: redactionEnabled,
|
|
1416
|
+
debugLogging: el("#fp-debug-logging").prop("checked"),
|
|
1188
1417
|
customIntents: Array.isArray(currentSettings.customIntents)
|
|
1189
1418
|
? currentSettings.customIntents : []
|
|
1190
1419
|
};
|
|
@@ -1330,10 +1559,25 @@
|
|
|
1330
1559
|
var payload = collectSettings();
|
|
1331
1560
|
var list = payload.providers || [];
|
|
1332
1561
|
|
|
1333
|
-
|
|
1334
|
-
|
|
1562
|
+
if (!isFinite(payload.requestTimeoutMs) || payload.requestTimeoutMs <= 0) {
|
|
1563
|
+
var timeoutMsg = "Cannot save: request timeout must be a positive number of seconds.";
|
|
1564
|
+
addMessage("error", timeoutMsg);
|
|
1565
|
+
if (announce) { showSaveStatus(timeoutMsg, true); }
|
|
1566
|
+
showSettings();
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
1569
|
+
if (!isFinite(payload.agentTurnMaxTokens) || payload.agentTurnMaxTokens <= 0 || Math.floor(payload.agentTurnMaxTokens) !== payload.agentTurnMaxTokens) {
|
|
1570
|
+
var tokenMsg = "Cannot save: max tokens per agent turn must be a positive whole number.";
|
|
1571
|
+
addMessage("error", tokenMsg);
|
|
1572
|
+
if (announce) { showSaveStatus(tokenMsg, true); }
|
|
1573
|
+
showSettings();
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
// Validation 1: every non-Anthropic provider needs a base URL.
|
|
1578
|
+
// Anthropic providers default to api.anthropic.com when baseUrl is blank.
|
|
1335
1579
|
var noUrl = list.filter(function (p) {
|
|
1336
|
-
return !p.baseUrl || !String(p.baseUrl).trim();
|
|
1580
|
+
return p.type !== "anthropic" && (!p.baseUrl || !String(p.baseUrl).trim());
|
|
1337
1581
|
});
|
|
1338
1582
|
if (noUrl.length) {
|
|
1339
1583
|
var urlNames = noUrl.map(function (p) { return p.providerName || "(unnamed)"; }).join(", ");
|
|
@@ -1562,6 +1806,470 @@
|
|
|
1562
1806
|
return Object.assign({ selected: true }, context);
|
|
1563
1807
|
}
|
|
1564
1808
|
|
|
1809
|
+
// ---------------------------------------------------------------------
|
|
1810
|
+
// W7 — WRITE tools (§16 of FlowPilot-Phase10-Rescope-Scoping.md).
|
|
1811
|
+
//
|
|
1812
|
+
// Exact argument schemas as landed by CODEX-005 (coordinated via
|
|
1813
|
+
// mailbox, 2026-07-26 19:28/19:37 UTC — these are NOT a client-side
|
|
1814
|
+
// guess): CODEX-005 deliberately reused the EXISTING Modify-envelope
|
|
1815
|
+
// field names/shapes so the client applies WRITE tool calls through
|
|
1816
|
+
// the SAME pipeline as the ordinary (non-agentic) Modify apply path —
|
|
1817
|
+
// apply_step: { summary, changes?: [{id, set}], newNodes?: [{id,
|
|
1818
|
+
// type, ...}], newWires?: [{from, fromPort, to}] }
|
|
1819
|
+
// `changes[].set` is a sparse property patch, `newNodes`/`newWires`
|
|
1820
|
+
// are byte-identical in shape to the top-level Modify envelope's
|
|
1821
|
+
// own fields (applyInsertions already accepts them directly).
|
|
1822
|
+
// "One call is one todo item even when these arrays contain a
|
|
1823
|
+
// small bundle" (§16 point 2) — arrays may hold more than one
|
|
1824
|
+
// entry in a single call.
|
|
1825
|
+
// remove_step: { summary, nodeId }
|
|
1826
|
+
// rename_node: { summary, nodeId, name }
|
|
1827
|
+
// ask_user: { question, options? } — tier "write-safe", non-mutating,
|
|
1828
|
+
// handled entirely in the agent loop (modes.js), never reaches
|
|
1829
|
+
// here in normal flow.
|
|
1830
|
+
//
|
|
1831
|
+
// Tier ("write-gated" vs "write-safe") arrives OUT OF BAND per call as
|
|
1832
|
+
// data.toolTiers[call.id] (modes.js reads this), not by tool name — a
|
|
1833
|
+
// write-gated tier means the call is ELIGIBLE for consent-gating, not
|
|
1834
|
+
// that every call of that tool prompts (CODEX-005's own clarification):
|
|
1835
|
+
// §16 point 4 still requires classifying the ACTUAL touched node
|
|
1836
|
+
// type(s) against SAFE_NODE_TYPES before deciding to gate.
|
|
1837
|
+
//
|
|
1838
|
+
// tool_result shape (§16 point 3 — mirrors the existing verifySteps
|
|
1839
|
+
// check vocabulary, scoped strictly to this call's own touched ids):
|
|
1840
|
+
// { requested, checks: [{check, nodeId|fromId/toId, prop?, expected?,
|
|
1841
|
+
// pass}], allPass, error? }
|
|
1842
|
+
// ---------------------------------------------------------------------
|
|
1843
|
+
|
|
1844
|
+
// Mirrors flowpilot.js's SAFE_NODE_TYPES verbatim (§16 point 4: "reuse
|
|
1845
|
+
// SAFE_NODE_TYPES/classifyFlowNodes verbatim"). This is a client-side
|
|
1846
|
+
// COPY, not a shared module — lib/core/*.js has no require/import, and
|
|
1847
|
+
// the server's set lives in a separate Node.js process. Must be kept in
|
|
1848
|
+
// sync by hand; flagged in the mailbox report as a drift risk to watch
|
|
1849
|
+
// if the server-side list ever changes.
|
|
1850
|
+
var WRITE_GATE_SAFE_NODE_TYPES = new Set([
|
|
1851
|
+
"inject", "function", "change", "switch", "filter", "json", "xml", "csv",
|
|
1852
|
+
"base64", "html", "split", "join", "sort", "batch", "debug", "status",
|
|
1853
|
+
"comment", "link in", "link out", "link call", "junction"
|
|
1854
|
+
]);
|
|
1855
|
+
|
|
1856
|
+
// Collects the node type(s) a WRITE tool call would touch — a NEW
|
|
1857
|
+
// node's own declared type, or an EXISTING node's live type looked up
|
|
1858
|
+
// via findLiveNode (apply-review.js, same closure). Used by
|
|
1859
|
+
// writeToolCallNeedsConsent (modes.js) to decide whether to gate.
|
|
1860
|
+
function collectWriteToolTouchedTypes(name, args) {
|
|
1861
|
+
args = args || {};
|
|
1862
|
+
var types = [];
|
|
1863
|
+
function addLiveType(id) {
|
|
1864
|
+
if (!id) { return; }
|
|
1865
|
+
var live = findLiveNode(id);
|
|
1866
|
+
if (live) { types.push(live.type); }
|
|
1867
|
+
}
|
|
1868
|
+
switch (name) {
|
|
1869
|
+
case "apply_step":
|
|
1870
|
+
(Array.isArray(args.newNodes) ? args.newNodes : []).forEach(function (n) {
|
|
1871
|
+
if (n && n.type) { types.push(n.type); }
|
|
1872
|
+
});
|
|
1873
|
+
(Array.isArray(args.newWires) ? args.newWires : []).forEach(function (w) {
|
|
1874
|
+
if (!w) { return; }
|
|
1875
|
+
addLiveType(w.from);
|
|
1876
|
+
addLiveType(w.to);
|
|
1877
|
+
});
|
|
1878
|
+
(Array.isArray(args.changes) ? args.changes : []).forEach(function (c) {
|
|
1879
|
+
if (c) { addLiveType(c.id); }
|
|
1880
|
+
});
|
|
1881
|
+
break;
|
|
1882
|
+
case "remove_step":
|
|
1883
|
+
addLiveType(args.nodeId);
|
|
1884
|
+
break;
|
|
1885
|
+
case "rename_node":
|
|
1886
|
+
addLiveType(args.nodeId);
|
|
1887
|
+
break;
|
|
1888
|
+
}
|
|
1889
|
+
return types;
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
// §16 point 4: gate ONLY when the call's tier is "write-gated" AND it
|
|
1893
|
+
// touches a node type outside SAFE_NODE_TYPES; autonomously apply
|
|
1894
|
+
// everything else (including every "write-safe" call, e.g. ask_user,
|
|
1895
|
+
// which never reaches this function at all in practice — see
|
|
1896
|
+
// handleStep). An id that doesn't resolve to a live node at all
|
|
1897
|
+
// (hallucinated id, or a brand-new node referenced by a wire before it
|
|
1898
|
+
// exists) can't be proven safe, so it's treated conservatively as
|
|
1899
|
+
// side-effecting — mirrors WS4's classifyFlowNodes default (unknown =>
|
|
1900
|
+
// sideEffecting).
|
|
1901
|
+
function writeToolCallNeedsConsent(tier, name, args) {
|
|
1902
|
+
if (tier !== "write-gated") { return false; }
|
|
1903
|
+
var types = collectWriteToolTouchedTypes(name, args);
|
|
1904
|
+
if (!types.length) { return true; }
|
|
1905
|
+
return types.some(function (t) { return !WRITE_GATE_SAFE_NODE_TYPES.has(t); });
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
// Builds the tool_result envelope from a checks array already carrying
|
|
1909
|
+
// .pass — shared tail for all three WRITE tool executors below.
|
|
1910
|
+
function buildWriteToolResult(requestedArgs, checks, extra) {
|
|
1911
|
+
var allPass = checks.length > 0 && checks.every(function (c) { return c.pass; });
|
|
1912
|
+
return Object.assign({ requested: requestedArgs, checks: checks, allPass: allPass }, extra || {});
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
// Runs the verifySteps-style check vocabulary (runSingleVerifyCheck,
|
|
1916
|
+
// modes.js, same closure) against a list of steps in this call's own
|
|
1917
|
+
// shape, scoped strictly to the touched id(s) — never a flow-wide
|
|
1918
|
+
// snapshot (§16 point 3).
|
|
1919
|
+
function runChecksForToolResult(steps, idMap) {
|
|
1920
|
+
var checks = [];
|
|
1921
|
+
(steps || []).forEach(function (step) {
|
|
1922
|
+
var result = runSingleVerifyCheck(step, idMap);
|
|
1923
|
+
if (!result) { return; }
|
|
1924
|
+
checks.push(Object.assign({}, step, { pass: result.ok }));
|
|
1925
|
+
});
|
|
1926
|
+
return checks;
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
// apply_step: newNodes/newWires go through applyInsertions — the SAME
|
|
1930
|
+
// layout/collision-avoidance/wiring path Generate/Modify insertions
|
|
1931
|
+
// already use, since CODEX-005 matched that exact field shape — then
|
|
1932
|
+
// changes[].set (sparse property patches on EXISTING nodes) goes
|
|
1933
|
+
// through applyModifications's Tier 1, using the resulting idMap so a
|
|
1934
|
+
// change value can reference a placeholder id from newNodes in the
|
|
1935
|
+
// SAME call (mirrors the existing "insertions run first so their
|
|
1936
|
+
// placeholder→real-id map is available" ordering in
|
|
1937
|
+
// addModifyReview's apply button). `changes[].set.wires` is the ONE
|
|
1938
|
+
// exception: CLAUDE-009-fix — an existing node's wiring is never a
|
|
1939
|
+
// generic property (mirrors computeNodeDiff's `if (k === "wires") {
|
|
1940
|
+
// wiresChanged = true; return; }`), so it's split out and routed
|
|
1941
|
+
// through computeWireDiff/Tier 3 (RED.nodes.addLink/removeLink,
|
|
1942
|
+
// canWire port validation) instead of a raw `liveNode.wires = val`
|
|
1943
|
+
// assignment, which would "succeed" without ever updating the link
|
|
1944
|
+
// registry the canvas and RED.nodes.eachLink actually read from.
|
|
1945
|
+
function executeApplyStepTool(args, runIdMap, runHistoryEvents) {
|
|
1946
|
+
args = args || {};
|
|
1947
|
+
var changes = Array.isArray(args.changes) ? args.changes : [];
|
|
1948
|
+
var newNodes = Array.isArray(args.newNodes) ? args.newNodes : [];
|
|
1949
|
+
var newWires = Array.isArray(args.newWires) ? args.newWires : [];
|
|
1950
|
+
|
|
1951
|
+
var idMap = {};
|
|
1952
|
+
var steps = [];
|
|
1953
|
+
|
|
1954
|
+
// CLAUDE-013: resolves a placeholder id (e.g. "fp-new-2") through
|
|
1955
|
+
// this call's own idMap first (ids it just created via
|
|
1956
|
+
// applyInsertions), then through runIdMap (ids created by an
|
|
1957
|
+
// EARLIER WRITE-tool call in the same agent run) — so referencing a
|
|
1958
|
+
// node created two tool calls ago works the same as referencing one
|
|
1959
|
+
// created in this call.
|
|
1960
|
+
function resolveId(id) {
|
|
1961
|
+
if (typeof id !== "string") { return id; }
|
|
1962
|
+
if (idMap && idMap[id]) { return idMap[id]; }
|
|
1963
|
+
if (runIdMap && runIdMap[id]) { return runIdMap[id]; }
|
|
1964
|
+
return id;
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
if (newNodes.length || newWires.length) {
|
|
1968
|
+
idMap = applyInsertions(newNodes, newWires, [], runHistoryEvents) || {};
|
|
1969
|
+
newNodes.forEach(function (n) {
|
|
1970
|
+
if (n && n.id) { steps.push({ check: "exists", nodeId: n.id }); }
|
|
1971
|
+
});
|
|
1972
|
+
newWires.forEach(function (w) {
|
|
1973
|
+
if (w && w.from && w.to) { steps.push({ check: "wire", fromId: w.from, fromPort: w.fromPort || 0, toId: w.to }); }
|
|
1974
|
+
});
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
if (changes.length) {
|
|
1978
|
+
var propDiffs = changes.filter(function (c) { return c && c.id && c.set && typeof c.set === "object"; })
|
|
1979
|
+
.map(function (c) {
|
|
1980
|
+
var nodeId = resolveId(c.id);
|
|
1981
|
+
var live = findLiveNode(nodeId);
|
|
1982
|
+
var hasWireChange = Object.prototype.hasOwnProperty.call(c.set, "wires");
|
|
1983
|
+
var propertyChanges = Object.keys(c.set).filter(function (k) { return k !== "wires"; })
|
|
1984
|
+
.map(function (k) {
|
|
1985
|
+
return { key: k, oldVal: live ? live[k] : undefined, newVal: c.set[k] };
|
|
1986
|
+
});
|
|
1987
|
+
var wiresDiff = { toAdd: [], toRemove: [] };
|
|
1988
|
+
if (hasWireChange && live) {
|
|
1989
|
+
// The tool-call path has no "flow the model had in
|
|
1990
|
+
// context" boundary the way the classic envelope's
|
|
1991
|
+
// validTargetIds does (scoped to the returned "flow"
|
|
1992
|
+
// array) — the model only sends this one node's
|
|
1993
|
+
// desired wires, not a full flow. Per sr-dev's
|
|
1994
|
+
// guidance, treat every id the diff could actually
|
|
1995
|
+
// flag for removal as valid: computeWireDiff only
|
|
1996
|
+
// ever consults validTargetIds for ids already found
|
|
1997
|
+
// among this node's CURRENT live targets, so seeding
|
|
1998
|
+
// validTargetIds from those current targets is
|
|
1999
|
+
// equivalent to "always valid" without needing a
|
|
2000
|
+
// magic always-true object.
|
|
2001
|
+
var validTargetIds = {};
|
|
2002
|
+
RED.nodes.eachLink(function (l) {
|
|
2003
|
+
if (l.source && l.source.id === nodeId && l.target) { validTargetIds[l.target.id] = true; }
|
|
2004
|
+
});
|
|
2005
|
+
wiresDiff = computeWireDiff(nodeId, c.set.wires, validTargetIds);
|
|
2006
|
+
}
|
|
2007
|
+
return {
|
|
2008
|
+
modNode: { id: nodeId },
|
|
2009
|
+
propertyChanges: propertyChanges,
|
|
2010
|
+
wiresChanged: hasWireChange && !!live,
|
|
2011
|
+
wiresDiff: wiresDiff
|
|
2012
|
+
};
|
|
2013
|
+
});
|
|
2014
|
+
// CLAUDE-013: applyModifications's own Tier 1 substitution
|
|
2015
|
+
// (apply-review.js) resolves c.set[k] placeholder values through
|
|
2016
|
+
// whatever idMap it's given — passing the call-local idMap alone
|
|
2017
|
+
// would WRITE the raw unresolved placeholder string onto the live
|
|
2018
|
+
// node (e.g. an mqtt-out's "broker" left as "fp-new-broker")
|
|
2019
|
+
// whenever the referenced node was created by an EARLIER call
|
|
2020
|
+
// this run, even though the verify step below correctly expects
|
|
2021
|
+
// the real id — a live-value/verify-step mismatch caught by
|
|
2022
|
+
// testing, not named explicitly in the ticket's cited line
|
|
2023
|
+
// numbers. Merge runIdMap in so the actual mutation and the
|
|
2024
|
+
// verify step agree.
|
|
2025
|
+
if (propDiffs.length) { applyModifications(propDiffs, [], null, Object.assign({}, runIdMap, idMap), runHistoryEvents); }
|
|
2026
|
+
changes.forEach(function (c) {
|
|
2027
|
+
if (!c || !c.id || !c.set || typeof c.set !== "object") { return; }
|
|
2028
|
+
var nodeId = resolveId(c.id);
|
|
2029
|
+
Object.keys(c.set).filter(function (k) { return k !== "wires"; }).forEach(function (k) {
|
|
2030
|
+
// Resolve through idMap/runIdMap the SAME way the diff
|
|
2031
|
+
// above just did, so a set value referencing a
|
|
2032
|
+
// placeholder from THIS call's own newNodes (e.g. an
|
|
2033
|
+
// mqtt-out's "broker" pointing at a new mqtt-broker) OR
|
|
2034
|
+
// from an EARLIER call this run is checked against what
|
|
2035
|
+
// actually landed, not the raw unresolved placeholder
|
|
2036
|
+
// string.
|
|
2037
|
+
var expected = resolveId(c.set[k]);
|
|
2038
|
+
steps.push({ check: "property", nodeId: nodeId, prop: k, expected: expected });
|
|
2039
|
+
});
|
|
2040
|
+
if (Object.prototype.hasOwnProperty.call(c.set, "wires")) {
|
|
2041
|
+
// Emit one "wire" check per desired (port, target) pair —
|
|
2042
|
+
// the same check shape/granularity newWires already uses
|
|
2043
|
+
// above — so the model's requested final wiring state is
|
|
2044
|
+
// verified against the live graph, not a property key.
|
|
2045
|
+
var desiredWires = Array.isArray(c.set.wires) ? c.set.wires : [];
|
|
2046
|
+
desiredWires.forEach(function (targets, port) {
|
|
2047
|
+
(Array.isArray(targets) ? targets : []).forEach(function (targetId) {
|
|
2048
|
+
steps.push({ check: "wire", fromId: nodeId, fromPort: port, toId: resolveId(targetId) });
|
|
2049
|
+
});
|
|
2050
|
+
});
|
|
2051
|
+
}
|
|
2052
|
+
});
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
if (!steps.length) {
|
|
2056
|
+
return buildWriteToolResult(args, [], { error: "apply_step call had nothing to apply" });
|
|
2057
|
+
}
|
|
2058
|
+
var checks = runChecksForToolResult(steps, idMap);
|
|
2059
|
+
var extra = {};
|
|
2060
|
+
if (idMap && Object.keys(idMap).length) { extra.idMap = idMap; }
|
|
2061
|
+
return buildWriteToolResult(args, checks, extra);
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
function executeRemoveStepTool(args, runIdMap, runHistoryEvents) {
|
|
2065
|
+
args = args || {};
|
|
2066
|
+
if (!args.nodeId) {
|
|
2067
|
+
return buildWriteToolResult(args, [], { error: "remove_step requires nodeId" });
|
|
2068
|
+
}
|
|
2069
|
+
// CLAUDE-013: args.nodeId may be a placeholder minted by an EARLIER
|
|
2070
|
+
// WRITE-tool call this run (e.g. "insert node, then remove it").
|
|
2071
|
+
var nodeId = (runIdMap && typeof args.nodeId === "string" && runIdMap[args.nodeId]) || args.nodeId;
|
|
2072
|
+
applyModifications([], [nodeId], null, {}, runHistoryEvents);
|
|
2073
|
+
var checks = runChecksForToolResult([{ check: "absent", nodeId: nodeId }], {});
|
|
2074
|
+
return buildWriteToolResult(args, checks);
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
function executeRenameNodeTool(args, runIdMap, runHistoryEvents) {
|
|
2078
|
+
args = args || {};
|
|
2079
|
+
if (!args.nodeId || typeof args.name !== "string") {
|
|
2080
|
+
return buildWriteToolResult(args, [], { error: "rename_node requires nodeId and name" });
|
|
2081
|
+
}
|
|
2082
|
+
// CLAUDE-013: same placeholder resolution as executeRemoveStepTool.
|
|
2083
|
+
var nodeId = (runIdMap && typeof args.nodeId === "string" && runIdMap[args.nodeId]) || args.nodeId;
|
|
2084
|
+
var live = findLiveNode(nodeId);
|
|
2085
|
+
var diff = [{
|
|
2086
|
+
modNode: { id: nodeId },
|
|
2087
|
+
propertyChanges: [{ key: "name", oldVal: live ? live.name : undefined, newVal: args.name }],
|
|
2088
|
+
wiresChanged: false,
|
|
2089
|
+
wiresDiff: { toAdd: [], toRemove: [] }
|
|
2090
|
+
}];
|
|
2091
|
+
applyModifications(diff, [], null, {}, runHistoryEvents);
|
|
2092
|
+
var checks = runChecksForToolResult([{ check: "property", nodeId: nodeId, prop: "name", expected: args.name }], {});
|
|
2093
|
+
return buildWriteToolResult(args, checks);
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
function executeRedirectModeTool(args) {
|
|
2097
|
+
args = args || {};
|
|
2098
|
+
if (["generate", "document", "chat"].indexOf(args.mode) === -1) {
|
|
2099
|
+
return { error: "redirect_mode requires mode = generate, document, or chat" };
|
|
2100
|
+
}
|
|
2101
|
+
if (typeof args.prompt !== "string" || !args.prompt.trim()) {
|
|
2102
|
+
return { error: "redirect_mode requires prompt" };
|
|
2103
|
+
}
|
|
2104
|
+
if (typeof args.explanation !== "string" || !args.explanation.trim()) {
|
|
2105
|
+
return { error: "redirect_mode requires explanation" };
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
var result = {
|
|
2109
|
+
redirected: true,
|
|
2110
|
+
explanation: args.explanation.trim(),
|
|
2111
|
+
suggestedAction: {
|
|
2112
|
+
mode: args.mode,
|
|
2113
|
+
prompt: args.prompt.trim()
|
|
2114
|
+
}
|
|
2115
|
+
};
|
|
2116
|
+
if (typeof args.selectionHint === "string" && args.selectionHint.trim()) {
|
|
2117
|
+
result.suggestedAction.selectionHint = args.selectionHint.trim();
|
|
2118
|
+
}
|
|
2119
|
+
if (args.targetNodeIds === "all") {
|
|
2120
|
+
result.suggestedAction.targetNodeIds = "all";
|
|
2121
|
+
} else if (Array.isArray(args.targetNodeIds)) {
|
|
2122
|
+
var ids = args.targetNodeIds
|
|
2123
|
+
.filter(function (id) { return typeof id === "string" && id.trim(); })
|
|
2124
|
+
.map(function (id) { return id.trim(); });
|
|
2125
|
+
if (ids.length) { result.suggestedAction.targetNodeIds = ids; }
|
|
2126
|
+
}
|
|
2127
|
+
return result;
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
// group_nodes (ADR-003 R3a / P10-B3): wraps the exact
|
|
2131
|
+
// RED.group.createGroup path already proven in apply-review.js's
|
|
2132
|
+
// applyGroupChanges create branch (~1356-1362) — no new group logic.
|
|
2133
|
+
// Pre-validates every id via findLiveNode (the established resolver
|
|
2134
|
+
// used by the other WRITE tool executors above, a superset of
|
|
2135
|
+
// RED.nodes.node that also resolves groups/junctions — needed here
|
|
2136
|
+
// specifically to detect a group id among nodeIds). "No partial
|
|
2137
|
+
// group": any missing id is an error, not a partial create. A
|
|
2138
|
+
// resolved id that's itself a group (nesting) or already belongs to a
|
|
2139
|
+
// group (would require editing that group's membership) is out of
|
|
2140
|
+
// scope for this minimal tool per ADR-003 and reported as
|
|
2141
|
+
// unsupported_operation instead of attempted.
|
|
2142
|
+
function executeGroupNodesTool(args, runIdMap, runHistoryEvents) {
|
|
2143
|
+
args = args || {};
|
|
2144
|
+
var name = typeof args.name === "string" ? args.name : "";
|
|
2145
|
+
var nodeIds = Array.isArray(args.nodeIds) ? args.nodeIds : [];
|
|
2146
|
+
if (!name || !nodeIds.length) {
|
|
2147
|
+
return buildWriteToolResult(args, [], { error: "group_nodes requires name and at least one nodeId" });
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
// CLAUDE-013: this is the exact call shape that surfaced the "node
|
|
2151
|
+
// not found: fp-new-2" error — a node created by an EARLIER
|
|
2152
|
+
// apply_step call in the same run, referenced here by its
|
|
2153
|
+
// placeholder id, with no idMap of its own to resolve against.
|
|
2154
|
+
var resolvedIds = nodeIds.map(function (id) {
|
|
2155
|
+
return (runIdMap && typeof id === "string" && runIdMap[id]) || id;
|
|
2156
|
+
});
|
|
2157
|
+
var uniqueIds = resolvedIds.filter(function (id, i) { return resolvedIds.indexOf(id) === i; });
|
|
2158
|
+
var missing = [], nested = [], alreadyGrouped = [], resolved = [];
|
|
2159
|
+
uniqueIds.forEach(function (id) {
|
|
2160
|
+
var live = findLiveNode(id);
|
|
2161
|
+
if (!live) { missing.push(id); return; }
|
|
2162
|
+
if (live.type === "group") { nested.push(id); return; }
|
|
2163
|
+
if (live.g) { alreadyGrouped.push(id); return; }
|
|
2164
|
+
resolved.push(live);
|
|
2165
|
+
});
|
|
2166
|
+
|
|
2167
|
+
if (missing.length) {
|
|
2168
|
+
return buildWriteToolResult(args, [], { error: "group_nodes: node id(s) not found: " + missing.join(", ") });
|
|
2169
|
+
}
|
|
2170
|
+
if (nested.length || alreadyGrouped.length) {
|
|
2171
|
+
var reasonParts = [];
|
|
2172
|
+
if (nested.length) { reasonParts.push("already a group: " + nested.join(", ")); }
|
|
2173
|
+
if (alreadyGrouped.length) { reasonParts.push("already in another group: " + alreadyGrouped.join(", ")); }
|
|
2174
|
+
return buildWriteToolResult(args, [], {
|
|
2175
|
+
unsupported: true,
|
|
2176
|
+
operation: "group_nodes",
|
|
2177
|
+
reason: "Nested groups and existing-group membership edits aren't supported (" + reasonParts.join("; ") + ").",
|
|
2178
|
+
available: ["apply_step", "remove_step", "rename_node", "group_nodes"]
|
|
2179
|
+
});
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
// CLAUDE-026: all resolved nodes must share one tab. Node-RED core's
|
|
2183
|
+
// own RED.group.createGroup (confirmed via source) registers an
|
|
2184
|
+
// EMPTY group via RED.nodes.addGroup() FIRST, using resolved[0].z,
|
|
2185
|
+
// and only THEN populates it via addToGroup — which validates every
|
|
2186
|
+
// node's .z matches and throws if not. createGroup catches that
|
|
2187
|
+
// throw itself, RED.notifies it, and returns undefined — but never
|
|
2188
|
+
// undoes the addGroup() call, so a mismatched-z request leaves a
|
|
2189
|
+
// real, empty, orphaned group on resolved[0]'s tab every time.
|
|
2190
|
+
// Checking up front avoids ever creating that orphan for this
|
|
2191
|
+
// (deterministic, reproducible-by-inspection) cause.
|
|
2192
|
+
var groupZ = resolved.length ? resolved[0].z : null;
|
|
2193
|
+
var mismatchedZ = resolved.some(function (n) { return n.z !== groupZ; });
|
|
2194
|
+
if (mismatchedZ) {
|
|
2195
|
+
return buildWriteToolResult(args, [], { error: "group_nodes: all nodes must be on the same tab to be grouped" });
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
// Belt-and-suspenders for any OTHER way createGroup can fail after
|
|
2199
|
+
// already registering that empty shell (a live QA run hit one:
|
|
2200
|
+
// "Node type not installed: group" on 1 of 3 attempts, whose exact
|
|
2201
|
+
// trigger wasn't pinned down) — snapshot the groups already on this
|
|
2202
|
+
// tab before each attempt, and if createGroup comes back falsy,
|
|
2203
|
+
// diff RED.nodes.groups(z) against the snapshot to find and remove
|
|
2204
|
+
// (via RED.group.ungroup — the same full-removal path
|
|
2205
|
+
// applyGroupChanges' disband branch already uses, confirmed safe on
|
|
2206
|
+
// a zero-member group) exactly what OUR call just orphaned, never
|
|
2207
|
+
// anything the user made themselves. One retry after cleanup in
|
|
2208
|
+
// case the underlying cause was transient.
|
|
2209
|
+
function attemptCreateGroup() {
|
|
2210
|
+
var before = {};
|
|
2211
|
+
RED.nodes.groups(groupZ).forEach(function (g) { before[g.id] = true; });
|
|
2212
|
+
var group;
|
|
2213
|
+
var caught = null;
|
|
2214
|
+
try {
|
|
2215
|
+
group = RED.group.createGroup(resolved);
|
|
2216
|
+
} catch (e) {
|
|
2217
|
+
caught = e;
|
|
2218
|
+
}
|
|
2219
|
+
if (!group) {
|
|
2220
|
+
RED.nodes.groups(groupZ).forEach(function (g) {
|
|
2221
|
+
if (!before[g.id]) { RED.group.ungroup(g); }
|
|
2222
|
+
});
|
|
2223
|
+
}
|
|
2224
|
+
return { group: group, error: caught };
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
var attempt = attemptCreateGroup();
|
|
2228
|
+
if (!attempt.group) { attempt = attemptCreateGroup(); }
|
|
2229
|
+
if (!attempt.group) {
|
|
2230
|
+
var errMsg = (attempt.error && attempt.error.message) || attempt.error || "createGroup returned nothing";
|
|
2231
|
+
return buildWriteToolResult(args, [], { error: "Failed to create group: " + errMsg });
|
|
2232
|
+
}
|
|
2233
|
+
var newGroup = attempt.group;
|
|
2234
|
+
newGroup.name = name;
|
|
2235
|
+
RED.group.markDirty(newGroup);
|
|
2236
|
+
// CLAUDE-027: collected into this run's shared accumulator (same
|
|
2237
|
+
// mechanism as applyInsertions/applyModifications above) rather than
|
|
2238
|
+
// pushed straight to RED.history, so this run's flush can fold it
|
|
2239
|
+
// together with the rest of this run's WRITE-tool calls into ONE
|
|
2240
|
+
// undo entry via t:"multi".
|
|
2241
|
+
var createGroupHistoryEvent = { t: "createGroup", groups: [newGroup], dirty: RED.nodes.dirty() };
|
|
2242
|
+
if (runHistoryEvents) { runHistoryEvents.push(createGroupHistoryEvent); } else { RED.history.push(createGroupHistoryEvent); }
|
|
2243
|
+
// markDirty alone doesn't force the editor to recompute the new
|
|
2244
|
+
// group's visible boundary immediately (apply-review.js's
|
|
2245
|
+
// applyInsertions already redraws after every insertion for the
|
|
2246
|
+
// same reason) — without this the group stays invisible until some
|
|
2247
|
+
// unrelated user action (zoom, node move) triggers a real redraw.
|
|
2248
|
+
RED.view.redraw(true);
|
|
2249
|
+
|
|
2250
|
+
// CLAUDE-020: group_nodes succeeded silently — no chat confirmation,
|
|
2251
|
+
// unlike its sibling WRITE-tool executor (apply-review.js's
|
|
2252
|
+
// applyInsertions), which reports a "Touchdown" note on every
|
|
2253
|
+
// successful insertion. Mirror that here so a Build-plan step that
|
|
2254
|
+
// groups nodes is actually confirmed in the chat, not just visible
|
|
2255
|
+
// on the canvas.
|
|
2256
|
+
var groupedNote = "Touchdown — created group \"" + name + "\" (" +
|
|
2257
|
+
resolved.length + " node(s)). Ctrl+Z to undo.";
|
|
2258
|
+
addMessage("assistant", groupedNote);
|
|
2259
|
+
pushHistory("assistant", groupedNote);
|
|
2260
|
+
updateSelectionStatus();
|
|
2261
|
+
|
|
2262
|
+
var steps = [
|
|
2263
|
+
{ check: "exists", nodeId: newGroup.id },
|
|
2264
|
+
{ check: "property", nodeId: newGroup.id, prop: "name", expected: name }
|
|
2265
|
+
];
|
|
2266
|
+
resolved.forEach(function (n) {
|
|
2267
|
+
steps.push({ check: "property", nodeId: n.id, prop: "g", expected: newGroup.id });
|
|
2268
|
+
});
|
|
2269
|
+
var checks = runChecksForToolResult(steps, {});
|
|
2270
|
+
return buildWriteToolResult(args, checks, { groupId: newGroup.id });
|
|
2271
|
+
}
|
|
2272
|
+
|
|
1565
2273
|
// Per-step narration: a short human-readable description of what a tool
|
|
1566
2274
|
// call is about to do, shown in the pending indicator (see runAgentChat).
|
|
1567
2275
|
function describeAgentToolCall(name, args) {
|
|
@@ -1579,6 +2287,18 @@
|
|
|
1579
2287
|
return "Checking the debug log…";
|
|
1580
2288
|
case "get_selection":
|
|
1581
2289
|
return "Checking the current selection…";
|
|
2290
|
+
case "apply_step":
|
|
2291
|
+
return "Applying step" + (args.summary ? ": " + args.summary : "") + "…";
|
|
2292
|
+
case "remove_step":
|
|
2293
|
+
return "Removing node" + (args.summary ? ": " + args.summary : " " + JSON.stringify(args.nodeId || "?")) + "…";
|
|
2294
|
+
case "rename_node":
|
|
2295
|
+
return "Renaming node to " + JSON.stringify(args.name || "?") + "…";
|
|
2296
|
+
case "group_nodes":
|
|
2297
|
+
return "Creating group " + JSON.stringify(args.name || "?") + "…";
|
|
2298
|
+
case "redirect_mode":
|
|
2299
|
+
return "Redirecting to " + JSON.stringify(args.mode || "?") + " mode…";
|
|
2300
|
+
case "ask_user":
|
|
2301
|
+
return "Asking a clarifying question…";
|
|
1582
2302
|
default:
|
|
1583
2303
|
return "Running " + (name || "a tool") + "…";
|
|
1584
2304
|
}
|
|
@@ -1592,7 +2312,7 @@
|
|
|
1592
2312
|
catch (e) { return {}; }
|
|
1593
2313
|
}
|
|
1594
2314
|
|
|
1595
|
-
function executeAgentToolCall(call) {
|
|
2315
|
+
function executeAgentToolCall(call, runIdMap, runHistoryEvents) {
|
|
1596
2316
|
var name = call && call.function && call.function.name;
|
|
1597
2317
|
var args = parseToolCallArgs(call);
|
|
1598
2318
|
switch (name) {
|
|
@@ -1608,6 +2328,21 @@
|
|
|
1608
2328
|
return executeReadDebugTool(args);
|
|
1609
2329
|
case "get_selection":
|
|
1610
2330
|
return executeGetSelectionTool();
|
|
2331
|
+
case "apply_step":
|
|
2332
|
+
return executeApplyStepTool(args, runIdMap, runHistoryEvents);
|
|
2333
|
+
case "remove_step":
|
|
2334
|
+
return executeRemoveStepTool(args, runIdMap, runHistoryEvents);
|
|
2335
|
+
case "rename_node":
|
|
2336
|
+
return executeRenameNodeTool(args, runIdMap, runHistoryEvents);
|
|
2337
|
+
case "group_nodes":
|
|
2338
|
+
return executeGroupNodesTool(args, runIdMap, runHistoryEvents);
|
|
2339
|
+
case "redirect_mode":
|
|
2340
|
+
return executeRedirectModeTool(args);
|
|
2341
|
+
case "ask_user":
|
|
2342
|
+
// Non-mutating and always intercepted by the agent loop
|
|
2343
|
+
// (modes.js's handleStep) before reaching here — this is a
|
|
2344
|
+
// safe fallback only, never expected in normal operation.
|
|
2345
|
+
return { error: "ask_user must be answered via the loop's question UI, not executed directly" };
|
|
1611
2346
|
default:
|
|
1612
2347
|
return { error: "Unknown tool: " + name };
|
|
1613
2348
|
}
|
|
@@ -1774,4 +2509,3 @@
|
|
|
1774
2509
|
|
|
1775
2510
|
renderChip("Open Settings", "fa fa-cog", showSettings);
|
|
1776
2511
|
}
|
|
1777
|
-
|