@manny-est/node-red-flowpilot 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/flowpilot.js CHANGED
@@ -7,6 +7,8 @@ const documentSystemPrompt = require("./lib/document-system-prompt");
7
7
  const modifySystemPrompt = require("./lib/modify-system-prompt");
8
8
  const buildSystemPrompt = require("./lib/build-system-prompt");
9
9
  const personaPrompt = require("./lib/persona-prompt");
10
+ const { buildCoreScript } = require("./lib/build-core-script");
11
+ const { extractJsonObject } = require("./lib/envelope");
10
12
 
11
13
  module.exports = function flowPilotRuntime(RED) {
12
14
  const storage = createStorage(RED.settings.userDir);
@@ -554,13 +556,18 @@ module.exports = function flowPilotRuntime(RED) {
554
556
 
555
557
  let streamResult;
556
558
  try {
557
- streamResult = await provider.chatStream(activeProvider, messages, function (delta) {
558
- const visible = splitter.push(delta);
559
- if (visible) {
560
- visibleText += visible;
561
- res.write("data: " + JSON.stringify({ delta: visible }) + "\n\n");
559
+ streamResult = await provider.chatStream(activeProvider, messages,
560
+ function (delta) {
561
+ const visible = splitter.push(delta);
562
+ if (visible) {
563
+ visibleText += visible;
564
+ res.write("data: " + JSON.stringify({ delta: visible }) + "\n\n");
565
+ }
566
+ },
567
+ function (reasoningDelta) {
568
+ res.write("data: " + JSON.stringify({ reasoningDelta: reasoningDelta }) + "\n\n");
562
569
  }
563
- });
570
+ );
564
571
  } catch (err) {
565
572
  res.write("data: " + JSON.stringify({ error: err.message }) + "\n\n");
566
573
  res.end();
@@ -625,6 +632,11 @@ module.exports = function flowPilotRuntime(RED) {
625
632
  // static lib/debug/view.html that loads the SAME debug-utils.js the
626
633
  // sidebar uses).
627
634
  //
635
+ // Phase 9 refactor: the SOURCE is now split into lib/core/*.js fragments
636
+ // (see lib/build-core-script.js for why and how), but this route's
637
+ // behavior is unchanged — it still serves one complete script at this
638
+ // same URL, just assembled instead of read off disk verbatim.
639
+ //
628
640
  // INTENTIONALLY UNGATED (fixed in 0.4.1 — was needsPermission("settings.
629
641
  // read") in 0.4.0, which broke the editor on every adminAuth-enabled
630
642
  // instance): these are static client assets, fetched via plain <script
@@ -639,7 +651,7 @@ module.exports = function flowPilotRuntime(RED) {
639
651
  // routes (settings, chat, generate, modify, etc. below) stay gated.
640
652
 
641
653
  RED.httpAdmin.get("/flowpilot/core.js", function (req, res) {
642
- res.sendFile(path.join(__dirname, "flowpilot-core.js"));
654
+ res.type("application/javascript").send(buildCoreScript());
643
655
  });
644
656
 
645
657
  RED.httpAdmin.get("/flowpilot/core.css", function (req, res) {
@@ -744,6 +756,10 @@ module.exports = function flowPilotRuntime(RED) {
744
756
  if (suggestedAction) { body.suggestedAction = suggestedAction; }
745
757
  const questionOptions = extractQuestionOptions(chatData);
746
758
  if (questionOptions) { body.questionOptions = questionOptions; }
759
+ // Pass reasoning_content through so the frontend can render a thinking
760
+ // block on the non-streaming (agent-loop) path too.
761
+ const rawMsg = result.raw && result.raw.choices && result.raw.choices[0] && result.raw.choices[0].message;
762
+ if (rawMsg && rawMsg.reasoning_content) { body.reasoningContent = rawMsg.reasoning_content; }
747
763
  res.json(body);
748
764
  } catch (err) {
749
765
  storage.appendAudit({ action: "chat_error", error: err.message });
@@ -926,29 +942,42 @@ module.exports = function flowPilotRuntime(RED) {
926
942
  // probe failure here just means "no tool support", not a /test failure.
927
943
  // Persist the result on the provider profile for the agentic tool-calling path.
928
944
  const probe = await provider.probeTools(activeProvider);
945
+ const reasoning = provider.detectReasoning(result.raw);
929
946
  storage.appendAudit({
930
947
  action: "capability_probe",
931
948
  providerName: activeProvider.providerName,
932
949
  baseUrl: activeProvider.baseUrl,
933
950
  model: activeProvider.model,
934
- supportsTools: probe.supportsTools
951
+ supportsTools: probe.supportsTools,
952
+ isReasoningModel: reasoning.isReasoningModel
935
953
  });
936
954
 
937
955
  const updatedProviders = (settings.providers || []).map(function (p) {
938
956
  return p.id === activeProvider.id
939
- ? Object.assign({}, p, { supportsTools: probe.supportsTools, toolsProbedAt: new Date().toISOString() })
957
+ ? Object.assign({}, p, {
958
+ supportsTools: probe.supportsTools,
959
+ toolsProbedAt: new Date().toISOString(),
960
+ isReasoningModel: reasoning.isReasoningModel,
961
+ reasoningProbedAt: new Date().toISOString(),
962
+ probedModel: activeProvider.model
963
+ })
940
964
  : p;
941
965
  });
942
966
  storage.saveSettings(Object.assign({}, settings, { providers: updatedProviders }));
943
967
 
968
+ const toolLabel = probe.supportsTools
969
+ ? "✓ Connected · ✓ Supports tools"
970
+ : "✓ Connected · ⚠ No tool support — compatibility mode";
971
+ const reasoningLabel = reasoning.isReasoningModel ? " · Reasoning model" : "";
972
+
944
973
  res.json({
945
974
  message: chatMessage || "[No assistant message returned by provider]",
946
975
  raw: result.raw ? "[raw response captured]" : null,
947
976
  capability: {
948
977
  supportsTools: probe.supportsTools,
949
- label: probe.supportsTools
950
- ? "✓ Connected · ✓ Supports tools"
951
- : "✓ Connected · ⚠ No tool support — compatibility mode"
978
+ isReasoningModel: reasoning.isReasoningModel,
979
+ probedModel: activeProvider.model,
980
+ label: toolLabel + reasoningLabel
952
981
  }
953
982
  });
954
983
  } catch (err) {
@@ -957,130 +986,63 @@ module.exports = function flowPilotRuntime(RED) {
957
986
  }
958
987
  });
959
988
 
960
- // ---- Generate: produce an importable flow fragment --------------------
961
- // Uses the generation system prompt and expects the model to return a single
962
- // JSON object { explanation, flow }. This first cut does NOT validate node
963
- // types or wire integrity yet (that's the next chunk) it returns the parsed
964
- // envelope so the frontend can display it for review.
989
+ // ---- Probe: silent capability re-check after model change -----------
990
+ // Called by the frontend when it detects that the active provider's model
991
+ // changed since the last full pre-flight stale supportsTools silently
992
+ // misroutes chat (agent-loop vs streaming/non-streaming). Runs probeTools
993
+ // + a minimal chat for reasoning detection, saves all results including
994
+ // probedModel, and returns { supportsTools, isReasoningModel, probedModel }.
965
995
 
966
- // Given s[startIdx] === "{", scans forward with brace-depth counting that
967
- // ignores braces inside string literals (so a value like "{{payload}}"
968
- // can't be mistaken for structure) to find the index of the MATCHING
969
- // closing "}". Returns -1 if the braces never balance before the string
970
- // ends (truncated/malformed input).
971
- function findMatchingBrace(s, startIdx) {
972
- let depth = 0;
973
- let inString = false;
974
- let escaped = false;
975
- for (let i = startIdx; i < s.length; i++) {
976
- const ch = s[i];
977
- if (inString) {
978
- if (escaped) { escaped = false; }
979
- else if (ch === "\\") { escaped = true; }
980
- else if (ch === "\"") { inString = false; }
981
- continue;
982
- }
983
- if (ch === "\"") { inString = true; }
984
- else if (ch === "{") { depth++; }
985
- else if (ch === "}") {
986
- depth--;
987
- if (depth === 0) { return i; }
988
- }
989
- }
990
- return -1;
991
- }
996
+ RED.httpAdmin.post("/flowpilot/probe", RED.auth.needsPermission("settings.write"), async function (req, res) {
997
+ try {
998
+ const settings = storage.getSettings();
999
+ const activeProvider = storage.getActiveProvider(settings);
992
1000
 
993
- function extractJsonObject(text) {
994
- if (!text) { throw new Error("Empty response from provider."); }
995
- let s = String(text).trim();
996
- // Strip markdown code fences if the model wrapped the JSON.
997
- s = s.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "").trim();
998
-
999
- const firstBrace = s.indexOf("{");
1000
- const firstBracket = s.indexOf("[");
1001
-
1002
- // The model occasionally returns a bare top-level array (e.g.
1003
- // `[ {...node...} ]`) instead of the {explanation, flow} envelope. If we
1004
- // fell through to the {...} extraction below, indexOf("{")/lastIndexOf("}")
1005
- // would grab just the first node object — which has no "flow" key and
1006
- // fails validation. Detect this case up front and wrap it as a minimal
1007
- // envelope instead.
1008
- if (firstBracket !== -1 && (firstBrace === -1 || firstBracket < firstBrace)) {
1009
- const lastBracket = s.lastIndexOf("]");
1010
- if (lastBracket !== -1 && lastBracket > firstBracket) {
1011
- try {
1012
- const arr = JSON.parse(s.slice(firstBracket, lastBracket + 1));
1013
- if (Array.isArray(arr)) {
1014
- return { explanation: "", flow: arr };
1015
- }
1016
- } catch (e) {
1017
- // Not a parseable array — fall through to the {...} extraction.
1018
- }
1019
- }
1020
- }
1001
+ const probe = await provider.probeTools(activeProvider);
1002
+ const chatResult = await provider.chat(activeProvider, [
1003
+ { role: "system", content: "You are a helpful assistant." },
1004
+ { role: "user", content: "Say hello." }
1005
+ ]);
1006
+ const reasoning = provider.detectReasoning(chatResult.raw);
1021
1007
 
1022
- const firstObjIdx = s.indexOf("{");
1023
- if (firstObjIdx === -1) {
1024
- // No JSON object found at all — flagged separately from a found-
1025
- // but-unparseable ({...} present, JSON.parse failed) "garbled" error,
1026
- // so callers can distinguish "model just answered in prose" (tolerate)
1027
- // from "model's JSON envelope is broken" (still an error).
1028
- const err = new Error("Provider did not return a JSON object.");
1029
- err.noJsonFound = true;
1030
- throw err;
1008
+ const updatedProviders = (settings.providers || []).map(function (p) {
1009
+ return p.id === activeProvider.id
1010
+ ? Object.assign({}, p, {
1011
+ supportsTools: probe.supportsTools,
1012
+ toolsProbedAt: new Date().toISOString(),
1013
+ isReasoningModel: reasoning.isReasoningModel,
1014
+ reasoningProbedAt: new Date().toISOString(),
1015
+ probedModel: activeProvider.model
1016
+ })
1017
+ : p;
1018
+ });
1019
+ storage.saveSettings(Object.assign({}, settings, { providers: updatedProviders }));
1020
+
1021
+ storage.appendAudit({
1022
+ action: "auto_probe",
1023
+ providerName: activeProvider.providerName,
1024
+ baseUrl: activeProvider.baseUrl,
1025
+ model: activeProvider.model,
1026
+ supportsTools: probe.supportsTools,
1027
+ isReasoningModel: reasoning.isReasoningModel
1028
+ });
1029
+
1030
+ res.json({
1031
+ supportsTools: probe.supportsTools,
1032
+ isReasoningModel: reasoning.isReasoningModel,
1033
+ probedModel: activeProvider.model
1034
+ });
1035
+ } catch (err) {
1036
+ storage.appendAudit({ action: "auto_probe_error", error: err.message });
1037
+ res.status(500).json({ error: err.message });
1031
1038
  }
1039
+ });
1032
1040
 
1033
- // There may be more than one "{" before the real envelope — e.g. prose
1034
- // explaining a fix that mentions inline code like "{{payload}}" before
1035
- // the actual JSON (seen live: a review response started with "The
1036
- // template node is using `{{payload}}` with...", and slicing from THAT
1037
- // brace to the envelope's real closing "}" produced unparseable
1038
- // garbage). Try each candidate "{" in order with string-aware brace
1039
- // matching (findMatchingBrace, which ignores braces inside quoted
1040
- // strings) rather than just slicing from the first "{" to the last
1041
- // "}".
1042
- //
1043
- // A candidate must not just PARSE, it must also look like one of the
1044
- // known envelope shapes (have at least one recognized top-level key) —
1045
- // seen live: a pure-prose advice response that mentioned structured
1046
- // logging included the illustrative example
1047
- // `{"level":"info","event":"trivia_answer","user":"alex","correct":true}`,
1048
- // which IS valid standalone JSON, so the old "first candidate that
1049
- // parses wins" rule accepted it as "the envelope" and the caller threw
1050
- // "no recognizable modify fields" — when the right answer was to treat
1051
- // the whole reply as prose, since there was no real envelope at all.
1052
- const ENVELOPE_KEYS = ["explanation", "flow", "question", "changes", "newNodes", "newWires", "removeNodes", "newGroups", "prose"];
1053
- function looksLikeEnvelope(obj) {
1054
- if (!obj || typeof obj !== "object" || Array.isArray(obj)) { return false; }
1055
- return ENVELOPE_KEYS.some(function (k) { return k in obj; });
1056
- }
1057
-
1058
- let lastError = null;
1059
- let searchFrom = firstObjIdx;
1060
- while (searchFrom !== -1 && searchFrom < s.length) {
1061
- const end = findMatchingBrace(s, searchFrom);
1062
- if (end !== -1) {
1063
- try {
1064
- const candidate = JSON.parse(s.slice(searchFrom, end + 1));
1065
- if (looksLikeEnvelope(candidate)) { return candidate; }
1066
- // Valid JSON, but not envelope-shaped (e.g. an illustrative
1067
- // example embedded in prose) — keep searching rather than
1068
- // accepting it.
1069
- } catch (e) {
1070
- lastError = e;
1071
- }
1072
- }
1073
- searchFrom = s.indexOf("{", searchFrom + 1);
1074
- }
1075
- // No candidate both parsed AND looked like a real envelope — equivalent
1076
- // to "the model just answered in prose," not "the envelope is broken."
1077
- // Let callers fall back to rendering this as a normal message instead
1078
- // of surfacing a parse error (same noJsonFound flag the "no { at all"
1079
- // branch above uses).
1080
- const err = lastError || new Error("Provider's JSON object could not be parsed.");
1081
- err.noJsonFound = true;
1082
- throw err;
1083
- }
1041
+ // ---- Generate: produce an importable flow fragment --------------------
1042
+ // Uses the generation system prompt and expects the model to return a single
1043
+ // JSON object { explanation, flow }. This first cut does NOT validate node
1044
+ // types or wire integrity yet (that's the next chunk) — it returns the parsed
1045
+ // envelope so the frontend can display it for review.
1084
1046
 
1085
1047
  // ---------------------------------------------------------------------
1086
1048
  // Shared helper: resolve the active provider and assemble the messages
@@ -1538,29 +1500,42 @@ module.exports = function flowPilotRuntime(RED) {
1538
1500
  // Validate that changes contains no hallucinated ids, and that no id is
1539
1501
  // both patched and marked for removal. A group id from contextGroupIds
1540
1502
  // is allowed here too (see above) even though it's not in originalIds.
1503
+ // Instead of rejecting the whole response when some ids are bad, drop
1504
+ // only the offending patches and apply the rest — same philosophy as the
1505
+ // applyInsertions partial-failure fix (Phase 8.5 #11). A skippedNote in
1506
+ // the response body tells the user what was dropped and why.
1541
1507
  const extraIds = changeIds.filter(function (id) { return !originalIds.has(String(id)) && !contextGroupIds.has(String(id)); });
1542
1508
  const wronglyRemovedIds = changeIds.filter(function (id) { return removeSet.has(String(id)); });
1543
1509
 
1544
- const idProblems = [];
1545
- if (extraIds.length) { idProblems.push("unexpected id(s) in changes: " + extraIds.join(", ")); }
1546
- if (wronglyRemovedIds.length) { idProblems.push("id(s) in both changes and removeNodes: " + wronglyRemovedIds.join(", ")); }
1547
-
1548
- if (idProblems.length > 0) {
1549
- storage.appendAudit({ action: "modify_id_mismatch", problems: idProblems });
1550
- return {
1551
- status: 422,
1552
- body: {
1553
- error: "The model returned inconsistent node ids (" + idProblems.join("; ") + "). Try again.",
1554
- raw: JSON.stringify(changes)
1555
- }
1556
- };
1510
+ const skippedDescriptions = [];
1511
+ if (extraIds.length) {
1512
+ skippedDescriptions.push(
1513
+ extraIds.length === 1
1514
+ ? "Skipped 1 change — that node wasn't in your current selection; reselect it to include it"
1515
+ : "Skipped " + extraIds.length + " changes — those nodes weren't in your current selection; reselect them to include them"
1516
+ );
1517
+ }
1518
+ if (wronglyRemovedIds.length) {
1519
+ skippedDescriptions.push(
1520
+ wronglyRemovedIds.length === 1
1521
+ ? "Skipped 1 change — that node was also marked for removal"
1522
+ : "Skipped " + wronglyRemovedIds.length + " changes — those nodes were also marked for removal"
1523
+ );
1557
1524
  }
1525
+ if (skippedDescriptions.length > 0) {
1526
+ storage.appendAudit({ action: "modify_id_mismatch_partial", skipped_extra: extraIds, skipped_conflict: wronglyRemovedIds });
1527
+ }
1528
+
1529
+ const badIdSet = new Set(extraIds.map(String).concat(wronglyRemovedIds.map(String)));
1530
+ const validChanges = changes.filter(function (c) {
1531
+ return c && c.id !== undefined && c.id !== null && !badIdSet.has(String(c.id));
1532
+ });
1558
1533
 
1559
1534
  // Each patch's "set" is shallow-merged onto a copy of the original node.
1560
1535
  // "id", "x", "y", "z" can never move via a patch — strip them
1561
1536
  // defensively even though the prompt already forbids them.
1562
1537
  const patchById = {};
1563
- changes.forEach(function (c) {
1538
+ validChanges.forEach(function (c) {
1564
1539
  const set = (c.set && typeof c.set === "object") ? c.set : {};
1565
1540
  const clean = Object.assign({}, set);
1566
1541
  delete clean.id;
@@ -1587,7 +1562,8 @@ module.exports = function flowPilotRuntime(RED) {
1587
1562
  // is, and findLiveNode() already resolves a group id to the live
1588
1563
  // group object (Phase 8.5 C2 slice 1). This is how a group gets
1589
1564
  // renamed/restyled — pure property edit, no new apply-side code.
1590
- const groupPatchIds = changeIds.filter(function (id) {
1565
+ const validChangeIds = validChanges.map(function (c) { return c.id; });
1566
+ const groupPatchIds = validChangeIds.filter(function (id) {
1591
1567
  return contextGroupIds.has(String(id)) && !originalIds.has(String(id));
1592
1568
  });
1593
1569
  groupPatchIds.forEach(function (id) {
@@ -1698,6 +1674,7 @@ module.exports = function flowPilotRuntime(RED) {
1698
1674
  removeNodes: finalRemoveNodes,
1699
1675
  newGroups: newGroups
1700
1676
  };
1677
+ if (skippedDescriptions.length > 0) { body.skippedNote = skippedDescriptions.join(". ") + "."; }
1701
1678
  if (result.suggestedAction) { body.suggestedAction = result.suggestedAction; }
1702
1679
 
1703
1680
  return { status: 200, body: body };
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+
6
+ // flowpilot-core.js is loaded directly in the BROWSER via a single
7
+ // <script src="flowpilot/core.js"> tag - one big IIFE, no module system,
8
+ // every function/var sharing one closure by lexical scope (not via
9
+ // require/import). Phase 9's refactor splits the SOURCE into focused files
10
+ // under lib/core/ for maintainability, but the served SCRIPT must stay
11
+ // exactly what it was: one concatenated text, so cross-fragment references
12
+ // keep working unmodified and script-loading/timing behavior never changes.
13
+ //
14
+ // Order matters only for top-level code that runs immediately at load time
15
+ // (e.g. a `var x = (function(){...})();` initializer) - plain function
16
+ // declarations are hoisted within the shared closure and safe in any
17
+ // order. Keep new fragments appended in the same relative order they held
18
+ // in the original single file unless a specific dependency says otherwise.
19
+ const FRAGMENT_ORDER = [
20
+ "redaction.js",
21
+ "history.js",
22
+ "markdown.js",
23
+ "selection-context.js",
24
+ "apply-review.js",
25
+ "modes.js",
26
+ "main.js",
27
+ "init.js"
28
+ ];
29
+
30
+ const HEADER = "(function () {\n \"use strict\";\n";
31
+ const FOOTER = "\n})();\n";
32
+
33
+ let cached = null;
34
+
35
+ function buildCoreScript() {
36
+ if (cached) { return cached; }
37
+ const body = FRAGMENT_ORDER.map(function (name) {
38
+ return fs.readFileSync(path.join(__dirname, "core", name), "utf8");
39
+ }).join("\n");
40
+ cached = HEADER + body + FOOTER;
41
+ return cached;
42
+ }
43
+
44
+ module.exports = { buildCoreScript: buildCoreScript };
@@ -25,6 +25,12 @@ Example "explanation" for a trivial goal:
25
25
 
26
26
  Deploy and trigger it; let me know what the debug output shows."
27
27
 
28
+ ALWAYS include at least one debug node in your proposed flow so the test-and-review cycle has something to observe. This is REQUIRED, not optional. Specific rules:
29
+ - HTTP endpoint flows (http in → function/change → http response): add a debug node tapping the function/change output BEFORE the http response node — wire the function/change output to BOTH the debug node AND the http response node.
30
+ - Inject-triggered flows: the debug node at the end is fine.
31
+ - Any other flow shape: add a debug node at the last meaningful output point.
32
+ Never generate a build flow without a debug node. The loop cannot review what it cannot see.
33
+
28
34
  Everything below describes the envelope/rules for THIS step specifically — they work exactly as written, including the parts that say "Generate mode": for the purposes of this prompt, treat that phrase as describing this build step, not a separate mode. The "explanation" field's content rules below still apply — your "Plan:" block comes first, then that content follows immediately after it in the same field.
29
35
 
30
36
  ---