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,257 @@
1
+ // Graph builder — parses Loom memory files (LOOM.md, .loom/graph/nodes/*.md)
2
+ // into a nodes/edges JSON structure for the /graph command.
3
+ //
4
+ // Per the Loom Graph View Design doc:
5
+ // * Memory files use structured Markdown with frontmatter (type, confidence)
6
+ // * ## headings -> nodes
7
+ // * #tags -> node tags
8
+ // * [[wikilinks]] -> edges between nodes
9
+ //
10
+ // The parser is deliberately regex-only (no full Markdown AST) — memory files
11
+ // are constrained in format, and a regex pass keeps dependencies at zero.
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const os = require('os');
15
+
16
+ /**
17
+ * @typedef {Object} GraphNode
18
+ * @property {string} id
19
+ * @property {string} title
20
+ * @property {string} type
21
+ * @property {number} confidence
22
+ * @property {string[]} tags
23
+ * @property {string} source
24
+ * @property {string} body
25
+ */
26
+
27
+ /**
28
+ * @typedef {Object} GraphEdge
29
+ * @property {string} source
30
+ * @property {string} target
31
+ * @property {string} type
32
+ */
33
+
34
+ /**
35
+ * @typedef {Object} GraphData
36
+ * @property {GraphNode[]} nodes
37
+ * @property {GraphEdge[]} edges
38
+ */
39
+
40
+ /**
41
+ * @typedef {Object} FileDefaults
42
+ * @property {string} [type]
43
+ * @property {number} [confidence]
44
+ */
45
+
46
+ /**
47
+ * @typedef {Object} Heading
48
+ * @property {number} index
49
+ * @property {string} text
50
+ */
51
+
52
+ const FRONTMATTER_RE = /^---\s*\n([\s\S]*?)\n---\s*\n/;
53
+ const HEADING_RE = /^(#{1,3})\s+(.+?)\s*$/gm;
54
+ const WIKILINK_RE = /\[\[([^\]]+)\]\]/g;
55
+ const TAG_RE = /(?:^|\s)#([a-z0-9][a-z0-9-]*)/gi;
56
+ const TYPE_RE = /type:\s*["']?([a-z_]+)["']?/i;
57
+ const CONFIDENCE_RE = /confidence:\s*([0-9.]+)/i;
58
+
59
+ // Slugify a heading into a stable id. Wikilinks use this same slug so a
60
+ // "## Use SQLite" heading can be referenced as [[use-sqlite]] elsewhere.
61
+ function slugify(text) {
62
+ return String(text || '')
63
+ .toLowerCase()
64
+ .replace(/[^\w\s-]/g, '')
65
+ .replace(/\s+/g, '-')
66
+ .replace(/-+/g, '-')
67
+ .replace(/^-|-$/g, '');
68
+ }
69
+
70
+ // Parse one .md file into nodes. "##" sections become top-level nodes;
71
+ // "###" subsections become child nodes with a 'child' edge to their parent
72
+ // "##" — so LOOM.md renders as a real hierarchy, not a flat list.
73
+ /**
74
+ * @param {string} filePath
75
+ * @returns {{ nodes: GraphNode[], edges: GraphEdge[], defaults: FileDefaults }}
76
+ */
77
+ function parseFile(filePath) {
78
+ const raw = fs.readFileSync(filePath, 'utf8');
79
+ /** @type {FileDefaults} */
80
+ const fileDefaults = {};
81
+ const fmMatch = FRONTMATTER_RE.exec(raw);
82
+ if (fmMatch) {
83
+ const fm = fmMatch[1];
84
+ const tm = TYPE_RE.exec(fm);
85
+ if (tm) fileDefaults.type = tm[1];
86
+ const cm = CONFIDENCE_RE.exec(fm);
87
+ if (cm) fileDefaults.confidence = Math.max(0, Math.min(1, parseFloat(cm[1])));
88
+ }
89
+
90
+ /** @type {GraphNode[]} */
91
+ const nodes = [];
92
+ /** @type {GraphEdge[]} */
93
+ const edges = [];
94
+
95
+ // Collect headings with their level (## or ###; # is the file title).
96
+ /** @type {{ level: number, index: number, text: string }[]} */
97
+ const headings = [];
98
+ HEADING_RE.lastIndex = 0;
99
+ let m;
100
+ while ((m = HEADING_RE.exec(raw)) !== null) {
101
+ headings.push({ level: m[1].length, index: m.index, text: m[2] });
102
+ }
103
+
104
+ /** @type {{ level: number, id: string }[]} */
105
+ const stack = [];
106
+ for (let i = 0; i < headings.length; i++) {
107
+ const h = headings[i];
108
+ if (h.level < 2) continue; // skip "# Title"
109
+ // Body runs until the next heading of the same or higher level.
110
+ let next = i + 1;
111
+ while (next < headings.length && headings[next].level > h.level) next++;
112
+ const nl = raw.indexOf('\n', h.index);
113
+ const start = nl === -1 ? raw.length : nl + 1;
114
+ const end = next < headings.length ? headings[next].index : raw.length;
115
+ let body = raw.slice(start, end).trim();
116
+
117
+ // Strip leading frontmatter-like artefacts and horizontal rules.
118
+ body = body.replace(/^---[\s\S]*?---\s*/m, '').replace(/^---\s*$/gm, '');
119
+
120
+ const id = slugify(h.text);
121
+ /** @type {string[]} */
122
+ const tags = [];
123
+ TAG_RE.lastIndex = 0;
124
+ let tm;
125
+ while ((tm = TAG_RE.exec(body)) !== null) {
126
+ const tag = tm[1].toLowerCase();
127
+ if (!tags.includes(tag)) tags.push(tag);
128
+ }
129
+ /** @type {GraphNode} */
130
+ const node = {
131
+ id,
132
+ title: h.text,
133
+ type: fileDefaults.type || 'note',
134
+ confidence: fileDefaults.confidence ?? 0.5,
135
+ tags,
136
+ source: filePath,
137
+ body: body.slice(0, 2000),
138
+ };
139
+ nodes.push(node);
140
+
141
+ // The immediate ancestor (last heading of a strictly lower level)
142
+ // becomes this node's parent — ## -> ###.
143
+ let parent = null;
144
+ for (let j = stack.length - 1; j >= 0; j--) {
145
+ if (stack[j].level < h.level) { parent = stack[j]; break; }
146
+ }
147
+ if (parent) edges.push({ source: parent.id, target: id, type: 'child' });
148
+ while (stack.length && stack[stack.length - 1].level >= h.level) stack.pop();
149
+ stack.push({ level: h.level, id });
150
+
151
+ // Wikilinks in the body become edges from this node to the slugified
152
+ // target. Targets that don't exist as nodes still get a stub so the
153
+ // graph doesn't silently drop the link.
154
+ WIKILINK_RE.lastIndex = 0;
155
+ let wm;
156
+ while ((wm = WIKILINK_RE.exec(body)) !== null) {
157
+ const target = slugify(wm[1]);
158
+ edges.push({ source: id, target, type: 'references' });
159
+ }
160
+ }
161
+
162
+ return { nodes, edges, defaults: fileDefaults };
163
+ }
164
+
165
+ /**
166
+ * Build the full graph for a project directory.
167
+ * Scans: ./LOOM.md, ./.loom/LOOM.md, ~/.loom/LOOM.md, ./.loom/graph/nodes/*.md
168
+ * @param {string} cwd
169
+ * @returns {GraphData}
170
+ */
171
+ function buildGraph(cwd) {
172
+ const roots = [
173
+ path.join(cwd, 'LOOM.md'),
174
+ path.join(cwd, '.loom', 'LOOM.md'),
175
+ path.join(cwd, '.loom', 'graph', 'nodes'),
176
+ path.join(os.homedir(), '.loom', 'LOOM.md'),
177
+ ];
178
+
179
+ /** @type {Map<string, GraphNode>} */
180
+ const nodeMap = new Map();
181
+ /** @type {GraphEdge[]} */
182
+ const edges = [];
183
+
184
+ for (const root of roots) {
185
+ if (!fs.existsSync(root)) continue;
186
+ const stat = fs.statSync(root);
187
+ if (stat.isDirectory()) {
188
+ // .loom/graph/nodes/*.md — one file per node (each is already a node).
189
+ const files = fs.readdirSync(root).filter(f => f.endsWith('.md'));
190
+ for (const f of files) {
191
+ const full = path.join(root, f);
192
+ const parsed = parseFile(full);
193
+ for (const n of parsed.nodes) nodeMap.set(n.id, n);
194
+ edges.push(...parsed.edges);
195
+ }
196
+ } else {
197
+ const parsed = parseFile(root);
198
+ for (const n of parsed.nodes) {
199
+ // Prefer an existing file-scoped node (from .loom/graph/nodes/) over
200
+ // a same-id section in a merged LOOM.md — files are more curated.
201
+ if (!nodeMap.has(n.id)) nodeMap.set(n.id, n);
202
+ }
203
+ edges.push(...parsed.edges);
204
+ }
205
+ }
206
+
207
+ // Stub nodes for wikilink targets that have no backing node — keeps the
208
+ // graph honest about what's referenced but not yet captured.
209
+ /** @type {Set<string>} */
210
+ const stubIds = new Set();
211
+ for (const e of edges) stubIds.add(e.target);
212
+ for (const id of stubIds) {
213
+ if (!nodeMap.has(id)) {
214
+ nodeMap.set(id, {
215
+ id,
216
+ title: id.replace(/-/g, ' '),
217
+ type: 'note',
218
+ confidence: 0.1,
219
+ tags: [],
220
+ source: '(not yet captured)',
221
+ body: '',
222
+ });
223
+ }
224
+ }
225
+
226
+ return {
227
+ nodes: Array.from(nodeMap.values()).sort((a, b) => a.title.localeCompare(b.title)),
228
+ edges,
229
+ };
230
+ }
231
+
232
+ /**
233
+ * Render the graph as a simple text representation for CLI/debugging.
234
+ * @param {{ nodes: GraphNode[], edges: GraphEdge[] }} graph
235
+ * @returns {string}
236
+ */
237
+ function renderGraphText(graph) {
238
+ /** @type {string[]} */
239
+ const lines = [];
240
+ lines.push('Loom Graph — ' + graph.nodes.length + ' nodes, ' + graph.edges.length + ' edges');
241
+ lines.push('');
242
+ for (const n of graph.nodes) {
243
+ const conf = (n.confidence * 100).toFixed(0);
244
+ lines.push(' ' + n.id + ' [' + n.type + '] (' + conf + '%) ' + (n.tags.length ? '#' + n.tags.join(' #') : ''));
245
+ lines.push(' ' + n.title);
246
+ }
247
+ if (graph.edges.length) {
248
+ lines.push('');
249
+ lines.push('Edges:');
250
+ for (const e of graph.edges) {
251
+ lines.push(' ' + e.source + ' --(' + e.type + ')--> ' + e.target);
252
+ }
253
+ }
254
+ return lines.join('\n');
255
+ }
256
+
257
+ module.exports = { buildGraph, parseFile, slugify, renderGraphText };
@@ -0,0 +1,82 @@
1
+ // Lifecycle hooks — user-configured shell commands fired at key points in the
2
+ // agent loop (Claude Code-style). Config lives in config.json:
3
+ //
4
+ // "hooks": {
5
+ // "preToolUse": "node .loom/hooks/guard.js", // may BLOCK the tool call
6
+ // "postToolUse": "node .loom/hooks/audit.js", // informational
7
+ // "stop": "node .loom/hooks/notify.js" // fires when a turn ends
8
+ // }
9
+ //
10
+ // Contract: the command receives JSON on stdin ({ hook, tool, input } for
11
+ // pre/post, { hook, reason } for stop) plus LOOM_HOOK / LOOM_TOOL /
12
+ // LOOM_TOOL_INPUT env vars (Windows one-liner friendly). Exit code non-zero,
13
+ // or stdout containing {"decision":"deny","reason":"..."}, blocks a
14
+ // preToolUse hook's tool call. A 10s timeout counts as a failure-to-run
15
+ // (allowed through) rather than a deny — hooks must not wedge the loop.
16
+ const { spawn } = require('child_process');
17
+ const { loadConfig } = require('../config/settings');
18
+
19
+ /** @typedef {'preToolUse'|'postToolUse'|'stop'} HookName */
20
+
21
+ /**
22
+ * Run one configured hook.
23
+ * @param {HookName} name
24
+ * @param {{ tool?: string, input?: object, reason?: string }} payload
25
+ * @returns {Promise<{ blocked: boolean, reason?: string, ran: boolean }>}
26
+ */
27
+ async function runHook(name, payload) {
28
+ let cmd = '';
29
+ try {
30
+ const cfg = loadConfig();
31
+ cmd = String((cfg.hooks || {})[name] || '');
32
+ } catch {}
33
+ if (!cmd.trim()) return { blocked: false, ran: false };
34
+
35
+ return await new Promise((resolve) => {
36
+ let out = '';
37
+ let settled = false;
38
+ const done = (r) => { if (!settled) { settled = true; resolve(r); } };
39
+ let child;
40
+ try {
41
+ child = spawn(cmd, {
42
+ shell: true,
43
+ windowsHide: true,
44
+ env: Object.assign({}, process.env, {
45
+ LOOM_HOOK: name,
46
+ LOOM_TOOL: payload.tool || '',
47
+ LOOM_TOOL_INPUT: payload.input ? JSON.stringify(payload.input) : '',
48
+ }),
49
+ stdio: ['pipe', 'pipe', 'pipe'],
50
+ });
51
+ } catch {
52
+ done({ blocked: false, ran: false });
53
+ return;
54
+ }
55
+ const timer = setTimeout(() => {
56
+ try { child.kill(); } catch {}
57
+ done({ blocked: false, ran: true });
58
+ }, 10000);
59
+ try { child.stdin.write(JSON.stringify({ hook: name, ...payload })); child.stdin.end(); } catch {}
60
+ child.stdout.on('data', (d) => { out += String(d); });
61
+ child.on('error', () => { clearTimeout(timer); done({ blocked: false, ran: false }); });
62
+ child.on('close', (code) => {
63
+ clearTimeout(timer);
64
+ // Structured deny wins over exit codes; plain non-zero also blocks preToolUse.
65
+ let denyReason = null;
66
+ const m = out.match(/\{[\s\S]*\}/);
67
+ if (m) {
68
+ try {
69
+ const j = JSON.parse(m[0]);
70
+ if (j && j.decision === 'deny') denyReason = String(j.reason || 'blocked by hook');
71
+ } catch {}
72
+ }
73
+ if (name === 'preToolUse' && (denyReason || code !== 0)) {
74
+ done({ blocked: true, reason: denyReason || ('hook exited ' + code), ran: true });
75
+ return;
76
+ }
77
+ done({ blocked: false, ran: true });
78
+ });
79
+ });
80
+ }
81
+
82
+ module.exports = { runHook };