flint-agent 1.14.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/.env.example +108 -0
- package/CHANGELOG.md +55 -0
- package/FEATURES.md +298 -0
- package/LICENSE +21 -0
- package/README.md +435 -0
- package/bin/flint.js +47 -0
- package/config/classifier-prompt.md +218 -0
- package/config/models-curated.json +4 -0
- package/config/providers.json +74 -0
- package/package.json +92 -0
- package/patches/ink+6.8.0.patch +78 -0
- package/profiles/desktop.md +65 -0
- package/profiles/generic.md +20 -0
- package/profiles/marketer.md +20 -0
- package/profiles/profiles.json +34 -0
- package/profiles/ux-reviewer.md +25 -0
- package/src/agent/agent.js +1743 -0
- package/src/agent/auto.js +346 -0
- package/src/agent/backoff.js +143 -0
- package/src/agent/compression.js +310 -0
- package/src/agent/content-resolver.js +180 -0
- package/src/agent/flow-controller.js +309 -0
- package/src/agent/intent-manifest.js +231 -0
- package/src/agent/intent-timeout.js +46 -0
- package/src/agent/intent.js +633 -0
- package/src/agent/knowledge.js +114 -0
- package/src/agent/learning.js +180 -0
- package/src/agent/modes.js +187 -0
- package/src/agent/outcome-ask.js +91 -0
- package/src/agent/project-context.js +76 -0
- package/src/agent/prompt-budget.js +117 -0
- package/src/agent/reflection-extractor.js +140 -0
- package/src/agent/steering.js +86 -0
- package/src/agent/supervisor.js +430 -0
- package/src/agent/swap.js +443 -0
- package/src/agent/system-prompt.js +446 -0
- package/src/agent/time-stamp.js +48 -0
- package/src/agent/tool-guard.js +201 -0
- package/src/agent/toolcall-text.js +162 -0
- package/src/agent/usage.js +297 -0
- package/src/agent/vision.js +94 -0
- package/src/agent/watchdog.js +139 -0
- package/src/agent/workspace-changes.js +177 -0
- package/src/api/address.js +14 -0
- package/src/api/client.js +280 -0
- package/src/api/server.js +535 -0
- package/src/api/stream-pipe.js +113 -0
- package/src/app-state.js +39 -0
- package/src/bootstrap.js +501 -0
- package/src/bus/drain-loop.js +497 -0
- package/src/bus/index.js +270 -0
- package/src/bus/plugins.js +65 -0
- package/src/child-idle.js +14 -0
- package/src/cli.js +118 -0
- package/src/commands/commands.js +1297 -0
- package/src/commands/registry.js +132 -0
- package/src/components/App.js +491 -0
- package/src/components/CarefulMenu.js +145 -0
- package/src/components/HistoryWriter.js +86 -0
- package/src/components/LineInput.js +69 -0
- package/src/components/LiveZone.js +294 -0
- package/src/components/OverlayMenu.js +179 -0
- package/src/components/SystemPanel.js +156 -0
- package/src/components/Table.js +54 -0
- package/src/config.js +249 -0
- package/src/free-models.js +230 -0
- package/src/index.js +1111 -0
- package/src/input-handler.js +13 -0
- package/src/input-text.js +123 -0
- package/src/launcher.js +129 -0
- package/src/logging/api-log.js +95 -0
- package/src/logging/chat-log-follower.js +113 -0
- package/src/logging/chat-log.js +15 -0
- package/src/logging/log-collector.js +182 -0
- package/src/logging/logger.js +112 -0
- package/src/logging/tool-log.js +20 -0
- package/src/mcp-client.js +314 -0
- package/src/memory/conversation-digest.js +113 -0
- package/src/memory/extract-facts.js +98 -0
- package/src/memory/facts.js +181 -0
- package/src/memory/inbox.js +63 -0
- package/src/memory/markdown.js +38 -0
- package/src/memory/patterns.js +185 -0
- package/src/memory/project.js +66 -0
- package/src/memory/reflections.js +74 -0
- package/src/memory/retrieval.js +84 -0
- package/src/memory/rules.js +105 -0
- package/src/memory/session-facts.js +125 -0
- package/src/memory/skills.js +191 -0
- package/src/memory/sqlite-store.js +653 -0
- package/src/memory/store.js +208 -0
- package/src/memory/tools.js +196 -0
- package/src/memory/user-model.js +86 -0
- package/src/message-handler.js +775 -0
- package/src/model-check.js +218 -0
- package/src/plugins/loader.js +120 -0
- package/src/plugins/manager.js +88 -0
- package/src/production-env.js +22 -0
- package/src/profiles.js +42 -0
- package/src/providers/adapters/anthropic.js +270 -0
- package/src/providers/adapters/openai.js +120 -0
- package/src/providers/keys-dpapi.js +41 -0
- package/src/providers/keys-fallback.js +31 -0
- package/src/providers/keys.js +132 -0
- package/src/providers/models.js +154 -0
- package/src/providers/registry.js +56 -0
- package/src/providers/state.js +56 -0
- package/src/registry.js +96 -0
- package/src/restart.js +29 -0
- package/src/sandbox/backend.js +130 -0
- package/src/security/api-auth.js +132 -0
- package/src/security/audit.js +98 -0
- package/src/security/child-policy.js +41 -0
- package/src/security/command-guard.js +173 -0
- package/src/security/content-fence.js +250 -0
- package/src/security/content-validator.js +132 -0
- package/src/security/index.js +143 -0
- package/src/security/network-guard.js +126 -0
- package/src/security/pairing.js +180 -0
- package/src/security/path-guard.js +140 -0
- package/src/security/persona-guard.js +67 -0
- package/src/security/policies.js +452 -0
- package/src/security/safety-constants.js +34 -0
- package/src/security/watchdog.js +107 -0
- package/src/sessions.js +130 -0
- package/src/spend.js +97 -0
- package/src/startup-watchdog.js +59 -0
- package/src/stdio/args.js +71 -0
- package/src/stdio/guard.js +59 -0
- package/src/stdio/protocol.js +167 -0
- package/src/stdio/run.js +106 -0
- package/src/stdio/session.js +180 -0
- package/src/store/agent-slice.js +306 -0
- package/src/store/dataset-slice.js +73 -0
- package/src/store/index.js +22 -0
- package/src/store/process-slice.js +135 -0
- package/src/store/session-slice.js +191 -0
- package/src/store/ui-slice.js +119 -0
- package/src/tasks/db.js +184 -0
- package/src/tasks/queries.js +589 -0
- package/src/tools/agent-tools.js +473 -0
- package/src/tools/checkpoint.js +152 -0
- package/src/tools/command-approvals.js +180 -0
- package/src/tools/dataset.js +50 -0
- package/src/tools/filesystem.js +682 -0
- package/src/tools/inbox-tools.js +48 -0
- package/src/tools/mesh.js +135 -0
- package/src/tools/own-env.js +136 -0
- package/src/tools/permissions.js +681 -0
- package/src/tools/plugin-tools.js +123 -0
- package/src/tools/process-tools.js +595 -0
- package/src/tools/registry.js +307 -0
- package/src/tools/swap-tools.js +72 -0
- package/src/tools/system.js +662 -0
- package/src/tools/tasks.js +532 -0
- package/src/tools/tool-search.js +171 -0
- package/src/ui/header.js +140 -0
- package/src/ui/input-cursor.js +23 -0
- package/src/ui/last-line.js +25 -0
- package/src/ui/line-edit.js +135 -0
- package/src/ui/output.js +399 -0
- package/src/ui/paste-tokens.js +131 -0
- package/src/ui/prompt-attention.js +134 -0
- package/src/ui/render-options.js +13 -0
- package/src/ui/replay.js +94 -0
- package/src/ui/splash.js +49 -0
- package/src/ui/status-level.js +36 -0
- package/src/ui/tool-ledger.js +203 -0
- package/src/ui/window-title.js +150 -0
- package/src/update.js +205 -0
- package/system.md +63 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// Anthropic Messages API adapter
|
|
2
|
+
// Transforms OpenAI-format messages ↔ Anthropic format
|
|
3
|
+
|
|
4
|
+
export function buildHeaders(provider, apiKey) {
|
|
5
|
+
return {
|
|
6
|
+
"Content-Type": "application/json",
|
|
7
|
+
"x-api-key": apiKey,
|
|
8
|
+
"anthropic-version": "2023-06-01",
|
|
9
|
+
...provider.headers,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getChatUrl(provider) {
|
|
14
|
+
return `${provider.baseUrl}/messages`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Convert OpenAI-style messages to Anthropic format
|
|
18
|
+
function convertMessages(messages) {
|
|
19
|
+
let systemPrompt = "";
|
|
20
|
+
const converted = [];
|
|
21
|
+
|
|
22
|
+
for (const msg of messages) {
|
|
23
|
+
if (msg.role === "system") {
|
|
24
|
+
// System messages → top-level system param
|
|
25
|
+
systemPrompt += (systemPrompt ? "\n\n" : "") + (typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content));
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (msg.role === "assistant") {
|
|
30
|
+
if (msg.tool_calls?.length) {
|
|
31
|
+
// Assistant with tool_calls → content blocks with tool_use
|
|
32
|
+
const content = [];
|
|
33
|
+
if (msg.content) {
|
|
34
|
+
content.push({ type: "text", text: msg.content });
|
|
35
|
+
}
|
|
36
|
+
for (const tc of msg.tool_calls) {
|
|
37
|
+
let args = tc.function.arguments;
|
|
38
|
+
if (typeof args === "string") {
|
|
39
|
+
try { args = JSON.parse(args); } catch { args = {}; }
|
|
40
|
+
}
|
|
41
|
+
content.push({
|
|
42
|
+
type: "tool_use",
|
|
43
|
+
id: tc.id,
|
|
44
|
+
name: tc.function.name,
|
|
45
|
+
input: args,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
converted.push({ role: "assistant", content });
|
|
49
|
+
} else {
|
|
50
|
+
converted.push({ role: "assistant", content: msg.content || "" });
|
|
51
|
+
}
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (msg.role === "tool") {
|
|
56
|
+
// Tool result → content block with tool_result
|
|
57
|
+
converted.push({
|
|
58
|
+
role: "user",
|
|
59
|
+
content: [{
|
|
60
|
+
type: "tool_result",
|
|
61
|
+
tool_use_id: msg.tool_call_id,
|
|
62
|
+
content: typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content),
|
|
63
|
+
}],
|
|
64
|
+
});
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (msg.role === "user") {
|
|
69
|
+
// User messages — handle multimodal content
|
|
70
|
+
if (Array.isArray(msg.content)) {
|
|
71
|
+
const content = msg.content.map((part) => {
|
|
72
|
+
if (part.type === "text") return { type: "text", text: part.text };
|
|
73
|
+
if (part.type === "image_url") {
|
|
74
|
+
const url = part.image_url?.url || "";
|
|
75
|
+
if (url.startsWith("data:")) {
|
|
76
|
+
const match = url.match(/^data:(image\/\w+);base64,(.+)$/);
|
|
77
|
+
if (match) {
|
|
78
|
+
return {
|
|
79
|
+
type: "image",
|
|
80
|
+
source: { type: "base64", media_type: match[1], data: match[2] },
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { type: "text", text: `[Image: ${url}]` };
|
|
85
|
+
}
|
|
86
|
+
return part;
|
|
87
|
+
});
|
|
88
|
+
converted.push({ role: "user", content });
|
|
89
|
+
} else {
|
|
90
|
+
converted.push({ role: "user", content: msg.content });
|
|
91
|
+
}
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Anthropic requires alternating user/assistant. Merge consecutive same-role messages.
|
|
97
|
+
const merged = [];
|
|
98
|
+
for (const msg of converted) {
|
|
99
|
+
const prev = merged[merged.length - 1];
|
|
100
|
+
if (prev && prev.role === msg.role) {
|
|
101
|
+
// Merge content
|
|
102
|
+
const prevContent = Array.isArray(prev.content)
|
|
103
|
+
? prev.content
|
|
104
|
+
: [{ type: "text", text: prev.content }];
|
|
105
|
+
const newContent = Array.isArray(msg.content)
|
|
106
|
+
? msg.content
|
|
107
|
+
: [{ type: "text", text: msg.content }];
|
|
108
|
+
prev.content = [...prevContent, ...newContent];
|
|
109
|
+
} else {
|
|
110
|
+
merged.push({ ...msg });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { system: systemPrompt || undefined, messages: merged };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Convert OpenAI tool definitions to Anthropic format
|
|
118
|
+
function convertTools(tools) {
|
|
119
|
+
if (!tools?.length) return undefined;
|
|
120
|
+
return tools.map((t) => ({
|
|
121
|
+
name: t.function.name,
|
|
122
|
+
description: t.function.description || "",
|
|
123
|
+
input_schema: t.function.parameters || { type: "object", properties: {} },
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// `extra`: see the openai adapter. Anthropic has no `seed` and no
|
|
128
|
+
// `response_format`, so a caller that asked for JSON gets ordinary text here
|
|
129
|
+
// and has to find the JSON in it. Every caller that asks already does — models
|
|
130
|
+
// ignored the flag often enough to make that necessary anyway.
|
|
131
|
+
export function buildBody(messages, model, tools, maxTokens, stream, extra = {}) {
|
|
132
|
+
const { system, messages: converted } = convertMessages(messages);
|
|
133
|
+
const body = {
|
|
134
|
+
model,
|
|
135
|
+
messages: converted,
|
|
136
|
+
max_tokens: maxTokens || 4096,
|
|
137
|
+
stream,
|
|
138
|
+
};
|
|
139
|
+
if (extra.temperature != null) body.temperature = extra.temperature;
|
|
140
|
+
if (system) body.system = system;
|
|
141
|
+
const convertedTools = convertTools(tools);
|
|
142
|
+
if (convertedTools) body.tools = convertedTools;
|
|
143
|
+
return body;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Parse non-streaming response
|
|
147
|
+
export function parseResponse(data, onToken) {
|
|
148
|
+
const content = data.content || [];
|
|
149
|
+
let text = "";
|
|
150
|
+
const toolCalls = [];
|
|
151
|
+
|
|
152
|
+
for (const block of content) {
|
|
153
|
+
if (block.type === "text") {
|
|
154
|
+
text += block.text;
|
|
155
|
+
} else if (block.type === "tool_use") {
|
|
156
|
+
toolCalls.push({
|
|
157
|
+
id: block.id,
|
|
158
|
+
type: "function",
|
|
159
|
+
function: {
|
|
160
|
+
name: block.name,
|
|
161
|
+
arguments: JSON.stringify(block.input || {}),
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (text) onToken?.(text);
|
|
168
|
+
|
|
169
|
+
const message = { role: "assistant", content: text };
|
|
170
|
+
if (toolCalls.length) message.tool_calls = toolCalls;
|
|
171
|
+
|
|
172
|
+
const usage = data.usage ? {
|
|
173
|
+
prompt_tokens: data.usage.input_tokens,
|
|
174
|
+
completion_tokens: data.usage.output_tokens,
|
|
175
|
+
total_tokens: (data.usage.input_tokens || 0) + (data.usage.output_tokens || 0),
|
|
176
|
+
} : null;
|
|
177
|
+
|
|
178
|
+
return { message, usage, generationId: data.id || null };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Parse streaming response (Anthropic SSE format)
|
|
182
|
+
export async function parseStreamResponse(response, onToken) {
|
|
183
|
+
let text = "";
|
|
184
|
+
const toolCalls = new Map(); // index → { id, name, arguments }
|
|
185
|
+
let currentToolIndex = -1;
|
|
186
|
+
let generationId = null;
|
|
187
|
+
let usage = null;
|
|
188
|
+
|
|
189
|
+
for await (const event of parseAnthropicSSE(response.body)) {
|
|
190
|
+
if (event.type === "message_start") {
|
|
191
|
+
generationId = event.message?.id || null;
|
|
192
|
+
if (event.message?.usage) {
|
|
193
|
+
usage = {
|
|
194
|
+
prompt_tokens: event.message.usage.input_tokens || 0,
|
|
195
|
+
completion_tokens: 0,
|
|
196
|
+
total_tokens: 0,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
} else if (event.type === "content_block_start") {
|
|
200
|
+
if (event.content_block?.type === "tool_use") {
|
|
201
|
+
currentToolIndex++;
|
|
202
|
+
toolCalls.set(currentToolIndex, {
|
|
203
|
+
id: event.content_block.id,
|
|
204
|
+
type: "function",
|
|
205
|
+
function: {
|
|
206
|
+
name: event.content_block.name,
|
|
207
|
+
arguments: "",
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
} else if (event.type === "content_block_delta") {
|
|
212
|
+
const delta = event.delta;
|
|
213
|
+
if (delta?.type === "text_delta") {
|
|
214
|
+
text += delta.text;
|
|
215
|
+
onToken?.(delta.text);
|
|
216
|
+
} else if (delta?.type === "input_json_delta") {
|
|
217
|
+
if (toolCalls.has(currentToolIndex)) {
|
|
218
|
+
toolCalls.get(currentToolIndex).function.arguments += delta.partial_json;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
} else if (event.type === "message_delta") {
|
|
222
|
+
if (event.usage) {
|
|
223
|
+
const outTokens = event.usage.output_tokens || 0;
|
|
224
|
+
if (usage) {
|
|
225
|
+
usage.completion_tokens = outTokens;
|
|
226
|
+
usage.total_tokens = usage.prompt_tokens + outTokens;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const message = { role: "assistant", content: text };
|
|
233
|
+
if (toolCalls.size > 0) {
|
|
234
|
+
message.tool_calls = [...toolCalls.values()];
|
|
235
|
+
}
|
|
236
|
+
return { message, usage, generationId };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function* parseAnthropicSSE(body) {
|
|
240
|
+
const reader = body.getReader();
|
|
241
|
+
const decoder = new TextDecoder();
|
|
242
|
+
let buffer = "";
|
|
243
|
+
|
|
244
|
+
while (true) {
|
|
245
|
+
const { done, value } = await reader.read();
|
|
246
|
+
if (done) break;
|
|
247
|
+
|
|
248
|
+
buffer += decoder.decode(value, { stream: true });
|
|
249
|
+
const lines = buffer.split("\n");
|
|
250
|
+
buffer = lines.pop();
|
|
251
|
+
|
|
252
|
+
let eventType = null;
|
|
253
|
+
for (const line of lines) {
|
|
254
|
+
const trimmed = line.trim();
|
|
255
|
+
if (trimmed.startsWith("event:")) {
|
|
256
|
+
eventType = trimmed.slice(6).trim();
|
|
257
|
+
} else if (trimmed.startsWith("data:")) {
|
|
258
|
+
const data = trimmed.slice(5).trim();
|
|
259
|
+
try {
|
|
260
|
+
const parsed = JSON.parse(data);
|
|
261
|
+
if (eventType) parsed.type = eventType;
|
|
262
|
+
yield parsed;
|
|
263
|
+
} catch {}
|
|
264
|
+
eventType = null;
|
|
265
|
+
} else if (!trimmed) {
|
|
266
|
+
eventType = null;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// OpenAI-compatible adapter — passthrough for OpenRouter, OpenAI, Groq, Together, Ollama
|
|
2
|
+
// This is the current format used by client.js, extracted as an adapter.
|
|
3
|
+
|
|
4
|
+
export function buildHeaders(provider, apiKey) {
|
|
5
|
+
const headers = {
|
|
6
|
+
"Content-Type": "application/json",
|
|
7
|
+
...provider.headers,
|
|
8
|
+
};
|
|
9
|
+
if (provider.authType === "bearer" && apiKey) {
|
|
10
|
+
headers["Authorization"] = `Bearer ${apiKey}`;
|
|
11
|
+
}
|
|
12
|
+
return headers;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// `extra` carries what a particular caller needs and the main loop does not:
|
|
16
|
+
// a pinned seed and json_object for the classifier, temperature 0 for anything
|
|
17
|
+
// that parses its own answer. It arrives here rather than in a second fetch
|
|
18
|
+
// somewhere because there is one door now.
|
|
19
|
+
export function buildBody(messages, model, tools, maxTokens, stream, extra = {}) {
|
|
20
|
+
const body = { model, messages, stream };
|
|
21
|
+
if (maxTokens) body.max_tokens = maxTokens;
|
|
22
|
+
if (tools?.length) body.tools = tools;
|
|
23
|
+
if (extra.temperature != null) body.temperature = extra.temperature;
|
|
24
|
+
if (extra.seed != null) body.seed = extra.seed;
|
|
25
|
+
if (extra.responseFormat) body.response_format = extra.responseFormat;
|
|
26
|
+
return body;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getChatUrl(provider) {
|
|
30
|
+
if (provider.ollamaCompat) {
|
|
31
|
+
return `${provider.baseUrl}/v1/chat/completions`;
|
|
32
|
+
}
|
|
33
|
+
return `${provider.baseUrl}/chat/completions`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Parse streaming response — same as current client.js logic
|
|
37
|
+
export async function parseStreamResponse(response, onToken) {
|
|
38
|
+
let content = "";
|
|
39
|
+
const toolCalls = new Map();
|
|
40
|
+
let generationId = null;
|
|
41
|
+
let usage = null;
|
|
42
|
+
// Which model answered: with a fallback list (free mode) it may not be the
|
|
43
|
+
// one asked for.
|
|
44
|
+
let servedModel = null;
|
|
45
|
+
|
|
46
|
+
for await (const chunk of parseSSE(response.body)) {
|
|
47
|
+
if (chunk.id && !generationId) generationId = chunk.id;
|
|
48
|
+
if (chunk.model && !servedModel) servedModel = chunk.model;
|
|
49
|
+
if (chunk.usage) usage = chunk.usage;
|
|
50
|
+
|
|
51
|
+
const delta = chunk.choices?.[0]?.delta;
|
|
52
|
+
if (!delta) continue;
|
|
53
|
+
|
|
54
|
+
if (delta.content) {
|
|
55
|
+
content += delta.content;
|
|
56
|
+
onToken?.(delta.content);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (delta.tool_calls) {
|
|
60
|
+
for (const tc of delta.tool_calls) {
|
|
61
|
+
const idx = tc.index;
|
|
62
|
+
if (!toolCalls.has(idx)) {
|
|
63
|
+
toolCalls.set(idx, { id: "", type: "function", function: { name: "", arguments: "" } });
|
|
64
|
+
}
|
|
65
|
+
const existing = toolCalls.get(idx);
|
|
66
|
+
if (tc.id) existing.id = tc.id;
|
|
67
|
+
if (tc.function?.name) existing.function.name += tc.function.name;
|
|
68
|
+
if (tc.function?.arguments) existing.function.arguments += tc.function.arguments;
|
|
69
|
+
// Gemini 3 (OpenAI-compat) attaches a per-call thought signature as
|
|
70
|
+
// extra_content; the next request 400s (missing thought_signature)
|
|
71
|
+
// unless it is echoed back verbatim. Preserve every non-standard field
|
|
72
|
+
// of the delta call instead of rebuilding a minimal object.
|
|
73
|
+
for (const k of Object.keys(tc)) {
|
|
74
|
+
if (!["index", "id", "type", "function"].includes(k)) existing[k] = tc[k];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const message = { role: "assistant", content: content || "" };
|
|
81
|
+
if (toolCalls.size > 0) {
|
|
82
|
+
message.tool_calls = [...toolCalls.values()];
|
|
83
|
+
}
|
|
84
|
+
return { message, usage, generationId, servedModel };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Parse non-streaming response
|
|
88
|
+
export function parseResponse(data, onToken) {
|
|
89
|
+
const choice = data.choices?.[0];
|
|
90
|
+
const message = choice?.message || { role: "assistant", content: "" };
|
|
91
|
+
if (message.content) onToken?.(message.content);
|
|
92
|
+
return { message, usage: data.usage || null, generationId: data.id || null, servedModel: data.model || null };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function* parseSSE(body) {
|
|
96
|
+
const reader = body.getReader();
|
|
97
|
+
const decoder = new TextDecoder();
|
|
98
|
+
let buffer = "";
|
|
99
|
+
|
|
100
|
+
while (true) {
|
|
101
|
+
const { done, value } = await reader.read();
|
|
102
|
+
if (done) break;
|
|
103
|
+
|
|
104
|
+
buffer += decoder.decode(value, { stream: true });
|
|
105
|
+
const parts = buffer.split("\n");
|
|
106
|
+
buffer = parts.pop();
|
|
107
|
+
|
|
108
|
+
for (const line of parts) {
|
|
109
|
+
const trimmed = line.trim();
|
|
110
|
+
if (!trimmed.startsWith("data:")) continue;
|
|
111
|
+
const data = trimmed.slice(5).trim();
|
|
112
|
+
if (data === "[DONE]") return;
|
|
113
|
+
try {
|
|
114
|
+
yield JSON.parse(data);
|
|
115
|
+
} catch {
|
|
116
|
+
// skip malformed chunks
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Windows DPAPI helper — protects/unprotects data using Windows Data Protection API
|
|
2
|
+
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
export function protectData(plaintext) {
|
|
6
|
+
const script = `
|
|
7
|
+
Add-Type -AssemblyName System.Security
|
|
8
|
+
$bytes = [System.Text.Encoding]::UTF8.GetBytes($input)
|
|
9
|
+
$encrypted = [System.Security.Cryptography.ProtectedData]::Protect($bytes, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)
|
|
10
|
+
[Convert]::ToBase64String($encrypted)
|
|
11
|
+
`;
|
|
12
|
+
const encoded = Buffer.from(script, "utf16le").toString("base64");
|
|
13
|
+
const result = execFileSync("powershell", [
|
|
14
|
+
"-NoProfile", "-NonInteractive", "-EncodedCommand", encoded,
|
|
15
|
+
], {
|
|
16
|
+
input: plaintext,
|
|
17
|
+
encoding: "utf-8",
|
|
18
|
+
timeout: 10000,
|
|
19
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
20
|
+
}).trim();
|
|
21
|
+
return result;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function unprotectData(base64) {
|
|
25
|
+
const script = `
|
|
26
|
+
Add-Type -AssemblyName System.Security
|
|
27
|
+
$encrypted = [Convert]::FromBase64String($input)
|
|
28
|
+
$bytes = [System.Security.Cryptography.ProtectedData]::Unprotect($encrypted, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)
|
|
29
|
+
[System.Text.Encoding]::UTF8.GetString($bytes)
|
|
30
|
+
`;
|
|
31
|
+
const encoded = Buffer.from(script, "utf16le").toString("base64");
|
|
32
|
+
const result = execFileSync("powershell", [
|
|
33
|
+
"-NoProfile", "-NonInteractive", "-EncodedCommand", encoded,
|
|
34
|
+
], {
|
|
35
|
+
input: base64,
|
|
36
|
+
encoding: "utf-8",
|
|
37
|
+
timeout: 10000,
|
|
38
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
39
|
+
}).trim();
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Linux/Mac fallback — PBKDF2 key derivation with random seed + random salt
|
|
2
|
+
|
|
3
|
+
import { randomBytes, pbkdf2Sync } from "node:crypto";
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
|
|
8
|
+
const FLINT_DIR = path.join(homedir(), ".flint");
|
|
9
|
+
const SEED_FILE = path.join(FLINT_DIR, ".seed");
|
|
10
|
+
const SALT_FILE = path.join(FLINT_DIR, ".salt");
|
|
11
|
+
const ITERATIONS = 600000; // NIST SP 800-132 minimum for SHA-256
|
|
12
|
+
|
|
13
|
+
function ensureDir() {
|
|
14
|
+
mkdirSync(FLINT_DIR, { recursive: true, mode: 0o700 });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getOrCreateFile(filePath) {
|
|
18
|
+
ensureDir();
|
|
19
|
+
if (existsSync(filePath)) {
|
|
20
|
+
return readFileSync(filePath, "utf-8").trim();
|
|
21
|
+
}
|
|
22
|
+
const value = randomBytes(32).toString("hex");
|
|
23
|
+
writeFileSync(filePath, value, { encoding: "utf-8", mode: 0o600 });
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function deriveKey() {
|
|
28
|
+
const seed = getOrCreateFile(SEED_FILE);
|
|
29
|
+
const salt = getOrCreateFile(SALT_FILE);
|
|
30
|
+
return pbkdf2Sync(seed, salt, ITERATIONS, 32, "sha256");
|
|
31
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Encrypted API key storage — per-provider keys in ~/.flint/keys.enc
|
|
2
|
+
// Windows: DPAPI-protected seed → AES-256-GCM
|
|
3
|
+
// Linux/Mac: PBKDF2 from ~/.flint/.seed + hostname + username → AES-256-GCM
|
|
4
|
+
|
|
5
|
+
import { createCipheriv, createDecipheriv, randomBytes, pbkdf2Sync } from "node:crypto";
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
|
|
10
|
+
const FLINT_DIR = path.join(homedir(), ".flint");
|
|
11
|
+
const KEYS_FILE = path.join(FLINT_DIR, "keys.enc");
|
|
12
|
+
|
|
13
|
+
// PBKDF2 iterations for key derivation (NIST-recommended minimum).
|
|
14
|
+
const PBKDF2_ITERATIONS = 200000;
|
|
15
|
+
|
|
16
|
+
let _encryptionKey = null;
|
|
17
|
+
|
|
18
|
+
async function getEncryptionKey() {
|
|
19
|
+
if (_encryptionKey) return _encryptionKey;
|
|
20
|
+
|
|
21
|
+
if (process.platform === "win32") {
|
|
22
|
+
// Windows: DPAPI-protected seed → derived AES key
|
|
23
|
+
const seedFile = path.join(FLINT_DIR, ".seed.dpapi");
|
|
24
|
+
mkdirSync(FLINT_DIR, { recursive: true });
|
|
25
|
+
|
|
26
|
+
if (existsSync(seedFile)) {
|
|
27
|
+
try {
|
|
28
|
+
const { unprotectData } = await import("./keys-dpapi.js");
|
|
29
|
+
const protected64 = readFileSync(seedFile, "utf-8").trim();
|
|
30
|
+
const seed = unprotectData(protected64);
|
|
31
|
+
_encryptionKey = pbkdf2Sync(seed, "flint-aes-key", PBKDF2_ITERATIONS, 32, "sha256");
|
|
32
|
+
return _encryptionKey;
|
|
33
|
+
} catch {
|
|
34
|
+
// DPAPI unprotect failed — drop through to regenerate seed
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Create new DPAPI-protected seed
|
|
39
|
+
try {
|
|
40
|
+
const { protectData } = await import("./keys-dpapi.js");
|
|
41
|
+
const seed = randomBytes(32).toString("hex");
|
|
42
|
+
const protected64 = protectData(seed);
|
|
43
|
+
writeFileSync(seedFile, protected64, { encoding: "utf-8" });
|
|
44
|
+
_encryptionKey = pbkdf2Sync(seed, "flint-aes-key", PBKDF2_ITERATIONS, 32, "sha256");
|
|
45
|
+
return _encryptionKey;
|
|
46
|
+
} catch {
|
|
47
|
+
// DPAPI not available — fall through to cross-platform fallback
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Linux/Mac (or Windows without DPAPI): PBKDF2 from local seed+salt files
|
|
52
|
+
const { deriveKey } = await import("./keys-fallback.js");
|
|
53
|
+
_encryptionKey = deriveKey();
|
|
54
|
+
return _encryptionKey;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function encrypt(plaintext, key) {
|
|
58
|
+
const iv = randomBytes(12);
|
|
59
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
60
|
+
const encrypted = Buffer.concat([cipher.update(plaintext, "utf-8"), cipher.final()]);
|
|
61
|
+
const tag = cipher.getAuthTag();
|
|
62
|
+
return iv.toString("hex") + ":" + Buffer.concat([encrypted, tag]).toString("hex");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function decrypt(stored, key) {
|
|
66
|
+
const [ivHex, dataHex] = stored.split(":");
|
|
67
|
+
const iv = Buffer.from(ivHex, "hex");
|
|
68
|
+
const data = Buffer.from(dataHex, "hex");
|
|
69
|
+
const tag = data.subarray(data.length - 16);
|
|
70
|
+
const encrypted = data.subarray(0, data.length - 16);
|
|
71
|
+
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
|
72
|
+
decipher.setAuthTag(tag);
|
|
73
|
+
return decipher.update(encrypted) + decipher.final("utf-8");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function loadKeysFile() {
|
|
77
|
+
if (!existsSync(KEYS_FILE)) return {};
|
|
78
|
+
try {
|
|
79
|
+
return JSON.parse(readFileSync(KEYS_FILE, "utf-8"));
|
|
80
|
+
} catch {
|
|
81
|
+
return {};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function saveKeysFile(keys) {
|
|
86
|
+
mkdirSync(FLINT_DIR, { recursive: true });
|
|
87
|
+
writeFileSync(KEYS_FILE, JSON.stringify(keys, null, 2), { encoding: "utf-8" });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function getKey(providerId) {
|
|
91
|
+
const keys = loadKeysFile();
|
|
92
|
+
const stored = keys[providerId];
|
|
93
|
+
if (!stored) return null;
|
|
94
|
+
try {
|
|
95
|
+
const key = await getEncryptionKey();
|
|
96
|
+
return decrypt(stored, key);
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function setKey(providerId, plaintext) {
|
|
103
|
+
const key = await getEncryptionKey();
|
|
104
|
+
const keys = loadKeysFile();
|
|
105
|
+
keys[providerId] = encrypt(plaintext, key);
|
|
106
|
+
saveKeysFile(keys);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function deleteKey(providerId) {
|
|
110
|
+
const keys = loadKeysFile();
|
|
111
|
+
delete keys[providerId];
|
|
112
|
+
saveKeysFile(keys);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function hasKey(providerId) {
|
|
116
|
+
const keys = loadKeysFile();
|
|
117
|
+
return !!keys[providerId];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function listConfiguredProviders() {
|
|
121
|
+
const keys = loadKeysFile();
|
|
122
|
+
return Object.keys(keys);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Import a key from an env var into encrypted storage. Used during startup migration. */
|
|
126
|
+
export async function migrateEnvKey(envVarName, providerId) {
|
|
127
|
+
const envKey = process.env[envVarName];
|
|
128
|
+
if (!envKey) return false;
|
|
129
|
+
if (hasKey(providerId)) return false; // already stored
|
|
130
|
+
await setKey(providerId, envKey);
|
|
131
|
+
return true;
|
|
132
|
+
}
|