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.
Files changed (171) hide show
  1. package/.env.example +108 -0
  2. package/CHANGELOG.md +55 -0
  3. package/FEATURES.md +298 -0
  4. package/LICENSE +21 -0
  5. package/README.md +435 -0
  6. package/bin/flint.js +47 -0
  7. package/config/classifier-prompt.md +218 -0
  8. package/config/models-curated.json +4 -0
  9. package/config/providers.json +74 -0
  10. package/package.json +92 -0
  11. package/patches/ink+6.8.0.patch +78 -0
  12. package/profiles/desktop.md +65 -0
  13. package/profiles/generic.md +20 -0
  14. package/profiles/marketer.md +20 -0
  15. package/profiles/profiles.json +34 -0
  16. package/profiles/ux-reviewer.md +25 -0
  17. package/src/agent/agent.js +1743 -0
  18. package/src/agent/auto.js +346 -0
  19. package/src/agent/backoff.js +143 -0
  20. package/src/agent/compression.js +310 -0
  21. package/src/agent/content-resolver.js +180 -0
  22. package/src/agent/flow-controller.js +309 -0
  23. package/src/agent/intent-manifest.js +231 -0
  24. package/src/agent/intent-timeout.js +46 -0
  25. package/src/agent/intent.js +633 -0
  26. package/src/agent/knowledge.js +114 -0
  27. package/src/agent/learning.js +180 -0
  28. package/src/agent/modes.js +187 -0
  29. package/src/agent/outcome-ask.js +91 -0
  30. package/src/agent/project-context.js +76 -0
  31. package/src/agent/prompt-budget.js +117 -0
  32. package/src/agent/reflection-extractor.js +140 -0
  33. package/src/agent/steering.js +86 -0
  34. package/src/agent/supervisor.js +430 -0
  35. package/src/agent/swap.js +443 -0
  36. package/src/agent/system-prompt.js +446 -0
  37. package/src/agent/time-stamp.js +48 -0
  38. package/src/agent/tool-guard.js +201 -0
  39. package/src/agent/toolcall-text.js +162 -0
  40. package/src/agent/usage.js +297 -0
  41. package/src/agent/vision.js +94 -0
  42. package/src/agent/watchdog.js +139 -0
  43. package/src/agent/workspace-changes.js +177 -0
  44. package/src/api/address.js +14 -0
  45. package/src/api/client.js +280 -0
  46. package/src/api/server.js +535 -0
  47. package/src/api/stream-pipe.js +113 -0
  48. package/src/app-state.js +39 -0
  49. package/src/bootstrap.js +501 -0
  50. package/src/bus/drain-loop.js +497 -0
  51. package/src/bus/index.js +270 -0
  52. package/src/bus/plugins.js +65 -0
  53. package/src/child-idle.js +14 -0
  54. package/src/cli.js +118 -0
  55. package/src/commands/commands.js +1297 -0
  56. package/src/commands/registry.js +132 -0
  57. package/src/components/App.js +491 -0
  58. package/src/components/CarefulMenu.js +145 -0
  59. package/src/components/HistoryWriter.js +86 -0
  60. package/src/components/LineInput.js +69 -0
  61. package/src/components/LiveZone.js +294 -0
  62. package/src/components/OverlayMenu.js +179 -0
  63. package/src/components/SystemPanel.js +156 -0
  64. package/src/components/Table.js +54 -0
  65. package/src/config.js +249 -0
  66. package/src/free-models.js +230 -0
  67. package/src/index.js +1111 -0
  68. package/src/input-handler.js +13 -0
  69. package/src/input-text.js +123 -0
  70. package/src/launcher.js +129 -0
  71. package/src/logging/api-log.js +95 -0
  72. package/src/logging/chat-log-follower.js +113 -0
  73. package/src/logging/chat-log.js +15 -0
  74. package/src/logging/log-collector.js +182 -0
  75. package/src/logging/logger.js +112 -0
  76. package/src/logging/tool-log.js +20 -0
  77. package/src/mcp-client.js +314 -0
  78. package/src/memory/conversation-digest.js +113 -0
  79. package/src/memory/extract-facts.js +98 -0
  80. package/src/memory/facts.js +181 -0
  81. package/src/memory/inbox.js +63 -0
  82. package/src/memory/markdown.js +38 -0
  83. package/src/memory/patterns.js +185 -0
  84. package/src/memory/project.js +66 -0
  85. package/src/memory/reflections.js +74 -0
  86. package/src/memory/retrieval.js +84 -0
  87. package/src/memory/rules.js +105 -0
  88. package/src/memory/session-facts.js +125 -0
  89. package/src/memory/skills.js +191 -0
  90. package/src/memory/sqlite-store.js +653 -0
  91. package/src/memory/store.js +208 -0
  92. package/src/memory/tools.js +196 -0
  93. package/src/memory/user-model.js +86 -0
  94. package/src/message-handler.js +775 -0
  95. package/src/model-check.js +218 -0
  96. package/src/plugins/loader.js +120 -0
  97. package/src/plugins/manager.js +88 -0
  98. package/src/production-env.js +22 -0
  99. package/src/profiles.js +42 -0
  100. package/src/providers/adapters/anthropic.js +270 -0
  101. package/src/providers/adapters/openai.js +120 -0
  102. package/src/providers/keys-dpapi.js +41 -0
  103. package/src/providers/keys-fallback.js +31 -0
  104. package/src/providers/keys.js +132 -0
  105. package/src/providers/models.js +154 -0
  106. package/src/providers/registry.js +56 -0
  107. package/src/providers/state.js +56 -0
  108. package/src/registry.js +96 -0
  109. package/src/restart.js +29 -0
  110. package/src/sandbox/backend.js +130 -0
  111. package/src/security/api-auth.js +132 -0
  112. package/src/security/audit.js +98 -0
  113. package/src/security/child-policy.js +41 -0
  114. package/src/security/command-guard.js +173 -0
  115. package/src/security/content-fence.js +250 -0
  116. package/src/security/content-validator.js +132 -0
  117. package/src/security/index.js +143 -0
  118. package/src/security/network-guard.js +126 -0
  119. package/src/security/pairing.js +180 -0
  120. package/src/security/path-guard.js +140 -0
  121. package/src/security/persona-guard.js +67 -0
  122. package/src/security/policies.js +452 -0
  123. package/src/security/safety-constants.js +34 -0
  124. package/src/security/watchdog.js +107 -0
  125. package/src/sessions.js +130 -0
  126. package/src/spend.js +97 -0
  127. package/src/startup-watchdog.js +59 -0
  128. package/src/stdio/args.js +71 -0
  129. package/src/stdio/guard.js +59 -0
  130. package/src/stdio/protocol.js +167 -0
  131. package/src/stdio/run.js +106 -0
  132. package/src/stdio/session.js +180 -0
  133. package/src/store/agent-slice.js +306 -0
  134. package/src/store/dataset-slice.js +73 -0
  135. package/src/store/index.js +22 -0
  136. package/src/store/process-slice.js +135 -0
  137. package/src/store/session-slice.js +191 -0
  138. package/src/store/ui-slice.js +119 -0
  139. package/src/tasks/db.js +184 -0
  140. package/src/tasks/queries.js +589 -0
  141. package/src/tools/agent-tools.js +473 -0
  142. package/src/tools/checkpoint.js +152 -0
  143. package/src/tools/command-approvals.js +180 -0
  144. package/src/tools/dataset.js +50 -0
  145. package/src/tools/filesystem.js +682 -0
  146. package/src/tools/inbox-tools.js +48 -0
  147. package/src/tools/mesh.js +135 -0
  148. package/src/tools/own-env.js +136 -0
  149. package/src/tools/permissions.js +681 -0
  150. package/src/tools/plugin-tools.js +123 -0
  151. package/src/tools/process-tools.js +595 -0
  152. package/src/tools/registry.js +307 -0
  153. package/src/tools/swap-tools.js +72 -0
  154. package/src/tools/system.js +662 -0
  155. package/src/tools/tasks.js +532 -0
  156. package/src/tools/tool-search.js +171 -0
  157. package/src/ui/header.js +140 -0
  158. package/src/ui/input-cursor.js +23 -0
  159. package/src/ui/last-line.js +25 -0
  160. package/src/ui/line-edit.js +135 -0
  161. package/src/ui/output.js +399 -0
  162. package/src/ui/paste-tokens.js +131 -0
  163. package/src/ui/prompt-attention.js +134 -0
  164. package/src/ui/render-options.js +13 -0
  165. package/src/ui/replay.js +94 -0
  166. package/src/ui/splash.js +49 -0
  167. package/src/ui/status-level.js +36 -0
  168. package/src/ui/tool-ledger.js +203 -0
  169. package/src/ui/window-title.js +150 -0
  170. package/src/update.js +205 -0
  171. 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
+ }