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,167 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+
5
+ const SESSION_SCHEMA_VERSION = 2;
6
+
7
+ const LOOM_DIR = path.join(os.homedir(), '.loom');
8
+ const SESSIONS_DIR = path.join(LOOM_DIR, 'sessions');
9
+
10
+ // Re-evaluated on every call so tests can isolate with LOOM_CONFIG_DIR.
11
+ function sessionsDir() {
12
+ return path.join(process.env.LOOM_CONFIG_DIR || LOOM_DIR, 'sessions');
13
+ }
14
+
15
+ function ensureDir() {
16
+ const d = sessionsDir();
17
+ if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
18
+ }
19
+
20
+ function convId() {
21
+ return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6);
22
+ }
23
+
24
+ // Coerce whatever came off disk (hand-edited, corrupt, older schema) into the
25
+ // guaranteed current shape: { id, createdAt, schemaVersion, messages } with
26
+ // every message having { role, content } while preserving any extra fields.
27
+ function normalizeSession(data) {
28
+ if (!data || typeof data !== 'object') return null;
29
+ const raw = Array.isArray(data.messages) ? data.messages : [];
30
+ const messages = raw.map((m) => {
31
+ if (!m || typeof m !== 'object') return { role: 'user', content: '' };
32
+ const { role, content, ...rest } = m;
33
+ return { role: role || 'user', content: typeof content === 'undefined' ? '' : content, ...rest };
34
+ });
35
+ return {
36
+ id: typeof data.id === 'string' ? data.id : null,
37
+ createdAt: typeof data.createdAt === 'string' ? data.createdAt : null,
38
+ schemaVersion: typeof data.schemaVersion === 'number' ? data.schemaVersion : 1,
39
+ messages,
40
+ };
41
+ }
42
+
43
+ // Session IDs become file names — keep them strictly alphanumeric to rule out
44
+ // path traversal via loadSession/deleteSession (e.g. "../../secrets").
45
+ const ID_RX = /^[a-zA-Z0-9_-]{3,128}$/;
46
+ function isValidSessionId(id) {
47
+ return typeof id === 'string' && ID_RX.test(id);
48
+ }
49
+
50
+ function saveSession(session) {
51
+ ensureDir();
52
+ const id = isValidSessionId(session.conversationId) ? session.conversationId : convId();
53
+ const now = new Date().toISOString();
54
+ // Overwriting an existing session keeps its original createdAt (it is the
55
+ // same conversation); updatedAt always reflects this write.
56
+ let createdAt = now;
57
+ const existingFile = path.join(sessionsDir(), id + '.json');
58
+ if (fs.existsSync(existingFile)) {
59
+ try {
60
+ const prev = JSON.parse(fs.readFileSync(existingFile, 'utf8'));
61
+ if (prev && typeof prev.createdAt === 'string') createdAt = prev.createdAt;
62
+ } catch {}
63
+ }
64
+ const data = {
65
+ id,
66
+ schemaVersion: SESSION_SCHEMA_VERSION,
67
+ createdAt,
68
+ updatedAt: now,
69
+ messages: session.messages || [],
70
+ provider: session.config ? session.config.provider : null,
71
+ model: session.config && session.config.model ? session.config.model[session.config.provider] : null,
72
+ };
73
+ const f = path.join(sessionsDir(), id + '.json');
74
+ fs.writeFileSync(f, JSON.stringify(data, null, 2));
75
+ return { id, file: f };
76
+ }
77
+
78
+ function listSessions() {
79
+ ensureDir();
80
+ const files = fs.readdirSync(sessionsDir()).filter((f) => f.endsWith('.json'));
81
+ const list = files.map((f) => {
82
+ const p = path.join(sessionsDir(), f);
83
+ try {
84
+ const data = JSON.parse(fs.readFileSync(p, 'utf8'));
85
+ let mtime = null;
86
+ try { mtime = fs.statSync(p).mtime.toISOString(); } catch {}
87
+ return {
88
+ id: data.id || f.replace('.json', ''),
89
+ createdAt: data.createdAt,
90
+ updatedAt: data.updatedAt,
91
+ mtime,
92
+ messageCount: (data.messages || []).length,
93
+ provider: data.provider,
94
+ model: data.model,
95
+ };
96
+ } catch {
97
+ let mtime = null;
98
+ try { mtime = fs.statSync(p).mtime.toISOString(); } catch {}
99
+ return { id: f.replace('.json', ''), createdAt: null, updatedAt: null, mtime, messageCount: 0 };
100
+ }
101
+ });
102
+ // Newest first by the last write time (updatedAt, else createdAt, else the
103
+ // file's own mtime). Sorting by file NAME put legacy "share-*" exports on
104
+ // top forever ("s" > "m"), burying the real, recent conversations.
105
+ const stamp = (s) => s.updatedAt || s.createdAt || s.mtime || '';
106
+ return list.filter((s) => isValidSessionId(s.id)).sort((a, b) => (stamp(b) < stamp(a) ? -1 : stamp(b) > stamp(a) ? 1 : 0));
107
+ }
108
+
109
+ function loadSession(id) {
110
+ if (!isValidSessionId(id)) return null;
111
+ const f = path.join(sessionsDir(), id + '.json');
112
+ if (!fs.existsSync(f)) return null;
113
+ try {
114
+ const raw = JSON.parse(fs.readFileSync(f, 'utf8'));
115
+ const norm = normalizeSession(raw);
116
+ if (!norm) return null;
117
+ norm.id = norm.id || id;
118
+ norm.file = f;
119
+ return norm;
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+
125
+ function deleteSession(id) {
126
+ if (!isValidSessionId(id)) return false;
127
+ const f = path.join(sessionsDir(), id + '.json');
128
+ if (fs.existsSync(f)) {
129
+ fs.unlinkSync(f);
130
+ return true;
131
+ }
132
+ return false;
133
+ }
134
+
135
+ function exportChat(session, format) {
136
+ const type = format || 'md';
137
+ const msgs = session.messages || [];
138
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
139
+ const file = path.join(process.cwd(), 'loom-chat-' + ts + '.' + type);
140
+ if (type === 'md') {
141
+ const lines = ['# Loom Code Chat Export', '', '**Date:** ' + new Date().toLocaleString(), ''];
142
+ for (const m of msgs) {
143
+ const role = m.role || 'unknown';
144
+ lines.push('### ' + role.charAt(0).toUpperCase() + role.slice(1));
145
+ lines.push('');
146
+ lines.push(m.content || '(empty)');
147
+ lines.push('');
148
+ }
149
+ fs.writeFileSync(file, lines.join('\n'));
150
+ } else {
151
+ const exportLines = msgs.map((m) => m.role + ': ' + (m.content || ''));
152
+ fs.writeFileSync(file, exportLines.join('\n'));
153
+ }
154
+ return file;
155
+ }
156
+
157
+ module.exports = {
158
+ saveSession,
159
+ listSessions,
160
+ loadSession,
161
+ deleteSession,
162
+ exportChat,
163
+ normalizeSession,
164
+ isValidSessionId,
165
+ SESSIONS_DIR,
166
+ SESSION_SCHEMA_VERSION,
167
+ };