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.
Files changed (79) hide show
  1. package/.env.example +25 -0
  2. package/CHANGELOG.md +402 -0
  3. package/LICENSE +21 -0
  4. package/LOOM.md +235 -0
  5. package/README.md +433 -0
  6. package/bin/loom-tui.js +43 -0
  7. package/bin/loom.js +44 -0
  8. package/docs/acp.md +151 -0
  9. package/docs/web.md +205 -0
  10. package/package.json +97 -0
  11. package/scripts/acp-smoke.js +146 -0
  12. package/src/acp/acp-server.js +287 -0
  13. package/src/config/provider-cmd.js +37 -0
  14. package/src/config/settings.js +164 -0
  15. package/src/core/agents.js +361 -0
  16. package/src/core/background-tasks.js +103 -0
  17. package/src/core/cli.js +579 -0
  18. package/src/core/custom-commands.js +70 -0
  19. package/src/core/errors.js +29 -0
  20. package/src/core/events.js +24 -0
  21. package/src/core/file-diffs.js +282 -0
  22. package/src/core/format.js +206 -0
  23. package/src/core/graph.js +257 -0
  24. package/src/core/hooks.js +82 -0
  25. package/src/core/lsp.js +385 -0
  26. package/src/core/memory.js +87 -0
  27. package/src/core/model-router.js +87 -0
  28. package/src/core/permissions.js +327 -0
  29. package/src/core/platform.js +33 -0
  30. package/src/core/plugin-cmd.js +380 -0
  31. package/src/core/restore.js +207 -0
  32. package/src/core/session-store.js +167 -0
  33. package/src/core/session.js +910 -0
  34. package/src/core/subagent-log.js +134 -0
  35. package/src/core/tokens.js +31 -0
  36. package/src/core/update.js +6 -0
  37. package/src/core/usage.js +166 -0
  38. package/src/index.js +41 -0
  39. package/src/mcp/mcp-client.js +201 -0
  40. package/src/mcp/mcp-manager.js +193 -0
  41. package/src/providers/anthropic.js +243 -0
  42. package/src/providers/google.js +29 -0
  43. package/src/providers/index.js +175 -0
  44. package/src/providers/local.js +27 -0
  45. package/src/providers/nvidia.js +85 -0
  46. package/src/providers/openai-compat.js +269 -0
  47. package/src/providers/openai.js +35 -0
  48. package/src/providers/openrouter.js +43 -0
  49. package/src/providers/registry.js +196 -0
  50. package/src/providers/tokenrouter.js +19 -0
  51. package/src/skills/skill-matcher.js +133 -0
  52. package/src/skills/skills-manager.js +213 -0
  53. package/src/tools/index.js +543 -0
  54. package/src/tui/App.tsx +1578 -0
  55. package/src/tui/components/BreadcrumbBar.tsx +34 -0
  56. package/src/tui/components/ChatArea.tsx +518 -0
  57. package/src/tui/components/InputBar.tsx +354 -0
  58. package/src/tui/components/MdText.tsx +105 -0
  59. package/src/tui/components/Modals.tsx +851 -0
  60. package/src/tui/components/PermissionPopup.tsx +264 -0
  61. package/src/tui/components/Sidebar.tsx +182 -0
  62. package/src/tui/components/SplashScreen.tsx +51 -0
  63. package/src/tui/components/SubagentPanel.tsx +217 -0
  64. package/src/tui/components/ToastOverlay.tsx +34 -0
  65. package/src/tui/keybinds.ts +318 -0
  66. package/src/tui/mcp-presets.ts +189 -0
  67. package/src/tui/md-render.ts +228 -0
  68. package/src/tui/store.ts +714 -0
  69. package/src/tui/suite-home.ts +20 -0
  70. package/src/tui/theme.ts +313 -0
  71. package/src/tui/themes.generated.ts +968 -0
  72. package/src/tui/tool-display.ts +176 -0
  73. package/src/tui/toolname.ts +60 -0
  74. package/src/tui/tui-config.ts +28 -0
  75. package/src/tui-open.tsx +51 -0
  76. package/src/web/attach.js +242 -0
  77. package/src/web/graph-view.html +262 -0
  78. package/src/web/index.html +824 -0
  79. package/src/web/web-server.js +470 -0
@@ -0,0 +1,133 @@
1
+ // Skill matcher — Phase 2.2. Finds installed skills whose keywords overlap with
2
+ // the user's current message. Cheap: zero model calls, pure heuristics on
3
+ // frontmatter + skill dir names + file-type keywords.
4
+ //
5
+ // The idea: a skill is "triggered" when its name, filename, or description
6
+ // mentions a token in the message (normalized to lowercase, word-boundary).
7
+ // The list is deterministic and sorted by match count (strongest first).
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const { listSkills } = require('./skills-manager');
11
+
12
+ // Keyword aliases: user-typed word -> keyword that matches in the skill name/description.
13
+ const KEYWORD_ALIASES = {
14
+ gcode: ['gcode', 'slice', 'slicer', 'print', 'fdm', '3d print'],
15
+ cad: ['cad', '3d', 'step', 'stp', 'dxf', 'stl', 'glb', 'parametric', 'model'],
16
+ bambulab: ['bambu', 'print', 'prusa', 'lan', '3d print'],
17
+ sring: ['srdf', 'urdf', 'robot', 'ros', 'gazebo', 'joint', 'link', 'moveit'],
18
+ gcodeviewer: ['gcode', 'slice', 'viewer'],
19
+ stepviewer: ['step', 'stp', 'cad', 'viewer'],
20
+ stlviewer: ['stl', '3d', 'viewer'],
21
+ payment: ['payment', 'pay', 'checkout', 'stripe', 'sepay', 'license', 'billing'],
22
+ theme: ['theme', 'color scheme', 'palette', 'dark', 'light'],
23
+ };
24
+
25
+ function normalizeWord(w) {
26
+ return w.toLowerCase().replace(/[^a-z0-9._-]/g, '');
27
+ }
28
+
29
+ function buildAliasMap() {
30
+ const aliases = {};
31
+ for (const [skillName, keywords] of Object.entries(KEYWORD_ALIASES)) {
32
+ for (const kw of keywords) {
33
+ if (!aliases[kw]) aliases[kw] = new Set();
34
+ aliases[kw].add(skillName);
35
+ }
36
+ }
37
+ // also map aliases for dir names
38
+ for (const [skillName, keywords] of Object.entries(KEYWORD_ALIASES)) {
39
+ const dir = skillName;
40
+ for (const kw of keywords) {
41
+ if (!aliases[kw]) aliases[kw] = new Set();
42
+ aliases[kw].add(dir);
43
+ }
44
+ }
45
+ return aliases;
46
+ }
47
+
48
+ const ALIASES = buildAliasMap();
49
+
50
+ // Split the user message into normalized "words" (strip punctuation, lowercase,
51
+ // split on whitespace). Common stop words are dropped so "a" or "this" can't trigger.
52
+ const STOP_WORDS = new Set(['a', 'an', 'the', 'this', 'that', 'these', 'those', 'i', 'you', 'he', 'she', 'we', 'they', 'it', 'to', 'in', 'on', 'at', 'for', 'with', 'and', 'or', 'of', 'is', 'are', 'be', 'my', 'me', 'your', 'that\'s', 'i\'m', 'how', 'what', 'why', 'when', 'where', 'show', 'tell', 'please', 'can', 'do', 'does', 'will', 'need', 'want', 'make', 'use', 'get']);
53
+
54
+ function tokenize(text) {
55
+ if (!text) return [];
56
+ return String(text)
57
+ .split(/\s+/)
58
+ .map(normalizeWord)
59
+ .filter((w) => w.length >= 3 && !STOP_WORDS.has(w));
60
+ }
61
+
62
+ // Score a single skill against a word list. Higher = stronger match.
63
+ function scoreSkill(skill, wordSet) {
64
+ const name = String(skill.name || '').toLowerCase();
65
+ const desc = String(skill.description || '').toLowerCase();
66
+ const dirName = path.basename(String(skill.dir || '')).toLowerCase();
67
+ const haystack = name + ' ' + desc + ' ' + dirName;
68
+ let score = 0;
69
+ let matched = [];
70
+ for (const w of wordSet) {
71
+ // direct containment check (substring covers plurals: "slicing" contains "slice")
72
+ if (haystack.includes(w)) {
73
+ score += 1;
74
+ matched.push(w);
75
+ continue;
76
+ }
77
+ // alias-word -> skill matches via alias expansion
78
+ if (ALIASES[w]) {
79
+ for (const alias of ALIASES[w]) {
80
+ if (haystack.includes(alias)) {
81
+ score += 1.5; // alias match > generic word
82
+ matched.push(`${w}→${alias}`);
83
+ break;
84
+ }
85
+ }
86
+ }
87
+ }
88
+ if (score === 0) return null;
89
+ return { skill, score, matched };
90
+ }
91
+
92
+ // Cache SKILL.md instructions keyed by dir path (with mtime invalidation) so we
93
+ // only re-read when the file changes.
94
+ const _instrCache = new Map();
95
+ function loadInstructions(dir) {
96
+ try {
97
+ const key = String(dir);
98
+ const p = path.join(dir, 'SKILL.md');
99
+ const stat = fs.statSync(p);
100
+ const mtime = stat.mtimeMs;
101
+ const cached = _instrCache.get(key);
102
+ if (cached && cached.mtime === mtime) return cached.instructions;
103
+ const instructions = fs.readFileSync(p, 'utf8').slice(0, 8000);
104
+ _instrCache.set(key, { mtime, instructions });
105
+ return instructions;
106
+ } catch { return ''; }
107
+ }
108
+
109
+ // Return the top-N triggered skills for a user message.
110
+ // `skillsList` can inject a fake set for testing.
111
+ function match(message, skillsList) {
112
+ const words = tokenize(message);
113
+ const wordSet = new Set(words);
114
+ const skills = skillsList || listSkills();
115
+ const out = [];
116
+ for (const s of skills) {
117
+ const hit = scoreSkill(s, wordSet);
118
+ if (!hit) continue;
119
+ // Require at least 2 matching words to avoid spurious one-word triggers like "print"
120
+ if (hit.score < 2) continue;
121
+ out.push(hit);
122
+ }
123
+ out.sort((a, b) => b.score - a.score);
124
+ // Attach the full SKILL.md instructions to each winner so the session can
125
+ // inject them into the system prompt. Lazy-load + mtime-cache per dir.
126
+ const top = out.slice(0, 3);
127
+ for (const hit of top) {
128
+ if (hit.skill.instructions === undefined) hit.skill.instructions = loadInstructions(hit.skill.dir);
129
+ }
130
+ return top.map((h) => h.skill);
131
+ }
132
+
133
+ module.exports = { match, tokenize, scoreSkill };
@@ -0,0 +1,213 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const { execSync } = require('child_process');
5
+
6
+ function validateUrl(url) {
7
+ try {
8
+ const u = new URL(url);
9
+ if (!['git:', 'http:', 'https:'].includes(u.protocol)) return false;
10
+ return true;
11
+ } catch { return false; }
12
+ }
13
+
14
+ const LOOM_DIR = path.join(os.homedir(), '.loom');
15
+
16
+ function globalSkillsDir() {
17
+ return path.join(process.env.LOOM_CONFIG_DIR || LOOM_DIR, 'skills');
18
+ }
19
+
20
+ // Third-party agent skills installed elsewhere on the machine (read-only):
21
+ // skills the user already has in ~/.agents/skills show up in the browser and
22
+ // can be toggled, but install/remove always target ~/.loom/skills.
23
+ function agentsSkillsDir() {
24
+ return path.join(process.env.LOOM_AGENTS_DIR || os.homedir(), '.agents', 'skills');
25
+ }
26
+
27
+ function trustFile() {
28
+ return path.join(process.env.LOOM_CONFIG_DIR || LOOM_DIR, 'skills-trust.json');
29
+ }
30
+
31
+ function loadTrust() {
32
+ try {
33
+ return JSON.parse(fs.readFileSync(trustFile(), 'utf8')) || {};
34
+ } catch {
35
+ return {};
36
+ }
37
+ }
38
+
39
+ function saveTrust(trust) {
40
+ fs.mkdirSync(path.dirname(trustFile()), { recursive: true });
41
+ fs.writeFileSync(trustFile(), JSON.stringify(trust, null, 2));
42
+ }
43
+
44
+ // Remote skills are injected into the system prompt and their instructions run
45
+ // with full tool access, so the first install from a source must be explicitly
46
+ // approved and the approval is pinned to the exact commit that was reviewed.
47
+ // Re-installing the same URL with different content requires re-approval.
48
+ const defaultGit = {
49
+ clone(url, tmp) {
50
+ execSync('git clone --depth 1 ' + url + ' ' + tmp, { stdio: 'ignore', windowsHide: true });
51
+ },
52
+ revParse(tmp) {
53
+ return execSync('git -C ' + tmp + ' rev-parse HEAD', { encoding: 'utf8', windowsHide: true }).trim();
54
+ },
55
+ };
56
+
57
+ function projectSkillsDir() {
58
+ return path.join(process.cwd(), '.loom', 'skills');
59
+ }
60
+
61
+ function skillDirs() {
62
+ const dirs = [globalSkillsDir(), agentsSkillsDir(), projectSkillsDir()];
63
+ const seen = new Set();
64
+ const out = [];
65
+ for (const d of dirs) {
66
+ if (seen.has(d)) continue;
67
+ seen.add(d);
68
+ if (fs.existsSync(d)) out.push(d);
69
+ }
70
+ return out;
71
+ }
72
+
73
+ function parseFrontmatter(text) {
74
+ const nameMatch = text.match(/^name:\s*(.+)$/m);
75
+ const descMatch = text.match(/^description:\s*(.+)$/m);
76
+ return {
77
+ name: nameMatch ? nameMatch[1].trim() : null,
78
+ description: descMatch ? descMatch[1].trim() : '',
79
+ };
80
+ }
81
+
82
+ function listSkills() {
83
+ const found = [];
84
+ for (const dir of skillDirs()) {
85
+ let entries;
86
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
87
+ for (const e of entries) {
88
+ if (!e.isDirectory()) continue;
89
+ const skillMd = path.join(dir, e.name, 'SKILL.md');
90
+ if (!fs.existsSync(skillMd)) continue;
91
+ let raw = '';
92
+ try { raw = fs.readFileSync(skillMd, 'utf8').slice(0, 4000); } catch { continue; }
93
+ const meta = parseFrontmatter(raw);
94
+ found.push({
95
+ name: meta.name || e.name,
96
+ dir: path.join(dir, e.name),
97
+ description: meta.description || '(no description)',
98
+ source: dir === globalSkillsDir() ? 'global' : (dir === agentsSkillsDir() ? 'agents' : 'project'),
99
+ });
100
+ }
101
+ }
102
+ return found;
103
+ }
104
+
105
+ function isInstalled(name) {
106
+ return listSkills().some((s) => s.name === name || path.basename(s.dir) === name);
107
+ }
108
+
109
+ function installFrom(srcDir, targetName) {
110
+ const src = path.resolve(srcDir);
111
+ if (!fs.existsSync(path.join(src, 'SKILL.md'))) {
112
+ return { error: `No SKILL.md in ${src}` };
113
+ }
114
+ const name = targetName || path.basename(src);
115
+ const dest = path.join(globalSkillsDir(), name);
116
+ if (fs.existsSync(dest)) {
117
+ fs.rmSync(dest, { recursive: true, force: true });
118
+ }
119
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
120
+ fs.cpSync(src, dest, { recursive: true });
121
+ return { installed: true, name: name, dir: dest };
122
+ }
123
+
124
+ function findSkillIn(root) {
125
+ const candidates = [path.join(root, 'SKILL.md')];
126
+ const subs = ['skill', 'skills', '.loom/skills', 'packages'];
127
+ for (const sub of subs) candidates.push(path.join(root, sub, 'SKILL.md'));
128
+ const existing = candidates.find((c) => fs.existsSync(c));
129
+ if (existing) return path.dirname(existing);
130
+ for (const name of fs.readdirSync(root, { withFileTypes: true })) {
131
+ if (!name.isDirectory()) continue;
132
+ const p = path.join(root, name.name, 'SKILL.md');
133
+ if (fs.existsSync(p)) return path.dirname(p);
134
+ }
135
+ return root;
136
+ }
137
+
138
+ function cloneFromGit(url, targetName, opts, git) {
139
+ if (!validateUrl(url)) throw new Error(`Invalid git URL: ${url}`);
140
+ const base = url.split('/').pop().replace(/\.git$/, '');
141
+ const name = targetName || base;
142
+ const tmp = path.join(os.tmpdir(), 'loom-skill-' + Date.now());
143
+ const impl = git || defaultGit;
144
+ try {
145
+ impl.clone(url, tmp);
146
+ const commit = impl.revParse(tmp);
147
+ const trust = loadTrust();
148
+ const record = trust[url];
149
+ // A string trust value is an approval bound to one specific commit (the
150
+ // one the user was shown). If HEAD moved since, refuse — re-review needed.
151
+ if (typeof opts.trust === 'string' && opts.trust !== commit) {
152
+ return {
153
+ error: 'Remote content changed since it was presented for approval',
154
+ trustRequired: { url, commit, previous: opts.trust },
155
+ };
156
+ }
157
+ if (!opts || !opts.trust) {
158
+ if (!record) {
159
+ return {
160
+ error: 'Untrusted remote skill: ' + url,
161
+ trustRequired: { url, commit },
162
+ };
163
+ }
164
+ if (record.commit !== commit) {
165
+ return {
166
+ error: 'Skill content changed since it was approved',
167
+ trustRequired: { url, commit, previous: record.commit, approvedAt: record.approvedAt },
168
+ };
169
+ }
170
+ }
171
+ const found = findSkillIn(tmp);
172
+ const res = installFrom(found, name);
173
+ if (res.error) return res;
174
+ trust[url] = { commit, approvedAt: new Date().toISOString() };
175
+ saveTrust(trust);
176
+ return res;
177
+ } finally {
178
+ if (fs.existsSync(tmp)) fs.rmSync(tmp, { recursive: true, force: true });
179
+ }
180
+ }
181
+
182
+ function installSkill(name, targetName, opts, git) {
183
+ if (!fs.existsSync(globalSkillsDir())) fs.mkdirSync(globalSkillsDir(), { recursive: true });
184
+ let src = name;
185
+ if (src.startsWith('file:')) src = src.slice(5);
186
+ if (src.startsWith('git') || src.startsWith('http')) {
187
+ return cloneFromGit(src, targetName, opts, git);
188
+ }
189
+ const found = findSkillIn(path.resolve(src));
190
+ return installFrom(found, targetName || path.basename(path.resolve(src)));
191
+ }
192
+
193
+ function removeSkill(name) {
194
+ // Only ~/.loom/skills is writable; agents-dir and project-dir skills are
195
+ // read-only and must never be deleted from here.
196
+ const p = path.join(globalSkillsDir(), name);
197
+ if (fs.existsSync(p)) {
198
+ fs.rmSync(p, { recursive: true, force: true });
199
+ return { removed: p };
200
+ }
201
+ return { error: 'Skill not installed: ' + name };
202
+ }
203
+
204
+ module.exports = {
205
+ listSkills,
206
+ isInstalled,
207
+ installSkill,
208
+ installFrom,
209
+ removeSkill,
210
+ globalSkillsDir,
211
+ agentsSkillsDir,
212
+ projectSkillsDir,
213
+ };