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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/core/main.js CHANGED
@@ -35,11 +35,35 @@
35
35
  // Every code path that appends DOM to #fp-messages also appends a record
36
36
  // here. refreshView() clears the message container and re-renders from
37
37
  // records, restoring interactive elements without losing conversation.
38
- // Records are in-memory only — no sessionStorage, no persistence.
38
+ // Records are in-memory only — no full sessionStorage persistence.
39
+ // Only the minimal interrupted-run marker is persisted separately.
39
40
  // ---------------------------------------------------------------------
40
41
  var messageRecords = [];
41
42
  var _nextRecordId = 0;
42
43
 
44
+ // P10-D1 (ADR-001 R5): per-conversation map of opId -> already-applied
45
+ // WRITE tool result. opId = runId + ":" + call.id (runId minted per
46
+ // runAgentLoop run, modes.js). A repeat opId (duplicate delivery, a
47
+ // retry, or the model repeating a call) returns the SAME result without
48
+ // re-invoking the executor — the graph is mutated at most once per
49
+ // opId. Keyed first by conversationId so switching conversations
50
+ // (clearChat / loading a saved transcript) can't cross-contaminate;
51
+ // cleared for the outgoing conversationId at those same points. In-
52
+ // memory only, matching messageRecords/conversationHistory's existing
53
+ // volatility (ADR-001: "persistence is minimal and schema-ready this
54
+ // phase" — full state-machine persistence is deferred to phase close).
55
+ var appliedOpsByConversation = {};
56
+
57
+ function getAppliedOp(convId, opId) {
58
+ var ops = appliedOpsByConversation[convId];
59
+ return ops ? ops[opId] : undefined;
60
+ }
61
+
62
+ function recordAppliedOp(convId, opId, result) {
63
+ if (!appliedOpsByConversation[convId]) { appliedOpsByConversation[convId] = {}; }
64
+ appliedOpsByConversation[convId][opId] = result;
65
+ }
66
+
43
67
  function addRecord(kind, payload) {
44
68
  var rec = { id: _nextRecordId++, ts: Date.now(), kind: kind };
45
69
  if (payload) {
@@ -83,6 +107,10 @@
83
107
  renderLoopCheckpoint(activeBuildLoop);
84
108
  } else if (rec.buildConsentGate) {
85
109
  renderBuildConsentGate(rec);
110
+ } else if (rec.agentToolConsent) {
111
+ renderAgentToolConsentGate(rec);
112
+ } else if (rec.askUserTool) {
113
+ renderAskUserQuestion(rec);
86
114
  } else {
87
115
  renderClarifyingQuestion(rec.options || []);
88
116
  }
@@ -223,7 +251,7 @@
223
251
  // side-effecting node's own real id here (status events report the
224
252
  // node itself, not a debug tap wired to it).
225
253
  if ((activeBuildLoop.skipCheckpointNodeIds || []).indexOf(nodeId) !== -1) { return; }
226
- if (attachedDebugMessages.length > 0) { return; }
254
+ if (freshBuildLoopEvidence(activeBuildLoop).length > 0) { return; }
227
255
 
228
256
  // Skip in-progress ("blue") statuses — e.g. http-request emits
229
257
  // fill:"blue" text:"requesting" before any response arrives. Locking
@@ -263,6 +291,27 @@
263
291
  }, BUILD_LOOP_ATTACH_DEBOUNCE_MS);
264
292
  }
265
293
 
294
+ // CLAUDE-025: attachedDebugMessages is deliberately STICKY across turns
295
+ // (see the block comment above — "sticky, like conversationHistory,
296
+ // until removed or Clear Chat"), which is exactly right for ordinary
297
+ // chat/generate/modify context. But the /build loop's own verification
298
+ // step must NOT inherit that stickiness: reviewing against a debug
299
+ // message left over from an earlier Build attempt (or one attached
300
+ // manually via the popout, or a still-running unrelated flow) as if it
301
+ // were evidence for THIS attempt's own goal is how a later attempt can
302
+ // declare Touchdown without ever having deployed or checked its own
303
+ // output. loop.deployedAt is stamped (see init.js's "deploy" listener)
304
+ // the moment THIS attempt's own "apply"->"attach" transition fires, so
305
+ // filtering on it scopes evidence to messages that arrived at or after
306
+ // that specific redeploy — never null (build review only runs once the
307
+ // loop has reached "attach", which requires deployedAt to be set).
308
+ function freshBuildLoopEvidence(loop) {
309
+ if (!loop || typeof loop.deployedAt !== "number") { return []; }
310
+ return attachedDebugMessages.filter(function (m) {
311
+ return m.timestamp >= loop.deployedAt;
312
+ });
313
+ }
314
+
266
315
  // The exact shape sent to the backend (and shown by "Preview debug") —
267
316
  // excludes previewValue, which exists only for the debug-log list.
268
317
  function buildDebugMessagesForSend() {
@@ -627,7 +676,21 @@
627
676
 
628
677
  // ---- View switching -------------------------------------------------
629
678
 
679
+ // CLAUDE-024: every mode-tab switch disarms whatever Execute action
680
+ // (Generate/Document/Modify) or Query intent was armed before the
681
+ // switch. Without this, arming Modify then tabbing away and back to
682
+ // Chat left armedExecuteAction pointing at the stale mutating action,
683
+ // so a plain question typed on what LOOKS like a fresh Chat tab still
684
+ // dispatched through Modify/Generate/Document on Send — a real
685
+ // unintended-mutation bug (reproduced: an unwanted node deletion).
686
+ // Send must always route to whatever is actually visible.
687
+ function disarmForModeSwitch() {
688
+ disarmExecuteAction();
689
+ disarmQueryIntent();
690
+ }
691
+
630
692
  function showChat() {
693
+ disarmForModeSwitch();
631
694
  el("#fp-chat-panel").removeClass("fp-hidden");
632
695
  el("#fp-settings-panel").addClass("fp-hidden");
633
696
  el("#fp-history-panel").addClass("fp-hidden");
@@ -637,6 +700,7 @@
637
700
  }
638
701
 
639
702
  function showSettings() {
703
+ disarmForModeSwitch();
640
704
  el("#fp-settings-panel").removeClass("fp-hidden");
641
705
  el("#fp-chat-panel").addClass("fp-hidden");
642
706
  el("#fp-history-panel").addClass("fp-hidden");
@@ -646,6 +710,7 @@
646
710
  }
647
711
 
648
712
  function showHistory() {
713
+ disarmForModeSwitch();
649
714
  el("#fp-history-panel").removeClass("fp-hidden");
650
715
  el("#fp-chat-panel").addClass("fp-hidden");
651
716
  el("#fp-settings-panel").addClass("fp-hidden");
@@ -658,6 +723,11 @@
658
723
  // Clears the visible chat AND resets the conversation history the model
659
724
  // sees — "start a fresh conversation".
660
725
  function clearChat() {
726
+ flowpilotStorageLog("log", "clearChat-enter", {
727
+ conversationId: conversationId,
728
+ messageCount: messageRecords.length,
729
+ href: location.href
730
+ });
661
731
  el("#fp-messages").empty();
662
732
  messageRecords = [];
663
733
  relayClearMessagesToPopout();
@@ -665,7 +735,9 @@
665
735
  attachedDebugMessages = [];
666
736
  activeBuildLoop = null;
667
737
  disarmExecuteAction(); // also clears pinnedSelectionIds
738
+ delete appliedOpsByConversation[conversationId];
668
739
  conversationId = newConversationId();
740
+ clearRunMarker("clearChat");
669
741
  fpChatSnappedToBottom = true;
670
742
  updateSelectionStatus();
671
743
  updateDebugStatus();
@@ -778,10 +850,21 @@
778
850
  // Switches to a past conversation: rebuilds conversationHistory and the
779
851
  // visible chat from its saved transcript, and continues using its
780
852
  // conversationId so new turns append to the same transcript file.
781
- function loadConversation(id) {
853
+ // onError: optional — CLAUDE-029's page-load rehydration passes one to
854
+ // stay quiet (no chat error bubble) and clear a stale sessionStorage
855
+ // entry on 404, instead of the ajaxJson default of addMessage("error", …).
856
+ function loadConversation(id, onError, onLoaded, options) {
857
+ options = options || {};
858
+ flowpilotStorageLog("log", "loadConversation-start", {
859
+ requestedConversationId: id,
860
+ currentConversationId: conversationId,
861
+ href: location.href
862
+ });
782
863
  ajaxJson("GET", "flowpilot/conversations/" + encodeURIComponent(id), null, function (data) {
864
+ delete appliedOpsByConversation[conversationId];
783
865
  conversationId = id;
784
- try { sessionStorage.setItem("fp-conversation-id", id); } catch (e) { /* storage unavailable */ }
866
+ persistConversationId(id, "loadConversation success");
867
+ if (!options.preserveRunMarker) { clearRunMarker("loadConversation"); }
785
868
 
786
869
  conversationHistory = [];
787
870
  relayClearMessagesToPopout();
@@ -796,7 +879,8 @@
796
879
  pinnedSelectionIds = null;
797
880
  updateSelectionStatus();
798
881
  showChat();
799
- });
882
+ if (onLoaded) { onLoaded(data); }
883
+ }, onError);
800
884
  }
801
885
 
802
886
  // Recall — searches OTHER past conversations' transcripts for the
@@ -963,7 +1047,9 @@
963
1047
  if (!$box.length) { return; }
964
1048
 
965
1049
  var label = role === "user" ? "YOU" : role === "error" ? "ERROR" : "FLOWPILOT";
966
- var cls = "fp-message" + (role === "user" ? " fp-user" : role === "error" ? " fp-error" : "");
1050
+ var cls = "fp-message" + (role === "user" ? " fp-user" :
1051
+ role === "error" ? " fp-error" :
1052
+ role === "fp-notice" ? " fp-secondary" : "");
967
1053
 
968
1054
  var $msg = $("<div>").addClass(cls);
969
1055
  $("<div>").addClass("fp-label").text(label).appendTo($msg);
@@ -982,17 +1068,52 @@
982
1068
  // removed in both the success and error paths so it can't get stuck.
983
1069
  // showStop adds a "Stop" button, used by the agent loop
984
1070
  // (runAgentChat) so the user can interrupt a multi-step tool-call run.
1071
+ //
1072
+ // fpPendingStartedAt / fpPendingElapsedInterval drive the live elapsed-time
1073
+ // counter (.fp-typing-elapsed). Real timing data (corpus + live browser
1074
+ // testing, ~150+ requests) is bimodal: the overwhelming majority finish in
1075
+ // 2-13s, with rare genuine outliers around 73-77s and nothing observed in
1076
+ // between. FP_PENDING_SLOW_MS picks 25s as the "this is a slow one"
1077
+ // threshold — comfortably past every normal request, comfortably before
1078
+ // the real outliers, so the reassurance text only ever appears when it's
1079
+ // actually warranted.
1080
+ var fpPendingStartedAt = null;
1081
+ var fpPendingElapsedInterval = null;
1082
+ var FP_PENDING_SLOW_MS = 25000;
1083
+
1084
+ function fpFormatElapsed(ms) {
1085
+ var totalSeconds = Math.floor(ms / 1000);
1086
+ var text;
1087
+ if (totalSeconds < 60) {
1088
+ text = totalSeconds + "s";
1089
+ } else {
1090
+ var mins = Math.floor(totalSeconds / 60);
1091
+ var secs = totalSeconds % 60;
1092
+ text = mins + "m " + secs + "s";
1093
+ }
1094
+ if (ms >= FP_PENDING_SLOW_MS) {
1095
+ text += " — still working, some requests take a minute or more";
1096
+ }
1097
+ return text;
1098
+ }
1099
+
985
1100
  function showPending(showStop) {
986
1101
  var $box = el("#fp-messages");
987
1102
  if (!$box.length) { return; }
988
1103
  // Guard against duplicates (e.g. fast double-send).
989
1104
  $box.find("#fp-pending").remove();
1105
+ if (fpPendingElapsedInterval) {
1106
+ clearInterval(fpPendingElapsedInterval);
1107
+ fpPendingElapsedInterval = null;
1108
+ }
990
1109
 
991
1110
  var $msg = $("<div>").addClass("fp-message").attr("id", "fp-pending");
992
1111
  $("<div>").addClass("fp-label").text("FLOWPILOT").appendTo($msg);
993
1112
  var $dots = $("<div>").addClass("fp-typing").attr("title", "Working…");
994
1113
  $dots.append($("<span>"), $("<span>"), $("<span>"));
995
1114
  $dots.append($("<span>").addClass("fp-typing-label").text("Cruising…"));
1115
+ var $elapsed = $("<span>").addClass("fp-typing-elapsed");
1116
+ $dots.append($elapsed);
996
1117
  if (showStop) {
997
1118
  $dots.append($("<button>")
998
1119
  .addClass("fp-agent-stop red-ui-button red-ui-button-small")
@@ -1000,6 +1121,7 @@
1000
1121
  .text("Stop")
1001
1122
  .on("click", function () {
1002
1123
  fpAgentStopRequested = true;
1124
+ if (fpCurrentAgentRequest) { fpCurrentAgentRequest.abort(); }
1003
1125
  $(this).prop("disabled", true).text("Stopping…");
1004
1126
  }));
1005
1127
  }
@@ -1007,10 +1129,28 @@
1007
1129
 
1008
1130
  $box.append($msg);
1009
1131
  scrollMessagesToBottom();
1132
+
1133
+ fpPendingStartedAt = Date.now();
1134
+ fpPendingElapsedInterval = setInterval(function () {
1135
+ var $label = el("#fp-pending .fp-typing-elapsed");
1136
+ if (!$label.length) {
1137
+ // Panel closed / #fp-pending gone without hidePending firing;
1138
+ // stop polling instead of leaking the interval forever.
1139
+ clearInterval(fpPendingElapsedInterval);
1140
+ fpPendingElapsedInterval = null;
1141
+ return;
1142
+ }
1143
+ $label.text(" · " + fpFormatElapsed(Date.now() - fpPendingStartedAt));
1144
+ }, 1000);
1010
1145
  }
1011
1146
 
1012
1147
  function hidePending() {
1013
1148
  el("#fp-messages").find("#fp-pending").remove();
1149
+ if (fpPendingElapsedInterval) {
1150
+ clearInterval(fpPendingElapsedInterval);
1151
+ fpPendingElapsedInterval = null;
1152
+ }
1153
+ fpPendingStartedAt = null;
1014
1154
  }
1015
1155
 
1016
1156
  // Updates the narration text shown in the pending indicator while the
@@ -1071,7 +1211,7 @@
1071
1211
  }
1072
1212
 
1073
1213
  function ajaxJson(method, url, payload, onSuccess, onError) {
1074
- $.ajax({
1214
+ return $.ajax({
1075
1215
  url: flowpilotUrl(url),
1076
1216
  method: method,
1077
1217
  contentType: "application/json",
@@ -1084,7 +1224,14 @@
1084
1224
  },
1085
1225
  success: onSuccess,
1086
1226
  error: function (xhr) {
1087
- var msg = (xhr.responseJSON && xhr.responseJSON.error) ||
1227
+ // Prefer .message (human-readable text) over .error (a
1228
+ // machine code like "provider_unconfirmed" or
1229
+ // "agent_strategy_unavailable") when both are present — a
1230
+ // plain-string-only error response (the common case,
1231
+ // {error:"..."} with no .message) is untouched, since
1232
+ // .message is simply absent there.
1233
+ var msg = (xhr.responseJSON && xhr.responseJSON.message) ||
1234
+ (xhr.responseJSON && xhr.responseJSON.error) ||
1088
1235
  xhr.responseText || xhr.statusText || "Unknown error";
1089
1236
  if (onError) { onError(msg, xhr); }
1090
1237
  else { addMessage("error", msg); }
@@ -1125,7 +1272,11 @@
1125
1272
  el("#fp-base-url").attr("placeholder", isAnthropic ? "Leave blank for api.anthropic.com" : "http://localhost:8080");
1126
1273
  }
1127
1274
 
1128
- // Write the form fields from a given provider profile.
1275
+ // Write the form fields from a given provider profile. p.apiKey is
1276
+ // never a real key here — the server masks it to a sentinel/"" on every
1277
+ // response (see maskProviderSecrets, flowpilot.js) — so leaving the
1278
+ // field untouched on save round-trips harmlessly (reconcileProviderSecrets,
1279
+ // lib/storage.js, keeps the real stored key).
1129
1280
  function fillProviderFields(p) {
1130
1281
  p = p || {};
1131
1282
  el("#fp-provider-name").val(p.providerName || "");
@@ -1134,20 +1285,24 @@
1134
1285
  toggleAnthropicHint(type);
1135
1286
  el("#fp-base-url").val(p.baseUrl || "");
1136
1287
  el("#fp-api-key").val(p.apiKey || "");
1288
+ el("#fp-api-key").attr("placeholder", p.hasApiKey ? "Saved — leave to keep, retype to change" : "Optional");
1137
1289
  el("#fp-model").val(p.model || "");
1290
+ el("#fp-num-ctx").val(p.numCtx !== undefined ? p.numCtx : 0);
1138
1291
  el("#fp-temperature").val(p.temperature !== undefined ? p.temperature : 0.2);
1139
1292
  // Test provider is disabled until this provider has a model.
1140
1293
  el("#fp-test-provider").prop("disabled", !(p.model && String(p.model).trim()));
1141
1294
  }
1142
1295
 
1143
- // Short descriptive band shown under the Personality slider, matching
1144
- // the reference points lib/persona-prompt.js gives the model.
1296
+ // CLAUDE-032: 5 discrete levels (was 1-10 with sparse interpolated
1297
+ // anchors live testing found no discernible voice difference across
1298
+ // the old scale). Labels match lib/persona-prompt.js's PERSONA_LEVELS
1299
+ // exactly, one label per slider position, no interpolation.
1145
1300
  function personaLabelFor(n) {
1146
1301
  n = Number(n);
1147
1302
  if (n <= 1) { return "Plain engineer — no aviation language at all."; }
1148
- if (n <= 4) { return "Subtle co-pilot (default) — light, occasional flavor."; }
1149
- if (n <= 7) { return "Noticeable captain energy — more frequent, more colorful."; }
1150
- if (n <= 9) { return "Heavy captain energy — leans hard into the bit."; }
1303
+ if (n === 2) { return "Subtle co-pilot (default) — light, occasional flavor."; }
1304
+ if (n === 3) { return "Noticeable captain energy — a sentence or two, every time."; }
1305
+ if (n === 4) { return "Heavy captain energy — leans hard into the bit."; }
1151
1306
  return "Full captain — comically over-the-top.";
1152
1307
  }
1153
1308
 
@@ -1157,32 +1312,86 @@
1157
1312
  el("#fp-persona-label").text(personaLabelFor(n));
1158
1313
  }
1159
1314
 
1160
- function fillSettings(settings) {
1315
+ function hideUpdateBanner() {
1316
+ el("#fp-update-banner").addClass("fp-hidden").empty();
1317
+ }
1318
+
1319
+ function showUpdateBanner(data) {
1320
+ var $banner = el("#fp-update-banner");
1321
+ if (!$banner.length) { return; }
1322
+ $banner.empty().removeClass("fp-hidden");
1323
+ $banner.append(document.createTextNode("Update available (v" + data.latestVersion + ") — see Palette Manager to install. "));
1324
+ $("<a>").attr("href", "#").text("Dismiss").on("click", function (ev) {
1325
+ ev.preventDefault();
1326
+ try {
1327
+ sessionStorage.setItem("fp-update-dismissed-" + data.latestVersion, "1");
1328
+ } catch (e) { /* storage unavailable */ }
1329
+ hideUpdateBanner();
1330
+ }).appendTo($banner);
1331
+ }
1332
+
1333
+ function checkForFlowPilotUpdates() {
1334
+ if (isPopoutContext) { return; }
1335
+ ajaxJson("GET", "flowpilot/update-check", null, function (data) {
1336
+ if (!data || data.enabled === false || data.updateAvailable === false) {
1337
+ hideUpdateBanner();
1338
+ return;
1339
+ }
1340
+ try {
1341
+ if (sessionStorage.getItem("fp-update-dismissed-" + data.latestVersion)) {
1342
+ hideUpdateBanner();
1343
+ return;
1344
+ }
1345
+ } catch (e) { /* storage unavailable */ }
1346
+ showUpdateBanner(data);
1347
+ }, function () {
1348
+ hideUpdateBanner();
1349
+ });
1350
+ }
1351
+
1352
+ function fillSettings(settings, reason) {
1161
1353
  settings = settings || {};
1162
1354
  currentSettings = settings;
1163
1355
 
1164
1356
  renderProviderDropdown();
1165
1357
  fillProviderFields(activeProvider());
1166
1358
 
1359
+ el("#fp-flowpilot-version").text("FlowPilot v" + (settings.flowpilotVersion || "unknown"));
1360
+ var systemPromptCurrent = el("#fp-system-prompt").val();
1361
+ var systemPromptIncoming = settings.systemPrompt || "";
1362
+ console.log("[FlowPilot][G3] fillSettings systemPrompt", {
1363
+ reason: reason || "unspecified",
1364
+ current: systemPromptCurrent,
1365
+ incoming: settings.systemPrompt,
1366
+ writing: systemPromptIncoming,
1367
+ differs: systemPromptCurrent !== systemPromptIncoming
1368
+ });
1167
1369
  el("#fp-system-prompt").val(settings.systemPrompt || "");
1168
- el("#fp-persona-intensity").val(settings.personaIntensity !== undefined ? settings.personaIntensity : 3);
1370
+ el("#fp-persona-intensity").val(settings.personaIntensity !== undefined ? settings.personaIntensity : 2);
1169
1371
  updatePersonaLabel();
1170
1372
  el("#fp-warn-tokens").val(settings.contextWarnTokens || 4000);
1171
1373
  el("#fp-high-tokens").val(settings.contextHighTokens || 8000);
1172
1374
  el("#fp-history-max").val(settings.historyMaxExchanges !== undefined ? settings.historyMaxExchanges : 10);
1173
1375
  el("#fp-streaming-enabled").prop("checked", !!settings.streamingEnabled);
1174
- el("#fp-request-timeout").val(Math.round((settings.requestTimeoutMs !== undefined ? settings.requestTimeoutMs : 180000) / 1000));
1376
+ el("#fp-request-timeout").val(settings.requestTimeoutMs !== undefined ? (settings.requestTimeoutMs / 1000) : 180);
1377
+ el("#fp-check-for-updates").prop("checked", settings.checkForUpdates !== false);
1378
+ el("#fp-agent-turn-max-tokens").val(settings.agentTurnMaxTokens !== undefined ? settings.agentTurnMaxTokens : 4096);
1379
+ el("#fp-agent-loop-token-ceiling").val(settings.agentLoopTokenCeiling !== undefined ? settings.agentLoopTokenCeiling : 50000);
1175
1380
  el("#fp-agent-loop-max-iterations").val(settings.agentLoopMaxIterations !== undefined ? settings.agentLoopMaxIterations : 5);
1176
1381
  el("#fp-loop-hold-step").prop("checked", !!settings.loopHoldStep);
1177
1382
  el("#fp-suppress-warnings").prop("checked", !!settings.suppressContextWarnings);
1178
1383
  el("#fp-redaction-disabled").prop("checked", settings.redactionEnabled === false);
1384
+ el("#fp-debug-logging").prop("checked", !!settings.debugLogging);
1179
1385
 
1180
- // The dev/test banner is part of the warning set the user can silence
1181
- // via the type-to-confirm acknowledgement.
1386
+ // Plain checkbox-driven dev/test warning icon visibility; this warning
1387
+ // no longer uses a type-to-confirm acknowledgement.
1182
1388
  if (settings.suppressContextWarnings) {
1183
- el("#fp-dev-banner").addClass("fp-hidden");
1389
+ el("#fp-dev-warning-status").addClass("fp-hidden");
1184
1390
  } else {
1185
- el("#fp-dev-banner").removeClass("fp-hidden");
1391
+ el("#fp-dev-warning-status").removeClass("fp-hidden");
1392
+ }
1393
+ if (settings.checkForUpdates === false) {
1394
+ hideUpdateBanner();
1186
1395
  }
1187
1396
 
1188
1397
  // Custom intents may have changed; rebuild buttons and the editor list.
@@ -1224,21 +1433,21 @@
1224
1433
  ap.baseUrl = el("#fp-base-url").val() || "";
1225
1434
  ap.apiKey = el("#fp-api-key").val() || "";
1226
1435
  ap.model = el("#fp-model").val() || "";
1436
+ ap.numCtx = Math.max(0, Number(el("#fp-num-ctx").val() || 0));
1227
1437
  ap.temperature = Number(el("#fp-temperature").val() || 0.2);
1228
1438
  currentSettings.providers = list;
1229
1439
  }
1230
1440
 
1231
1441
  function collectSettings() {
1232
- // Only honour the suppression toggle if the confirmation phrase was
1233
- // typed exactly. Otherwise warnings stay on regardless of the checkbox.
1234
- var wantSuppress = el("#fp-suppress-warnings").prop("checked");
1235
- var typed = (el("#fp-suppress-confirm").val() || "").trim();
1236
- var suppress = wantSuppress && typed === "I understand the risk";
1237
-
1238
- // Same type-to-confirm gate as suppressContextWarnings above, and for
1239
- // the same reason: the confirm box is never pre-filled from settings,
1240
- // so disabling redaction stays off unless re-confirmed on every save —
1241
- // "off-able, not off-by-accident".
1442
+ // Plain checkbox no confirmation phrase required (Manny's explicit
1443
+ // call: excessive friction for this particular warning, unlike the
1444
+ // redaction opt-out below which stays gated).
1445
+ var suppress = el("#fp-suppress-warnings").prop("checked");
1446
+
1447
+ // Same type-to-confirm gate as suppressContextWarnings used to have,
1448
+ // and for the same reason: the confirm box is never pre-filled from
1449
+ // settings, so disabling redaction stays off unless re-confirmed on
1450
+ // every save "off-able, not off-by-accident".
1242
1451
  var wantRedactionOff = el("#fp-redaction-disabled").prop("checked");
1243
1452
  var redactionTyped = (el("#fp-redaction-confirm").val() || "").trim();
1244
1453
  var redactionEnabled = !(wantRedactionOff && redactionTyped === "disable redaction");
@@ -1250,28 +1459,38 @@
1250
1459
  if (!isFinite(historyMax) || historyMax < 0) { historyMax = 10; }
1251
1460
 
1252
1461
  var requestTimeoutSec = Number(el("#fp-request-timeout").val());
1253
- if (!isFinite(requestTimeoutSec) || requestTimeoutSec < 5) { requestTimeoutSec = 180; }
1254
1462
 
1255
1463
  var personaIntensity = Number(el("#fp-persona-intensity").val());
1256
- if (!isFinite(personaIntensity) || personaIntensity < 1 || personaIntensity > 10) { personaIntensity = 3; }
1464
+ if (!isFinite(personaIntensity) || personaIntensity < 1 || personaIntensity > 5) { personaIntensity = 2; }
1257
1465
 
1258
1466
  var agentLoopMaxIterations = Number(el("#fp-agent-loop-max-iterations").val());
1259
1467
  if (!isFinite(agentLoopMaxIterations) || agentLoopMaxIterations < 1) { agentLoopMaxIterations = 5; }
1260
1468
 
1469
+ var agentTurnMaxTokens = Number(el("#fp-agent-turn-max-tokens").val());
1470
+ var agentLoopTokenCeiling = Number(el("#fp-agent-loop-token-ceiling").val());
1471
+ var systemPrompt = el("#fp-system-prompt").val();
1472
+ console.log("[FlowPilot][G3] saveSettings reading systemPrompt", {
1473
+ value: systemPrompt
1474
+ });
1475
+
1261
1476
  return {
1262
1477
  providers: providersList(),
1263
1478
  activeProviderId: currentSettings.activeProviderId,
1264
- systemPrompt: el("#fp-system-prompt").val(),
1479
+ systemPrompt: systemPrompt,
1265
1480
  personaIntensity: personaIntensity,
1266
1481
  contextWarnTokens: Number(el("#fp-warn-tokens").val() || 4000),
1267
1482
  contextHighTokens: Number(el("#fp-high-tokens").val() || 8000),
1268
1483
  historyMaxExchanges: historyMax,
1269
1484
  streamingEnabled: el("#fp-streaming-enabled").prop("checked"),
1270
1485
  requestTimeoutMs: Math.round(requestTimeoutSec * 1000),
1486
+ checkForUpdates: el("#fp-check-for-updates").prop("checked"),
1487
+ agentTurnMaxTokens: agentTurnMaxTokens,
1488
+ agentLoopTokenCeiling: agentLoopTokenCeiling,
1271
1489
  agentLoopMaxIterations: agentLoopMaxIterations,
1272
1490
  loopHoldStep: el("#fp-loop-hold-step").prop("checked"),
1273
1491
  suppressContextWarnings: suppress,
1274
1492
  redactionEnabled: redactionEnabled,
1493
+ debugLogging: el("#fp-debug-logging").prop("checked"),
1275
1494
  customIntents: Array.isArray(currentSettings.customIntents)
1276
1495
  ? currentSettings.customIntents : []
1277
1496
  };
@@ -1307,6 +1526,7 @@
1307
1526
  baseUrl: "http://localhost:8080",
1308
1527
  apiKey: "",
1309
1528
  model: "",
1529
+ numCtx: 0,
1310
1530
  temperature: 0.2
1311
1531
  });
1312
1532
  currentSettings.providers = list;
@@ -1400,7 +1620,7 @@
1400
1620
  return;
1401
1621
  }
1402
1622
  ajaxJson("GET", "flowpilot/settings", null, function (data) {
1403
- fillSettings(data);
1623
+ fillSettings(data, "loadSettings GET response");
1404
1624
  maybeShowFirstRun(data);
1405
1625
  updateSelectionStatus();
1406
1626
  }, function (msg) {
@@ -1417,6 +1637,28 @@
1417
1637
  var payload = collectSettings();
1418
1638
  var list = payload.providers || [];
1419
1639
 
1640
+ if (!isFinite(payload.requestTimeoutMs) || payload.requestTimeoutMs <= 0) {
1641
+ var timeoutMsg = "Cannot save: request timeout must be a positive number of seconds.";
1642
+ addMessage("error", timeoutMsg);
1643
+ if (announce) { showSaveStatus(timeoutMsg, true); }
1644
+ showSettings();
1645
+ return;
1646
+ }
1647
+ if (!isFinite(payload.agentTurnMaxTokens) || payload.agentTurnMaxTokens <= 0 || Math.floor(payload.agentTurnMaxTokens) !== payload.agentTurnMaxTokens) {
1648
+ var tokenMsg = "Cannot save: max tokens per model response (agent step) must be a positive whole number.";
1649
+ addMessage("error", tokenMsg);
1650
+ if (announce) { showSaveStatus(tokenMsg, true); }
1651
+ showSettings();
1652
+ return;
1653
+ }
1654
+ if (!isFinite(payload.agentLoopTokenCeiling) || payload.agentLoopTokenCeiling <= 0 || Math.floor(payload.agentLoopTokenCeiling) !== payload.agentLoopTokenCeiling) {
1655
+ var ceilingMsg = "Cannot save: max total tokens per agent turn must be a positive whole number.";
1656
+ addMessage("error", ceilingMsg);
1657
+ if (announce) { showSaveStatus(ceilingMsg, true); }
1658
+ showSettings();
1659
+ return;
1660
+ }
1661
+
1420
1662
  // Validation 1: every non-Anthropic provider needs a base URL.
1421
1663
  // Anthropic providers default to api.anthropic.com when baseUrl is blank.
1422
1664
  var noUrl = list.filter(function (p) {
@@ -1470,7 +1712,7 @@
1470
1712
  }
1471
1713
 
1472
1714
  ajaxJson("POST", "flowpilot/settings", payload, function (data) {
1473
- fillSettings(data);
1715
+ fillSettings(data, "saveSettings POST response");
1474
1716
  addMessage("assistant", "Settings saved.");
1475
1717
  if (announce) { showSaveStatus("Settings saved."); }
1476
1718
  updateSelectionStatus();
@@ -1649,6 +1891,470 @@
1649
1891
  return Object.assign({ selected: true }, context);
1650
1892
  }
1651
1893
 
1894
+ // ---------------------------------------------------------------------
1895
+ // W7 — WRITE tools (§16 of FlowPilot-Phase10-Rescope-Scoping.md).
1896
+ //
1897
+ // Exact argument schemas as landed by CODEX-005 (coordinated via
1898
+ // mailbox, 2026-07-26 19:28/19:37 UTC — these are NOT a client-side
1899
+ // guess): CODEX-005 deliberately reused the EXISTING Modify-envelope
1900
+ // field names/shapes so the client applies WRITE tool calls through
1901
+ // the SAME pipeline as the ordinary (non-agentic) Modify apply path —
1902
+ // apply_step: { summary, changes?: [{id, set}], newNodes?: [{id,
1903
+ // type, ...}], newWires?: [{from, fromPort, to}] }
1904
+ // `changes[].set` is a sparse property patch, `newNodes`/`newWires`
1905
+ // are byte-identical in shape to the top-level Modify envelope's
1906
+ // own fields (applyInsertions already accepts them directly).
1907
+ // "One call is one todo item even when these arrays contain a
1908
+ // small bundle" (§16 point 2) — arrays may hold more than one
1909
+ // entry in a single call.
1910
+ // remove_step: { summary, nodeId }
1911
+ // rename_node: { summary, nodeId, name }
1912
+ // ask_user: { question, options? } — tier "write-safe", non-mutating,
1913
+ // handled entirely in the agent loop (modes.js), never reaches
1914
+ // here in normal flow.
1915
+ //
1916
+ // Tier ("write-gated" vs "write-safe") arrives OUT OF BAND per call as
1917
+ // data.toolTiers[call.id] (modes.js reads this), not by tool name — a
1918
+ // write-gated tier means the call is ELIGIBLE for consent-gating, not
1919
+ // that every call of that tool prompts (CODEX-005's own clarification):
1920
+ // §16 point 4 still requires classifying the ACTUAL touched node
1921
+ // type(s) against SAFE_NODE_TYPES before deciding to gate.
1922
+ //
1923
+ // tool_result shape (§16 point 3 — mirrors the existing verifySteps
1924
+ // check vocabulary, scoped strictly to this call's own touched ids):
1925
+ // { requested, checks: [{check, nodeId|fromId/toId, prop?, expected?,
1926
+ // pass}], allPass, error? }
1927
+ // ---------------------------------------------------------------------
1928
+
1929
+ // Mirrors flowpilot.js's SAFE_NODE_TYPES verbatim (§16 point 4: "reuse
1930
+ // SAFE_NODE_TYPES/classifyFlowNodes verbatim"). This is a client-side
1931
+ // COPY, not a shared module — lib/core/*.js has no require/import, and
1932
+ // the server's set lives in a separate Node.js process. Must be kept in
1933
+ // sync by hand; flagged in the mailbox report as a drift risk to watch
1934
+ // if the server-side list ever changes.
1935
+ var WRITE_GATE_SAFE_NODE_TYPES = new Set([
1936
+ "inject", "function", "change", "switch", "filter", "json", "xml", "csv",
1937
+ "base64", "html", "split", "join", "sort", "batch", "debug", "status",
1938
+ "comment", "link in", "link out", "link call", "junction"
1939
+ ]);
1940
+
1941
+ // Collects the node type(s) a WRITE tool call would touch — a NEW
1942
+ // node's own declared type, or an EXISTING node's live type looked up
1943
+ // via findLiveNode (apply-review.js, same closure). Used by
1944
+ // writeToolCallNeedsConsent (modes.js) to decide whether to gate.
1945
+ function collectWriteToolTouchedTypes(name, args) {
1946
+ args = args || {};
1947
+ var types = [];
1948
+ function addLiveType(id) {
1949
+ if (!id) { return; }
1950
+ var live = findLiveNode(id);
1951
+ if (live) { types.push(live.type); }
1952
+ }
1953
+ switch (name) {
1954
+ case "apply_step":
1955
+ (Array.isArray(args.newNodes) ? args.newNodes : []).forEach(function (n) {
1956
+ if (n && n.type) { types.push(n.type); }
1957
+ });
1958
+ (Array.isArray(args.newWires) ? args.newWires : []).forEach(function (w) {
1959
+ if (!w) { return; }
1960
+ addLiveType(w.from);
1961
+ addLiveType(w.to);
1962
+ });
1963
+ (Array.isArray(args.changes) ? args.changes : []).forEach(function (c) {
1964
+ if (c) { addLiveType(c.id); }
1965
+ });
1966
+ break;
1967
+ case "remove_step":
1968
+ addLiveType(args.nodeId);
1969
+ break;
1970
+ case "rename_node":
1971
+ addLiveType(args.nodeId);
1972
+ break;
1973
+ }
1974
+ return types;
1975
+ }
1976
+
1977
+ // §16 point 4: gate ONLY when the call's tier is "write-gated" AND it
1978
+ // touches a node type outside SAFE_NODE_TYPES; autonomously apply
1979
+ // everything else (including every "write-safe" call, e.g. ask_user,
1980
+ // which never reaches this function at all in practice — see
1981
+ // handleStep). An id that doesn't resolve to a live node at all
1982
+ // (hallucinated id, or a brand-new node referenced by a wire before it
1983
+ // exists) can't be proven safe, so it's treated conservatively as
1984
+ // side-effecting — mirrors WS4's classifyFlowNodes default (unknown =>
1985
+ // sideEffecting).
1986
+ function writeToolCallNeedsConsent(tier, name, args) {
1987
+ if (tier !== "write-gated") { return false; }
1988
+ var types = collectWriteToolTouchedTypes(name, args);
1989
+ if (!types.length) { return true; }
1990
+ return types.some(function (t) { return !WRITE_GATE_SAFE_NODE_TYPES.has(t); });
1991
+ }
1992
+
1993
+ // Builds the tool_result envelope from a checks array already carrying
1994
+ // .pass — shared tail for all three WRITE tool executors below.
1995
+ function buildWriteToolResult(requestedArgs, checks, extra) {
1996
+ var allPass = checks.length > 0 && checks.every(function (c) { return c.pass; });
1997
+ return Object.assign({ requested: requestedArgs, checks: checks, allPass: allPass }, extra || {});
1998
+ }
1999
+
2000
+ // Runs the verifySteps-style check vocabulary (runSingleVerifyCheck,
2001
+ // modes.js, same closure) against a list of steps in this call's own
2002
+ // shape, scoped strictly to the touched id(s) — never a flow-wide
2003
+ // snapshot (§16 point 3).
2004
+ function runChecksForToolResult(steps, idMap) {
2005
+ var checks = [];
2006
+ (steps || []).forEach(function (step) {
2007
+ var result = runSingleVerifyCheck(step, idMap);
2008
+ if (!result) { return; }
2009
+ checks.push(Object.assign({}, step, { pass: result.ok }));
2010
+ });
2011
+ return checks;
2012
+ }
2013
+
2014
+ // apply_step: newNodes/newWires go through applyInsertions — the SAME
2015
+ // layout/collision-avoidance/wiring path Generate/Modify insertions
2016
+ // already use, since CODEX-005 matched that exact field shape — then
2017
+ // changes[].set (sparse property patches on EXISTING nodes) goes
2018
+ // through applyModifications's Tier 1, using the resulting idMap so a
2019
+ // change value can reference a placeholder id from newNodes in the
2020
+ // SAME call (mirrors the existing "insertions run first so their
2021
+ // placeholder→real-id map is available" ordering in
2022
+ // addModifyReview's apply button). `changes[].set.wires` is the ONE
2023
+ // exception: CLAUDE-009-fix — an existing node's wiring is never a
2024
+ // generic property (mirrors computeNodeDiff's `if (k === "wires") {
2025
+ // wiresChanged = true; return; }`), so it's split out and routed
2026
+ // through computeWireDiff/Tier 3 (RED.nodes.addLink/removeLink,
2027
+ // canWire port validation) instead of a raw `liveNode.wires = val`
2028
+ // assignment, which would "succeed" without ever updating the link
2029
+ // registry the canvas and RED.nodes.eachLink actually read from.
2030
+ function executeApplyStepTool(args, runIdMap, runHistoryEvents) {
2031
+ args = args || {};
2032
+ var changes = Array.isArray(args.changes) ? args.changes : [];
2033
+ var newNodes = Array.isArray(args.newNodes) ? args.newNodes : [];
2034
+ var newWires = Array.isArray(args.newWires) ? args.newWires : [];
2035
+
2036
+ var idMap = {};
2037
+ var steps = [];
2038
+
2039
+ // CLAUDE-013: resolves a placeholder id (e.g. "fp-new-2") through
2040
+ // this call's own idMap first (ids it just created via
2041
+ // applyInsertions), then through runIdMap (ids created by an
2042
+ // EARLIER WRITE-tool call in the same agent run) — so referencing a
2043
+ // node created two tool calls ago works the same as referencing one
2044
+ // created in this call.
2045
+ function resolveId(id) {
2046
+ if (typeof id !== "string") { return id; }
2047
+ if (idMap && idMap[id]) { return idMap[id]; }
2048
+ if (runIdMap && runIdMap[id]) { return runIdMap[id]; }
2049
+ return id;
2050
+ }
2051
+
2052
+ if (newNodes.length || newWires.length) {
2053
+ idMap = applyInsertions(newNodes, newWires, [], runHistoryEvents) || {};
2054
+ newNodes.forEach(function (n) {
2055
+ if (n && n.id) { steps.push({ check: "exists", nodeId: n.id }); }
2056
+ });
2057
+ newWires.forEach(function (w) {
2058
+ if (w && w.from && w.to) { steps.push({ check: "wire", fromId: w.from, fromPort: w.fromPort || 0, toId: w.to }); }
2059
+ });
2060
+ }
2061
+
2062
+ if (changes.length) {
2063
+ var propDiffs = changes.filter(function (c) { return c && c.id && c.set && typeof c.set === "object"; })
2064
+ .map(function (c) {
2065
+ var nodeId = resolveId(c.id);
2066
+ var live = findLiveNode(nodeId);
2067
+ var hasWireChange = Object.prototype.hasOwnProperty.call(c.set, "wires");
2068
+ var propertyChanges = Object.keys(c.set).filter(function (k) { return k !== "wires"; })
2069
+ .map(function (k) {
2070
+ return { key: k, oldVal: live ? live[k] : undefined, newVal: c.set[k] };
2071
+ });
2072
+ var wiresDiff = { toAdd: [], toRemove: [] };
2073
+ if (hasWireChange && live) {
2074
+ // The tool-call path has no "flow the model had in
2075
+ // context" boundary the way the classic envelope's
2076
+ // validTargetIds does (scoped to the returned "flow"
2077
+ // array) — the model only sends this one node's
2078
+ // desired wires, not a full flow. Per sr-dev's
2079
+ // guidance, treat every id the diff could actually
2080
+ // flag for removal as valid: computeWireDiff only
2081
+ // ever consults validTargetIds for ids already found
2082
+ // among this node's CURRENT live targets, so seeding
2083
+ // validTargetIds from those current targets is
2084
+ // equivalent to "always valid" without needing a
2085
+ // magic always-true object.
2086
+ var validTargetIds = {};
2087
+ RED.nodes.eachLink(function (l) {
2088
+ if (l.source && l.source.id === nodeId && l.target) { validTargetIds[l.target.id] = true; }
2089
+ });
2090
+ wiresDiff = computeWireDiff(nodeId, c.set.wires, validTargetIds);
2091
+ }
2092
+ return {
2093
+ modNode: { id: nodeId },
2094
+ propertyChanges: propertyChanges,
2095
+ wiresChanged: hasWireChange && !!live,
2096
+ wiresDiff: wiresDiff
2097
+ };
2098
+ });
2099
+ // CLAUDE-013: applyModifications's own Tier 1 substitution
2100
+ // (apply-review.js) resolves c.set[k] placeholder values through
2101
+ // whatever idMap it's given — passing the call-local idMap alone
2102
+ // would WRITE the raw unresolved placeholder string onto the live
2103
+ // node (e.g. an mqtt-out's "broker" left as "fp-new-broker")
2104
+ // whenever the referenced node was created by an EARLIER call
2105
+ // this run, even though the verify step below correctly expects
2106
+ // the real id — a live-value/verify-step mismatch caught by
2107
+ // testing, not named explicitly in the ticket's cited line
2108
+ // numbers. Merge runIdMap in so the actual mutation and the
2109
+ // verify step agree.
2110
+ if (propDiffs.length) { applyModifications(propDiffs, [], null, Object.assign({}, runIdMap, idMap), runHistoryEvents); }
2111
+ changes.forEach(function (c) {
2112
+ if (!c || !c.id || !c.set || typeof c.set !== "object") { return; }
2113
+ var nodeId = resolveId(c.id);
2114
+ Object.keys(c.set).filter(function (k) { return k !== "wires"; }).forEach(function (k) {
2115
+ // Resolve through idMap/runIdMap the SAME way the diff
2116
+ // above just did, so a set value referencing a
2117
+ // placeholder from THIS call's own newNodes (e.g. an
2118
+ // mqtt-out's "broker" pointing at a new mqtt-broker) OR
2119
+ // from an EARLIER call this run is checked against what
2120
+ // actually landed, not the raw unresolved placeholder
2121
+ // string.
2122
+ var expected = resolveId(c.set[k]);
2123
+ steps.push({ check: "property", nodeId: nodeId, prop: k, expected: expected });
2124
+ });
2125
+ if (Object.prototype.hasOwnProperty.call(c.set, "wires")) {
2126
+ // Emit one "wire" check per desired (port, target) pair —
2127
+ // the same check shape/granularity newWires already uses
2128
+ // above — so the model's requested final wiring state is
2129
+ // verified against the live graph, not a property key.
2130
+ var desiredWires = Array.isArray(c.set.wires) ? c.set.wires : [];
2131
+ desiredWires.forEach(function (targets, port) {
2132
+ (Array.isArray(targets) ? targets : []).forEach(function (targetId) {
2133
+ steps.push({ check: "wire", fromId: nodeId, fromPort: port, toId: resolveId(targetId) });
2134
+ });
2135
+ });
2136
+ }
2137
+ });
2138
+ }
2139
+
2140
+ if (!steps.length) {
2141
+ return buildWriteToolResult(args, [], { error: "apply_step call had nothing to apply" });
2142
+ }
2143
+ var checks = runChecksForToolResult(steps, idMap);
2144
+ var extra = {};
2145
+ if (idMap && Object.keys(idMap).length) { extra.idMap = idMap; }
2146
+ return buildWriteToolResult(args, checks, extra);
2147
+ }
2148
+
2149
+ function executeRemoveStepTool(args, runIdMap, runHistoryEvents) {
2150
+ args = args || {};
2151
+ if (!args.nodeId) {
2152
+ return buildWriteToolResult(args, [], { error: "remove_step requires nodeId" });
2153
+ }
2154
+ // CLAUDE-013: args.nodeId may be a placeholder minted by an EARLIER
2155
+ // WRITE-tool call this run (e.g. "insert node, then remove it").
2156
+ var nodeId = (runIdMap && typeof args.nodeId === "string" && runIdMap[args.nodeId]) || args.nodeId;
2157
+ applyModifications([], [nodeId], null, {}, runHistoryEvents);
2158
+ var checks = runChecksForToolResult([{ check: "absent", nodeId: nodeId }], {});
2159
+ return buildWriteToolResult(args, checks);
2160
+ }
2161
+
2162
+ function executeRenameNodeTool(args, runIdMap, runHistoryEvents) {
2163
+ args = args || {};
2164
+ if (!args.nodeId || typeof args.name !== "string") {
2165
+ return buildWriteToolResult(args, [], { error: "rename_node requires nodeId and name" });
2166
+ }
2167
+ // CLAUDE-013: same placeholder resolution as executeRemoveStepTool.
2168
+ var nodeId = (runIdMap && typeof args.nodeId === "string" && runIdMap[args.nodeId]) || args.nodeId;
2169
+ var live = findLiveNode(nodeId);
2170
+ var diff = [{
2171
+ modNode: { id: nodeId },
2172
+ propertyChanges: [{ key: "name", oldVal: live ? live.name : undefined, newVal: args.name }],
2173
+ wiresChanged: false,
2174
+ wiresDiff: { toAdd: [], toRemove: [] }
2175
+ }];
2176
+ applyModifications(diff, [], null, {}, runHistoryEvents);
2177
+ var checks = runChecksForToolResult([{ check: "property", nodeId: nodeId, prop: "name", expected: args.name }], {});
2178
+ return buildWriteToolResult(args, checks);
2179
+ }
2180
+
2181
+ function executeRedirectModeTool(args) {
2182
+ args = args || {};
2183
+ if (["generate", "document", "chat"].indexOf(args.mode) === -1) {
2184
+ return { error: "redirect_mode requires mode = generate, document, or chat" };
2185
+ }
2186
+ if (typeof args.prompt !== "string" || !args.prompt.trim()) {
2187
+ return { error: "redirect_mode requires prompt" };
2188
+ }
2189
+ if (typeof args.explanation !== "string" || !args.explanation.trim()) {
2190
+ return { error: "redirect_mode requires explanation" };
2191
+ }
2192
+
2193
+ var result = {
2194
+ redirected: true,
2195
+ explanation: args.explanation.trim(),
2196
+ suggestedAction: {
2197
+ mode: args.mode,
2198
+ prompt: args.prompt.trim()
2199
+ }
2200
+ };
2201
+ if (typeof args.selectionHint === "string" && args.selectionHint.trim()) {
2202
+ result.suggestedAction.selectionHint = args.selectionHint.trim();
2203
+ }
2204
+ if (args.targetNodeIds === "all" || args.targetNodeIds === "instance") {
2205
+ result.suggestedAction.targetNodeIds = args.targetNodeIds;
2206
+ } else if (Array.isArray(args.targetNodeIds)) {
2207
+ var ids = args.targetNodeIds
2208
+ .filter(function (id) { return typeof id === "string" && id.trim(); })
2209
+ .map(function (id) { return id.trim(); });
2210
+ if (ids.length) { result.suggestedAction.targetNodeIds = ids; }
2211
+ }
2212
+ return result;
2213
+ }
2214
+
2215
+ // group_nodes (ADR-003 R3a / P10-B3): wraps the exact
2216
+ // RED.group.createGroup path already proven in apply-review.js's
2217
+ // applyGroupChanges create branch (~1356-1362) — no new group logic.
2218
+ // Pre-validates every id via findLiveNode (the established resolver
2219
+ // used by the other WRITE tool executors above, a superset of
2220
+ // RED.nodes.node that also resolves groups/junctions — needed here
2221
+ // specifically to detect a group id among nodeIds). "No partial
2222
+ // group": any missing id is an error, not a partial create. A
2223
+ // resolved id that's itself a group (nesting) or already belongs to a
2224
+ // group (would require editing that group's membership) is out of
2225
+ // scope for this minimal tool per ADR-003 and reported as
2226
+ // unsupported_operation instead of attempted.
2227
+ function executeGroupNodesTool(args, runIdMap, runHistoryEvents) {
2228
+ args = args || {};
2229
+ var name = typeof args.name === "string" ? args.name : "";
2230
+ var nodeIds = Array.isArray(args.nodeIds) ? args.nodeIds : [];
2231
+ if (!name || !nodeIds.length) {
2232
+ return buildWriteToolResult(args, [], { error: "group_nodes requires name and at least one nodeId" });
2233
+ }
2234
+
2235
+ // CLAUDE-013: this is the exact call shape that surfaced the "node
2236
+ // not found: fp-new-2" error — a node created by an EARLIER
2237
+ // apply_step call in the same run, referenced here by its
2238
+ // placeholder id, with no idMap of its own to resolve against.
2239
+ var resolvedIds = nodeIds.map(function (id) {
2240
+ return (runIdMap && typeof id === "string" && runIdMap[id]) || id;
2241
+ });
2242
+ var uniqueIds = resolvedIds.filter(function (id, i) { return resolvedIds.indexOf(id) === i; });
2243
+ var missing = [], nested = [], alreadyGrouped = [], resolved = [];
2244
+ uniqueIds.forEach(function (id) {
2245
+ var live = findLiveNode(id);
2246
+ if (!live) { missing.push(id); return; }
2247
+ if (live.type === "group") { nested.push(id); return; }
2248
+ if (live.g) { alreadyGrouped.push(id); return; }
2249
+ resolved.push(live);
2250
+ });
2251
+
2252
+ if (missing.length) {
2253
+ return buildWriteToolResult(args, [], { error: "group_nodes: node id(s) not found: " + missing.join(", ") });
2254
+ }
2255
+ if (nested.length || alreadyGrouped.length) {
2256
+ var reasonParts = [];
2257
+ if (nested.length) { reasonParts.push("already a group: " + nested.join(", ")); }
2258
+ if (alreadyGrouped.length) { reasonParts.push("already in another group: " + alreadyGrouped.join(", ")); }
2259
+ return buildWriteToolResult(args, [], {
2260
+ unsupported: true,
2261
+ operation: "group_nodes",
2262
+ reason: "Nested groups and existing-group membership edits aren't supported (" + reasonParts.join("; ") + ").",
2263
+ available: ["apply_step", "remove_step", "rename_node", "group_nodes"]
2264
+ });
2265
+ }
2266
+
2267
+ // CLAUDE-026: all resolved nodes must share one tab. Node-RED core's
2268
+ // own RED.group.createGroup (confirmed via source) registers an
2269
+ // EMPTY group via RED.nodes.addGroup() FIRST, using resolved[0].z,
2270
+ // and only THEN populates it via addToGroup — which validates every
2271
+ // node's .z matches and throws if not. createGroup catches that
2272
+ // throw itself, RED.notifies it, and returns undefined — but never
2273
+ // undoes the addGroup() call, so a mismatched-z request leaves a
2274
+ // real, empty, orphaned group on resolved[0]'s tab every time.
2275
+ // Checking up front avoids ever creating that orphan for this
2276
+ // (deterministic, reproducible-by-inspection) cause.
2277
+ var groupZ = resolved.length ? resolved[0].z : null;
2278
+ var mismatchedZ = resolved.some(function (n) { return n.z !== groupZ; });
2279
+ if (mismatchedZ) {
2280
+ return buildWriteToolResult(args, [], { error: "group_nodes: all nodes must be on the same tab to be grouped" });
2281
+ }
2282
+
2283
+ // Belt-and-suspenders for any OTHER way createGroup can fail after
2284
+ // already registering that empty shell (a live QA run hit one:
2285
+ // "Node type not installed: group" on 1 of 3 attempts, whose exact
2286
+ // trigger wasn't pinned down) — snapshot the groups already on this
2287
+ // tab before each attempt, and if createGroup comes back falsy,
2288
+ // diff RED.nodes.groups(z) against the snapshot to find and remove
2289
+ // (via RED.group.ungroup — the same full-removal path
2290
+ // applyGroupChanges' disband branch already uses, confirmed safe on
2291
+ // a zero-member group) exactly what OUR call just orphaned, never
2292
+ // anything the user made themselves. One retry after cleanup in
2293
+ // case the underlying cause was transient.
2294
+ function attemptCreateGroup() {
2295
+ var before = {};
2296
+ RED.nodes.groups(groupZ).forEach(function (g) { before[g.id] = true; });
2297
+ var group;
2298
+ var caught = null;
2299
+ try {
2300
+ group = RED.group.createGroup(resolved);
2301
+ } catch (e) {
2302
+ caught = e;
2303
+ }
2304
+ if (!group) {
2305
+ RED.nodes.groups(groupZ).forEach(function (g) {
2306
+ if (!before[g.id]) { RED.group.ungroup(g); }
2307
+ });
2308
+ }
2309
+ return { group: group, error: caught };
2310
+ }
2311
+
2312
+ var attempt = attemptCreateGroup();
2313
+ if (!attempt.group) { attempt = attemptCreateGroup(); }
2314
+ if (!attempt.group) {
2315
+ var errMsg = (attempt.error && attempt.error.message) || attempt.error || "createGroup returned nothing";
2316
+ return buildWriteToolResult(args, [], { error: "Failed to create group: " + errMsg });
2317
+ }
2318
+ var newGroup = attempt.group;
2319
+ newGroup.name = name;
2320
+ RED.group.markDirty(newGroup);
2321
+ // CLAUDE-027: collected into this run's shared accumulator (same
2322
+ // mechanism as applyInsertions/applyModifications above) rather than
2323
+ // pushed straight to RED.history, so this run's flush can fold it
2324
+ // together with the rest of this run's WRITE-tool calls into ONE
2325
+ // undo entry via t:"multi".
2326
+ var createGroupHistoryEvent = { t: "createGroup", groups: [newGroup], dirty: RED.nodes.dirty() };
2327
+ if (runHistoryEvents) { runHistoryEvents.push(createGroupHistoryEvent); } else { RED.history.push(createGroupHistoryEvent); }
2328
+ // markDirty alone doesn't force the editor to recompute the new
2329
+ // group's visible boundary immediately (apply-review.js's
2330
+ // applyInsertions already redraws after every insertion for the
2331
+ // same reason) — without this the group stays invisible until some
2332
+ // unrelated user action (zoom, node move) triggers a real redraw.
2333
+ RED.view.redraw(true);
2334
+
2335
+ // CLAUDE-020: group_nodes succeeded silently — no chat confirmation,
2336
+ // unlike its sibling WRITE-tool executor (apply-review.js's
2337
+ // applyInsertions), which reports a "Touchdown" note on every
2338
+ // successful insertion. Mirror that here so a Build-plan step that
2339
+ // groups nodes is actually confirmed in the chat, not just visible
2340
+ // on the canvas.
2341
+ var groupedNote = "Touchdown — created group \"" + name + "\" (" +
2342
+ resolved.length + " node(s)). Ctrl+Z to undo.";
2343
+ addMessage("assistant", groupedNote);
2344
+ pushHistory("assistant", groupedNote);
2345
+ updateSelectionStatus();
2346
+
2347
+ var steps = [
2348
+ { check: "exists", nodeId: newGroup.id },
2349
+ { check: "property", nodeId: newGroup.id, prop: "name", expected: name }
2350
+ ];
2351
+ resolved.forEach(function (n) {
2352
+ steps.push({ check: "property", nodeId: n.id, prop: "g", expected: newGroup.id });
2353
+ });
2354
+ var checks = runChecksForToolResult(steps, {});
2355
+ return buildWriteToolResult(args, checks, { groupId: newGroup.id });
2356
+ }
2357
+
1652
2358
  // Per-step narration: a short human-readable description of what a tool
1653
2359
  // call is about to do, shown in the pending indicator (see runAgentChat).
1654
2360
  function describeAgentToolCall(name, args) {
@@ -1666,6 +2372,18 @@
1666
2372
  return "Checking the debug log…";
1667
2373
  case "get_selection":
1668
2374
  return "Checking the current selection…";
2375
+ case "apply_step":
2376
+ return "Applying step" + (args.summary ? ": " + args.summary : "") + "…";
2377
+ case "remove_step":
2378
+ return "Removing node" + (args.summary ? ": " + args.summary : " " + JSON.stringify(args.nodeId || "?")) + "…";
2379
+ case "rename_node":
2380
+ return "Renaming node to " + JSON.stringify(args.name || "?") + "…";
2381
+ case "group_nodes":
2382
+ return "Creating group " + JSON.stringify(args.name || "?") + "…";
2383
+ case "redirect_mode":
2384
+ return "Redirecting to " + JSON.stringify(args.mode || "?") + " mode…";
2385
+ case "ask_user":
2386
+ return "Asking a clarifying question…";
1669
2387
  default:
1670
2388
  return "Running " + (name || "a tool") + "…";
1671
2389
  }
@@ -1679,7 +2397,7 @@
1679
2397
  catch (e) { return {}; }
1680
2398
  }
1681
2399
 
1682
- function executeAgentToolCall(call) {
2400
+ function executeAgentToolCall(call, runIdMap, runHistoryEvents) {
1683
2401
  var name = call && call.function && call.function.name;
1684
2402
  var args = parseToolCallArgs(call);
1685
2403
  switch (name) {
@@ -1695,6 +2413,21 @@
1695
2413
  return executeReadDebugTool(args);
1696
2414
  case "get_selection":
1697
2415
  return executeGetSelectionTool();
2416
+ case "apply_step":
2417
+ return executeApplyStepTool(args, runIdMap, runHistoryEvents);
2418
+ case "remove_step":
2419
+ return executeRemoveStepTool(args, runIdMap, runHistoryEvents);
2420
+ case "rename_node":
2421
+ return executeRenameNodeTool(args, runIdMap, runHistoryEvents);
2422
+ case "group_nodes":
2423
+ return executeGroupNodesTool(args, runIdMap, runHistoryEvents);
2424
+ case "redirect_mode":
2425
+ return executeRedirectModeTool(args);
2426
+ case "ask_user":
2427
+ // Non-mutating and always intercepted by the agent loop
2428
+ // (modes.js's handleStep) before reaching here — this is a
2429
+ // safe fallback only, never expected in normal operation.
2430
+ return { error: "ask_user must be answered via the loop's question UI, not executed directly" };
1698
2431
  default:
1699
2432
  return { error: "Unknown tool: " + name };
1700
2433
  }
@@ -1774,6 +2507,9 @@
1774
2507
  } else {
1775
2508
  var warnAt = Number(currentSettings.contextWarnTokens) || 4000;
1776
2509
  var highAt = Number(currentSettings.contextHighTokens) || 8000;
2510
+ var ap = activeProvider();
2511
+ var numCtx = ap ? Number(ap.numCtx) : 0;
2512
+ var hasNumCtx = isFinite(numCtx) && numCtx > 0;
1777
2513
 
1778
2514
  var parts = [];
1779
2515
  if (contextTokens) { parts.push("context ~" + contextTokens.toLocaleString()); }
@@ -1784,9 +2520,15 @@
1784
2520
  }
1785
2521
  var sizeText = "~" + tokens.toLocaleString() + " tokens" +
1786
2522
  (parts.length ? " (" + parts.join(", ") + ")" : "");
2523
+ if (hasNumCtx) {
2524
+ sizeText += " — " + Math.round((tokens / numCtx) * 100) + "% of " +
2525
+ (ap.providerName || "this provider") + "'s " + numCtx.toLocaleString() +
2526
+ " context window";
2527
+ }
2528
+ var nearProviderLimit = hasNumCtx && tokens >= numCtx * 0.8;
1787
2529
 
1788
2530
  $size.removeClass("fp-hidden fp-size-warn fp-size-high");
1789
- if (tokens >= highAt) {
2531
+ if (tokens >= highAt || nearProviderLimit) {
1790
2532
  $size.text(sizeText + " — large; may exceed smaller local models. " +
1791
2533
  "Consider selecting fewer nodes, clearing chat history, or splitting your request.")
1792
2534
  .addClass("fp-size-high");