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
package/src/core/lsp.js
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
// LSP — tiny Language Server Protocol client that surfaces diagnostics to the
|
|
2
|
+
// agent (OpenCode-style). Disabled by default; enable via config.json `lsp`:
|
|
3
|
+
//
|
|
4
|
+
// lsp: true → all built-in servers enabled
|
|
5
|
+
// lsp: { ... } → built-ins + overrides / custom servers
|
|
6
|
+
// lsp: { typescript: { command: ["npx","typescript-language-server","--stdio"], extensions: [".ts"] } } → override
|
|
7
|
+
// lsp: { myls: { command: ["my-ls","--stdio"], extensions: [".zzz"] } } → custom
|
|
8
|
+
// lsp: { typescript: { disabled: true } } → disable one
|
|
9
|
+
//
|
|
10
|
+
// JSON-RPC runs over the server's stdio using Content-Length framing (the LSP
|
|
11
|
+
// wire format). Diagnostics pushed as textDocument/publishDiagnostics are
|
|
12
|
+
// captured per document.
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const { spawn } = require('child_process');
|
|
16
|
+
const { loadConfig } = require('../config/settings');
|
|
17
|
+
|
|
18
|
+
/** @typedef {Object} LspServerDef
|
|
19
|
+
* @property {Array<string>} command
|
|
20
|
+
* @property {Array<string>} extensions
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** @typedef {Object} LspDiagnostic
|
|
24
|
+
* @property {string} message
|
|
25
|
+
* @property {string} source
|
|
26
|
+
* @property {string} severity error | warning | info | hint
|
|
27
|
+
* @property {number} severityCode
|
|
28
|
+
* @property {number} line
|
|
29
|
+
* @property {number} character
|
|
30
|
+
* @property {number} endLine
|
|
31
|
+
* @property {number} endCharacter
|
|
32
|
+
* @property {string} [code]
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** @type {Record<string, LspServerDef>} */
|
|
36
|
+
const DEFAULT_LSP = {
|
|
37
|
+
typescript: {
|
|
38
|
+
command: ['npx', 'typescript-language-server', '--stdio'],
|
|
39
|
+
extensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'],
|
|
40
|
+
},
|
|
41
|
+
eslint: {
|
|
42
|
+
command: ['npx', 'vscode-langservers-extracted', '--stdio'],
|
|
43
|
+
extensions: ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.mts', '.cts', '.vue'],
|
|
44
|
+
},
|
|
45
|
+
pyright: {
|
|
46
|
+
command: ['npx', 'pyright-langserver', '--stdio'],
|
|
47
|
+
extensions: ['.py', '.pyi'],
|
|
48
|
+
},
|
|
49
|
+
bash: { command: ['bash-language-server', 'start'], extensions: ['.sh', '.bash', '.zsh', '.ksh'] },
|
|
50
|
+
gopls: { command: ['gopls', 'serve'], extensions: ['.go'] },
|
|
51
|
+
rust: { command: ['rust-analyzer'], extensions: ['.rs'] },
|
|
52
|
+
dart: { command: ['dart', 'language-server'], extensions: ['.dart'] },
|
|
53
|
+
yaml: { command: ['yaml-language-server', '--stdio'], extensions: ['.yaml', '.yml'] },
|
|
54
|
+
terraform: { command: ['terraform-ls', 'serve'], extensions: ['.tf', '.tfvars'] },
|
|
55
|
+
clangd: { command: ['clangd', '--background-index'], extensions: ['.c', '.h', '.cpp', '.hpp', '.cc', '.cxx', '.hxx', '.ino'] },
|
|
56
|
+
zig: { command: ['zls'], extensions: ['.zig', '.zon'] },
|
|
57
|
+
lua: { command: ['lua-language-server'], extensions: ['.lua'] },
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Resolve the enabled LSP servers from config + built-ins.
|
|
61
|
+
* @returns {{enabled: boolean, servers: Record<string, LspServerDef>}} */
|
|
62
|
+
function enabledServers() {
|
|
63
|
+
const cfg = loadConfig();
|
|
64
|
+
const l = cfg.lsp;
|
|
65
|
+
if (l === false || l === undefined || l === null) return { enabled: false, servers: /** @type {Record<string, LspServerDef>} */ ({}) };
|
|
66
|
+
if (l === true) return { enabled: true, servers: { ...DEFAULT_LSP } };
|
|
67
|
+
if (l && typeof l === 'object') {
|
|
68
|
+
const out = /** @type {Record<string, LspServerDef>} */ ({});
|
|
69
|
+
for (const [id, def] of Object.entries(DEFAULT_LSP)) {
|
|
70
|
+
const u = l[id];
|
|
71
|
+
const merged = { command: def.command, extensions: def.extensions, ...(u && typeof u === 'object' ? u : {}) };
|
|
72
|
+
if (merged.disabled) continue;
|
|
73
|
+
out[id] = /** @type {LspServerDef} */ ({ command: merged.command, extensions: merged.extensions });
|
|
74
|
+
}
|
|
75
|
+
for (const [id, u] of Object.entries(l)) {
|
|
76
|
+
if (!u || typeof u !== 'object') continue;
|
|
77
|
+
if (u.command && Array.isArray(u.extensions)) {
|
|
78
|
+
if (!u.disabled) out[id] = /** @type {LspServerDef} */ ({ command: u.command, extensions: u.extensions });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { enabled: true, servers: out };
|
|
82
|
+
}
|
|
83
|
+
return { enabled: false, servers: /** @type {Record<string, LspServerDef>} */ ({}) };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Pick the server that handles an extension (deterministic id order).
|
|
87
|
+
* @param {string} ext
|
|
88
|
+
* @returns {{found: boolean, id?: string, def?: LspServerDef, reason?: string}} */
|
|
89
|
+
function findServerForExt(ext) {
|
|
90
|
+
const { enabled, servers } = enabledServers();
|
|
91
|
+
if (!enabled) return { found: false, reason: 'LSP is disabled (set config lsp: true to enable)' };
|
|
92
|
+
const extLower = ext.toLowerCase();
|
|
93
|
+
const ids = Object.keys(servers).sort();
|
|
94
|
+
for (const id of ids) {
|
|
95
|
+
if (servers[id].extensions.some((e) => e.toLowerCase() === extLower)) {
|
|
96
|
+
return { found: true, id, def: servers[id] };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { found: false, reason: `no enabled LSP server handles extension "${ext}"` };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let nextId = 1;
|
|
103
|
+
const connections = new Map();
|
|
104
|
+
|
|
105
|
+
/** Incremental parser for Content-Length framed JSON-RPC messages. */
|
|
106
|
+
class FrameDecoder {
|
|
107
|
+
constructor() {
|
|
108
|
+
this.buffer = Buffer.alloc(0);
|
|
109
|
+
}
|
|
110
|
+
/** Append a chunk and return any fully framed messages.
|
|
111
|
+
* @param {Buffer|string} chunk
|
|
112
|
+
* @returns {Array<object>} */
|
|
113
|
+
push(chunk) {
|
|
114
|
+
this.buffer = Buffer.concat([this.buffer, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, 'utf8')]);
|
|
115
|
+
const out = [];
|
|
116
|
+
let idx;
|
|
117
|
+
while ((idx = this.buffer.indexOf('\r\n\r\n')) !== -1) {
|
|
118
|
+
const header = this.buffer.slice(0, idx).toString('utf8');
|
|
119
|
+
const m = /Content-Length:\s*(\d+)/i.exec(header);
|
|
120
|
+
const headLen = idx + 4;
|
|
121
|
+
if (!m) {
|
|
122
|
+
this.buffer = this.buffer.slice(headLen);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const len = Number(m[1]);
|
|
126
|
+
if (this.buffer.length < headLen + len) break;
|
|
127
|
+
const body = this.buffer.slice(headLen, headLen + len);
|
|
128
|
+
this.buffer = this.buffer.slice(headLen + len);
|
|
129
|
+
try {
|
|
130
|
+
out.push(JSON.parse(body.toString('utf8')));
|
|
131
|
+
} catch {}
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** A single LSP server process with pending-request routing. */
|
|
138
|
+
class LspConnection {
|
|
139
|
+
/**
|
|
140
|
+
* @param {string} id
|
|
141
|
+
* @param {LspServerDef} def
|
|
142
|
+
*/
|
|
143
|
+
constructor(id, def) {
|
|
144
|
+
this.id = id;
|
|
145
|
+
this.def = def;
|
|
146
|
+
this.child = null;
|
|
147
|
+
this.decoder = new FrameDecoder();
|
|
148
|
+
this.pending = new Map();
|
|
149
|
+
this.diagnostics = new Map(); // uri -> array of diagnostics
|
|
150
|
+
this.ready = null;
|
|
151
|
+
this.openedFiles = new Set();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
start() {
|
|
155
|
+
if (this.child) return this.ready || Promise.resolve();
|
|
156
|
+
const def = this.def;
|
|
157
|
+
const isWin = process.platform === 'win32';
|
|
158
|
+
// npx on Windows must go through cmd so the shim resolves.
|
|
159
|
+
const cmd = def.command[0];
|
|
160
|
+
const rest = def.command.slice(1);
|
|
161
|
+
this.child = isWin && cmd === 'npx'
|
|
162
|
+
? spawn('cmd', ['/c', 'npx', ...rest], { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
|
|
163
|
+
: spawn(cmd, rest, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
|
|
164
|
+
this.child.stdout.on('data', (d) => {
|
|
165
|
+
for (const msg of this.decoder.push(d)) this.handleMessage(msg);
|
|
166
|
+
});
|
|
167
|
+
const initTimer = setTimeout(() => {
|
|
168
|
+
if (this._readyReject) this._readyReject(new Error(`LSP server ${this.id} initialize timed out`));
|
|
169
|
+
}, 30000);
|
|
170
|
+
this.ready = new Promise((resolve, reject) => {
|
|
171
|
+
this._readyResolve = (v) => { clearTimeout(initTimer); resolve(v); };
|
|
172
|
+
this._readyReject = (e) => { clearTimeout(initTimer); reject(e); };
|
|
173
|
+
});
|
|
174
|
+
this.child.on('error', (err) => {
|
|
175
|
+
if (this._readyReject) this._readyReject(err && err.message ? err : new Error(`LSP server ${this.id} failed to start`));
|
|
176
|
+
this.stop();
|
|
177
|
+
});
|
|
178
|
+
this.child.on('exit', () => {
|
|
179
|
+
this.child = null;
|
|
180
|
+
if (this._readyReject) this._readyReject(new Error(`LSP server ${this.id} exited`));
|
|
181
|
+
for (const p of this.pending.values()) p.reject(new Error(`LSP server ${this.id} exited`));
|
|
182
|
+
this.pending.clear();
|
|
183
|
+
});
|
|
184
|
+
// initialize handshake
|
|
185
|
+
this.sendRequest('initialize', {
|
|
186
|
+
processId: process.pid,
|
|
187
|
+
rootUri: pathToUri(process.cwd()),
|
|
188
|
+
capabilities: { textDocument: { publishDiagnostics: { relatedInformation: true } }, workspace: {} },
|
|
189
|
+
}).then(() => {
|
|
190
|
+
this.sendNotification('initialized', {});
|
|
191
|
+
if (this._readyResolve) this._readyResolve(null);
|
|
192
|
+
}).catch((e) => {
|
|
193
|
+
if (this._readyReject) this._readyReject(e);
|
|
194
|
+
});
|
|
195
|
+
return this.ready;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Route a parsed frame to a response or a notification handler.
|
|
199
|
+
* @param {object} msg */
|
|
200
|
+
handleMessage(msg) {
|
|
201
|
+
if (msg.id != null) {
|
|
202
|
+
const p = this.pending.get(String(msg.id));
|
|
203
|
+
if (p) {
|
|
204
|
+
this.pending.delete(String(msg.id));
|
|
205
|
+
if (msg.error) p.reject(new Error(msg.error.message || 'LSP error'));
|
|
206
|
+
else p.resolve(msg.result);
|
|
207
|
+
}
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (msg.method === 'textDocument/publishDiagnostics') {
|
|
211
|
+
const uri = msg.params && msg.params.uri;
|
|
212
|
+
if (uri) this.diagnostics.set(uri, msg.params.diagnostics || []);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** @param {string} method
|
|
217
|
+
* @param {object} params
|
|
218
|
+
* @returns {Promise<any>} */
|
|
219
|
+
sendRequest(method, params) {
|
|
220
|
+
if (!this.child) return Promise.reject(new Error('LSP server not started'));
|
|
221
|
+
const id = String(nextId++);
|
|
222
|
+
return new Promise((resolve, reject) => {
|
|
223
|
+
this.pending.set(id, { resolve, reject });
|
|
224
|
+
this.write({ jsonrpc: '2.0', id: Number(id), method, params: params || {} });
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** @param {string} method
|
|
229
|
+
* @param {object} params */
|
|
230
|
+
sendNotification(method, params) {
|
|
231
|
+
if (!this.child) return;
|
|
232
|
+
this.write({ jsonrpc: '2.0', method, params: params || {} });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** @param {object} obj */
|
|
236
|
+
write(obj) {
|
|
237
|
+
if (!this.child) return;
|
|
238
|
+
const body = JSON.stringify(obj);
|
|
239
|
+
this.child.stdin.write(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** @param {string} filePath */
|
|
243
|
+
async didOpen(filePath) {
|
|
244
|
+
if (this.openedFiles.has(filePath)) return;
|
|
245
|
+
const text = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '';
|
|
246
|
+
const languageId = {
|
|
247
|
+
'.ts': 'typescript', '.tsx': 'typescriptreact',
|
|
248
|
+
'.js': 'javascript', '.jsx': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript',
|
|
249
|
+
'.py': 'python',
|
|
250
|
+
}[path.extname(filePath).toLowerCase()] || 'plaintext';
|
|
251
|
+
this.sendNotification('textDocument/didOpen', {
|
|
252
|
+
textDocument: { uri: pathToUri(filePath), languageId, version: 1, text },
|
|
253
|
+
});
|
|
254
|
+
this.openedFiles.add(filePath);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** @param {string} uri
|
|
258
|
+
* @returns {Array<LspDiagnostic>} */
|
|
259
|
+
getDiagnosticsForUri(uri) {
|
|
260
|
+
return normalizeDiagnostics(this.diagnostics.get(uri) || []);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
stop() {
|
|
264
|
+
if (this.child) {
|
|
265
|
+
try {
|
|
266
|
+
this.child.stdin.end();
|
|
267
|
+
this.child.kill();
|
|
268
|
+
} catch {}
|
|
269
|
+
this.child = null;
|
|
270
|
+
}
|
|
271
|
+
this.ready = null;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** @param {string} p
|
|
276
|
+
* @returns {string} */
|
|
277
|
+
function pathToUri(p) {
|
|
278
|
+
const abs = path.resolve(p).replace(/\\/g, '/');
|
|
279
|
+
return 'file://' + (abs.startsWith('/') ? '' : '/') + abs;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Map LSP severity ints to readable labels.
|
|
283
|
+
* @param {Array<object>} diags
|
|
284
|
+
* @returns {Array<LspDiagnostic>} */
|
|
285
|
+
function normalizeDiagnostics(diags) {
|
|
286
|
+
return (diags || []).map((d) => {
|
|
287
|
+
const sev = { 1: 'error', 2: 'warning', 3: 'info', 4: 'hint' }[d.severity] || 'info';
|
|
288
|
+
const r = d.range || { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } };
|
|
289
|
+
return {
|
|
290
|
+
message: String(d.message || '').replace(/\s+/g, ' ').trim(),
|
|
291
|
+
source: String(d.source || ''),
|
|
292
|
+
severity: sev,
|
|
293
|
+
severityCode: d.severity || 0,
|
|
294
|
+
line: (r.start && r.start.line) || 0,
|
|
295
|
+
character: (r.start && r.start.character) || 0,
|
|
296
|
+
endLine: (r.end && r.end.line) || 0,
|
|
297
|
+
endCharacter: (r.end && r.end.character) || 0,
|
|
298
|
+
code: d.code != null ? String(d.code) : undefined,
|
|
299
|
+
};
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const STALE = new Map(); // filePath -> last diagnostics
|
|
304
|
+
const OPEN_TIMER = new Map();
|
|
305
|
+
|
|
306
|
+
/** Run diagnostics for a file: start the server if needed, open the doc,
|
|
307
|
+
* wait for publishDiagnostics, cache it, and return it.
|
|
308
|
+
* @param {string} filePath
|
|
309
|
+
* @param {object} [opts]
|
|
310
|
+
* @param {number} [opts.timeoutMs]
|
|
311
|
+
* @returns {Promise<{ok: boolean, diagnostics: Array<LspDiagnostic>, id?: string, error?: string}>} */
|
|
312
|
+
async function checkFile(filePath, opts = {}) {
|
|
313
|
+
const timeoutMs = opts.timeoutMs || 4000;
|
|
314
|
+
const ext = path.extname(filePath);
|
|
315
|
+
const found = findServerForExt(ext);
|
|
316
|
+
if (!found.found) return { ok: false, diagnostics: [], error: found.reason };
|
|
317
|
+
if (found.id == null || found.def == null) return { ok: false, diagnostics: [], error: 'server not resolved' };
|
|
318
|
+
const id = found.id;
|
|
319
|
+
const def = found.def;
|
|
320
|
+
if (!connections.has(id)) connections.set(id, new LspConnection(id, def));
|
|
321
|
+
const conn = connections.get(id);
|
|
322
|
+
try {
|
|
323
|
+
await conn.start();
|
|
324
|
+
} catch (e) {
|
|
325
|
+
connections.delete(id);
|
|
326
|
+
return { ok: false, diagnostics: [], error: `failed to start ${id}: ${e && e.message ? e.message : e}` };
|
|
327
|
+
}
|
|
328
|
+
await conn.didOpen(filePath);
|
|
329
|
+
const uri = pathToUri(filePath);
|
|
330
|
+
// Give the server a window to push diagnostics (may already be cached).
|
|
331
|
+
if (!conn.diagnostics.has(uri)) {
|
|
332
|
+
await new Promise((resolve) => {
|
|
333
|
+
const t = setTimeout(resolve, timeoutMs);
|
|
334
|
+
OPEN_TIMER.set(uri, t);
|
|
335
|
+
});
|
|
336
|
+
OPEN_TIMER.delete(uri);
|
|
337
|
+
}
|
|
338
|
+
const diags = conn.getDiagnosticsForUri(uri);
|
|
339
|
+
STALE.set(filePath, diags);
|
|
340
|
+
return { ok: true, diagnostics: diags, id };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** @param {string} filePath
|
|
344
|
+
* @returns {Array<LspDiagnostic>} */
|
|
345
|
+
function cachedDiagnostics(filePath) {
|
|
346
|
+
return STALE.get(filePath) || [];
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** @returns {Array<string>} */
|
|
350
|
+
function statusLines() {
|
|
351
|
+
const { enabled, servers } = enabledServers();
|
|
352
|
+
const lines = [`LSP: ${enabled ? 'ENABLED' : 'DISABLED'} (set config.json "lsp": true to enable)`];
|
|
353
|
+
if (enabled) {
|
|
354
|
+
for (const [id, def] of Object.entries(servers)) {
|
|
355
|
+
lines.push(` ${id.padEnd(14)} ${(def.command[0] + ' ...') || ''} [${def.extensions.join(' ')}]`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return lines;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function shutdownAll() {
|
|
362
|
+
for (const conn of connections.values()) {
|
|
363
|
+
try {
|
|
364
|
+
conn.sendNotification('shutdown');
|
|
365
|
+
conn.sendNotification('exit');
|
|
366
|
+
} catch {}
|
|
367
|
+
conn.stop();
|
|
368
|
+
}
|
|
369
|
+
connections.clear();
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
process.once('exit', shutdownAll);
|
|
373
|
+
|
|
374
|
+
module.exports = {
|
|
375
|
+
DEFAULT_LSP,
|
|
376
|
+
enabledServers,
|
|
377
|
+
findServerForExt,
|
|
378
|
+
checkFile,
|
|
379
|
+
cachedDiagnostics,
|
|
380
|
+
statusLines,
|
|
381
|
+
shutdownAll,
|
|
382
|
+
LspConnection,
|
|
383
|
+
FrameDecoder,
|
|
384
|
+
pathToUri,
|
|
385
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Memory — layered LOOM.md files injected into every session's system prompt.
|
|
2
|
+
// Layers: global (~/.loom/LOOM.md) then project (<cwd>/LOOM.md). Any file may
|
|
3
|
+
// pull in others with `@relative/path.md` lines (depth-capped, cycle-safe).
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
|
|
8
|
+
const MAX_IMPORT_DEPTH = 3;
|
|
9
|
+
// Cap each layer so a runaway memory file can't eat the context window.
|
|
10
|
+
const MAX_CHARS_PER_LAYER = 12000;
|
|
11
|
+
|
|
12
|
+
function globalMemoryPath() {
|
|
13
|
+
const base = process.env.LOOM_CONFIG_DIR || path.join(os.homedir(), '.loom');
|
|
14
|
+
return path.join(base, 'LOOM.md');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Read one markdown file and expand @imports.
|
|
19
|
+
* @param {string} file
|
|
20
|
+
* @param {number} depth
|
|
21
|
+
* @param {Set<string>} seen
|
|
22
|
+
* @returns {string}
|
|
23
|
+
*/
|
|
24
|
+
function loadFile(file, depth, seen) {
|
|
25
|
+
const abs = path.resolve(file);
|
|
26
|
+
if (seen.has(abs)) return '';
|
|
27
|
+
let raw = '';
|
|
28
|
+
try { raw = fs.readFileSync(abs, 'utf8'); } catch { return ''; }
|
|
29
|
+
seen.add(abs);
|
|
30
|
+
const lines = raw.split(/\r?\n/);
|
|
31
|
+
const out = [];
|
|
32
|
+
for (const line of lines) {
|
|
33
|
+
const m = line.match(/^\s*@([\w.\-/\\]+\.md)\s*$/);
|
|
34
|
+
if (m && depth < MAX_IMPORT_DEPTH) {
|
|
35
|
+
out.push(loadFile(path.join(path.dirname(abs), m[1]), depth + 1, seen));
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
out.push(line);
|
|
39
|
+
}
|
|
40
|
+
return out.join('\n').trim();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Build the full memory block: "## Global memory" + "## Project memory"
|
|
45
|
+
* sections with imports expanded. Empty string when no memory exists.
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
48
|
+
function loadMemory() {
|
|
49
|
+
const parts = [];
|
|
50
|
+
const g = loadFile(globalMemoryPath(), 0, new Set());
|
|
51
|
+
if (g) parts.push('## Global memory\n\n' + g.slice(0, MAX_CHARS_PER_LAYER));
|
|
52
|
+
let p = loadFile(path.join(process.cwd(), 'LOOM.md'), 0, new Set());
|
|
53
|
+
if (!p) p = loadFile(path.join(process.cwd(), '.loom', 'LOOM.md'), 0, new Set());
|
|
54
|
+
if (p) parts.push('## Project memory\n\n' + p.slice(0, MAX_CHARS_PER_LAYER));
|
|
55
|
+
return parts.join('\n\n');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Append a remembered fact as a dated bullet under "## Remembered" in the
|
|
60
|
+
* chosen layer's LOOM.md (default: project). Creates the file/heading.
|
|
61
|
+
* @param {string} text
|
|
62
|
+
* @param {'project'|'global'} [layer]
|
|
63
|
+
* @returns {boolean}
|
|
64
|
+
*/
|
|
65
|
+
function appendMemory(text, layer) {
|
|
66
|
+
const clean = String(text || '').trim();
|
|
67
|
+
if (!clean) return false;
|
|
68
|
+
const file = layer === 'global'
|
|
69
|
+
? globalMemoryPath()
|
|
70
|
+
: path.join(process.cwd(), 'LOOM.md');
|
|
71
|
+
let body = '';
|
|
72
|
+
try { body = fs.readFileSync(file, 'utf8'); } catch {}
|
|
73
|
+
if (!/^\s*##\s*Remembered\s*$/m.test(body)) {
|
|
74
|
+
body = (body ? body.replace(/\s*$/, '\n\n') : '') + '## Remembered\n';
|
|
75
|
+
}
|
|
76
|
+
const stamp = new Date().toISOString().slice(0, 10);
|
|
77
|
+
body = body.replace(/(##\s*Remembered\s*\n)/, '$1- [' + stamp + '] ' + clean.replace(/\s*\n+\s*/g, ' ') + '\n');
|
|
78
|
+
try {
|
|
79
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
80
|
+
fs.writeFileSync(file, body);
|
|
81
|
+
return true;
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = { loadMemory, globalMemoryPath, appendMemory };
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Model router — picks the right model for the current budget level.
|
|
2
|
+
// Levels: free (only $0 models) · cheap (free + low-cost) · best (anything) ·
|
|
3
|
+
// auto (explicit provider/model picks, no routing). Only providers with a
|
|
4
|
+
// configured key are considered, so the router never bricks on a missing key.
|
|
5
|
+
const { PROVIDERS, PROVIDER_ORDER, getModelMeta } = require('../providers/index.js');
|
|
6
|
+
const { getRecentModels, hasApiKey } = require('../config/settings');
|
|
7
|
+
|
|
8
|
+
const LEVELS = ['free', 'cheap', 'best', 'auto'];
|
|
9
|
+
|
|
10
|
+
// A model is "cheap" when a 1M-token input+output turn stays under this cap.
|
|
11
|
+
const CHEAP_TURN_USD = 2.5;
|
|
12
|
+
const CHEAP_IN = 0.5;
|
|
13
|
+
const CHEAP_OUT = 2;
|
|
14
|
+
|
|
15
|
+
function levelOf(meta) {
|
|
16
|
+
if (!meta) return 'best';
|
|
17
|
+
if ((meta.priceIn || 0) === 0 && (meta.priceOut || 0) === 0) return 'free';
|
|
18
|
+
if ((meta.priceIn || 0) <= CHEAP_IN && (meta.priceOut || 0) <= CHEAP_OUT) return 'cheap';
|
|
19
|
+
return 'best';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function matchesLevel(meta, level) {
|
|
23
|
+
if (!level || level === 'auto') return true;
|
|
24
|
+
if (level === 'free') return levelOf(meta) === 'free';
|
|
25
|
+
if (level === 'cheap') return levelOf(meta) === 'free' || levelOf(meta) === 'cheap';
|
|
26
|
+
return true; // best = everything
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Every model reachable with the keys currently configured. Local backends are
|
|
30
|
+
// excluded — the router must never auto-route to a server that may not be
|
|
31
|
+
// running; local is used only via explicit /model picks.
|
|
32
|
+
function usableModels() {
|
|
33
|
+
const out = [];
|
|
34
|
+
for (const p of PROVIDER_ORDER) {
|
|
35
|
+
if (p === 'local') continue;
|
|
36
|
+
if (!hasApiKey(p)) continue;
|
|
37
|
+
for (const m of (PROVIDERS[p] && PROVIDERS[p].models) || []) {
|
|
38
|
+
out.push({ provider: p, model: m.id, meta: m });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Pick the best model for a level. Preference: recently-used models (newest
|
|
45
|
+
// first), then lowest per-token price. `tried` skips models that already failed
|
|
46
|
+
// this session. Returns { provider, model } or null when nothing qualifies.
|
|
47
|
+
function pickModel(level, opts = {}) {
|
|
48
|
+
if (!level || level === 'auto') return null;
|
|
49
|
+
const tried = opts.tried || [];
|
|
50
|
+
const all = usableModels().filter((c) => matchesLevel(c.meta, level));
|
|
51
|
+
if (!all.length) return null;
|
|
52
|
+
|
|
53
|
+
const fresh = all.filter((c) => !tried.includes(c.provider + '/' + c.model));
|
|
54
|
+
const pool = fresh.length ? fresh : all;
|
|
55
|
+
|
|
56
|
+
for (const r of getRecentModels()) {
|
|
57
|
+
const hit = pool.find((c) => c.provider === r.provider && c.model === r.model);
|
|
58
|
+
if (hit) return { provider: hit.provider, model: hit.model };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
pool.sort((a, b) =>
|
|
62
|
+
(a.meta.priceIn + a.meta.priceOut) - (b.meta.priceIn + b.meta.priceOut));
|
|
63
|
+
return { provider: pool[0].provider, model: pool[0].model };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Estimated USD cost of a turn on a given model.
|
|
67
|
+
function estimateTurnCost(providerName, modelId, inputTokens, outputTokens) {
|
|
68
|
+
const meta = getModelMeta(providerName, modelId);
|
|
69
|
+
if (!meta) return null;
|
|
70
|
+
return ((inputTokens || 0) / 1e6) * (meta.priceIn || 0) +
|
|
71
|
+
((outputTokens || 0) / 1e6) * (meta.priceOut || 0);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Human-readable summary of what a level means right now.
|
|
75
|
+
function describeLevel(level) {
|
|
76
|
+
const picked = pickModel(level);
|
|
77
|
+
const free = usableModels().some((c) => matchesLevel(c.meta, 'free'));
|
|
78
|
+
const cheap = usableModels().some((c) => matchesLevel(c.meta, 'cheap'));
|
|
79
|
+
return {
|
|
80
|
+
level,
|
|
81
|
+
picked,
|
|
82
|
+
freeAvailable: free,
|
|
83
|
+
cheapAvailable: cheap,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = { LEVELS, levelOf, matchesLevel, usableModels, pickModel, estimateTurnCost, describeLevel, CHEAP_TURN_USD };
|