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,380 @@
|
|
|
1
|
+
const { listSkills, installSkill, removeSkill } = require('../skills/skills-manager');
|
|
2
|
+
const { loadServers, addServer, parseMcpAddArgs, removeServer, listServers, toggleServer, seedDefaults } = require('../mcp/mcp-manager');
|
|
3
|
+
const sessionStore = require('./session-store');
|
|
4
|
+
|
|
5
|
+
// Quote-aware CLI tokenizer for slash commands, so paths with spaces survive:
|
|
6
|
+
// /mcp add stm32 -- "C:\stm32-mcp\.venv\Scripts\python.exe" -m stm32_mcp.server
|
|
7
|
+
/**
|
|
8
|
+
* @param {string} s
|
|
9
|
+
* @returns {string[]}
|
|
10
|
+
*/
|
|
11
|
+
function tokenizeCli(s) {
|
|
12
|
+
const out = [];
|
|
13
|
+
let cur = '';
|
|
14
|
+
let quote = '';
|
|
15
|
+
for (let i = 0; i < s.length; i++) {
|
|
16
|
+
const c = s[i];
|
|
17
|
+
if (quote) {
|
|
18
|
+
if (c === quote) quote = '';
|
|
19
|
+
else cur += c;
|
|
20
|
+
} else if (c === '"' || c === "'") {
|
|
21
|
+
quote = c;
|
|
22
|
+
} else if (/\s/.test(c)) {
|
|
23
|
+
if (cur) { out.push(cur); cur = ''; }
|
|
24
|
+
} else {
|
|
25
|
+
cur += c;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (cur) out.push(cur);
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function skillHelp() {
|
|
33
|
+
return [
|
|
34
|
+
'Skill commands:',
|
|
35
|
+
' /skills List installed skills',
|
|
36
|
+
' /skills install <dir|git> Install a skill (local folder or git URL)',
|
|
37
|
+
' /skills install <git-url> --trust Approve + install a remote skill (pinned to its commit)',
|
|
38
|
+
' /skills remove <name> Uninstall a skill',
|
|
39
|
+
'',
|
|
40
|
+
'Skills live in ~/.loom/skills and are injected into the system prompt',
|
|
41
|
+
].join('\n');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function listSkillsText() {
|
|
45
|
+
const skills = listSkills();
|
|
46
|
+
if (!skills.length) return 'No skills installed yet. Use /skills install <dir|git-url>.\n\n' + skillHelp();
|
|
47
|
+
const { loadConfig } = require('../config/settings');
|
|
48
|
+
const disabled = (loadConfig().skillDisabled || []);
|
|
49
|
+
const lines = ['Installed skills (' + skills.length + '):', ''];
|
|
50
|
+
for (const s of skills) {
|
|
51
|
+
const status = disabled.includes(s.name) ? 'OFF' : 'ON';
|
|
52
|
+
lines.push(' [' + status + '] ' + s.name.padEnd(26) + ' [' + s.source + '] ' + s.description);
|
|
53
|
+
}
|
|
54
|
+
lines.push('', 'Toggle via /skills modal, or add the name to config.json skillDisabled[].');
|
|
55
|
+
return lines.join('\n');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// A trust approval must be bound to the exact commit that was shown in the
|
|
59
|
+
// block message — approving "the URL" again after the remote moved would
|
|
60
|
+
// bless content the user never reviewed. Remember url → commit from the last
|
|
61
|
+
// trustRequired response and require a matching --trust before installing.
|
|
62
|
+
const pendingTrust = new Map();
|
|
63
|
+
|
|
64
|
+
function installSkillCmd(args) {
|
|
65
|
+
const rest = args.filter((a) => a !== '--trust' && a !== '-t');
|
|
66
|
+
const trust = rest.length !== args.length;
|
|
67
|
+
const target = rest[0];
|
|
68
|
+
const name = rest[1];
|
|
69
|
+
if (!target) return 'Usage: /skills install <folder-path|git-url> [name] [--trust]';
|
|
70
|
+
const pending = trust ? pendingTrust.get(target) : undefined;
|
|
71
|
+
const trustValue = typeof pending === 'string' ? pending : false;
|
|
72
|
+
const res = require('../skills/skills-manager').installSkill(target, name, { trust: trustValue });
|
|
73
|
+
if (res.error) {
|
|
74
|
+
if (res.trustRequired) {
|
|
75
|
+
pendingTrust.set(res.trustRequired.url, res.trustRequired.commit);
|
|
76
|
+
const lines = [
|
|
77
|
+
'Install blocked: ' + res.error + '.',
|
|
78
|
+
'',
|
|
79
|
+
'Remote skills run with full tool access, so the exact content must be',
|
|
80
|
+
'reviewed and approved once. Approval is pinned to the commit hash.',
|
|
81
|
+
'',
|
|
82
|
+
' source: ' + res.trustRequired.url,
|
|
83
|
+
' commit: ' + res.trustRequired.commit,
|
|
84
|
+
];
|
|
85
|
+
if (res.trustRequired.previous) {
|
|
86
|
+
lines.push(
|
|
87
|
+
'',
|
|
88
|
+
' WARNING: this content differs from the approved version:',
|
|
89
|
+
' approved: ' + res.trustRequired.previous + ' (' + (res.trustRequired.approvedAt || '?') + ')',
|
|
90
|
+
' now: ' + res.trustRequired.commit
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
lines.push('', 'If you trust this source, approve this exact commit:');
|
|
94
|
+
lines.push(' /skills install ' + res.trustRequired.url + ' --trust');
|
|
95
|
+
return lines.join('\n');
|
|
96
|
+
}
|
|
97
|
+
pendingTrust.delete(target);
|
|
98
|
+
return 'Install failed: ' + res.error;
|
|
99
|
+
}
|
|
100
|
+
pendingTrust.delete(target);
|
|
101
|
+
return 'Installed skill "' + res.name + '" to ' + res.dir;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function removeSkillCmd(args) {
|
|
105
|
+
const name = args[0];
|
|
106
|
+
if (!name) return 'Usage: /skills remove <name>';
|
|
107
|
+
const res = removeSkill(name);
|
|
108
|
+
return res.error ? res.error : 'Removed skill: ' + res.removed;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function mcpHelp() {
|
|
112
|
+
return [
|
|
113
|
+
'MCP (Model Context Protocol) connector commands:',
|
|
114
|
+
' /mcp List MCP servers & tools',
|
|
115
|
+
' /mcp add [-e KEY=V] <name> [--] <command> [args...] Add a stdio server',
|
|
116
|
+
' /mcp remove <name> Remove a server',
|
|
117
|
+
' /mcp toggle <name> Enable/disable a server',
|
|
118
|
+
' /connectors Browse hosting/cloud connectors (Supabase, Railway, Vercel, Netlify, Cloudflare, Next.js)',
|
|
119
|
+
'',
|
|
120
|
+
'Default servers (installed once, first launch):',
|
|
121
|
+
' enabled: fetch, memory',
|
|
122
|
+
' disabled: time, sequential-thinking, github, filesystem, brave-search (toggle on after setup)',
|
|
123
|
+
'',
|
|
124
|
+
'Example (claude-compatible): /mcp add stm32 -- "C:\\stm32-mcp\\.venv\\Scripts\\python.exe" -m stm32_mcp.server',
|
|
125
|
+
'Example with env: /mcp add -e BRAVE_API_KEY=x brave-search -- npx -y @modelcontextprotocol/server-brave-search',
|
|
126
|
+
'The "--" separator is optional; quote any path with spaces. Env keys are usable as $KEY in args.',
|
|
127
|
+
'In the /mcp and /connectors browsers, press A to add with the same one-liner syntax.',
|
|
128
|
+
].join('\n');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function listMcpText() {
|
|
132
|
+
const servers = listServers();
|
|
133
|
+
if (!servers.length) return 'No MCP servers configured. Use /mcp add <name> <command> [args].\n\n' + mcpHelp();
|
|
134
|
+
const lines = ['MCP servers (' + servers.length + '):', ''];
|
|
135
|
+
for (const s of servers) {
|
|
136
|
+
lines.push(' ' + (s.enabled ? '[on] ' : '[off] ') + s.name + ' -> ' + s.command + ' ' + s.args.join(' '));
|
|
137
|
+
}
|
|
138
|
+
lines.push('', 'Tools refresh on your next message. Use /mcp add, /mcp remove, /mcp toggle.');
|
|
139
|
+
return lines.join('\n');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Shared engine for /mcp add and the TUI one-line add modal. Takes the
|
|
143
|
+
// argv-level parse, resolves any $KEY placeholder in args from the -e env,
|
|
144
|
+
// wraps npx for Windows (cmd /c npx ...), persists, and returns a message.
|
|
145
|
+
function addServerFromArgv(argv) {
|
|
146
|
+
const parsed = parseMcpAddArgs(argv);
|
|
147
|
+
if ('error' in parsed) return parsed.error;
|
|
148
|
+
const env = parsed.env || {};
|
|
149
|
+
let cmd = parsed.command;
|
|
150
|
+
let args = parsed.args.map(a =>
|
|
151
|
+
typeof a === 'string' && a.startsWith('$') && env[a.slice(1)] !== undefined ? env[a.slice(1)] : a
|
|
152
|
+
);
|
|
153
|
+
if (process.platform === 'win32' && cmd === 'npx') { cmd = 'cmd'; args = ['/c', 'npx'].concat(args); }
|
|
154
|
+
const res = addServer(parsed.name, cmd, args, Object.keys(env).length ? { env } : undefined);
|
|
155
|
+
if (res.error) return res.error;
|
|
156
|
+
let line = 'Added MCP server "' + parsed.name + '" -> ' + cmd + ' ' + args.join(' ');
|
|
157
|
+
if (Object.keys(env).length) line += ' [env: ' + Object.keys(env).join(', ') + ']';
|
|
158
|
+
return line;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function mcpAddCmd(args) {
|
|
162
|
+
return addServerFromArgv(args);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Same thing, from a single raw line (the TUI one-line add input). Forgiving
|
|
166
|
+
// of a leading "add" / "mcp add" prefix, and quote-aware via tokenizeCli.
|
|
167
|
+
function mcpAddLineCmd(line) {
|
|
168
|
+
let argv = tokenizeCli(String(line || '').trim());
|
|
169
|
+
if (argv[0] === 'add') argv = argv.slice(1);
|
|
170
|
+
else if (argv[0] === 'mcp' && argv[1] === 'add') argv = argv.slice(2);
|
|
171
|
+
return addServerFromArgv(argv);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function mcpRemoveCmd(args) {
|
|
175
|
+
const name = args[0];
|
|
176
|
+
if (!name) return 'Usage: /mcp remove <name>';
|
|
177
|
+
const res = removeServer(name);
|
|
178
|
+
return res.error ? res.error : 'Removed MCP server: ' + name;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function mcpToggleCmd(args) {
|
|
182
|
+
const name = args[0];
|
|
183
|
+
if (!name) return 'Usage: /mcp toggle <name>';
|
|
184
|
+
const res = toggleServer(name);
|
|
185
|
+
return res.error ? res.error : 'MCP server "' + name + '" now ' + (res.enabled ? 'enabled' : 'disabled');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function diffCmd() {
|
|
189
|
+
const { execSync } = require('child_process');
|
|
190
|
+
try {
|
|
191
|
+
const stat = execSync('git diff --stat', { cwd: process.cwd(), encoding: 'utf8', timeout: 5000, windowsHide: true }).trim();
|
|
192
|
+
if (!stat) return 'No changes to show (clean working tree).';
|
|
193
|
+
const full = execSync('git diff', { cwd: process.cwd(), encoding: 'utf8', timeout: 5000, maxBuffer: 500 * 1024, windowsHide: true }).trim();
|
|
194
|
+
return '## Git Diff\n\n```\n' + full.slice(0, 8000) + '\n```';
|
|
195
|
+
} catch {
|
|
196
|
+
return 'Not a git repository or git is not available. Run in a git project directory.';
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function debugCmd() {
|
|
201
|
+
const os = require('os');
|
|
202
|
+
const c = require('../config/settings').loadConfig();
|
|
203
|
+
const lines = [
|
|
204
|
+
'=== Debug Info ===',
|
|
205
|
+
'Node: ' + process.version + ' on ' + os.platform() + '-' + os.arch(),
|
|
206
|
+
'CWD: ' + process.cwd(),
|
|
207
|
+
'Config provider: ' + (c.provider || 'none'),
|
|
208
|
+
'Config keys: ' + (c.apiKeys ? Object.keys(c.apiKeys).join(',') : 'none'),
|
|
209
|
+
'Default provider: ' + (c.provider || 'none'),
|
|
210
|
+
'Default model: ' + ((c.model && c.model[c.provider]) || 'none'),
|
|
211
|
+
];
|
|
212
|
+
return lines.join('\n');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function editorCmd() {
|
|
216
|
+
const path = require('path');
|
|
217
|
+
const fs = require('fs');
|
|
218
|
+
const { execSync } = require('child_process');
|
|
219
|
+
const { MEMORY_TEMPLATE } = require('./session');
|
|
220
|
+
const loomMd = path.join(process.cwd(), 'LOOM.md');
|
|
221
|
+
if (!fs.existsSync(loomMd)) {
|
|
222
|
+
fs.writeFileSync(loomMd, MEMORY_TEMPLATE);
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
if (process.platform === 'win32') {
|
|
226
|
+
execSync('start "" "' + loomMd + '"', { stdio: 'ignore', windowsHide: true });
|
|
227
|
+
} else if (process.platform === 'darwin') {
|
|
228
|
+
execSync('open "' + loomMd + '"', { stdio: 'ignore' });
|
|
229
|
+
} else {
|
|
230
|
+
execSync('xdg-open "' + loomMd + '"', { stdio: 'ignore' });
|
|
231
|
+
}
|
|
232
|
+
return 'Opening LOOM.md in default editor...';
|
|
233
|
+
} catch (e) {
|
|
234
|
+
return 'Could not open editor: ' + e.message;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function exportCmd(messages) {
|
|
239
|
+
try {
|
|
240
|
+
const file = sessionStore.exportChat({ messages: messages || [] }, 'md');
|
|
241
|
+
return 'Exported chat to: ' + file;
|
|
242
|
+
} catch (e) {
|
|
243
|
+
return 'Export failed: ' + e.message;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function sessionsCmd() {
|
|
248
|
+
const list = sessionStore.listSessions();
|
|
249
|
+
if (!list.length) return 'No saved sessions. Type /exit or /fork to save current.';
|
|
250
|
+
const lines = ['Saved sessions (resume with: loom -s <id>):', ''];
|
|
251
|
+
for (const s of list) {
|
|
252
|
+
lines.push(' ' + s.id + ' [ ' + (s.createdAt || 'unknown') + ' ] ' + s.messageCount + ' messages');
|
|
253
|
+
}
|
|
254
|
+
return lines.join('\n');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function forkCmd(session) {
|
|
258
|
+
if (!session) return 'No active session to fork.';
|
|
259
|
+
const dup = { conversationId: session.conversationId, messages: session.messages.slice(), config: session.config };
|
|
260
|
+
const saved = sessionStore.saveSession(dup);
|
|
261
|
+
return 'Forked session saved as: ' + saved.id + '\nResume with: loom -s ' + saved.id;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function defaultMcpInstall() {
|
|
265
|
+
return seedDefaults();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ─── Formatters / LSP (OpenCode-style, enabled via config.json) ───
|
|
269
|
+
const format = require('./format');
|
|
270
|
+
const lsp = require('./lsp');
|
|
271
|
+
|
|
272
|
+
function formatHelp() {
|
|
273
|
+
return [
|
|
274
|
+
'Formatter commands:',
|
|
275
|
+
' /format Show formatter status + built-ins',
|
|
276
|
+
' /format on Enable all built-in formatters',
|
|
277
|
+
' /format off Disable all formatters',
|
|
278
|
+
' /format <id> on|off Toggle one formatter (prettier, gofmt, ruff, ...)',
|
|
279
|
+
'',
|
|
280
|
+
'Formatters run automatically in the background after the agent writes or',
|
|
281
|
+
'edits a file. Custom formatters: config.json "formatter": { "<name>":',
|
|
282
|
+
'{ "command": ["cmd", "$FILE"], "extensions": [".ext"] } }. The $FILE',
|
|
283
|
+
'placeholder is replaced with the file path.',
|
|
284
|
+
].join('\n');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function formatCmd(args) {
|
|
288
|
+
const { loadConfig, saveConfig } = require('../config/settings');
|
|
289
|
+
const state = args[0];
|
|
290
|
+
if (state === 'help') return formatHelp();
|
|
291
|
+
let cfg = loadConfig();
|
|
292
|
+
if (state === 'on') { cfg.formatter = true; saveConfig(cfg); return 'Formatters enabled (all built-ins).'; }
|
|
293
|
+
if (state === 'off') { cfg.formatter = false; saveConfig(cfg); return 'Formatters disabled.'; }
|
|
294
|
+
if (args.length === 2 && /^(on|off)$/.test(args[1])) {
|
|
295
|
+
const id = args[0];
|
|
296
|
+
if (!(id in format.DEFAULT_FORMATTERS)) {
|
|
297
|
+
return 'Unknown formatter: ' + id + '. Known: ' + Object.keys(format.DEFAULT_FORMATTERS).join(', ');
|
|
298
|
+
}
|
|
299
|
+
const f = cfg.formatter === true ? {} : (cfg.formatter && typeof cfg.formatter === 'object' ? cfg.formatter : {});
|
|
300
|
+
f[id] = { ...(f[id] || {}), disabled: args[1] === 'off' };
|
|
301
|
+
cfg.formatter = f;
|
|
302
|
+
saveConfig(cfg);
|
|
303
|
+
return 'Formatter "' + id + '" now ' + (args[1] === 'on' ? 'enabled' : 'disabled') + '.';
|
|
304
|
+
}
|
|
305
|
+
return format.formatStatusLines().join('\n') + '\n\n' + formatHelp();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function lspHelp() {
|
|
309
|
+
return [
|
|
310
|
+
'LSP commands:',
|
|
311
|
+
' /lsp Show LSP server status + built-ins',
|
|
312
|
+
' /lsp on Enable all built-in LSP servers',
|
|
313
|
+
' /lsp off Disable LSP',
|
|
314
|
+
' /lsp check <file> Run diagnostics on a file',
|
|
315
|
+
' /lsp <id> on|off Toggle one server (typescript, pyright, ...)',
|
|
316
|
+
'',
|
|
317
|
+
'Custom servers: config.json "lsp": { "<name>": { "command": [...],',
|
|
318
|
+
'"extensions": [".ext"] } }. The agent can also call the "lsp" tool.',
|
|
319
|
+
].join('\n');
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function lspCmd(args) {
|
|
323
|
+
const { loadConfig, saveConfig } = require('../config/settings');
|
|
324
|
+
const state = args[0];
|
|
325
|
+
if (state === 'help') return lspHelp();
|
|
326
|
+
let cfg = loadConfig();
|
|
327
|
+
if (state === 'on') { cfg.lsp = true; saveConfig(cfg); return 'LSP enabled (all built-in servers).'; }
|
|
328
|
+
if (state === 'off') { cfg.lsp = false; saveConfig(cfg); return 'LSP disabled.'; }
|
|
329
|
+
if (state === 'check') {
|
|
330
|
+
const file = args[1];
|
|
331
|
+
if (!file) return 'Usage: /lsp check <file>';
|
|
332
|
+
return lsp.checkFile(file).then((res) => {
|
|
333
|
+
if (!res.ok) return 'LSP check failed: ' + res.error;
|
|
334
|
+
if (!res.diagnostics.length) return 'LSP (' + res.id + '): no diagnostics for ' + file;
|
|
335
|
+
const errs = res.diagnostics.filter((d) => d.severity === 'error').length;
|
|
336
|
+
const warns = res.diagnostics.filter((d) => d.severity === 'warning').length;
|
|
337
|
+
const lines = res.diagnostics.map((d) =>
|
|
338
|
+
(d.severity === 'error' ? 'E' : d.severity === 'warning' ? 'W' : 'I') +
|
|
339
|
+
' ' + (d.line + 1) + ':' + (d.character + 1) + ' [' + (d.source || res.id) + '] ' + d.message);
|
|
340
|
+
return 'LSP (' + res.id + ') - ' + errs + ' error(s), ' + warns + ' warning(s):\n' + lines.join('\n');
|
|
341
|
+
}).catch((e) => 'LSP check failed: ' + (e && e.message ? e.message : e));
|
|
342
|
+
}
|
|
343
|
+
if (args.length === 2 && /^(on|off)$/.test(args[1])) {
|
|
344
|
+
const id = args[0];
|
|
345
|
+
if (!(id in lsp.DEFAULT_LSP)) {
|
|
346
|
+
return 'Unknown LSP server: ' + id + '. Known: ' + Object.keys(lsp.DEFAULT_LSP).join(', ');
|
|
347
|
+
}
|
|
348
|
+
const lv = cfg.lsp === true ? {} : (cfg.lsp && typeof cfg.lsp === 'object' ? cfg.lsp : {});
|
|
349
|
+
lv[id] = { ...(lv[id] || {}), disabled: args[1] === 'off' };
|
|
350
|
+
cfg.lsp = lv;
|
|
351
|
+
saveConfig(cfg);
|
|
352
|
+
return 'LSP server "' + id + '" now ' + (args[1] === 'on' ? 'enabled' : 'disabled') + '.';
|
|
353
|
+
}
|
|
354
|
+
return lsp.statusLines().join('\n') + '\n\n' + lspHelp();
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
module.exports = {
|
|
358
|
+
tokenizeCli,
|
|
359
|
+
skillHelp,
|
|
360
|
+
listSkillsText,
|
|
361
|
+
installSkillCmd,
|
|
362
|
+
removeSkillCmd,
|
|
363
|
+
mcpHelp,
|
|
364
|
+
listMcpText,
|
|
365
|
+
mcpAddCmd,
|
|
366
|
+
mcpAddLineCmd,
|
|
367
|
+
mcpRemoveCmd,
|
|
368
|
+
mcpToggleCmd,
|
|
369
|
+
diffCmd,
|
|
370
|
+
debugCmd,
|
|
371
|
+
editorCmd,
|
|
372
|
+
exportCmd,
|
|
373
|
+
sessionsCmd,
|
|
374
|
+
forkCmd,
|
|
375
|
+
defaultMcpInstall,
|
|
376
|
+
formatHelp,
|
|
377
|
+
formatCmd,
|
|
378
|
+
lspHelp,
|
|
379
|
+
lspCmd,
|
|
380
|
+
};
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Restore points — snapshot the project's file tree on every user prompt so
|
|
2
|
+
// the user can /restore to any earlier state if the agent breaks something.
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
|
|
7
|
+
const RESTORE_FILE = process.env.LOOM_RESTORE_FILE || path.join(os.homedir(), '.loom', 'restore.json');
|
|
8
|
+
const MAX_POINTS = 20;
|
|
9
|
+
|
|
10
|
+
// Mirrors the TUI file walker's ignore list (node_modules, .git, dist, …).
|
|
11
|
+
const IGNORE_RX = /(^|[\/])(node_modules|\.git|dist|build|\.next|\.venv|venv|coverage|__pycache__|\.loom|\.idea|\.vscode)([\/]|$)/i;
|
|
12
|
+
|
|
13
|
+
const cwd = process.cwd();
|
|
14
|
+
|
|
15
|
+
function walkFiles(root, depth, out) {
|
|
16
|
+
if (depth > 5 || out.length > 400) return;
|
|
17
|
+
let entries;
|
|
18
|
+
try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return; }
|
|
19
|
+
for (const e of entries) {
|
|
20
|
+
const full = path.join(root, e.name);
|
|
21
|
+
if (IGNORE_RX.test(full)) continue;
|
|
22
|
+
if (e.isDirectory()) walkFiles(full, depth + 1, out);
|
|
23
|
+
else out.push(full);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Per-file content cap: larger files (lockfiles, bundles, big data) are not
|
|
28
|
+
// snapshotted — they are recorded as null and left untouched by a restore.
|
|
29
|
+
const FILE_CAP = 200 * 1024;
|
|
30
|
+
// Total snapshot cap: keeps restore.json small (~MBs, not hundreds of MBs).
|
|
31
|
+
// Once the budget is spent, remaining files are recorded as null.
|
|
32
|
+
const TOTAL_CAP = 2 * 1024 * 1024;
|
|
33
|
+
// Store-wide budget: when the restore file exceeds this, the oldest points
|
|
34
|
+
// are dropped on load (20 x TOTAL_CAP would otherwise mean 40MB on disk).
|
|
35
|
+
const FILE_BUDGET = 8 * 1024 * 1024;
|
|
36
|
+
|
|
37
|
+
// Snapshot current project: rel path -> content.
|
|
38
|
+
// - string = file was captured (small, text); restore overwrites it.
|
|
39
|
+
// - null = file existed but was NOT captured (too big / unreadable /
|
|
40
|
+
// budget exhausted); restore must leave it alone.
|
|
41
|
+
// - absent = file did not exist at the point; restore deletes it if it
|
|
42
|
+
// appeared later.
|
|
43
|
+
function snapshotProject(base) {
|
|
44
|
+
const list = [];
|
|
45
|
+
walkFiles(base, 0, list);
|
|
46
|
+
const files = {};
|
|
47
|
+
let budget = TOTAL_CAP;
|
|
48
|
+
for (const abs of list) {
|
|
49
|
+
const rel = path.relative(base, abs).replace(/\\/g, '/');
|
|
50
|
+
let content = null;
|
|
51
|
+
try {
|
|
52
|
+
const st = fs.statSync(abs);
|
|
53
|
+
if (st.size <= FILE_CAP && st.size <= budget) {
|
|
54
|
+
const raw = fs.readFileSync(abs, 'utf8');
|
|
55
|
+
if (!raw.includes('\u0000')) {
|
|
56
|
+
content = raw;
|
|
57
|
+
budget -= st.size;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} catch {}
|
|
61
|
+
files[rel] = content;
|
|
62
|
+
}
|
|
63
|
+
return files;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function loadPoints() {
|
|
67
|
+
let points;
|
|
68
|
+
try { points = JSON.parse(fs.readFileSync(RESTORE_FILE, 'utf8')); } catch { return []; }
|
|
69
|
+
if (!Array.isArray(points)) return [];
|
|
70
|
+
// Self-heal: older points could snapshot whole trees (huge files, hundreds
|
|
71
|
+
// of MB). Compacting here keeps the file small forever after the first load.
|
|
72
|
+
let size = 0;
|
|
73
|
+
let needsCompact = points.length > MAX_POINTS;
|
|
74
|
+
for (const p of points) {
|
|
75
|
+
if (!p || typeof p.files !== 'object') continue;
|
|
76
|
+
for (const c of Object.values(p.files)) {
|
|
77
|
+
if (typeof c === 'string') {
|
|
78
|
+
size += c.length;
|
|
79
|
+
if (c.length > FILE_CAP) needsCompact = true;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// File-level budget: drop the oldest points until the whole store fits.
|
|
84
|
+
// MAX_POINTS caps count; this caps bytes (20 x 2MB snapshots would be 40MB).
|
|
85
|
+
if (size > FILE_BUDGET) {
|
|
86
|
+
needsCompact = true;
|
|
87
|
+
while (points.length > 1 && size > FILE_BUDGET) {
|
|
88
|
+
const oldest = points.shift();
|
|
89
|
+
if (oldest && typeof oldest.files === 'object') {
|
|
90
|
+
for (const c of Object.values(oldest.files)) {
|
|
91
|
+
if (typeof c === 'string') size -= c.length;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (needsCompact) {
|
|
97
|
+
points = points.slice(-MAX_POINTS).map(p => {
|
|
98
|
+
if (!p || typeof p.files !== 'object') return p;
|
|
99
|
+
const files = {};
|
|
100
|
+
for (const [rel, c] of Object.entries(p.files)) {
|
|
101
|
+
if (typeof c === 'string' && c.length > FILE_CAP) files[rel] = null;
|
|
102
|
+
else files[rel] = c;
|
|
103
|
+
}
|
|
104
|
+
return Object.assign({}, p, { files });
|
|
105
|
+
});
|
|
106
|
+
savePoints(points);
|
|
107
|
+
}
|
|
108
|
+
return points;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function savePoints(points) {
|
|
112
|
+
try {
|
|
113
|
+
fs.mkdirSync(path.dirname(RESTORE_FILE), { recursive: true });
|
|
114
|
+
fs.writeFileSync(RESTORE_FILE, JSON.stringify(points, null, 1));
|
|
115
|
+
try { fs.chmodSync(RESTORE_FILE, 0o600); } catch {}
|
|
116
|
+
} catch {}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Create a restore point labeled with the user prompt. Keeps the last
|
|
120
|
+
// MAX_POINTS, oldest dropped.
|
|
121
|
+
export function createRestorePoint(label, base) {
|
|
122
|
+
base = base || cwd;
|
|
123
|
+
const files = snapshotProject(base);
|
|
124
|
+
const point = {
|
|
125
|
+
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
|
126
|
+
at: Date.now(),
|
|
127
|
+
label: String(label || '').slice(0, 80),
|
|
128
|
+
cwd: base,
|
|
129
|
+
files,
|
|
130
|
+
};
|
|
131
|
+
const points = loadPoints();
|
|
132
|
+
points.push(point);
|
|
133
|
+
while (points.length > MAX_POINTS) points.shift();
|
|
134
|
+
savePoints(points);
|
|
135
|
+
return point;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Newest first, for the picker. Only points belonging to the current project
|
|
139
|
+
// (matched by cwd) are shown, so switching projects can't restore the wrong tree.
|
|
140
|
+
export function listRestorePoints(base) {
|
|
141
|
+
base = base || cwd;
|
|
142
|
+
return loadPoints().filter(p => p.cwd === base).slice().reverse();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function getRestorePoint(id) {
|
|
146
|
+
return loadPoints().find(p => p.id === id) || null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Restore the project to the state captured at `id`: overwrite captured files,
|
|
150
|
+
// re-create ones that were deleted since, and remove files that were created
|
|
151
|
+
// after the point. Files recorded as null (too big to snapshot) are left alone.
|
|
152
|
+
// Returns a summary of what changed.
|
|
153
|
+
export function restoreTo(id, base) {
|
|
154
|
+
base = base || cwd;
|
|
155
|
+
const point = getRestorePoint(id);
|
|
156
|
+
if (!point) return { ok: false, error: 'Restore point not found.' };
|
|
157
|
+
|
|
158
|
+
const restored = [];
|
|
159
|
+
const deleted = [];
|
|
160
|
+
const errors = [];
|
|
161
|
+
|
|
162
|
+
// 1. Overwrite / re-create captured files; skip the ones recorded as null
|
|
163
|
+
// (they existed but were not snapshotted — never touch them).
|
|
164
|
+
for (const [rel, content] of Object.entries(point.files)) {
|
|
165
|
+
if (content === null) continue;
|
|
166
|
+
const abs = path.join(base, rel);
|
|
167
|
+
try {
|
|
168
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
169
|
+
fs.writeFileSync(abs, content, 'utf8');
|
|
170
|
+
restored.push(rel);
|
|
171
|
+
} catch (e) {
|
|
172
|
+
errors.push(rel + ': ' + String(e.message || e).slice(0, 80));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 2. Remove files created after the point (not present in the snapshot).
|
|
177
|
+
const nowList = [];
|
|
178
|
+
walkFiles(base, 0, nowList);
|
|
179
|
+
for (const abs of nowList) {
|
|
180
|
+
const rel = path.relative(base, abs).replace(/\\/g, '/');
|
|
181
|
+
if (!(rel in point.files)) {
|
|
182
|
+
try { fs.rmSync(abs); deleted.push(rel); } catch {}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 3. Best-effort cleanup of now-empty directories (max depth 5, safe).
|
|
187
|
+
for (let depth = 5; depth >= 0; depth--) {
|
|
188
|
+
const dirs = [];
|
|
189
|
+
walkDirs(base, 0, depth, dirs);
|
|
190
|
+
for (const d of dirs) {
|
|
191
|
+
try { if (fs.readdirSync(d).length === 0) fs.rmdirSync(d); } catch {}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return { ok: true, restored, deleted, errors, fileCount: Object.keys(point.files).length };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function walkDirs(root, curDepth, maxDepth, out) {
|
|
199
|
+
if (curDepth > maxDepth) return;
|
|
200
|
+
let entries;
|
|
201
|
+
try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return; }
|
|
202
|
+
for (const e of entries) {
|
|
203
|
+
const full = path.join(root, e.name);
|
|
204
|
+
if (IGNORE_RX.test(full)) continue;
|
|
205
|
+
if (e.isDirectory()) { out.push(full); walkDirs(full, curDepth + 1, maxDepth, out); }
|
|
206
|
+
}
|
|
207
|
+
}
|