loom-agent 1.2.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 +25 -0
- package/CHANGELOG.md +402 -0
- package/LICENSE +21 -0
- package/LOOM.md +235 -0
- package/README.md +433 -0
- package/bin/loom-tui.js +43 -0
- package/bin/loom.js +44 -0
- package/docs/acp.md +151 -0
- package/docs/web.md +205 -0
- package/package.json +97 -0
- package/scripts/acp-smoke.js +146 -0
- package/src/acp/acp-server.js +287 -0
- package/src/config/provider-cmd.js +37 -0
- package/src/config/settings.js +164 -0
- package/src/core/agents.js +361 -0
- package/src/core/background-tasks.js +103 -0
- package/src/core/cli.js +579 -0
- package/src/core/custom-commands.js +70 -0
- package/src/core/errors.js +29 -0
- package/src/core/events.js +24 -0
- package/src/core/file-diffs.js +282 -0
- package/src/core/format.js +206 -0
- package/src/core/graph.js +257 -0
- package/src/core/hooks.js +82 -0
- package/src/core/lsp.js +385 -0
- package/src/core/memory.js +87 -0
- package/src/core/model-router.js +87 -0
- package/src/core/permissions.js +327 -0
- package/src/core/platform.js +33 -0
- package/src/core/plugin-cmd.js +380 -0
- package/src/core/restore.js +207 -0
- package/src/core/session-store.js +167 -0
- package/src/core/session.js +910 -0
- package/src/core/subagent-log.js +134 -0
- package/src/core/tokens.js +31 -0
- package/src/core/update.js +6 -0
- package/src/core/usage.js +166 -0
- package/src/index.js +41 -0
- package/src/mcp/mcp-client.js +201 -0
- package/src/mcp/mcp-manager.js +193 -0
- package/src/providers/anthropic.js +243 -0
- package/src/providers/google.js +29 -0
- package/src/providers/index.js +175 -0
- package/src/providers/local.js +27 -0
- package/src/providers/nvidia.js +85 -0
- package/src/providers/openai-compat.js +269 -0
- package/src/providers/openai.js +35 -0
- package/src/providers/openrouter.js +43 -0
- package/src/providers/registry.js +196 -0
- package/src/providers/tokenrouter.js +19 -0
- package/src/skills/skill-matcher.js +133 -0
- package/src/skills/skills-manager.js +213 -0
- package/src/tools/index.js +543 -0
- package/src/tui/App.tsx +1578 -0
- package/src/tui/components/BreadcrumbBar.tsx +34 -0
- package/src/tui/components/ChatArea.tsx +518 -0
- package/src/tui/components/InputBar.tsx +354 -0
- package/src/tui/components/MdText.tsx +105 -0
- package/src/tui/components/Modals.tsx +851 -0
- package/src/tui/components/PermissionPopup.tsx +264 -0
- package/src/tui/components/Sidebar.tsx +182 -0
- package/src/tui/components/SplashScreen.tsx +51 -0
- package/src/tui/components/SubagentPanel.tsx +217 -0
- package/src/tui/components/ToastOverlay.tsx +34 -0
- package/src/tui/keybinds.ts +318 -0
- package/src/tui/mcp-presets.ts +189 -0
- package/src/tui/md-render.ts +228 -0
- package/src/tui/store.ts +714 -0
- package/src/tui/suite-home.ts +20 -0
- package/src/tui/theme.ts +313 -0
- package/src/tui/themes.generated.ts +968 -0
- package/src/tui/tool-display.ts +176 -0
- package/src/tui/toolname.ts +60 -0
- package/src/tui/tui-config.ts +28 -0
- package/src/tui-open.tsx +51 -0
- package/src/web/attach.js +242 -0
- package/src/web/graph-view.html +262 -0
- package/src/web/index.html +824 -0
- package/src/web/web-server.js +470 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// Agent Client Protocol (ACP) server — lets ACP-compatible editors (Zed,
|
|
2
|
+
// JetBrains, Avante.nvim, CodeCompanion.nvim, ...) drive Loom as a subprocess.
|
|
3
|
+
//
|
|
4
|
+
// Transport: JSON-RPC 2.0 messages, one per line (newline-delimited JSON) over
|
|
5
|
+
// stdio. The editor sends requests on stdin; Loom answers on stdout and queues
|
|
6
|
+
// agent events that the client retrieves with fetchAgentEvent.
|
|
7
|
+
//
|
|
8
|
+
// Implemented methods (subset of the ACP spec):
|
|
9
|
+
// initialize, connect, storeMessage, sendChatRequest, fetchAgentEvent,
|
|
10
|
+
// cancelCurrentTask, updateAgentConfig, changeDefaultMode, enableToolUse,
|
|
11
|
+
// disposeTool
|
|
12
|
+
//
|
|
13
|
+
// Run with: loom acp (or: bun src/acp/acp-server.js)
|
|
14
|
+
const readline = require('readline');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const { Session, isAbortError } = require('../core/session');
|
|
18
|
+
const { getToolDefinitions } = require('../tools');
|
|
19
|
+
const { loadConfig, saveConfig } = require('../config/settings');
|
|
20
|
+
|
|
21
|
+
const ACP_METHODS = new Set([
|
|
22
|
+
'initialize',
|
|
23
|
+
'connect',
|
|
24
|
+
'storeMessage',
|
|
25
|
+
'sendChatRequest',
|
|
26
|
+
'fetchAgentEvent',
|
|
27
|
+
'cancelCurrentTask',
|
|
28
|
+
'updateAgentConfig',
|
|
29
|
+
'changeDefaultMode',
|
|
30
|
+
'enableToolUse',
|
|
31
|
+
'disposeTool',
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
/** @typedef {Object} AcpTask
|
|
35
|
+
* @property {Session} session
|
|
36
|
+
* @property {boolean} active
|
|
37
|
+
* @property {Array<object>} events
|
|
38
|
+
* @property {string} mode
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
class AcpServer {
|
|
42
|
+
/**
|
|
43
|
+
* @param {object} [opts]
|
|
44
|
+
* @param {NodeJS.ReadableStream|any} [opts.input]
|
|
45
|
+
* @param {NodeJS.WritableStream|any} [opts.output]
|
|
46
|
+
*/
|
|
47
|
+
constructor(opts = {}) {
|
|
48
|
+
this.input = opts.input || process.stdin;
|
|
49
|
+
this.output = opts.output || process.stdout;
|
|
50
|
+
/** @type {Map<string, AcpTask>} */
|
|
51
|
+
this.tasks = new Map();
|
|
52
|
+
this.rl = null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
start() {
|
|
56
|
+
this.rl = readline.createInterface({ input: this.input, terminal: false });
|
|
57
|
+
this.rl.on('line', (line) => {
|
|
58
|
+
const trimmed = String(line).trim();
|
|
59
|
+
if (!trimmed) return;
|
|
60
|
+
let req;
|
|
61
|
+
try { req = JSON.parse(trimmed); } catch { return; }
|
|
62
|
+
this.handleRequest(req).catch((e) => {
|
|
63
|
+
if (req && req.id != null) this.reply(req.id, null, { code: -32603, message: e && e.message ? e.message : String(e) });
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** @param {object} req */
|
|
69
|
+
async handleRequest(req) {
|
|
70
|
+
if (!req || typeof req.method !== 'string') return;
|
|
71
|
+
const method = req.method;
|
|
72
|
+
const params = req.params || {};
|
|
73
|
+
const id = req.id != null ? req.id : null;
|
|
74
|
+
if (!ACP_METHODS.has(method)) {
|
|
75
|
+
if (id != null) this.reply(id, null, { code: -32601, message: 'Method not found: ' + method });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (id == null) {
|
|
79
|
+
// Notification — methods that expect a response still work; ignore result.
|
|
80
|
+
try { await this[method](params); } catch {}
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const result = await this[method](params);
|
|
85
|
+
this.reply(id, result === undefined ? null : result, null);
|
|
86
|
+
} catch (e) {
|
|
87
|
+
this.reply(id, null, { code: -32603, message: e && e.message ? e.message : String(e) });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** @param {number|string|null} id
|
|
92
|
+
* @param {*} result
|
|
93
|
+
* @param {{code:number, message:string}|null} error */
|
|
94
|
+
reply(id, result, error) {
|
|
95
|
+
const msg = { jsonrpc: '2.0', id };
|
|
96
|
+
if (error) msg.error = error;
|
|
97
|
+
else msg.result = result !== undefined ? result : null;
|
|
98
|
+
this.write(msg);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** @param {object} obj */
|
|
102
|
+
write(obj) {
|
|
103
|
+
try { this.output.write(JSON.stringify(obj) + '\n'); } catch {}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── ACP methods ──
|
|
107
|
+
|
|
108
|
+
initialize() {
|
|
109
|
+
const defs = getToolDefinitions('build');
|
|
110
|
+
return {
|
|
111
|
+
protocolVersion: 1,
|
|
112
|
+
capabilities: { openai: true, customInstructions: true },
|
|
113
|
+
toolSchemas: defs.map((d) => ({
|
|
114
|
+
type: 'function',
|
|
115
|
+
function: {
|
|
116
|
+
name: d.name,
|
|
117
|
+
description: d.description,
|
|
118
|
+
parameters: d.input_schema || { type: 'object', properties: {} },
|
|
119
|
+
},
|
|
120
|
+
})),
|
|
121
|
+
agentConfig: {
|
|
122
|
+
builtInTools: defs.map((d) => d.name),
|
|
123
|
+
customInstructions: null,
|
|
124
|
+
includeAgentContext: false,
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @param {{taskId?: string, agentConfig?: object}} params
|
|
131
|
+
*/
|
|
132
|
+
connect(params) {
|
|
133
|
+
const taskId = params.taskId || 'task-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
134
|
+
const session = new Session();
|
|
135
|
+
const agentConfig = params.agentConfig || {};
|
|
136
|
+
if (agentConfig.mode === 'plan' || agentConfig.mode === 'chat' || agentConfig.mode === 'build') {
|
|
137
|
+
session.setMode(agentConfig.mode);
|
|
138
|
+
}
|
|
139
|
+
if (typeof agentConfig.instructions === 'string' && agentConfig.instructions.trim()) {
|
|
140
|
+
session._agentBlock = '\n\n[ACP session instructions]\n' + agentConfig.instructions.trim() + '\n';
|
|
141
|
+
}
|
|
142
|
+
/** @type {AcpTask} */
|
|
143
|
+
const task = { session, active: false, events: [], mode: session.mode };
|
|
144
|
+
this.tasks.set(taskId, task);
|
|
145
|
+
this.pushEvent(task, 'session.updated', { type: 'created' });
|
|
146
|
+
return { taskId };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* @param {{taskId: string, message: object}} params
|
|
151
|
+
*/
|
|
152
|
+
storeMessage(params) {
|
|
153
|
+
const task = this.getTask(params.taskId);
|
|
154
|
+
const msg = params.message || {};
|
|
155
|
+
task.session.addMessage({ role: msg.role === 'assistant' ? 'assistant' : 'user', content: String(msg.content || '') });
|
|
156
|
+
this.pushEvent(task, 'session.updated', { type: 'expanded' });
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* @param {{taskId: string, message: {content?: string}}} params
|
|
162
|
+
*/
|
|
163
|
+
async sendChatRequest(params) {
|
|
164
|
+
const task = this.getTask(params.taskId);
|
|
165
|
+
if (task.active) throw new Error('Task already has an active request; wait for it to complete or cancel it.');
|
|
166
|
+
const text = String((params.message && params.message.content) || '');
|
|
167
|
+
if (!text.trim()) throw new Error('Message content is empty.');
|
|
168
|
+
const requestId = 'req-' + Date.now().toString(36) + '-' + Math.floor(Math.random() * 1e6).toString(36);
|
|
169
|
+
const messageId = 'msg-' + Date.now().toString(36);
|
|
170
|
+
task.active = true;
|
|
171
|
+
this.pushEvent(task, 'session.updated', { type: 'expanded' });
|
|
172
|
+
(() => {
|
|
173
|
+
task.session.sendUserMessage(text, {
|
|
174
|
+
onDelta: (t) => this.pushEvent(task, 'agent.message', { messageId, content: { type: 'text', content: t } }),
|
|
175
|
+
onReasoning: (t) => this.pushEvent(task, 'agent.message', { messageId: messageId + '-r', content: { type: 'reasoning', content: t } }),
|
|
176
|
+
onTool: (toolName, input) => this.pushEvent(task, 'tool.use', { toolName, id: 'tool-' + Date.now().toString(36), input }),
|
|
177
|
+
onToolResult: (toolName, out, input) =>
|
|
178
|
+
this.pushEvent(task, 'tool.result', { toolName, input, result: out && out.result != null ? out.result : (out && out.error) }),
|
|
179
|
+
}).then((resp) => {
|
|
180
|
+
this.pushEvent(task, 'session.updated', { type: 'expanded' });
|
|
181
|
+
if (resp.interrupted) {
|
|
182
|
+
this.pushEvent(task, 'request.error', { requestId, message: '(interrupted)' });
|
|
183
|
+
} else if (resp.type === 'error') {
|
|
184
|
+
this.pushEvent(task, 'request.error', { requestId, message: String(resp.content || '') });
|
|
185
|
+
} else {
|
|
186
|
+
this.pushEvent(task, 'agent.message.completed', { messageId });
|
|
187
|
+
this.pushEvent(task, 'request.completed', { requestId, response: { type: 'text', text: String(resp.content || '') } });
|
|
188
|
+
}
|
|
189
|
+
}).catch((e) => {
|
|
190
|
+
this.pushEvent(task, 'session.updated', { type: 'expanded' });
|
|
191
|
+
if (isAbortError(e)) {
|
|
192
|
+
this.pushEvent(task, 'request.error', { requestId, message: '(interrupted)' });
|
|
193
|
+
} else {
|
|
194
|
+
this.pushEvent(task, 'request.error', { requestId, message: e && e.message ? e.message : String(e) });
|
|
195
|
+
}
|
|
196
|
+
}).finally(() => {
|
|
197
|
+
task.active = false;
|
|
198
|
+
});
|
|
199
|
+
})();
|
|
200
|
+
return { requestId };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* @param {{taskId: string, cursor?: number}} params
|
|
205
|
+
*/
|
|
206
|
+
fetchAgentEvent(params) {
|
|
207
|
+
const task = this.getTask(params.taskId);
|
|
208
|
+
const cursor = Number(params.cursor || 0);
|
|
209
|
+
const events = task.events.slice(cursor);
|
|
210
|
+
return { events, cursor: cursor + events.length };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* @param {{taskId: string}} params
|
|
215
|
+
*/
|
|
216
|
+
cancelCurrentTask(params) {
|
|
217
|
+
const task = this.getTask(params.taskId);
|
|
218
|
+
try { task.session.interrupt(); } catch {}
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* @param {{taskId: string, agentConfig?: object}} params
|
|
224
|
+
*/
|
|
225
|
+
updateAgentConfig(params) {
|
|
226
|
+
const task = this.getTask(params.taskId);
|
|
227
|
+
const agentConfig = params.agentConfig || {};
|
|
228
|
+
if (typeof agentConfig.instructions === 'string' && agentConfig.instructions.trim()) {
|
|
229
|
+
task.session._agentBlock = '\n\n[ACP session instructions]\n' + agentConfig.instructions.trim() + '\n';
|
|
230
|
+
}
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* @param {{taskId: string, mode?: string}} params
|
|
236
|
+
*/
|
|
237
|
+
changeDefaultMode(params) {
|
|
238
|
+
const task = this.getTask(params.taskId);
|
|
239
|
+
if (params.mode === 'plan' || params.mode === 'chat' || params.mode === 'build') {
|
|
240
|
+
task.session.setMode(params.mode);
|
|
241
|
+
task.mode = params.mode;
|
|
242
|
+
}
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
enableToolUse() {
|
|
247
|
+
// All built-in tools + MCP tools are enabled by default; nothing to gate.
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
disposeTool() {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** @param {string} taskId
|
|
256
|
+
* @returns {AcpTask} */
|
|
257
|
+
getTask(taskId) {
|
|
258
|
+
const task = this.tasks.get(String(taskId));
|
|
259
|
+
if (!task) throw new Error('Unknown task: ' + taskId);
|
|
260
|
+
return task;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** @param {AcpTask} task
|
|
264
|
+
* @param {string} event
|
|
265
|
+
* @param {object} payload */
|
|
266
|
+
pushEvent(task, event, payload) {
|
|
267
|
+
const item = { id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8), event, ...payload };
|
|
268
|
+
task.events.push(item);
|
|
269
|
+
return item;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Print a tiny readiness line to stderr (never corrupts the stdio protocol). */
|
|
274
|
+
function announce() {
|
|
275
|
+
const c = loadConfig();
|
|
276
|
+
const loc = process.env.LOOM_CONFIG_DIR || path.join(os.homedir(), '.loom', 'config.json');
|
|
277
|
+
process.stderr.write(`[loom acp] started — provider: ${c.provider}, config: ${loc}\n`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function main() {
|
|
281
|
+
announce();
|
|
282
|
+
const server = new AcpServer();
|
|
283
|
+
server.start();
|
|
284
|
+
process.stdin.resume();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
module.exports = { AcpServer, main };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const { loadConfig, saveConfig } = require('./settings');
|
|
2
|
+
const { loadRegistry, envNamesFor } = require('../providers/registry');
|
|
3
|
+
|
|
4
|
+
function connect(provider, apiKey) {
|
|
5
|
+
const valid = ['anthropic', 'openai', 'nvidia', 'google', 'local'];
|
|
6
|
+
const reg = loadRegistry() || {};
|
|
7
|
+
if (!valid.includes(provider) && !reg[provider]) {
|
|
8
|
+
throw new Error(`Unknown provider: ${provider}. Run /providers to list every supported provider.`);
|
|
9
|
+
}
|
|
10
|
+
const config = loadConfig();
|
|
11
|
+
config.provider = provider;
|
|
12
|
+
config.apiKeys = config.apiKeys || {};
|
|
13
|
+
if (apiKey) config.apiKeys[provider] = apiKey;
|
|
14
|
+
saveConfig(config);
|
|
15
|
+
return `Connected to ${provider}.`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function disconnect() {
|
|
19
|
+
const config = loadConfig();
|
|
20
|
+
const old = config.provider;
|
|
21
|
+
config.provider = '';
|
|
22
|
+
config.apiKeys = config.apiKeys || {};
|
|
23
|
+
delete config.apiKeys[old];
|
|
24
|
+
saveConfig(config);
|
|
25
|
+
return `Disconnected from ${old}.`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function status() {
|
|
29
|
+
const config = loadConfig();
|
|
30
|
+
return {
|
|
31
|
+
provider: config.provider,
|
|
32
|
+
model: config.model?.[config.provider] || 'default',
|
|
33
|
+
hasKey: !!(config.apiKeys?.[config.provider] || (envNamesFor(config.provider) || []).some(n => !!process.env[n]))
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { connect, disconnect, status };
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
|
|
5
|
+
const LOOM_DIR = path.join(os.homedir(), '.loom');
|
|
6
|
+
const CONFIG_FILE = path.join(LOOM_DIR, 'config.json');
|
|
7
|
+
const GLOBAL_LOOM_MD = path.join(LOOM_DIR, 'LOOM.md');
|
|
8
|
+
|
|
9
|
+
// Re-evaluated on every call so tests/CI can point config elsewhere with
|
|
10
|
+
// LOOM_CONFIG_DIR; the exported LOOM_DIR/CONFIG_FILE constants keep the
|
|
11
|
+
// default for display purposes.
|
|
12
|
+
function loomDir() {
|
|
13
|
+
return process.env.LOOM_CONFIG_DIR || LOOM_DIR;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function configFile() {
|
|
17
|
+
return path.join(loomDir(), 'config.json');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DEFAULTS = {
|
|
21
|
+
provider: 'anthropic',
|
|
22
|
+
model: {
|
|
23
|
+
anthropic: 'claude-sonnet-4-20250514',
|
|
24
|
+
openai: 'gpt-5-fast',
|
|
25
|
+
nvidia: 'meta/llama-3.1-8b-instruct',
|
|
26
|
+
google: 'gemini-2.5-flash',
|
|
27
|
+
openrouter: 'anthropic/claude-sonnet-4',
|
|
28
|
+
tokenrouter: 'moonshotai/kimi-k3-free',
|
|
29
|
+
local: 'llama3.2',
|
|
30
|
+
},
|
|
31
|
+
maxTokens: 16384,
|
|
32
|
+
temperature: 0.3,
|
|
33
|
+
compactThreshold: 0.75,
|
|
34
|
+
budgetLevel: 'auto', // free | cheap | best | auto (explicit picks)
|
|
35
|
+
apiKeys: {},
|
|
36
|
+
customEndpoints: {},
|
|
37
|
+
recentModels: [],
|
|
38
|
+
skillDisabled: [],
|
|
39
|
+
// OpenCode-style formatters / LSP. false = disabled (default). true = enable
|
|
40
|
+
// all built-ins. object = built-ins + per-id overrides/customs.
|
|
41
|
+
formatter: false,
|
|
42
|
+
lsp: false,
|
|
43
|
+
baseUrls: {
|
|
44
|
+
anthropic: 'https://api.anthropic.com',
|
|
45
|
+
openai: 'https://api.openai.com/v1',
|
|
46
|
+
nvidia: 'https://integrate.api.nvidia.com/v1',
|
|
47
|
+
google: 'https://generativelanguage.googleapis.com/v1beta/openai/',
|
|
48
|
+
openrouter: 'https://openrouter.ai/api/v1',
|
|
49
|
+
tokenrouter: 'https://api.tokenrouter.com/v1',
|
|
50
|
+
local: 'http://localhost:11434/v1',
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function ensureLoomDir() {
|
|
55
|
+
const dir = loomDir();
|
|
56
|
+
if (!fs.existsSync(dir)) {
|
|
57
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function loadConfig() {
|
|
62
|
+
ensureLoomDir();
|
|
63
|
+
const file = configFile();
|
|
64
|
+
if (fs.existsSync(file)) {
|
|
65
|
+
try { fs.chmodSync(file, 0o600); } catch {}
|
|
66
|
+
try {
|
|
67
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
68
|
+
return { ...DEFAULTS, ...JSON.parse(raw) };
|
|
69
|
+
} catch {
|
|
70
|
+
return { ...DEFAULTS };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { ...DEFAULTS };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function saveConfig(config) {
|
|
77
|
+
ensureLoomDir();
|
|
78
|
+
const file = configFile();
|
|
79
|
+
fs.writeFileSync(file, JSON.stringify(config, null, 2));
|
|
80
|
+
try { fs.chmodSync(file, 0o600); } catch {}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function getApiKey(provider) {
|
|
84
|
+
const { envNamesFor } = require('../providers/registry');
|
|
85
|
+
const envNames = envNamesFor(provider) || [];
|
|
86
|
+
for (const key of envNames) {
|
|
87
|
+
if (process.env[key]) return process.env[key];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const config = loadConfig();
|
|
91
|
+
return config.apiKeys?.[provider] || null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function setApiKey(provider, key) {
|
|
95
|
+
const config = loadConfig();
|
|
96
|
+
config.apiKeys = config.apiKeys || {};
|
|
97
|
+
config.apiKeys[provider] = key;
|
|
98
|
+
saveConfig(config);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function resolveApiKey(provider) {
|
|
102
|
+
const key = getApiKey(provider);
|
|
103
|
+
if (!key) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`No API key found for ${provider}. Set the env variable or run: loom connect ${provider} <key>`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return key;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function getBaseUrl(provider) {
|
|
112
|
+
const envMap = {
|
|
113
|
+
anthropic: ['ANTHROPIC_BASE_URL'],
|
|
114
|
+
openai: ['OPENAI_BASE_URL'],
|
|
115
|
+
nvidia: ['NVIDIA_BASE_URL'],
|
|
116
|
+
google: ['GOOGLE_BASE_URL'],
|
|
117
|
+
openrouter: ['OPENROUTER_BASE_URL'],
|
|
118
|
+
tokenrouter: ['TOKENROUTER_BASE_URL'],
|
|
119
|
+
};
|
|
120
|
+
for (const env of (envMap[provider] || [])) {
|
|
121
|
+
if (process.env[env]) return process.env[env];
|
|
122
|
+
}
|
|
123
|
+
const cfg = loadConfig();
|
|
124
|
+
return cfg.baseUrls?.[provider] || cfg.customEndpoints?.[provider] || DEFAULTS.baseUrls?.[provider] || null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function setBaseUrl(provider, url) {
|
|
128
|
+
const config = loadConfig();
|
|
129
|
+
config.baseUrls = config.baseUrls || {};
|
|
130
|
+
config.baseUrls[provider] = url;
|
|
131
|
+
saveConfig(config);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Remember which provider+model was used, most recent first (deduped, capped).
|
|
135
|
+
function recordModelUse(provider, modelId) {
|
|
136
|
+
if (!provider || !modelId) return;
|
|
137
|
+
const config = loadConfig();
|
|
138
|
+
const list = (config.recentModels || []).filter(r => !(r.provider === provider && r.model === modelId));
|
|
139
|
+
list.unshift({ provider, model: modelId, at: Date.now() });
|
|
140
|
+
config.recentModels = list.slice(0, 8);
|
|
141
|
+
saveConfig(config);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function getRecentModels() {
|
|
145
|
+
const cfg = loadConfig();
|
|
146
|
+
return (cfg.recentModels || []).slice(0, 8);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Whether a usable key exists for a provider (or it's a keyless local backend).
|
|
150
|
+
function hasApiKey(provider) {
|
|
151
|
+
if (provider === 'local') return true;
|
|
152
|
+
const { envNamesFor } = require('../providers/registry');
|
|
153
|
+
const cfg = loadConfig();
|
|
154
|
+
if (cfg.apiKeys?.[provider]) return true;
|
|
155
|
+
return (envNamesFor(provider) || []).some(n => !!process.env[n]);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = {
|
|
159
|
+
LOOM_DIR, CONFIG_FILE, GLOBAL_LOOM_MD, DEFAULTS,
|
|
160
|
+
loadConfig, saveConfig,
|
|
161
|
+
getApiKey, setApiKey, resolveApiKey,
|
|
162
|
+
ensureLoomDir, getBaseUrl, setBaseUrl,
|
|
163
|
+
recordModelUse, getRecentModels, hasApiKey
|
|
164
|
+
};
|