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,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 };
|