@manny-est/node-red-flowpilot 0.5.1 → 0.5.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.
@@ -0,0 +1,385 @@
1
+ const https = require("https");
2
+ const http = require("http");
3
+
4
+ const ANTHROPIC_API_BASE = "https://api.anthropic.com";
5
+ const ANTHROPIC_VERSION = "2023-06-01";
6
+ const DEFAULT_MAX_TOKENS = 8192;
7
+
8
+ // ---- HTTP helpers ----
9
+
10
+ function postJson(urlString, headers, body, timeoutMs) {
11
+ return new Promise((resolve, reject) => {
12
+ let url;
13
+ try { url = new URL(urlString); } catch (err) { reject(new Error("Invalid provider URL: " + urlString)); return; }
14
+
15
+ const payload = JSON.stringify(body);
16
+ const transport = url.protocol === "https:" ? https : http;
17
+ const req = transport.request({
18
+ method: "POST",
19
+ hostname: url.hostname,
20
+ port: url.port || (url.protocol === "https:" ? 443 : 80),
21
+ path: url.pathname + url.search,
22
+ headers: Object.assign({ "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }, headers || {})
23
+ }, (res) => {
24
+ let data = "";
25
+ res.setEncoding("utf8");
26
+ res.on("data", chunk => { data += chunk; });
27
+ res.on("end", () => {
28
+ let parsed = null;
29
+ try { parsed = data ? JSON.parse(data) : null; } catch (err) {
30
+ reject(new Error("Provider returned non-JSON response (" + res.statusCode + "): " + data.slice(0, 500)));
31
+ return;
32
+ }
33
+ if (res.statusCode < 200 || res.statusCode >= 300) {
34
+ const msg = parsed && parsed.error ? JSON.stringify(parsed.error) : data;
35
+ reject(new Error("Provider request failed (" + res.statusCode + "): " + msg));
36
+ return;
37
+ }
38
+ resolve(parsed);
39
+ });
40
+ });
41
+ req.on("error", reject);
42
+ req.setTimeout(timeoutMs || 180000, () => {
43
+ req.destroy(new Error("Provider request timed out after " + (timeoutMs || 180000) + "ms — increase the request timeout in Settings → Behavior for slower hardware."));
44
+ });
45
+ req.write(payload);
46
+ req.end();
47
+ });
48
+ }
49
+
50
+ function getJson(urlString, headers, timeoutMs) {
51
+ return new Promise((resolve, reject) => {
52
+ let url;
53
+ try { url = new URL(urlString); } catch (err) { reject(new Error("Invalid provider URL: " + urlString)); return; }
54
+
55
+ const transport = url.protocol === "https:" ? https : http;
56
+ const req = transport.request({
57
+ method: "GET",
58
+ hostname: url.hostname,
59
+ port: url.port || (url.protocol === "https:" ? 443 : 80),
60
+ path: url.pathname + url.search,
61
+ headers: headers || {}
62
+ }, (res) => {
63
+ let data = "";
64
+ res.setEncoding("utf8");
65
+ res.on("data", chunk => { data += chunk; });
66
+ res.on("end", () => {
67
+ let parsed = null;
68
+ try { parsed = data ? JSON.parse(data) : null; } catch (err) {
69
+ reject(new Error("Provider returned non-JSON response (" + res.statusCode + "): " + data.slice(0, 500)));
70
+ return;
71
+ }
72
+ if (res.statusCode < 200 || res.statusCode >= 300) {
73
+ const msg = parsed && parsed.error ? JSON.stringify(parsed.error) : data;
74
+ reject(new Error("Provider request failed (" + res.statusCode + "): " + msg));
75
+ return;
76
+ }
77
+ resolve(parsed);
78
+ });
79
+ });
80
+ req.on("error", reject);
81
+ req.setTimeout(timeoutMs || 30000, () => {
82
+ req.destroy(new Error("Timeout"));
83
+ });
84
+ req.end();
85
+ });
86
+ }
87
+
88
+ function postStream(urlString, headers, body, timeoutMs, onDelta, onReasoningDelta) {
89
+ return new Promise((resolve, reject) => {
90
+ let url;
91
+ try { url = new URL(urlString); } catch (err) { reject(new Error("Invalid provider URL: " + urlString)); return; }
92
+
93
+ const payload = JSON.stringify(body);
94
+ const transport = url.protocol === "https:" ? https : http;
95
+ const startedAt = Date.now();
96
+ let firstTokenAt = null;
97
+ let usage = null;
98
+ let full = "";
99
+
100
+ const req = transport.request({
101
+ method: "POST",
102
+ hostname: url.hostname,
103
+ port: url.port || (url.protocol === "https:" ? 443 : 80),
104
+ path: url.pathname + url.search,
105
+ headers: Object.assign({ "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }, headers || {})
106
+ }, (res) => {
107
+ res.setEncoding("utf8");
108
+ if (res.statusCode < 200 || res.statusCode >= 300) {
109
+ let errData = "";
110
+ res.on("data", chunk => { errData += chunk; });
111
+ res.on("end", () => { reject(new Error("Provider request failed (" + res.statusCode + "): " + errData.slice(0, 500))); });
112
+ return;
113
+ }
114
+
115
+ let sseBuf = "";
116
+ res.on("data", (chunk) => {
117
+ sseBuf += chunk;
118
+ const lines = sseBuf.split("\n");
119
+ sseBuf = lines.pop();
120
+ lines.forEach((line) => {
121
+ line = line.trim();
122
+ if (!line.startsWith("data:")) { return; }
123
+ const dataStr = line.slice(5).trim();
124
+ if (!dataStr) { return; }
125
+ let evt;
126
+ try { evt = JSON.parse(dataStr); } catch (e) { return; }
127
+
128
+ // Anthropic SSE event types used here:
129
+ // message_start: carries input_tokens
130
+ // content_block_delta: text_delta or thinking_delta
131
+ // message_delta: carries output_tokens
132
+ if (evt.type === "message_start" && evt.message && evt.message.usage) {
133
+ usage = { prompt_tokens: evt.message.usage.input_tokens || 0, completion_tokens: 0 };
134
+ } else if (evt.type === "content_block_delta" && evt.delta) {
135
+ if (evt.delta.type === "text_delta" && evt.delta.text) {
136
+ if (firstTokenAt === null) { firstTokenAt = Date.now(); }
137
+ full += evt.delta.text;
138
+ onDelta(evt.delta.text);
139
+ } else if (evt.delta.type === "thinking_delta" && evt.delta.thinking && onReasoningDelta) {
140
+ onReasoningDelta(evt.delta.thinking);
141
+ }
142
+ } else if (evt.type === "message_delta" && evt.usage) {
143
+ if (usage) { usage.completion_tokens = evt.usage.output_tokens || 0; }
144
+ else { usage = { prompt_tokens: 0, completion_tokens: evt.usage.output_tokens || 0 }; }
145
+ }
146
+ });
147
+ });
148
+ res.on("end", () => {
149
+ resolve({ content: full, ttftMs: firstTokenAt !== null ? firstTokenAt - startedAt : null, totalMs: Date.now() - startedAt, usage: usage });
150
+ });
151
+ });
152
+
153
+ req.on("error", reject);
154
+ req.setTimeout(timeoutMs || 180000, () => {
155
+ req.destroy(new Error("Provider request timed out after " + (timeoutMs || 180000) + "ms — increase the request timeout in Settings → Behavior for slower hardware."));
156
+ });
157
+ req.write(payload);
158
+ req.end();
159
+ });
160
+ }
161
+
162
+ // ---- Message format conversion ----
163
+
164
+ // OpenAI {type:"function", function:{name,description,parameters}} →
165
+ // Anthropic {name, description, input_schema}
166
+ function toAnthropicTool(tool) {
167
+ const fn = tool && tool.function;
168
+ if (!fn) { return null; }
169
+ return {
170
+ name: fn.name,
171
+ description: fn.description || "",
172
+ input_schema: fn.parameters || { type: "object", properties: {} }
173
+ };
174
+ }
175
+
176
+ // Anthropic {type:"tool_use", id, name, input} →
177
+ // OpenAI {id, type:"function", function:{name, arguments:string}}
178
+ function toOpenAiToolCall(block) {
179
+ return {
180
+ id: block.id,
181
+ type: "function",
182
+ function: {
183
+ name: block.name,
184
+ arguments: JSON.stringify(block.input || {})
185
+ }
186
+ };
187
+ }
188
+
189
+ // Convert an OpenAI-shaped messages array to Anthropic format.
190
+ // Returns { system: string, messages: [...] }
191
+ //
192
+ // - role:"system" messages are extracted and concatenated into top-level system param
193
+ // - role:"assistant" + tool_calls converted to Anthropic tool_use content blocks
194
+ // - role:"tool" results converted and grouped into user messages with tool_result blocks
195
+ // - role:"user" and plain role:"assistant" pass through unchanged
196
+ function convertMessages(messages) {
197
+ const systemParts = [];
198
+ const converted = [];
199
+
200
+ for (let i = 0; i < messages.length; i++) {
201
+ const msg = messages[i];
202
+
203
+ if (msg.role === "system") {
204
+ if (typeof msg.content === "string" && msg.content) {
205
+ systemParts.push(msg.content);
206
+ }
207
+ continue;
208
+ }
209
+
210
+ // Group consecutive tool result messages into one user message
211
+ if (msg.role === "tool") {
212
+ const toolResults = [];
213
+ while (i < messages.length && messages[i].role === "tool") {
214
+ toolResults.push({
215
+ type: "tool_result",
216
+ tool_use_id: messages[i].tool_call_id,
217
+ content: messages[i].content || ""
218
+ });
219
+ i++;
220
+ }
221
+ i--; // outer loop will increment
222
+ converted.push({ role: "user", content: toolResults });
223
+ continue;
224
+ }
225
+
226
+ // Assistant with tool_calls → Anthropic tool_use content blocks
227
+ if (msg.role === "assistant" && Array.isArray(msg.tool_calls) && msg.tool_calls.length) {
228
+ const content = [];
229
+ if (msg.content) { content.push({ type: "text", text: msg.content }); }
230
+ msg.tool_calls.forEach(function (tc) {
231
+ let input = {};
232
+ try { input = JSON.parse(tc.function.arguments || "{}"); } catch (e) {}
233
+ content.push({ type: "tool_use", id: tc.id, name: tc.function.name, input: input });
234
+ });
235
+ converted.push({ role: "assistant", content: content });
236
+ continue;
237
+ }
238
+
239
+ converted.push({ role: msg.role, content: msg.content });
240
+ }
241
+
242
+ return { system: systemParts.join("\n\n"), messages: converted };
243
+ }
244
+
245
+ function anthropicHeaders(settings) {
246
+ return {
247
+ "x-api-key": settings.apiKey || "",
248
+ "anthropic-version": ANTHROPIC_VERSION
249
+ };
250
+ }
251
+
252
+ function resolveBaseUrl(settings) {
253
+ return String(settings.baseUrl || ANTHROPIC_API_BASE).replace(/\/+$/, "") || ANTHROPIC_API_BASE;
254
+ }
255
+
256
+ // ---- chat ----
257
+
258
+ async function chat(settings, messages, options) {
259
+ if (!settings.model) { throw new Error("Model is required."); }
260
+ const baseUrl = resolveBaseUrl(settings);
261
+ const { system, messages: anthropicMessages } = convertMessages(messages);
262
+ const temperature = settings.temperature !== undefined ? Number(settings.temperature) : 0.2;
263
+
264
+ const body = {
265
+ model: settings.model,
266
+ messages: anthropicMessages,
267
+ max_tokens: DEFAULT_MAX_TOKENS,
268
+ temperature: temperature,
269
+ stream: false
270
+ };
271
+ if (system) { body.system = system; }
272
+ if (options && Array.isArray(options.tools) && options.tools.length) {
273
+ body.tools = options.tools.map(toAnthropicTool).filter(Boolean);
274
+ body.tool_choice = { type: "auto" };
275
+ }
276
+
277
+ const startedAt = Date.now();
278
+ const response = await postJson(baseUrl + "/v1/messages", anthropicHeaders(settings), body, settings.requestTimeoutMs || 180000);
279
+ const totalMs = Date.now() - startedAt;
280
+
281
+ const contentArray = (response && Array.isArray(response.content)) ? response.content : [];
282
+ const textContent = contentArray.filter(function (b) { return b.type === "text"; }).map(function (b) { return b.text; }).join("");
283
+ const toolUseBlocks = contentArray.filter(function (b) { return b.type === "tool_use"; });
284
+ const toolCalls = toolUseBlocks.length ? toolUseBlocks.map(toOpenAiToolCall) : null;
285
+ const usage = (response && response.usage)
286
+ ? { prompt_tokens: response.usage.input_tokens, completion_tokens: response.usage.output_tokens }
287
+ : null;
288
+
289
+ return {
290
+ raw: response,
291
+ content: textContent || (toolCalls ? "" : "[No assistant message returned by provider]"),
292
+ toolCalls: toolCalls,
293
+ timing: { totalMs: totalMs },
294
+ usage: usage
295
+ };
296
+ }
297
+
298
+ // ---- chatStream ----
299
+
300
+ async function chatStream(settings, messages, onDelta, onReasoningDelta) {
301
+ if (!settings.model) { throw new Error("Model is required."); }
302
+ const baseUrl = resolveBaseUrl(settings);
303
+ const { system, messages: anthropicMessages } = convertMessages(messages);
304
+ const temperature = settings.temperature !== undefined ? Number(settings.temperature) : 0.2;
305
+
306
+ const body = {
307
+ model: settings.model,
308
+ messages: anthropicMessages,
309
+ max_tokens: DEFAULT_MAX_TOKENS,
310
+ temperature: temperature,
311
+ stream: true
312
+ };
313
+ if (system) { body.system = system; }
314
+
315
+ const result = await postStream(baseUrl + "/v1/messages", anthropicHeaders(settings), body, settings.requestTimeoutMs || 180000, onDelta, onReasoningDelta);
316
+
317
+ return {
318
+ content: result.content || "",
319
+ timing: { ttftMs: result.ttftMs, totalMs: result.totalMs },
320
+ usage: result.usage
321
+ };
322
+ }
323
+
324
+ // ---- listModels ----
325
+ // Tries GET /v1/models; falls back to a hardcoded list if that endpoint
326
+ // is unavailable (non-standard proxy) or returns an error.
327
+ async function listModels(settings) {
328
+ const baseUrl = resolveBaseUrl(settings);
329
+ try {
330
+ const response = await getJson(baseUrl + "/v1/models", anthropicHeaders(settings), settings.requestTimeoutMs || 30000);
331
+ const data = response && Array.isArray(response.data) ? response.data : [];
332
+ const models = data.map(function (m) { return m && m.id; }).filter(function (id) { return typeof id === "string" && id; });
333
+ if (models.length) { return { models: models }; }
334
+ throw new Error("Empty model list from provider");
335
+ } catch (err) {
336
+ return {
337
+ models: [
338
+ "claude-opus-4-8",
339
+ "claude-sonnet-5",
340
+ "claude-haiku-4-5-20251001"
341
+ ],
342
+ error: err.message
343
+ };
344
+ }
345
+ }
346
+
347
+ // ---- probeTools ----
348
+ async function probeTools(settings) {
349
+ if (!settings.model) { throw new Error("Model is required."); }
350
+ const baseUrl = resolveBaseUrl(settings);
351
+
352
+ try {
353
+ const response = await postJson(baseUrl + "/v1/messages", anthropicHeaders(settings), {
354
+ model: settings.model,
355
+ messages: [{ role: "user", content: "Call the \"ping\" tool now with no arguments." }],
356
+ system: "You are being tested for tool/function-calling support.",
357
+ tools: [{
358
+ name: "ping",
359
+ description: "Respond to a connectivity probe. Takes no arguments.",
360
+ input_schema: { type: "object", properties: {}, additionalProperties: false }
361
+ }],
362
+ tool_choice: { type: "auto" },
363
+ max_tokens: 128,
364
+ temperature: 0,
365
+ stream: false
366
+ }, settings.requestTimeoutMs || 30000);
367
+
368
+ const contentArray = (response && Array.isArray(response.content)) ? response.content : [];
369
+ const hasToolUse = contentArray.some(function (b) { return b.type === "tool_use" && b.name === "ping"; });
370
+ return { supportsTools: hasToolUse };
371
+ } catch (err) {
372
+ return { supportsTools: false, error: err.message };
373
+ }
374
+ }
375
+
376
+ // ---- detectReasoning ----
377
+ // Extended thinking responses include content blocks with type:"thinking".
378
+ // Standard non-thinking responses never include them.
379
+ function detectReasoning(rawResponse) {
380
+ const contentArray = (rawResponse && Array.isArray(rawResponse.content)) ? rawResponse.content : [];
381
+ const isReasoningModel = contentArray.some(function (b) { return b && b.type === "thinking" && b.thinking; });
382
+ return { isReasoningModel: isReasoningModel };
383
+ }
384
+
385
+ module.exports = { chat, chatStream, probeTools, listModels, detectReasoning };
@@ -169,6 +169,9 @@ async function chat(settings, messages, options) {
169
169
  body.tools = options.tools;
170
170
  body.tool_choice = options.toolChoice || "auto";
171
171
  }
172
+ if (options && options.responseFormat) {
173
+ body.response_format = options.responseFormat;
174
+ }
172
175
 
173
176
  const startedAt = Date.now();
174
177
  const response = await postJson(`${baseUrl}/v1/chat/completions`, headers, body, settings.requestTimeoutMs || 180000);
@@ -368,7 +371,7 @@ function postStream(urlString, headers, body, timeoutMs, onDelta, onReasoningDel
368
371
  });
369
372
  }
370
373
 
371
- async function chatStream(settings, messages, onDelta, onReasoningDelta) {
374
+ async function chatStream(settings, messages, onDelta, onReasoningDelta, options) {
372
375
  const baseUrl = String(settings.baseUrl || "").replace(/\/+$/, "");
373
376
  if (!baseUrl) throw new Error("Provider base URL is required.");
374
377
  if (!settings.model) throw new Error("Model is required.");
@@ -384,13 +387,19 @@ async function chatStream(settings, messages, onDelta, onReasoningDelta) {
384
387
  // final SSE chunk carrying token usage (no delta) before [DONE]. Providers
385
388
  // that don't support it just ignore the option; postStream treats a
386
389
  // missing usage field as null either way.
387
- const result = await postStream(`${baseUrl}/v1/chat/completions`, headers, {
390
+ const body = {
388
391
  model: settings.model,
389
392
  messages,
390
393
  temperature,
391
394
  stream: true,
392
395
  stream_options: { include_usage: true }
393
- }, settings.requestTimeoutMs || 180000, onDelta, onReasoningDelta);
396
+ };
397
+ if (options && options.responseFormat) {
398
+ body.response_format = options.responseFormat;
399
+ }
400
+
401
+ const result = await postStream(`${baseUrl}/v1/chat/completions`, headers,
402
+ body, settings.requestTimeoutMs || 180000, onDelta, onReasoningDelta);
394
403
 
395
404
  return {
396
405
  content: result.content || "",
package/lib/storage.js CHANGED
@@ -18,10 +18,15 @@ function createStorage(userDir) {
18
18
  return {
19
19
  id: "default",
20
20
  providerName: "LocalAI",
21
+ // "openai-compatible" (default) or "anthropic"
22
+ type: "openai-compatible",
21
23
  baseUrl: "http://localhost:8080",
22
24
  apiKey: "",
23
25
  model: "",
24
- temperature: 0.2
26
+ temperature: 0.2,
27
+ // Configured context window size for this provider in tokens (0 = unknown).
28
+ // When set, FlowPilot warns when the assembled prompt approaches the limit.
29
+ numCtx: 0
25
30
  };
26
31
  }
27
32
 
@@ -33,7 +38,11 @@ function createStorage(userDir) {
33
38
  maxContextChars: 12000,
34
39
  defaultContextMode: "selected",
35
40
  allowConfigContext: false,
36
- logFullContext: false,
41
+ // When true, each assembled prompt (post-redaction messages array) plus the
42
+ // raw provider response and parse outcome is appended to assembled-prompts.log
43
+ // (0600 perms). Auth headers/keys are never logged — only the content bytes
44
+ // that the provider actually received. Off by default (diagnostic tool).
45
+ logAssembledPrompts: false,
37
46
  streamingEnabled: true,
38
47
  // First-run welcome/warning shows until the user saves settings once.
39
48
  firstRunAcknowledged: false,
@@ -62,6 +71,10 @@ function createStorage(userDir) {
62
71
  // and shows a checkpoint question ("Continue with AI review, or stop?")
63
72
  // instead of auto-advancing. Default false = original auto-advance behavior.
64
73
  loopHoldStep: false,
74
+ // Routes Generate through the Phase 10 step-queue engine (graph read-back
75
+ // verification after import). The legacy path remains available when a
76
+ // user explicitly disables this setting.
77
+ enableStepQueue: true,
65
78
  // Lets the user silence the recurring secrets/size reminder bar after
66
79
  // typing an explicit acknowledgement in settings.
67
80
  suppressContextWarnings: false,
@@ -290,6 +303,32 @@ function createStorage(userDir) {
290
303
  return defaultSettings.systemPrompt;
291
304
  }
292
305
 
306
+ const assembledPromptsFile = path.join(baseDir, "assembled-prompts.log");
307
+
308
+ // W0.4: Append one JSON-lines entry to assembled-prompts.log.
309
+ // Written 0600 — diagnostic data, never world-readable.
310
+ // Auth keys are NOT included (only baseUrl + model from the provider
311
+ // profile, never apiKey). The bytes logged are post-redaction: the same
312
+ // content the provider actually received.
313
+ function appendAssembledPromptLog(entry) {
314
+ init();
315
+ const line = JSON.stringify({
316
+ timestamp: new Date().toISOString(),
317
+ ...entry
318
+ });
319
+ try {
320
+ // Open with O_APPEND | O_CREAT, mode 0600 so secrets never land
321
+ // in a world-readable file even on first write.
322
+ const fd = fs.openSync(assembledPromptsFile, "a", 0o600);
323
+ fs.writeSync(fd, line + "\n");
324
+ fs.closeSync(fd);
325
+ // Ensure 0600 regardless of umask on subsequent opens.
326
+ fs.chmodSync(assembledPromptsFile, 0o600);
327
+ } catch (err) {
328
+ console.error("[FlowPilot] assembled-prompts log write failed:", err.message);
329
+ }
330
+ }
331
+
293
332
  init();
294
333
 
295
334
  return {
@@ -298,11 +337,13 @@ function createStorage(userDir) {
298
337
  backupsDir,
299
338
  settingsFile,
300
339
  auditFile,
340
+ assembledPromptsFile,
301
341
  getSettings,
302
342
  saveSettings,
303
343
  getActiveProvider,
304
344
  getDefaultSystemPrompt,
305
345
  appendAudit,
346
+ appendAssembledPromptLog,
306
347
  appendTranscript,
307
348
  readTranscript,
308
349
  deleteTranscript,