loom-agent 1.2.36 → 1.3.2
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/CHANGELOG.md +128 -0
- package/README.md +38 -56
- package/bin/loom-bun.js +2 -10
- package/bin/loom.js +0 -10
- package/package.json +126 -126
- package/src/connectors/convex.js +356 -0
- package/src/connectors/deploy.js +390 -0
- package/src/connectors/index.js +23 -0
- package/src/connectors/supabase.js +279 -0
- package/src/core/agents.js +32 -2
- package/src/core/cli.js +30 -5
- package/src/core/permissions.js +12 -1
- package/src/core/plugin-cmd.js +1 -0
- package/src/core/sandbox.js +400 -0
- package/src/core/session-store.js +245 -167
- package/src/core/session.js +157 -11
- package/src/providers/anthropic.js +10 -5
- package/src/providers/openai-compat.js +12 -4
- package/src/tools/browser.js +204 -0
- package/src/tools/index.js +35 -1
- package/src/tui/App.tsx +103 -8
- package/src/tui/components/BreadcrumbBar.tsx +10 -9
- package/src/tui/components/ChatArea.tsx +3 -3
- package/src/tui/components/InputBar.tsx +22 -11
- package/src/tui/components/MdText.tsx +2 -1
- package/src/tui/components/Modals.tsx +191 -17
- package/src/tui/components/PermissionPopup.tsx +15 -12
- package/src/tui/components/Sidebar.tsx +6 -2
- package/src/tui/components/SplashScreen.tsx +17 -24
- package/src/tui/components/SubagentPanel.tsx +1 -1
- package/src/tui/components/ToastOverlay.tsx +23 -12
- package/src/tui/store.ts +38 -35
- package/src/tui/theme.ts +45 -30
- package/src/tui/tool-display.ts +13 -2
- package/docs/web.md +0 -205
- package/src/web/attach.js +0 -242
- package/src/web/graph-view.html +0 -262
- package/src/web/index.html +0 -824
- package/src/web/web-server.js +0 -535
|
@@ -1,167 +1,245 @@
|
|
|
1
|
-
const fs = require('fs');
|
|
2
|
-
const path = require('path');
|
|
3
|
-
const os = require('os');
|
|
4
|
-
|
|
5
|
-
const SESSION_SCHEMA_VERSION =
|
|
6
|
-
|
|
7
|
-
const LOOM_DIR = path.join(os.homedir(), '.loom');
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
schemaVersion:
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
|
|
5
|
+
const SESSION_SCHEMA_VERSION = 3;
|
|
6
|
+
|
|
7
|
+
const LOOM_DIR = path.join(os.homedir(), '.loom');
|
|
8
|
+
|
|
9
|
+
// Re-evaluated on every call so tests can isolate with LOOM_CONFIG_DIR.
|
|
10
|
+
// Layout is claude-code/opencode style: one folder per session inside the
|
|
11
|
+
// PROJECT's .loom/ directory:
|
|
12
|
+
//
|
|
13
|
+
// <project>/.loom/sessions/<id>/session.json metadata
|
|
14
|
+
// <project>/.loom/sessions/<id>/messages.jsonl one message per line
|
|
15
|
+
//
|
|
16
|
+
// Flat legacy files (~/.loom/sessions/<id>.json, schema v1/v2) stay
|
|
17
|
+
// readable; the next saveSession migrates them into the project folder.
|
|
18
|
+
function baseDir() {
|
|
19
|
+
if (process.env.LOOM_CONFIG_DIR) return path.join(process.env.LOOM_CONFIG_DIR, 'sessions');
|
|
20
|
+
return path.join(process.cwd(), '.loom', 'sessions');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The pre-v3 global store. Same as baseDir() under LOOM_CONFIG_DIR (tests),
|
|
24
|
+
// the user's old ~/.loom/sessions otherwise.
|
|
25
|
+
function legacyDir() {
|
|
26
|
+
if (process.env.LOOM_CONFIG_DIR) return path.join(process.env.LOOM_CONFIG_DIR, 'sessions');
|
|
27
|
+
return path.join(LOOM_DIR, 'sessions');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function ensureDir() {
|
|
31
|
+
const d = baseDir();
|
|
32
|
+
if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function convId() {
|
|
36
|
+
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Session IDs become folder names — keep them strictly alphanumeric to rule
|
|
40
|
+
// out path traversal via loadSession/deleteSession (e.g. "../../secrets").
|
|
41
|
+
const ID_RX = /^[a-zA-Z0-9_-]{3,128}$/;
|
|
42
|
+
function isValidSessionId(id) {
|
|
43
|
+
return typeof id === 'string' && ID_RX.test(id);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Coerce whatever came off disk (hand-edited, corrupt, older schema) into the
|
|
47
|
+
// guaranteed current shape: { id, createdAt, schemaVersion, messages } with
|
|
48
|
+
// every message having { role, content } while preserving any extra fields.
|
|
49
|
+
// Snake-case tool fields from old saves are aliased to camelCase so a resumed
|
|
50
|
+
// session can always rebuild a valid provider payload (toolCallId is sent as
|
|
51
|
+
// tool_call_id / tool_use_id — losing it 400s on strict providers).
|
|
52
|
+
function normalizeSession(data) {
|
|
53
|
+
if (!data || typeof data !== 'object') return null;
|
|
54
|
+
const raw = Array.isArray(data.messages) ? data.messages : [];
|
|
55
|
+
const messages = raw.map((m) => {
|
|
56
|
+
if (!m || typeof m !== 'object') return { role: 'user', content: '' };
|
|
57
|
+
const { role, content, ...rest } = m;
|
|
58
|
+
const out = { role: role || 'user', content: typeof content === 'undefined' ? '' : content, ...rest };
|
|
59
|
+
if (!out.toolCalls && Array.isArray(rest.tool_calls)) out.toolCalls = rest.tool_calls;
|
|
60
|
+
if (!out.toolCallId && rest.tool_call_id) out.toolCallId = rest.tool_call_id;
|
|
61
|
+
return out;
|
|
62
|
+
});
|
|
63
|
+
return {
|
|
64
|
+
id: typeof data.id === 'string' ? data.id : null,
|
|
65
|
+
createdAt: typeof data.createdAt === 'string' ? data.createdAt : null,
|
|
66
|
+
schemaVersion: typeof data.schemaVersion === 'number' ? data.schemaVersion : 1,
|
|
67
|
+
messages,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function sessionFolder(id) {
|
|
72
|
+
return path.join(baseDir(), id);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function readFolderSession(id) {
|
|
76
|
+
const dir = sessionFolder(id);
|
|
77
|
+
const metaFile = path.join(dir, 'session.json');
|
|
78
|
+
if (!fs.existsSync(metaFile)) return null;
|
|
79
|
+
try {
|
|
80
|
+
const meta = JSON.parse(fs.readFileSync(metaFile, 'utf8'));
|
|
81
|
+
const messages = [];
|
|
82
|
+
const msgFile = path.join(dir, 'messages.jsonl');
|
|
83
|
+
if (fs.existsSync(msgFile)) {
|
|
84
|
+
const lines = fs.readFileSync(msgFile, 'utf8').split('\n');
|
|
85
|
+
for (const line of lines) {
|
|
86
|
+
const t = line.trim();
|
|
87
|
+
if (!t) continue;
|
|
88
|
+
try { messages.push(JSON.parse(t)); } catch {}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const norm = normalizeSession({ ...meta, messages });
|
|
92
|
+
if (!norm) return null;
|
|
93
|
+
norm.id = norm.id || id;
|
|
94
|
+
norm.file = metaFile;
|
|
95
|
+
norm.dir = dir;
|
|
96
|
+
return norm;
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function readFlatSession(dir, id) {
|
|
103
|
+
const f = path.join(dir, id + '.json');
|
|
104
|
+
if (!fs.existsSync(f)) return null;
|
|
105
|
+
try {
|
|
106
|
+
const raw = JSON.parse(fs.readFileSync(f, 'utf8'));
|
|
107
|
+
const norm = normalizeSession(raw);
|
|
108
|
+
if (!norm) return null;
|
|
109
|
+
norm.id = norm.id || id;
|
|
110
|
+
norm.file = f;
|
|
111
|
+
return norm;
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function saveSession(session) {
|
|
118
|
+
ensureDir();
|
|
119
|
+
const id = isValidSessionId(session.conversationId) ? session.conversationId : convId();
|
|
120
|
+
const now = new Date().toISOString();
|
|
121
|
+
// Overwriting an existing session keeps its original createdAt (it is the
|
|
122
|
+
// same conversation); updatedAt always reflects this write.
|
|
123
|
+
let createdAt = now;
|
|
124
|
+
const prev = readFolderSession(id) || readFlatSession(baseDir(), id) || readFlatSession(legacyDir(), id);
|
|
125
|
+
if (prev && typeof prev.createdAt === 'string') createdAt = prev.createdAt;
|
|
126
|
+
const messages = session.messages || [];
|
|
127
|
+
const data = {
|
|
128
|
+
id,
|
|
129
|
+
schemaVersion: SESSION_SCHEMA_VERSION,
|
|
130
|
+
createdAt,
|
|
131
|
+
updatedAt: now,
|
|
132
|
+
provider: session.config ? session.config.provider : null,
|
|
133
|
+
model: session.config && session.config.model ? session.config.model[session.config.provider] : null,
|
|
134
|
+
messageCount: messages.length,
|
|
135
|
+
};
|
|
136
|
+
const dir = sessionFolder(id);
|
|
137
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
138
|
+
const metaFile = path.join(dir, 'session.json');
|
|
139
|
+
// Write messages first, metadata last: a crash mid-save leaves the old
|
|
140
|
+
// session.json pointing at a complete messages file, never the reverse.
|
|
141
|
+
const msgFile = path.join(dir, 'messages.jsonl');
|
|
142
|
+
const tmp = msgFile + '.tmp';
|
|
143
|
+
fs.writeFileSync(tmp, messages.map((m) => JSON.stringify(m)).join('\n') + (messages.length ? '\n' : ''));
|
|
144
|
+
fs.renameSync(tmp, msgFile);
|
|
145
|
+
fs.writeFileSync(metaFile, JSON.stringify(data, null, 2));
|
|
146
|
+
return { id, file: metaFile, dir };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function listDir(dir, fromFolder) {
|
|
150
|
+
let names;
|
|
151
|
+
try { names = fs.readdirSync(dir); } catch { return []; }
|
|
152
|
+
const out = [];
|
|
153
|
+
for (const name of names) {
|
|
154
|
+
if (fromFolder) {
|
|
155
|
+
const entry = readFolderSession(name);
|
|
156
|
+
if (entry && isValidSessionId(entry.id)) out.push(entry);
|
|
157
|
+
} else if (name.endsWith('.json')) {
|
|
158
|
+
const id = name.replace(/\.json$/, '');
|
|
159
|
+
const entry = readFlatSession(dir, id);
|
|
160
|
+
if (entry && isValidSessionId(entry.id)) out.push(entry);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function listSessions() {
|
|
167
|
+
ensureDir();
|
|
168
|
+
// Newest first by the last write time (updatedAt, else createdAt). Folder
|
|
169
|
+
// sessions shadow same-id flat files (a migrated session must not appear
|
|
170
|
+
// twice). Legacy ~/.loom/sessions files stay listed so old history remains
|
|
171
|
+
// reachable after upgrading to the per-project folder layout.
|
|
172
|
+
const byId = new Map();
|
|
173
|
+
for (const s of listDir(legacyDir(), false)) byId.set(s.id, s);
|
|
174
|
+
for (const s of listDir(baseDir(), false)) byId.set(s.id, s);
|
|
175
|
+
for (const s of listDir(baseDir(), true)) byId.set(s.id, s);
|
|
176
|
+
const list = [...byId.values()].map((data) => {
|
|
177
|
+
let mtime = null;
|
|
178
|
+
try { mtime = fs.statSync(data.file).mtime.toISOString(); } catch {}
|
|
179
|
+
return {
|
|
180
|
+
id: data.id,
|
|
181
|
+
createdAt: data.createdAt,
|
|
182
|
+
updatedAt: data.updatedAt,
|
|
183
|
+
mtime,
|
|
184
|
+
messageCount: data.messageCount || (data.messages || []).length,
|
|
185
|
+
provider: data.provider,
|
|
186
|
+
model: data.model,
|
|
187
|
+
};
|
|
188
|
+
});
|
|
189
|
+
const stamp = (s) => s.updatedAt || s.createdAt || s.mtime || '';
|
|
190
|
+
return list.sort((a, b) => (stamp(b) < stamp(a) ? -1 : stamp(b) > stamp(a) ? 1 : 0));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function loadSession(id) {
|
|
194
|
+
if (!isValidSessionId(id)) return null;
|
|
195
|
+
return readFolderSession(id) || readFlatSession(baseDir(), id) || readFlatSession(legacyDir(), id);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function deleteSession(id) {
|
|
199
|
+
if (!isValidSessionId(id)) return false;
|
|
200
|
+
let removed = false;
|
|
201
|
+
const dir = sessionFolder(id);
|
|
202
|
+
if (fs.existsSync(dir)) {
|
|
203
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); removed = true; } catch {}
|
|
204
|
+
}
|
|
205
|
+
for (const d of new Set([baseDir(), legacyDir()])) {
|
|
206
|
+
const f = path.join(d, id + '.json');
|
|
207
|
+
if (fs.existsSync(f)) {
|
|
208
|
+
try { fs.unlinkSync(f); removed = true; } catch {}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return removed;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function exportChat(session, format) {
|
|
215
|
+
const type = format || 'md';
|
|
216
|
+
const msgs = session.messages || [];
|
|
217
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
218
|
+
const file = path.join(process.cwd(), 'loom-chat-' + ts + '.' + type);
|
|
219
|
+
if (type === 'md') {
|
|
220
|
+
const lines = ['# Loom Code Chat Export', '', '**Date:** ' + new Date().toLocaleString(), ''];
|
|
221
|
+
for (const m of msgs) {
|
|
222
|
+
const role = m.role || 'unknown';
|
|
223
|
+
lines.push('### ' + role.charAt(0).toUpperCase() + role.slice(1));
|
|
224
|
+
lines.push('');
|
|
225
|
+
lines.push(m.content || '(empty)');
|
|
226
|
+
lines.push('');
|
|
227
|
+
}
|
|
228
|
+
fs.writeFileSync(file, lines.join('\n'));
|
|
229
|
+
} else {
|
|
230
|
+
const exportLines = msgs.map((m) => m.role + ': ' + (m.content || ''));
|
|
231
|
+
fs.writeFileSync(file, exportLines.join('\n'));
|
|
232
|
+
}
|
|
233
|
+
return file;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
module.exports = {
|
|
237
|
+
saveSession,
|
|
238
|
+
listSessions,
|
|
239
|
+
loadSession,
|
|
240
|
+
deleteSession,
|
|
241
|
+
exportChat,
|
|
242
|
+
normalizeSession,
|
|
243
|
+
isValidSessionId,
|
|
244
|
+
SESSION_SCHEMA_VERSION,
|
|
245
|
+
};
|