@manny-est/node-red-flowpilot 0.6.0-beta.1 → 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/modes.js CHANGED
@@ -273,6 +273,65 @@
273
273
  dispatch();
274
274
  }
275
275
 
276
+ function summarizeConversationHistory() {
277
+ if (isPopoutContext) {
278
+ addMessage("assistant", "`/summarize` only runs in the main FlowPilot panel.");
279
+ return;
280
+ }
281
+
282
+ var totalMessages = conversationHistory.length;
283
+ if (totalMessages <= 4) {
284
+ addMessage("assistant", "Nothing much to summarize yet — only " + totalMessages +
285
+ " message(s) so far.");
286
+ return;
287
+ }
288
+
289
+ var recentTail = conversationHistory.slice(-4);
290
+ var olderSlice = conversationHistory.slice(0, -4);
291
+ var beforeTokens = estimateTokens(olderSlice);
292
+ var prompt = "Summarize the earlier conversation history provided here. " +
293
+ "Keep it concise and factual. Preserve decisions, constraints, important context, " +
294
+ "and unresolved questions. Return plain text only.";
295
+ var payload = {
296
+ prompt: prompt,
297
+ context: null,
298
+ history: olderSlice.slice(),
299
+ historyTruncated: false,
300
+ conversationId: conversationId,
301
+ strategy: "classic",
302
+ entry: "chat"
303
+ };
304
+
305
+ setBusy(true);
306
+ showPending(false);
307
+
308
+ ajaxJson("POST", "flowpilot/chat", payload, function (data) {
309
+ hidePending();
310
+ var summaryText = String(data && data.message ? data.message : "").trim();
311
+ if (!summaryText) {
312
+ addMessage("error", "Summarize failed: no summary text returned.");
313
+ setBusy(false);
314
+ updateSelectionStatus();
315
+ return;
316
+ }
317
+
318
+ var summaryMessage = {
319
+ role: "assistant",
320
+ content: "[Summary of earlier conversation]\n" + summaryText
321
+ };
322
+ conversationHistory = [summaryMessage].concat(recentTail);
323
+ addMessage("assistant", "Compacted " + olderSlice.length + " earlier message(s) into a summary (~" +
324
+ beforeTokens.toLocaleString() + " → ~" + estimateTokens(summaryMessage).toLocaleString() + " tokens).");
325
+ setBusy(false);
326
+ updateSelectionStatus();
327
+ }, function (msg) {
328
+ hidePending();
329
+ addMessage("error", "Summarize failed: " + msg);
330
+ setBusy(false);
331
+ updateSelectionStatus();
332
+ });
333
+ }
334
+
276
335
  // ---------------------------------------------------------------------
277
336
  // Bounded read-tool loop, shared by chat and
278
337
  // generate/document/modify ("explore-then-propose"). Sends the
@@ -301,7 +360,6 @@
301
360
  // than erroring out.
302
361
  // ---------------------------------------------------------------------
303
362
  var AGENT_LOOP_MAX_STEPS = 8;
304
- var AGENT_LOOP_TOKEN_CEILING = 50000;
305
363
  // W7 §16 point 5: ask_user round-trips get their own small budget,
306
364
  // separate from AGENT_LOOP_MAX_STEPS, so a couple of clarifying
307
365
  // questions don't eat the real step budget.
@@ -323,6 +381,8 @@
323
381
  if (!payload || !payload.strategy || !payload.entry) {
324
382
  throw new Error("runAgentLoop requires strategy and entry in the initial payload.");
325
383
  }
384
+ var AGENT_LOOP_TOKEN_CEILING = (Number(currentSettings.agentLoopTokenCeiling) > 0)
385
+ ? Number(currentSettings.agentLoopTokenCeiling) : 50000;
326
386
  var runStrategy = payload.strategy;
327
387
  var runEntry = payload.entry;
328
388
  // P10-D1 follow-up (sr-dev review): captured once, like
@@ -422,12 +482,27 @@
422
482
  var runEvents = [];
423
483
  var runRec = null;
424
484
 
485
+ function syncRunMarker(reason) {
486
+ if (!runRec) { return; }
487
+ writeRunMarker({
488
+ runId: runId,
489
+ action: runEntry,
490
+ appliedCount: runEvents.filter(function (e) { return e.t === "applied"; }).length,
491
+ conversationId: runConversationId
492
+ }, reason || "syncRunMarker");
493
+ }
494
+
425
495
  function recordRunEvent(t, extra) {
426
496
  if (!runRec) {
427
497
  runRec = addRecord("todo", { action: runEntry, items: [], events: runEvents });
428
498
  }
429
499
  runEvents.push(Object.assign({ t: t, at: Date.now() }, extra || {}));
430
500
  runRec.events = runEvents;
501
+ if (t === "done") {
502
+ clearRunMarker("run done: " + runId);
503
+ } else {
504
+ syncRunMarker("run event: " + t);
505
+ }
431
506
  }
432
507
 
433
508
  function addUsage(usage) {
@@ -466,7 +541,8 @@
466
541
  }, stepExtra, {
467
542
  strategy: runStrategy,
468
543
  entry: runEntry,
469
- runId: runId
544
+ runId: runId,
545
+ events: runEvents.slice()
470
546
  });
471
547
  if (pendingDebugNote) {
472
548
  stepPayload.debugNote = pendingDebugNote;
@@ -540,7 +616,11 @@
540
616
  }
541
617
 
542
618
  function continueWithResult(resultObj) {
543
- if (isWriteTool) { agentWriteResults.push({ allPass: !!(resultObj && resultObj.allPass) }); }
619
+ var _reason = null;
620
+ if (resultObj && !resultObj.allPass) {
621
+ _reason = resultObj.error || resultObj.reason || null;
622
+ }
623
+ if (isWriteTool) { agentWriteResults.push({ allPass: !!(resultObj && resultObj.allPass), reason: _reason }); }
544
624
  nextMessages.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify(resultObj) });
545
625
  processToolCallsFrom(calls, idx + 1, nextMessages, toolTiers);
546
626
  }
@@ -642,9 +722,10 @@
642
722
  if (totalTokens > AGENT_LOOP_TOKEN_CEILING) {
643
723
  if (runRec) { recordRunEvent("interrupted", { detail: "token ceiling exceeded" }); rerenderTodoRecord(runRec); }
644
724
  onError("FlowPilot stopped after using " + totalTokens +
645
- " tokens on this turn without a final answer. Try " +
646
- "selecting fewer nodes, or asking a more specific " +
647
- "question so fewer tool calls are needed.");
725
+ " tokens on this turn (limit: " + AGENT_LOOP_TOKEN_CEILING +
726
+ ") without a final answer. Try selecting fewer nodes, asking a " +
727
+ "more specific question, or raising \"Max total tokens per " +
728
+ "agent turn\" in Settings → Behavior.");
648
729
  return;
649
730
  }
650
731
  if (fpAgentStopRequested) {
@@ -961,10 +1042,11 @@
961
1042
  // overwriting on a non-empty result, so a bad/empty resolution
962
1043
  // doesn't blow away a legitimate live-selection pin.
963
1044
  if (suggestedAction.targetNodeIds === "all") {
964
- var activeTabId = RED.workspaces && RED.workspaces.active ? RED.workspaces.active() : null;
965
- var allTabIds = [];
966
- RED.nodes.eachNode(function (n) { if (n.z === activeTabId) { allTabIds.push(n.id); } });
1045
+ var allTabIds = allActiveTabNodeIds();
967
1046
  if (allTabIds.length) { pinnedSelectionIds = allTabIds; }
1047
+ } else if (suggestedAction.targetNodeIds === "instance") {
1048
+ var allInstanceIds = allInstanceNodeIds();
1049
+ if (allInstanceIds.length) { pinnedSelectionIds = allInstanceIds; }
968
1050
  } else if (Array.isArray(suggestedAction.targetNodeIds)) {
969
1051
  var resolvedIds = suggestedAction.targetNodeIds.filter(function (id) {
970
1052
  return !!findLiveNode(id);
@@ -1038,6 +1120,7 @@
1038
1120
  // here: if this chip targets Document and doesn't carry a real
1039
1121
  // resolved target, always show the one hint that's actually true.
1040
1122
  var targetResolved = suggestedAction.targetNodeIds === "all" ||
1123
+ suggestedAction.targetNodeIds === "instance" ||
1041
1124
  (Array.isArray(suggestedAction.targetNodeIds) && suggestedAction.targetNodeIds.length > 0);
1042
1125
  var hintText = (suggestedAction.mode === "document" && !targetResolved)
1043
1126
  ? "Select the node(s) you want documented first."
@@ -1203,7 +1286,34 @@
1203
1286
 
1204
1287
  // Lay nodes out before review/import — see layoutGeneratedFlow for why.
1205
1288
  var flow = Array.isArray(data.flow) ? layoutGeneratedFlow(data.flow) : data.flow;
1206
- addMessage("assistant", data.explanation || "(no explanation returned)");
1289
+ var planItems = parseTodoPlan(data.explanation || "");
1290
+ var todoRec = null;
1291
+ var writeResults = Array.isArray(data._agentWriteResults) ? data._agentWriteResults : [];
1292
+ if (writeResults.length) {
1293
+ if (!planItems.length) {
1294
+ planItems = [{ text: goalPrompt || "Generate flow", status: "pending" }];
1295
+ }
1296
+ planItems.forEach(function (item, i) {
1297
+ item.status = (i < writeResults.length)
1298
+ ? (writeResults[i].allPass ? "done" : "failed")
1299
+ : "active";
1300
+ });
1301
+ if (data._agentRunRecord) {
1302
+ todoRec = data._agentRunRecord;
1303
+ todoRec.action = "generate";
1304
+ todoRec.items = planItems;
1305
+ } else {
1306
+ todoRec = addRecord("todo", { action: "generate", items: planItems });
1307
+ }
1308
+ rerenderTodoRecord(todoRec);
1309
+ var deterministicSummary = buildDeterministicRunSummary(planItems, writeResults);
1310
+ addMessage("assistant", deterministicSummary || "(no explanation returned)");
1311
+ if (shouldShowSecondaryExplanation(deterministicSummary, data.explanation)) {
1312
+ addMessage("fp-notice", data.explanation);
1313
+ }
1314
+ } else {
1315
+ addMessage("assistant", data.explanation || "(no explanation returned)");
1316
+ }
1207
1317
  pushHistory("assistant", data.explanation || "(no explanation returned)");
1208
1318
  // B1: bake the deploy-verify option into the review panel as the
1209
1319
  // primary chip rather than a separate chip below it. Only for
@@ -1214,7 +1324,9 @@
1214
1324
  var _buildOnImported = (goalPrompt && !activeBuildLoop && _hasDeployable)
1215
1325
  ? function (importResult) { startBuildLoop(goalPrompt, flow, importResult); }
1216
1326
  : null;
1217
- addGeneratedReview(flow, _buildOnImported, _buildOnImported ? goalPrompt : null);
1327
+ if (!writeResults.length) {
1328
+ addGeneratedReview(flow, _buildOnImported, _buildOnImported ? goalPrompt : null);
1329
+ }
1218
1330
  // Suppress a server-suggested build chip when deploy-verify is already
1219
1331
  // the primary action inside the review panel — it would be a duplicate.
1220
1332
  renderActionChip(_buildOnImported && data.suggestedAction && data.suggestedAction.mode === "build"
@@ -1286,7 +1398,15 @@
1286
1398
  rerenderTodoRecord(todoRec);
1287
1399
  }
1288
1400
 
1289
- addMessage("assistant", data.explanation || "(no explanation returned)");
1401
+ if (writeResults.length) {
1402
+ var deterministicModifySummary = buildDeterministicRunSummary(planItems, writeResults);
1403
+ addMessage("assistant", deterministicModifySummary || "(no explanation returned)");
1404
+ if (shouldShowSecondaryExplanation(deterministicModifySummary, data.explanation)) {
1405
+ addMessage("fp-notice", data.explanation);
1406
+ }
1407
+ } else {
1408
+ addMessage("assistant", data.explanation || "(no explanation returned)");
1409
+ }
1290
1410
  pushHistory("assistant", data.explanation || "(no explanation returned)");
1291
1411
  if (data.skippedNote) { addMessage("assistant", "⚠ " + data.skippedNote); }
1292
1412
 
@@ -1487,6 +1607,8 @@
1487
1607
 
1488
1608
  var ap = activeProvider();
1489
1609
  var isAgentLoop = ap && ap.supportsTools;
1610
+ var useAgentStrategy = isAgentLoop && endpointName === "generate" &&
1611
+ currentSettings.enableAgentWrite === true;
1490
1612
 
1491
1613
  setBusy(true);
1492
1614
  showPending(isAgentLoop);
@@ -1494,7 +1616,7 @@
1494
1616
  prompt: prompt, context: context,
1495
1617
  history: historyPayload.messages, historyTruncated: historyPayload.truncated,
1496
1618
  conversationId: conversationId,
1497
- strategy: "classic",
1619
+ strategy: useAgentStrategy ? "agent" : "classic",
1498
1620
  entry: endpointName
1499
1621
  };
1500
1622
 
@@ -1559,6 +1681,40 @@
1559
1681
  return items;
1560
1682
  }
1561
1683
 
1684
+ function buildDeterministicRunSummary(planItems, writeResults) {
1685
+ var lines = [];
1686
+ var unreached = 0;
1687
+ var items = planItems || [];
1688
+ var counted = Math.max(items.length, (writeResults || []).length);
1689
+ for (var i = 0; i < counted; i++) {
1690
+ var item = items[i];
1691
+ if (item && (i >= writeResults.length || item.status === "active" || item.status === "pending")) {
1692
+ unreached++;
1693
+ continue;
1694
+ }
1695
+ var itemText = (item && item.text) ? item.text : ("Step " + (i + 1));
1696
+ if (writeResults[i] && writeResults[i].allPass) {
1697
+ lines.push("✓ " + itemText);
1698
+ continue;
1699
+ }
1700
+ var reason = (writeResults[i] && writeResults[i].reason) || "failed verification";
1701
+ lines.push("✗ " + itemText + " — " + reason);
1702
+ }
1703
+ if (unreached) {
1704
+ lines.push("▶ " + unreached + " step(s) not reached");
1705
+ }
1706
+ return lines.join("\n");
1707
+ }
1708
+
1709
+ function shouldShowSecondaryExplanation(summaryText, explanationText) {
1710
+ if (!explanationText || typeof explanationText !== "string") { return false; }
1711
+ if (!summaryText || typeof summaryText !== "string") { return true; }
1712
+ function normalize(text) {
1713
+ return String(text || "").replace(/\s+/g, " ").trim().toLowerCase();
1714
+ }
1715
+ return normalize(summaryText) !== normalize(explanationText);
1716
+ }
1717
+
1562
1718
  // W4: render or re-render a "todo" record. For a 1-item plan, renders
1563
1719
  // as a compact status line (one chip). For N>1 items, renders as a
1564
1720
  // checklist card. Updates in place when the record already has a
@@ -1819,6 +1975,7 @@
1819
1975
  hidePending();
1820
1976
  if (renderQuestionOrProse(data)) { return; }
1821
1977
  var flow = Array.isArray(data.flow) ? layoutGeneratedFlow(data.flow) : data.flow;
1978
+ var writeResults = Array.isArray(data._agentWriteResults) ? data._agentWriteResults : [];
1822
1979
 
1823
1980
  // Build the todo plan. Parse "Plan:" from explanation if present;
1824
1981
  // fall back to an implicit single item from the goal prompt.
@@ -1828,11 +1985,34 @@
1828
1985
  }
1829
1986
  // Same aggregate-verification reasoning as the Modify path: mark
1830
1987
  // every item active up front so a multi-item plan resolves together.
1831
- planItems.forEach(function (item) { item.status = "active"; });
1832
- var todoRec = addRecord("todo", { action: "generate", items: planItems });
1988
+ if (writeResults.length) {
1989
+ planItems.forEach(function (item, i) {
1990
+ item.status = (i < writeResults.length)
1991
+ ? (writeResults[i].allPass ? "done" : "failed")
1992
+ : "active";
1993
+ });
1994
+ } else {
1995
+ planItems.forEach(function (item) { item.status = "active"; });
1996
+ }
1997
+ var todoRec;
1998
+ if (data._agentRunRecord) {
1999
+ todoRec = data._agentRunRecord;
2000
+ todoRec.action = "generate";
2001
+ todoRec.items = planItems;
2002
+ } else {
2003
+ todoRec = addRecord("todo", { action: "generate", items: planItems });
2004
+ }
1833
2005
  rerenderTodoRecord(todoRec);
1834
2006
 
1835
- addMessage("assistant", data.explanation || "(no explanation returned)");
2007
+ if (writeResults.length) {
2008
+ var deterministicQueueSummary = buildDeterministicRunSummary(planItems, writeResults);
2009
+ addMessage("assistant", deterministicQueueSummary || "(no explanation returned)");
2010
+ if (shouldShowSecondaryExplanation(deterministicQueueSummary, data.explanation)) {
2011
+ addMessage("fp-notice", data.explanation);
2012
+ }
2013
+ } else {
2014
+ addMessage("assistant", data.explanation || "(no explanation returned)");
2015
+ }
1836
2016
  pushHistory("assistant", data.explanation || "(no explanation returned)");
1837
2017
  // B1: when a build loop is appropriate, bake deploy-verify into the
1838
2018
  // primary chip (same as handleSimpleGenerationResult). The callback
@@ -1847,7 +2027,9 @@
1847
2027
  startBuildLoop(goalPrompt, flow, importResult);
1848
2028
  }
1849
2029
  : function (importResult) { verifyImportedNodes(importResult, todoRec); };
1850
- addGeneratedReview(flow, _onImported, _wantLoop ? goalPrompt : null);
2030
+ if (!writeResults.length) {
2031
+ addGeneratedReview(flow, _onImported, _wantLoop ? goalPrompt : null);
2032
+ }
1851
2033
  // Suppress a server-suggested build chip when deploy-verify is already
1852
2034
  // the primary action inside the review panel.
1853
2035
  renderActionChip(_wantLoop && data.suggestedAction && data.suggestedAction.mode === "build"
@@ -2336,12 +2518,34 @@
2336
2518
  // Generate — a comment node is just a regular flow-JSON node, so there's
2337
2519
  // nothing import-mechanism-specific to build here. The prompt box holds
2338
2520
  // OPTIONAL notes to steer the explanation; the selection is the real input.
2521
+ // Nothing selected/pinned for a Document send: rather than hard-erroring
2522
+ // (the old behavior — Document previously only ever meant "the
2523
+ // selection"), offer a deterministic one-click scope choice. This is a
2524
+ // pure client-side UX decision, not something worth routing through the
2525
+ // model — a weak/local provider's suggestedAction may omit
2526
+ // targetNodeIds even when the user's intent was clear (see the "all"/
2527
+ // "instance" vocabulary in the system prompts), so this is the backstop
2528
+ // that always works regardless of model reliability.
2529
+ function offerDocumentScopeClarification() {
2530
+ addMessage("info", "Nothing is selected. What would you like documented?");
2531
+ renderChip("This flow", "fa fa-sitemap", function () {
2532
+ var ids = allActiveTabNodeIds();
2533
+ if (ids.length) { pinnedSelectionIds = ids; }
2534
+ documentFlow();
2535
+ });
2536
+ renderChip("Entire instance", "fa fa-server", function () {
2537
+ var ids = allInstanceNodeIds();
2538
+ if (ids.length) { pinnedSelectionIds = ids; }
2539
+ documentFlow();
2540
+ });
2541
+ }
2542
+
2339
2543
  function documentFlow() {
2340
2544
  // Falls back to the pinned selection if nothing is currently
2341
2545
  // selected, so follow-up turns need no reselection.
2342
2546
  var context = collectSelectionContext(activeSelectionIds());
2343
2547
  if (!context || !Array.isArray(context.nodes) || context.nodes.length === 0) {
2344
- addMessage("error", "Select the node(s) you want documented first.");
2548
+ offerDocumentScopeClarification();
2345
2549
  return;
2346
2550
  }
2347
2551
  context = attachDebugContext(context);
@@ -46,6 +46,23 @@
46
46
  return { nodes: expanded, groupCount: groupCount };
47
47
  }
48
48
 
49
+ // The "all"/"instance" targetNodeIds scopes (suggestedAction chips,
50
+ // redirect_mode tool calls): every node in the active flow tab, or
51
+ // every node in the whole instance (every tab, enabled or disabled —
52
+ // RED.nodes.eachNode iterates all of them regardless of tab state).
53
+ function allActiveTabNodeIds() {
54
+ var activeTabId = RED.workspaces && RED.workspaces.active ? RED.workspaces.active() : null;
55
+ var ids = [];
56
+ RED.nodes.eachNode(function (n) { if (n.z === activeTabId) { ids.push(n.id); } });
57
+ return ids;
58
+ }
59
+
60
+ function allInstanceNodeIds() {
61
+ var ids = [];
62
+ RED.nodes.eachNode(function (n) { ids.push(n.id); });
63
+ return ids;
64
+ }
65
+
49
66
  function pinCurrentSelection() {
50
67
  var sel = (RED.view && RED.view.selection) ? RED.view.selection() : null;
51
68
  var ids = expandGroupSelection((sel && sel.nodes) ? sel.nodes : []).nodes
@@ -33,12 +33,12 @@ Whenever the user describes something they want built, changed, fixed, wired up,
33
33
  To do this, end your response with a hidden data block: on its own, after all visible reply text, with nothing after it and not inside a code fence, write the literal marker on its own line followed immediately by a single JSON object:
34
34
 
35
35
  <<<FLOWPILOT_DATA>>>
36
- {"suggestedAction": {"mode": "generate" | "document" | "modify", "prompt": "...", "selectionHint": "...", "targetNodeIds": "all" | ["real-node-id", "..."]}}
36
+ {"suggestedAction": {"mode": "generate" | "document" | "modify", "prompt": "...", "selectionHint": "...", "targetNodeIds": "all" | "instance" | ["real-node-id", "..."]}}
37
37
 
38
38
  - "mode": which FlowPilot action the chip switches to.
39
39
  - "prompt": the exact instruction text to pre-fill in the user's compose box — written as a ready-to-send request to FlowPilot, in the user's voice. Keep it as close as possible to the user's own words and requested scope. Do not expand it into a longer spec or add requirements, implementation choices, or assumptions the user did not state.
40
40
  - "selectionHint" (optional): plain-language description of which node(s) the user should select before sending (only useful for "modify"/"document", which act on a selection).
41
- - "targetNodeIds" (only for "modify"/"document"): REQUIRED whenever the current selection, active flow/tab, or other provided context makes the target resolvable. Use "all" when the entire active flow/tab is the resolved target, or a non-empty array of real node ids when a specific subset is the resolved target. Omit it only when you genuinely cannot resolve the target from the current context. Modify and Document require a resolved node set: never say or imply in "selectionHint" that no selection is needed unless "targetNodeIds" supplies that target, and never emit an empty array.
41
+ - "targetNodeIds" (only for "modify"/"document"): REQUIRED whenever the current selection, active flow/tab, whole instance, or other provided context makes the target resolvable. Use "all" when the entire active flow/tab is the resolved target, "instance" when the user means every flow tab in the whole Node-RED instance (only ever valid for "document" — Modify always acts on one flow), or a non-empty array of real node ids when a specific subset is the resolved target. Omit it only when you genuinely cannot resolve the target from the current context — don't guess between "all" and "instance" when the wording doesn't make it clear. Modify and Document require a resolved node set: never say or imply in "selectionHint" that no selection is needed unless "targetNodeIds" supplies that target, and never emit an empty array.
42
42
 
43
43
  The user reviews the prepared prompt and clicks the chip themselves — nothing is sent automatically. Skip the data block only when there's truly no actionable follow-up (e.g. plain factual Q&A, status checks).
44
44
 
@@ -3,7 +3,7 @@ const {
3
3
  buildSuggestedActionFragment
4
4
  } = require("./prompt-fragments");
5
5
 
6
- const identity = `You are FlowPilot's documentation generator. The user has selected existing Node-RED nodes (provided to you as context: sanitized configuration plus how they're wired together). Your job is to explain what that selection does, in detail, and package the explanation as a single Node-RED comment node the user can drop onto their canvas as a "read me" for that part of their flow.`;
6
+ const identity = `You are FlowPilot's documentation generator. You are given a set of existing Node-RED nodes as context (sanitized configuration plus how they're wired together) — this may be a hand-picked selection, an entire flow tab, or every flow tab in the whole instance, depending on what the user asked for and what got resolved before you were called. Your job is to explain what that context does, in detail, and package the explanation as a single Node-RED comment node the user can drop onto their canvas as a "read me". If the context spans multiple flow tabs, structure the explanation and diagram around that (e.g. group by tab) rather than writing as if it's all one flow.`;
7
7
 
8
8
  const modeRouting = `Before documenting — check this is actually a "document" request:
9
9
 
@@ -23,6 +23,8 @@ When one of these applies, do NOT produce the {"explanation", "flow"} JSON envel
23
23
  - "selectionHint" (optional): for "modify", which node(s) to select first (Generate needs no selection).
24
24
  - "targetNodeIds" (only for "modify"): REQUIRED whenever the current selection, active flow/tab, or other provided context makes the target resolvable. Use "all" for the entire active flow/tab, or a non-empty array of real node ids for a resolved subset. Omit it only when you genuinely cannot resolve the target from the current context. Never imply no selection is needed for Modify unless this field supplies the target, and never emit an empty array.
25
25
 
26
+ This escape hatch is for redirecting AWAY from Document (to Generate/Modify/Chat) — it never applies to a request that legitimately wants documentation, including "document my whole flow" or "document this entire Node-RED instance". Those stay in Document; see below for how their scope gets resolved.
27
+
26
28
  The data block (marker and JSON) is never shown to the user — keep your visible reply complete on its own. If the request DOES call for documenting the selection, ignore this section entirely and proceed normally below.
27
29
 
28
30
  IMPORTANT: this escape hatch is ONLY for requests that belong to a different
@@ -53,7 +55,7 @@ What "info" should contain:
53
55
  - A Mermaid diagram of the flow using a fenced code block: \`\`\`mermaid ... \`\`\` (e.g. a \`graph LR\` or \`flowchart LR\` showing each node as a labeled box and arrows for the wiring). Use the node names/types from the context, not raw ids.
54
56
  - If the user added their own notes alongside the selection, treat those as instructions for emphasis or audience (e.g. "explain like I'm new to Node-RED") — fold them into how you write the explanation, not as a separate section.
55
57
 
56
- Base everything on the actual selected nodes and their wiring — never invent nodes that aren't in the context. If the selection is empty or you were given nothing useful to document, say so plainly in "explanation" and still return a single comment node whose "info" explains that nothing could be documented.`;
58
+ Base everything on the actual nodes given as context and their wiring — never invent nodes that aren't in the context. If you were given nothing useful to document, say so plainly in "explanation" and still return a single comment node whose "info" explains that nothing could be documented.`;
57
59
 
58
60
  const suggestedAction = buildSuggestedActionFragment({
59
61
  responseContext: "e.g. you noticed something worth fixing while documenting",
@@ -16,12 +16,12 @@ The user is currently in Generate mode, which always produces a NEW, disconnecte
16
16
  When one of these applies, do NOT produce the {"explanation", "flow"} JSON envelope. Instead, respond in plain text (no JSON, no code fences) addressing what they actually asked — answer the question, or explain that this looks like a Modify/Document/Chat request — and end your reply with a hidden data block: on its own line, after all visible text, not inside a code fence:
17
17
 
18
18
  <<<FLOWPILOT_DATA>>>
19
- {"suggestedAction": {"mode": "modify" | "document" | "chat", "prompt": "...", "selectionHint": "...", "targetNodeIds": "all" | ["real-node-id", "..."]}}
19
+ {"suggestedAction": {"mode": "modify" | "document" | "chat", "prompt": "...", "selectionHint": "...", "targetNodeIds": "all" | "instance" | ["real-node-id", "..."]}}
20
20
 
21
21
  - "mode": "modify" or "document" if their request matches one of those actions instead; "chat" if it's a question, explanation, or remark with no further action needed — including questions about the existing flow, since answering IS the action (don't pick "document" just because the question mentions the flow).
22
22
  - "prompt": the exact instruction text to pre-fill in their compose box after switching modes, written as a ready-to-send request in the user's voice.
23
23
  - "selectionHint" (optional): for "modify"/"document", plain-language description of which node(s) to select first (Generate needs no selection, so this never applies to a "generate" suggestion here).
24
- - "targetNodeIds" (optional, only for "modify"/"document"): "all" for the entire active flow/tab, or a non-empty array of real node ids for a resolved subset. Omit when no resolved target is known. Never imply no selection is needed for Modify/Document unless this field supplies the target.
24
+ - "targetNodeIds" (optional, only for "modify"/"document"): "all" for the entire active flow/tab, "instance" for the whole Node-RED instance across every flow tab (only ever valid for "document"), or a non-empty array of real node ids for a resolved subset. Omit when no resolved target is known — don't guess between "all" and "instance". Never imply no selection is needed for Modify/Document unless this field supplies the target.
25
25
 
26
26
  The data block (marker and JSON) is never shown to the user — keep your visible reply complete on its own. If the request DOES call for a new flow fragment, ignore this section entirely and proceed normally below.
27
27
 
@@ -31,7 +31,7 @@ const agentWriteRules = `IMPORTANT — WRITE-tool execution overrides the other
31
31
  - Never use \`ask_user\` to triage a mode mismatch. \`ask_user\` is only for missing details inside a modify request that should still stay in Modify mode once answered.
32
32
  - A tool result shaped as \`{"unsupported":true,"operation":"...","reason":"...","available":[...]}\` means that operation is outside the current WRITE surface. Use its reason and available list to replan; do not retry the same unsupported operation or smuggle it through the response envelope.
33
33
  - If part of the request cannot be achieved with the available WRITE tools, complete every part that can be achieved and state the remainder plainly in the final explanation; do not abandon the whole request or redirect modes. Never emit flow JSON in your final message — it will be discarded.
34
- - Only after every plan item has been executed, return the normal single JSON response with a concise explanation of what was completed. Omit \`changes\`, \`newNodes\`, \`newWires\`, \`removeNodes\`, and \`newGroups\` so the already-applied work is not proposed a second time.`;
34
+ - Only after every plan item has been executed, return the normal single JSON response with a concise explanation of what was completed. Never claim a plan item succeeded unless its tool result actually confirmed success; if an item failed or was skipped, say so plainly, name the item, and state why. Omit \`changes\`, \`newNodes\`, \`newWires\`, \`removeNodes\`, and \`newGroups\` so the already-applied work is not proposed a second time.`;
35
35
 
36
36
  const modeRouting = `Before modifying — check this is actually a "modify" request:
37
37
 
@@ -22,7 +22,7 @@ If there's an obvious, single one-click follow-up the user would want after this
22
22
  response${responseContext} include an${optionalPrefix} "suggestedAction" key alongside ${responseTarget}:
23
23
 
24
24
  {
25
- "suggestedAction": { "mode": "generate" | "document" | "modify" | "chat", "prompt": "...", "selectionHint": "...", "targetNodeIds": "all" | ["real-node-id", "..."] }
25
+ "suggestedAction": { "mode": "generate" | "document" | "modify" | "chat", "prompt": "...", "selectionHint": "...", "targetNodeIds": "all" | "instance" | ["real-node-id", "..."] }
26
26
  }
27
27
 
28
28
  - "mode": which FlowPilot action the chip switches to ("chat" for a follow-up
@@ -36,11 +36,15 @@ response${responseContext} include an${optionalPrefix} "suggestedAction" key alo
36
36
  should select before sending (only useful for "modify"/"document", which act on a
37
37
  selection).
38
38
  - "targetNodeIds" (only for "modify"/"document"): REQUIRED whenever the current
39
- selection, active flow/tab, or other provided context makes the target
40
- resolvable. Use "all" when the entire active flow/tab is the resolved target,
41
- or a non-empty array of real node ids when a specific subset is the resolved
42
- target. Omit it only when you genuinely cannot resolve the target from the
43
- current context. Modify and Document require a resolved node set: never imply
39
+ selection, active flow/tab, whole instance, or other provided context makes
40
+ the target resolvable. Use "all" when the entire active flow/tab is the
41
+ resolved target, "instance" when the user means every flow tab in this
42
+ Node-RED instance (only ever valid for "document" Modify always acts on
43
+ one flow), or a non-empty array of real node ids when a specific subset is
44
+ the resolved target. Omit it only when you genuinely cannot resolve the
45
+ target from the current context — don't guess between "all" and "instance"
46
+ when the user's wording doesn't make it clear; omitting it is correct there.
47
+ Modify and Document require a resolved node set: never imply
44
48
  in "selectionHint" that no selection is needed unless "targetNodeIds"
45
49
  supplies it, and never emit an empty array.
46
50
 
@@ -1,5 +1,6 @@
1
1
  const https = require("https");
2
2
  const http = require("http");
3
+ const { isModelsListShaped } = require("./provider-shape-check");
3
4
 
4
5
  const ANTHROPIC_API_BASE = "https://api.anthropic.com";
5
6
  const ANTHROPIC_VERSION = "2023-06-01";
@@ -327,12 +328,21 @@ async function chatStream(settings, messages, onDelta, onReasoningDelta) {
327
328
  // ---- listModels ----
328
329
  // Tries GET /v1/models; falls back to a hardcoded list if that endpoint
329
330
  // is unavailable (non-standard proxy) or returns an error.
331
+ //
332
+ // Allowed to run against an UNCONFIRMED provider (ADR-007), same as the
333
+ // OpenAI-compatible provider's listModels — see its comment for the full
334
+ // rationale. isModelsListShaped gates the success path here too: a
335
+ // non-provider target's response never gets its ids reflected back, it
336
+ // just falls through to the safe hardcoded fallback below like any other
337
+ // failure.
330
338
  async function listModels(settings) {
331
339
  const baseUrl = resolveBaseUrl(settings);
332
340
  try {
333
341
  const response = await getJson(baseUrl + "/v1/models", anthropicHeaders(settings), settings.requestTimeoutMs || 30000);
334
- const data = response && Array.isArray(response.data) ? response.data : [];
335
- const models = data.map(function (m) { return m && m.id; }).filter(function (id) { return typeof id === "string" && id; });
342
+ if (!isModelsListShaped(response)) {
343
+ throw new Error("Not a valid provider endpoint (no FlowPilot-compatible response).");
344
+ }
345
+ const models = response.data.map(function (m) { return m && m.id; }).filter(function (id) { return typeof id === "string" && id; });
336
346
  if (models.length) { return { models: models }; }
337
347
  throw new Error("Empty model list from provider");
338
348
  } catch (err) {
@@ -1,5 +1,6 @@
1
1
  const http = require("http");
2
2
  const https = require("https");
3
+ const { isModelsListShaped } = require("./provider-shape-check");
3
4
 
4
5
  function postJson(urlString, headers, body, timeoutMs) {
5
6
  return new Promise((resolve, reject) => {
@@ -125,6 +126,15 @@ function getJson(urlString, headers, timeoutMs) {
125
126
  // the user picks one. Never throws: a provider without /v1/models (or any
126
127
  // other failure) just means an empty list with an explanatory error, which
127
128
  // the UI shows as a hint while leaving the model field free-text.
129
+ //
130
+ // Allowed to run against an UNCONFIRMED provider (ADR-007) — same blind
131
+ // treatment as /flowpilot/test and /flowpilot/probe: getJson's error path
132
+ // is already generic (never reflects the upstream body), and a "successful"
133
+ // response is only trusted if it's genuinely models-list-shaped
134
+ // (isModelsListShaped) — a non-provider target returning some unrelated
135
+ // JSON blob with data[].id-shaped entries doesn't get those ids reflected
136
+ // back to the client. This route never writes confirmedBaseUrl/confirmedAt;
137
+ // only /flowpilot/test performs the deliberate confirming action.
128
138
  // ---------------------------------------------------------------------
129
139
  async function listModels(settings) {
130
140
  const baseUrl = String(settings.baseUrl || "").replace(/\/+$/, "");
@@ -137,8 +147,10 @@ async function listModels(settings) {
137
147
 
138
148
  try {
139
149
  const response = await getJson(`${baseUrl}/v1/models`, headers, settings.requestTimeoutMs || 30000);
140
- const data = response && Array.isArray(response.data) ? response.data : [];
141
- const models = data
150
+ if (!isModelsListShaped(response)) {
151
+ return { models: [], error: "Not a valid provider endpoint (no FlowPilot-compatible response)." };
152
+ }
153
+ const models = response.data
142
154
  .map(function (m) { return m && m.id; })
143
155
  .filter(function (id) { return typeof id === "string" && id; });
144
156
  return { models: models };
@@ -31,4 +31,4 @@ function isProviderShapedResponse(providerType, raw) {
31
31
  return isChatShaped(providerType, raw) || isModelsListShaped(raw);
32
32
  }
33
33
 
34
- module.exports = { isProviderShapedResponse };
34
+ module.exports = { isProviderShapedResponse, isModelsListShaped };
package/lib/storage.js CHANGED
@@ -135,6 +135,12 @@ function createStorage(userDir) {
135
135
  // Hard output bound for each tool-capable agent turn. Classic completions
136
136
  // remain uncapped so ordinary flow envelopes are never silently clipped.
137
137
  agentTurnMaxTokens: 4096,
138
+ // Cumulative token budget across one read/write tool-calling turn
139
+ // before stopping with an honest error instead of continuing forever.
140
+ // User-configurable since "reasonable" varies by provider context
141
+ // window. Unrelated to agentTurnMaxTokens above, which caps one
142
+ // individual model response's output length, not the running total.
143
+ agentLoopTokenCeiling: 50000,
138
144
  // Max build->deploy->test->fix cycles the /build agentic loop will run
139
145
  // before stopping with an honest "couldn't fully verify" instead of
140
146
  // proposing another fix. Bounds against a non-converging loop burning
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@manny-est/node-red-flowpilot",
3
- "version": "0.6.0-beta.1",
3
+ "version": "0.6.0",
4
4
  "description": "FlowPilot for Node-RED - an AI-powered development assistant sidebar",
5
5
  "main": "flowpilot.js",
6
6
  "keywords": [
@@ -26,7 +26,7 @@
26
26
  "node-red": {
27
27
  "version": ">=4.0.0",
28
28
  "nodes": {
29
- "flowpilot": "flowpilot.js"
29
+ "flowpilot": "flowpilot-node-entry.js"
30
30
  },
31
31
  "plugins": {
32
32
  "flowpilot": "flowpilot.html"
@@ -37,6 +37,7 @@
37
37
  },
38
38
  "files": [
39
39
  "flowpilot.js",
40
+ "flowpilot-node-entry.js",
40
41
  "flowpilot.html",
41
42
  "flowpilot-core.css",
42
43
  "lib",