@manny-est/node-red-flowpilot 0.6.1 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,40 @@
2
2
 
3
3
  All notable changes to FlowPilot are documented here.
4
4
 
5
+ ## [0.6.2] - 2026-09-22
6
+
7
+ ### Fixed
8
+ - **Newer Anthropic models (Sonnet 5, Fable 5.1) didn't work at all —
9
+ every request failed with a 400 error.** ([#9](https://github.com/manny-est/flowpilot/issues/9))
10
+ Models released after Claude Opus 4.6 reject any `temperature` value
11
+ other than 1.0; FlowPilot's default of 0.2 meant every single chat,
12
+ generate, modify, document, and provider-capability-probe request
13
+ against a newer model failed outright with "temperature is deprecated
14
+ for this model." Older models (confirmed: `claude-sonnet-4-5`) were
15
+ unaffected. Fixed reactively — no model-name list to maintain: FlowPilot
16
+ now detects that exact error live, retries once with `temperature`
17
+ omitted, and remembers the model needs that treatment so it's a
18
+ one-time cost. Live-verified against Sonnet 5, Fable 5.1, and an older
19
+ model to confirm no regression there.
20
+ - **A multi-section plan could silently lose most of its steps from the
21
+ todo checklist.** A model's own explanation can legitimately structure
22
+ a larger plan as several numbered sections under short labels (e.g. a
23
+ Dashboard 2.0 build broken into "Widgets to add:" / "Final steps:")
24
+ rather than one unbroken numbered list. The checklist parser stopped
25
+ reading at the first blank line, so only the first section's items
26
+ ever made it onto the checklist — a 5-step plan could show as a
27
+ 1-item checklist, which read as "the numbering is wrong" but was
28
+ actually silent data loss. Now keeps reading across section breaks and
29
+ only stops at the real closing explanation paragraph.
30
+ - **No way to clear a pinned node selection without losing your place.**
31
+ Once a mode (Generate/Modify/Document/Build) is armed with a
32
+ selection, that selection intentionally stays attached across
33
+ follow-up turns so you don't have to reselect every time — but there
34
+ was no way to just drop it if you no longer wanted those nodes
35
+ attached, short of fully re-arming the mode or clearing the whole
36
+ chat. The status line now shows a small ✕ next to a pinned selection
37
+ that clears just the pin, leaving the mode itself armed.
38
+
5
39
  ## [0.6.1] - 2026-09-02
6
40
 
7
41
  ### Fixed
package/lib/core/main.js CHANGED
@@ -2482,9 +2482,25 @@
2482
2482
  : armedExecuteAction === "document" ? "Document"
2483
2483
  : armedExecuteAction === "modify" ? "Modify"
2484
2484
  : armedExecuteAction === "build" ? "Build" : "Execute";
2485
- $status.text("Pinned: " + count + (count === 1 ? " node" : " nodes") +
2486
- " for " + actionLabel + " — will be sent as context")
2487
- .addClass("fp-has-selection");
2485
+ // 0.6.2 hotfix (pinned nodes don't clear): a pin is coupled to
2486
+ // armedExecuteAction by design (persists across follow-up turns
2487
+ // in the same mode so the user doesn't need to reselect every
2488
+ // time) — that part is intentional, not a bug. What was
2489
+ // actually missing is any way to clear JUST the pin, short of
2490
+ // re-arming/disarming the mode entirely or Clear Chat. Mirrors
2491
+ // updateDebugStatus's own "attached context + a ✕ to drop it"
2492
+ // pattern (main-window only, same as Modify/Build's other
2493
+ // live-RED.*-state features) rather than a plain text label.
2494
+ $status.empty().addClass("fp-has-selection");
2495
+ $status.append(document.createTextNode("Pinned: " + count + (count === 1 ? " node" : " nodes") +
2496
+ " for " + actionLabel + " — will be sent as context "));
2497
+ var $clearPin = $("<a>").attr("href", "#").text("✕").attr("title", "Clear pinned node context (stays in " + actionLabel + " mode)");
2498
+ $clearPin.on("click", function (ev) {
2499
+ ev.preventDefault();
2500
+ pinnedSelectionIds = null;
2501
+ updateSelectionStatus();
2502
+ });
2503
+ $status.append($clearPin);
2488
2504
  } else {
2489
2505
  $status.text(count + (count === 1 ? " node" : " nodes") + groupNote +
2490
2506
  " selected — will be sent as context")
package/lib/core/modes.js CHANGED
@@ -1675,21 +1675,46 @@
1675
1675
 
1676
1676
  // W4: parse a "Plan:" block from the model's explanation field.
1677
1677
  // Returns an array of { text, status } items, or [] if none found.
1678
- // Each numbered/bulleted line under "Plan:" up to the first blank line
1679
- // becomes one item. Status starts as "pending" for all items — the
1680
- // caller sets the first to "active" before rendering.
1678
+ // Status starts as "pending" for all items — the caller sets the first
1679
+ // to "active" before rendering.
1680
+ //
1681
+ // 0.6.2 hotfix (todo-list numbering bug): this used to stop at the
1682
+ // FIRST blank line after "Plan:", on the assumption a plan is always
1683
+ // one unbroken numbered block followed by closing prose. A real
1684
+ // Dashboard 2.0 explanation can legitimately structure its plan as
1685
+ // several numbered sections separated by short labels ("Widgets to
1686
+ // add:", "Final steps:") — each its own blank-line-delimited block —
1687
+ // and the old logic silently dropped every item after the first
1688
+ // section (observed as "wrong numbering": a 5-item plan producing a
1689
+ // 1-item checklist). Now scans every line after "Plan:" to the end of
1690
+ // the explanation and keeps collecting numbered/bulleted lines across
1691
+ // blank lines and short one-line section labels; it only stops at two
1692
+ // CONSECUTIVE non-list, non-blank lines — a real prose paragraph, the
1693
+ // closing explanation that follows every plan today. A single-line
1694
+ // label between numbered sections never counts as that paragraph, so
1695
+ // sections keep getting picked up; a genuine multi-line prose block
1696
+ // still ends collection exactly as before.
1681
1697
  function parseTodoPlan(explanation) {
1682
1698
  if (!explanation || typeof explanation !== "string") { return []; }
1683
1699
  var planStart = explanation.indexOf("Plan:");
1684
1700
  if (planStart === -1) { return []; }
1685
1701
  var afterPlan = explanation.slice(planStart + 5);
1686
- var planBlock = afterPlan.split(/\n\n/)[0];
1687
- var lines = planBlock.split("\n");
1702
+ var lines = afterPlan.split("\n");
1688
1703
  var items = [];
1689
- lines.forEach(function (line) {
1690
- var stripped = line.replace(/^\s*\d+[.):\s]+/, "").replace(/^\s*[-*]\s+/, "").trim();
1691
- if (stripped) { items.push({ text: stripped, status: "pending" }); }
1692
- });
1704
+ var proseStreak = 0;
1705
+ for (var i = 0; i < lines.length; i++) {
1706
+ var line = lines[i];
1707
+ if (!line.trim()) { continue; } // a blank line alone never ends the plan
1708
+ var isListLine = /^\s*\d+[.):\s]/.test(line) || /^\s*[-*]\s+/.test(line);
1709
+ if (isListLine) {
1710
+ proseStreak = 0;
1711
+ var stripped = line.replace(/^\s*\d+[.):\s]+/, "").replace(/^\s*[-*]\s+/, "").trim();
1712
+ if (stripped) { items.push({ text: stripped, status: "pending" }); }
1713
+ continue;
1714
+ }
1715
+ proseStreak++;
1716
+ if (proseStreak >= 2) { break; }
1717
+ }
1693
1718
  return items;
1694
1719
  }
1695
1720
 
@@ -6,6 +6,28 @@ const ANTHROPIC_API_BASE = "https://api.anthropic.com";
6
6
  const ANTHROPIC_VERSION = "2023-06-01";
7
7
  const DEFAULT_MAX_TOKENS = 8192;
8
8
 
9
+ // ---- temperature-deprecated detection (GitHub #9) ----
10
+ // Models released after Claude Opus 4.6 reject any temperature value other
11
+ // than 1.0 (Anthropic's own Messages API docs) — FlowPilot's default is 0.2,
12
+ // so every request against a newer model fails outright with no reactive
13
+ // handling. No model-name allowlist or version parsing: detect the actual
14
+ // error shape live and adapt. In-memory only (module scope, keyed by model
15
+ // name) — resets on a server restart, which is an acceptable, deliberate
16
+ // simplification for a hotfix; the storage-backed persistence
17
+ // `supportsTools` uses would need threading through every one of this
18
+ // module's ~8 call sites in flowpilot.js and isn't worth that churn for a
19
+ // per-process cache that already makes this a one-time cost per model for
20
+ // the life of the running server.
21
+ const modelsWithoutTemperatureSupport = new Map();
22
+
23
+ function isTemperatureDeprecatedError(err) {
24
+ if (!err || typeof err.message !== "string") { return false; }
25
+ const msg = err.message;
26
+ return msg.indexOf("invalid_request_error") !== -1 &&
27
+ msg.indexOf("temperature") !== -1 &&
28
+ msg.indexOf("deprecated") !== -1;
29
+ }
30
+
9
31
  // ---- HTTP helpers ----
10
32
 
11
33
  function postJson(urlString, headers, body, timeoutMs) {
@@ -109,8 +131,19 @@ function postStream(urlString, headers, body, timeoutMs, onDelta, onReasoningDel
109
131
  }, (res) => {
110
132
  res.setEncoding("utf8");
111
133
  if (res.statusCode < 200 || res.statusCode >= 300) {
112
- res.on("data", () => {});
113
- res.on("end", () => { reject(new Error("Provider request failed (status " + res.statusCode + ").")); });
134
+ // Capture the error body (same shape as postJson's non-2xx path)
135
+ // so callers can detect a specific error type (e.g. the
136
+ // temperature-deprecated case below) — this stays internal to the
137
+ // retry decision, never echoed to the HTTP client as the raw body
138
+ // (same ADR-007 boundary postJson already holds).
139
+ let errBody = "";
140
+ res.on("data", (chunk) => { errBody += chunk; });
141
+ res.on("end", () => {
142
+ let parsed = null;
143
+ try { parsed = errBody ? JSON.parse(errBody) : null; } catch (e) { /* fall through to generic message */ }
144
+ const msg = parsed && parsed.error ? JSON.stringify(parsed.error) : ("status " + res.statusCode);
145
+ reject(new Error("Provider request failed (" + res.statusCode + "): " + msg));
146
+ });
114
147
  return;
115
148
  }
116
149
 
@@ -263,23 +296,37 @@ async function chat(settings, messages, options) {
263
296
  const { system, messages: anthropicMessages } = convertMessages(messages);
264
297
  const temperature = settings.temperature !== undefined ? Number(settings.temperature) : 0.2;
265
298
 
266
- const body = {
267
- model: settings.model,
268
- messages: anthropicMessages,
269
- max_tokens: DEFAULT_MAX_TOKENS,
270
- temperature: temperature,
271
- stream: false
272
- };
273
- if (system) { body.system = system; }
274
- if (options && Array.isArray(options.tools) && options.tools.length) {
275
- body.tools = options.tools.map(toAnthropicTool).filter(Boolean);
276
- body.tool_choice = {
277
- type: options.toolChoice === "required" ? "any" : "auto"
299
+ function buildBody(includeTemperature) {
300
+ const body = {
301
+ model: settings.model,
302
+ messages: anthropicMessages,
303
+ max_tokens: DEFAULT_MAX_TOKENS,
304
+ stream: false
278
305
  };
306
+ if (includeTemperature) { body.temperature = temperature; }
307
+ if (system) { body.system = system; }
308
+ if (options && Array.isArray(options.tools) && options.tools.length) {
309
+ body.tools = options.tools.map(toAnthropicTool).filter(Boolean);
310
+ body.tool_choice = {
311
+ type: options.toolChoice === "required" ? "any" : "auto"
312
+ };
313
+ }
314
+ return body;
279
315
  }
280
316
 
317
+ const skipTemperature = modelsWithoutTemperatureSupport.get(settings.model) === true;
281
318
  const startedAt = Date.now();
282
- const response = await postJson(baseUrl + "/v1/messages", anthropicHeaders(settings), body, settings.requestTimeoutMs || 180000);
319
+ let response;
320
+ try {
321
+ response = await postJson(baseUrl + "/v1/messages", anthropicHeaders(settings), buildBody(!skipTemperature), settings.requestTimeoutMs || 180000);
322
+ } catch (err) {
323
+ if (skipTemperature || !isTemperatureDeprecatedError(err)) { throw err; }
324
+ // First time seeing this on this model this process — cache it so
325
+ // every later call to this model skips straight to the no-temperature
326
+ // body, then retry this one request once.
327
+ modelsWithoutTemperatureSupport.set(settings.model, true);
328
+ response = await postJson(baseUrl + "/v1/messages", anthropicHeaders(settings), buildBody(false), settings.requestTimeoutMs || 180000);
329
+ }
283
330
  const totalMs = Date.now() - startedAt;
284
331
 
285
332
  const contentArray = (response && Array.isArray(response.content)) ? response.content : [];
@@ -307,16 +354,31 @@ async function chatStream(settings, messages, onDelta, onReasoningDelta) {
307
354
  const { system, messages: anthropicMessages } = convertMessages(messages);
308
355
  const temperature = settings.temperature !== undefined ? Number(settings.temperature) : 0.2;
309
356
 
310
- const body = {
311
- model: settings.model,
312
- messages: anthropicMessages,
313
- max_tokens: DEFAULT_MAX_TOKENS,
314
- temperature: temperature,
315
- stream: true
316
- };
317
- if (system) { body.system = system; }
357
+ function buildBody(includeTemperature) {
358
+ const body = {
359
+ model: settings.model,
360
+ messages: anthropicMessages,
361
+ max_tokens: DEFAULT_MAX_TOKENS,
362
+ stream: true
363
+ };
364
+ if (includeTemperature) { body.temperature = temperature; }
365
+ if (system) { body.system = system; }
366
+ return body;
367
+ }
318
368
 
319
- const result = await postStream(baseUrl + "/v1/messages", anthropicHeaders(settings), body, settings.requestTimeoutMs || 180000, onDelta, onReasoningDelta);
369
+ const skipTemperature = modelsWithoutTemperatureSupport.get(settings.model) === true;
370
+ let result;
371
+ try {
372
+ result = await postStream(baseUrl + "/v1/messages", anthropicHeaders(settings), buildBody(!skipTemperature), settings.requestTimeoutMs || 180000, onDelta, onReasoningDelta);
373
+ } catch (err) {
374
+ if (skipTemperature || !isTemperatureDeprecatedError(err)) { throw err; }
375
+ // Same one-time detection as chat() above. Safe to retry whole-hog:
376
+ // postStream rejects on the response's status line, before any SSE
377
+ // data event is parsed, so onDelta/onReasoningDelta can't have already
378
+ // emitted partial content from the failed attempt.
379
+ modelsWithoutTemperatureSupport.set(settings.model, true);
380
+ result = await postStream(baseUrl + "/v1/messages", anthropicHeaders(settings), buildBody(false), settings.requestTimeoutMs || 180000, onDelta, onReasoningDelta);
381
+ }
320
382
 
321
383
  return {
322
384
  content: result.content || "",
@@ -362,8 +424,8 @@ async function probeTools(settings) {
362
424
  if (!settings.model) { throw new Error("Model is required."); }
363
425
  const baseUrl = resolveBaseUrl(settings);
364
426
 
365
- try {
366
- const response = await postJson(baseUrl + "/v1/messages", anthropicHeaders(settings), {
427
+ function buildProbeBody(includeTemperature) {
428
+ const body = {
367
429
  model: settings.model,
368
430
  messages: [{ role: "user", content: "Call the \"ping\" tool now with no arguments." }],
369
431
  system: "You are being tested for tool/function-calling support.",
@@ -374,9 +436,26 @@ async function probeTools(settings) {
374
436
  }],
375
437
  tool_choice: { type: "auto" },
376
438
  max_tokens: 128,
377
- temperature: 0,
378
439
  stream: false
379
- }, settings.requestTimeoutMs || 30000);
440
+ };
441
+ if (includeTemperature) { body.temperature = 0; }
442
+ return body;
443
+ }
444
+
445
+ const skipTemperature = modelsWithoutTemperatureSupport.get(settings.model) === true;
446
+ try {
447
+ let response;
448
+ try {
449
+ response = await postJson(baseUrl + "/v1/messages", anthropicHeaders(settings), buildProbeBody(!skipTemperature), settings.requestTimeoutMs || 30000);
450
+ } catch (err) {
451
+ // A temperature-deprecated failure here is not "no tool support" —
452
+ // it's an unrelated request-shape error. Retry without temperature
453
+ // before concluding anything about tool support (same detection as
454
+ // chat()/chatStream() above).
455
+ if (skipTemperature || !isTemperatureDeprecatedError(err)) { throw err; }
456
+ modelsWithoutTemperatureSupport.set(settings.model, true);
457
+ response = await postJson(baseUrl + "/v1/messages", anthropicHeaders(settings), buildProbeBody(false), settings.requestTimeoutMs || 30000);
458
+ }
380
459
 
381
460
  const contentArray = (response && Array.isArray(response.content)) ? response.content : [];
382
461
  const hasToolUse = contentArray.some(function (b) { return b.type === "tool_use" && b.name === "ping"; });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@manny-est/node-red-flowpilot",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "FlowPilot for Node-RED - an AI-powered development assistant sidebar",
5
5
  "main": "flowpilot.js",
6
6
  "keywords": [