@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.
- package/CHANGELOG.md +79 -0
- package/README.md +21 -7
- package/USER-GUIDE.md +22 -13
- package/flowpilot-core.css +128 -2
- package/flowpilot.js +382 -27
- package/lib/build-system-prompt.js +26 -4
- package/lib/core/apply-review.js +234 -53
- package/lib/core/init.js +10 -0
- package/lib/core/main.js +92 -6
- package/lib/core/modes.js +698 -24
- package/lib/core/selection-context.js +28 -1
- package/lib/default-system-prompt.js +13 -9
- package/lib/document-system-prompt.js +19 -30
- package/lib/generation-system-prompt.js +24 -40
- package/lib/modify-system-prompt.js +98 -70
- package/lib/prompt-fragments.js +45 -0
- package/lib/provider-anthropic.js +385 -0
- package/lib/provider-openai-compatible.js +12 -3
- package/lib/storage.js +43 -2
- package/lib/validator.js +238 -0
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
}
|
|
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
|
-
|
|
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,
|