@swarmai/local-agent 0.1.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.
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Tool Scanner for Local Agent
3
+ *
4
+ * Auto-detects installed developer tools on the local machine.
5
+ * Reports tool inventory to the server on connect.
6
+ * Uses parallel scanning for faster startup.
7
+ */
8
+
9
+ const { execSync, exec } = require('child_process');
10
+ const os = require('os');
11
+
12
+ const isWindows = os.platform() === 'win32';
13
+
14
+ /**
15
+ * Tools to scan for (name, detection command, version command)
16
+ */
17
+ const TOOL_DEFINITIONS = [
18
+ { id: 'git', versionCmd: 'git --version', versionRegex: /git version ([\d.]+)/ },
19
+ { id: 'node', versionCmd: 'node --version', versionRegex: /v?([\d.]+)/ },
20
+ { id: 'npm', versionCmd: 'npm --version', versionRegex: /([\d.]+)/ },
21
+ { id: 'python', versionCmd: 'python3 --version || python --version', versionRegex: /Python ([\d.]+)/ },
22
+ { id: 'docker', versionCmd: 'docker --version', versionRegex: /Docker version ([\d.]+)/ },
23
+ { id: 'claude', versionCmd: 'claude --version', versionRegex: /([\d.]+)/ },
24
+ { id: 'gemini', versionCmd: 'gemini --version', versionRegex: /([\d.]+)/ },
25
+ { id: 'aws', versionCmd: 'aws --version', versionRegex: /aws-cli\/([\d.]+)/ },
26
+ { id: 'gh', versionCmd: 'gh --version', versionRegex: /gh version ([\d.]+)/ },
27
+ { id: 'code', versionCmd: 'code --version', versionRegex: /([\d.]+)/ },
28
+ { id: 'curl', versionCmd: 'curl --version', versionRegex: /curl ([\d.]+)/ },
29
+ { id: 'ffmpeg', versionCmd: 'ffmpeg -version', versionRegex: /ffmpeg version ([\d.]+)/ },
30
+ { id: 'opencode', versionCmd: 'opencode --version', versionRegex: /([\d.]+)/ },
31
+ { id: 'ollama', versionCmd: 'ollama --version', versionRegex: /ollama version ([\d.]+)/i },
32
+ { id: 'lmstudio', versionCmd: 'lms version', versionRegex: /([\d.]+)/ },
33
+ ];
34
+
35
+ /**
36
+ * Check if a tool exists and get its version (async, non-blocking)
37
+ */
38
+ function checkToolAsync(toolDef) {
39
+ return new Promise((resolve) => {
40
+ exec(toolDef.versionCmd, {
41
+ encoding: 'utf-8',
42
+ timeout: 5000,
43
+ shell: true,
44
+ windowsHide: true,
45
+ }, (err, stdout) => {
46
+ if (err) {
47
+ return resolve({ installed: false, version: null, path: null });
48
+ }
49
+
50
+ const output = (stdout || '').trim();
51
+ const match = output.match(toolDef.versionRegex);
52
+ const version = match ? match[1] : 'unknown';
53
+
54
+ // Try to get the path (async)
55
+ const whichCmd = isWindows ? `where ${toolDef.id}` : `which ${toolDef.id}`;
56
+ exec(whichCmd, { encoding: 'utf-8', timeout: 3000, windowsHide: true }, (pathErr, pathOut) => {
57
+ const toolPath = pathErr ? null : (pathOut || '').trim().split('\n')[0].trim();
58
+ resolve({ installed: true, version, path: toolPath });
59
+ });
60
+ });
61
+ });
62
+ }
63
+
64
+ /**
65
+ * Check if a tool exists (sync fallback)
66
+ */
67
+ function checkTool(toolDef) {
68
+ try {
69
+ const output = execSync(toolDef.versionCmd, {
70
+ encoding: 'utf-8',
71
+ timeout: 5000,
72
+ stdio: ['pipe', 'pipe', 'pipe'],
73
+ shell: true,
74
+ windowsHide: true,
75
+ }).trim();
76
+
77
+ const match = output.match(toolDef.versionRegex);
78
+ const version = match ? match[1] : 'unknown';
79
+
80
+ let toolPath = null;
81
+ try {
82
+ const whichCmd = isWindows ? `where ${toolDef.id}` : `which ${toolDef.id}`;
83
+ toolPath = execSync(whichCmd, {
84
+ encoding: 'utf-8',
85
+ timeout: 3000,
86
+ stdio: ['pipe', 'pipe', 'pipe'],
87
+ windowsHide: true,
88
+ }).trim().split('\n')[0].trim();
89
+ } catch { /* path detection optional */ }
90
+
91
+ return { installed: true, version, path: toolPath };
92
+ } catch {
93
+ return { installed: false, version: null, path: null };
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Scan Docker environment — running containers, images, compose availability
99
+ */
100
+ function scanDocker() {
101
+ const result = { available: false, containers: [], images: 0, composeAvailable: false };
102
+
103
+ try {
104
+ execSync('docker --version', { encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'], shell: true, windowsHide: true });
105
+ result.available = true;
106
+ } catch {
107
+ return result;
108
+ }
109
+
110
+ try {
111
+ const raw = execSync('docker ps --format "{{.Names}}\\t{{.Image}}\\t{{.Status}}\\t{{.State}}"', {
112
+ encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'], shell: true, windowsHide: true,
113
+ }).trim();
114
+
115
+ if (raw) {
116
+ result.containers = raw.split('\n').filter(Boolean).map(line => {
117
+ const [name, image, status, state] = line.split('\t');
118
+ return { name, image, status, state };
119
+ });
120
+ }
121
+ } catch { /* docker daemon might not be running */ }
122
+
123
+ try {
124
+ const raw = execSync('docker images -q', {
125
+ encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'], shell: true, windowsHide: true,
126
+ }).trim();
127
+ result.images = raw ? raw.split('\n').length : 0;
128
+ } catch {}
129
+
130
+ try {
131
+ execSync('docker compose version', { encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'], shell: true, windowsHide: true });
132
+ result.composeAvailable = true;
133
+ } catch {}
134
+
135
+ return result;
136
+ }
137
+
138
+ /**
139
+ * Check if Ollama is signed in and has cloud models available.
140
+ * Runs `ollama list` and checks for `:cloud` suffix models.
141
+ */
142
+ function scanOllamaCloud() {
143
+ return new Promise((resolve) => {
144
+ exec('ollama list', {
145
+ encoding: 'utf-8',
146
+ timeout: 10000,
147
+ shell: true,
148
+ windowsHide: true,
149
+ }, (err, stdout) => {
150
+ if (err) {
151
+ return resolve({ signedIn: false, cloudModels: [], localModels: [] });
152
+ }
153
+
154
+ const lines = (stdout || '').trim().split('\n').slice(1); // Skip header
155
+ const cloudModels = [];
156
+ const localModels = [];
157
+
158
+ for (const line of lines) {
159
+ const name = line.split(/\s+/)[0];
160
+ if (!name) continue;
161
+ if (name.includes(':cloud') || name.includes('-cloud')) {
162
+ cloudModels.push(name);
163
+ } else {
164
+ localModels.push(name);
165
+ }
166
+ }
167
+
168
+ resolve({
169
+ signedIn: cloudModels.length > 0 || lines.length > 0,
170
+ cloudModels,
171
+ localModels,
172
+ allModels: [...cloudModels, ...localModels],
173
+ });
174
+ });
175
+ });
176
+ }
177
+
178
+ /**
179
+ * Scan all known tools in parallel (async). Much faster than sequential sync scan.
180
+ * Falls back to sync if called from sync context.
181
+ */
182
+ async function scanToolsAsync() {
183
+ const results = await Promise.all(
184
+ TOOL_DEFINITIONS.map(async (toolDef) => {
185
+ const result = await checkToolAsync(toolDef);
186
+ return [toolDef.id, result];
187
+ })
188
+ );
189
+
190
+ const registry = Object.fromEntries(results);
191
+ registry._docker = scanDocker();
192
+
193
+ // Enrich Ollama entry with cloud model info
194
+ if (registry.ollama && registry.ollama.installed) {
195
+ try {
196
+ const ollamaCloud = await scanOllamaCloud();
197
+ registry.ollama.cloudModels = ollamaCloud.cloudModels;
198
+ registry.ollama.localModels = ollamaCloud.localModels;
199
+ registry.ollama.allModels = ollamaCloud.allModels;
200
+ registry.ollama.signedIn = ollamaCloud.signedIn;
201
+ // Flag agenticChat capability
202
+ registry.ollama.agenticChatCapable = !!(registry.claude && registry.claude.installed);
203
+ } catch { /* non-critical enrichment */ }
204
+ }
205
+
206
+ return registry;
207
+ }
208
+
209
+ /**
210
+ * Scan all known tools (sync fallback — used if async not possible)
211
+ */
212
+ function scanTools() {
213
+ const registry = {};
214
+ for (const toolDef of TOOL_DEFINITIONS) {
215
+ registry[toolDef.id] = checkTool(toolDef);
216
+ }
217
+ registry._docker = scanDocker();
218
+ return registry;
219
+ }
220
+
221
+ module.exports = { scanTools, scanToolsAsync, TOOL_DEFINITIONS };
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Workspace Manager for Local Agent
3
+ *
4
+ * Manages structured workspace directories for agentic AI profiles:
5
+ * {root}/Workspace/{profileName}/ — per-profile workspace with CLAUDE.md
6
+ * {root}/temp/ — screenshot/job outputs (auto-cleaned 24h)
7
+ * {root}/downloads/ — files from agentic AI (auto-cleaned 7d)
8
+ *
9
+ * Two-phase initialization:
10
+ * 1. On connect: initSharedDirs() creates temp/ and downloads/
11
+ * 2. On first cliSession: ensureProfileWorkspace() lazily creates profile dir
12
+ */
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const os = require('os');
17
+
18
+ class WorkspaceManager {
19
+ /**
20
+ * @param {object} config - Workspace config (merged defaults + user overrides)
21
+ * @param {string} config.rootPath
22
+ * @param {number} config.tempMaxAgeMs
23
+ * @param {number} config.downloadsMaxAgeMs
24
+ */
25
+ constructor(config) {
26
+ this._rootPath = config.rootPath;
27
+ this._tempMaxAgeMs = config.tempMaxAgeMs;
28
+ this._downloadsMaxAgeMs = config.downloadsMaxAgeMs;
29
+ this._activeWorkspaces = new Map(); // profileName → workspacePath
30
+ }
31
+
32
+ getRootPath() {
33
+ return this._rootPath;
34
+ }
35
+
36
+ getWorkspacePath(profileName) {
37
+ return path.join(this._rootPath, 'Workspace', sanitizeProfileName(profileName));
38
+ }
39
+
40
+ getTempPath() {
41
+ return path.join(this._rootPath, 'temp');
42
+ }
43
+
44
+ getDownloadsPath() {
45
+ return path.join(this._rootPath, 'downloads');
46
+ }
47
+
48
+ /**
49
+ * Create shared directories (temp, downloads). Called on agent connect.
50
+ * @returns {{ rootPath: string, tempPath: string, downloadsPath: string }}
51
+ */
52
+ initSharedDirs() {
53
+ const tempPath = this.getTempPath();
54
+ const downloadsPath = this.getDownloadsPath();
55
+
56
+ fs.mkdirSync(tempPath, { recursive: true });
57
+ fs.mkdirSync(downloadsPath, { recursive: true });
58
+
59
+ return {
60
+ rootPath: this._rootPath,
61
+ tempPath,
62
+ downloadsPath,
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Lazily create a profile workspace with CLAUDE.md context.
68
+ * Cached — second call for same profile returns instantly.
69
+ *
70
+ * @param {string} profileName
71
+ * @param {object} [contextData]
72
+ * @param {string} [contextData.systemPrompt] - Agent system prompt for CLAUDE.md
73
+ * @returns {string} workspacePath
74
+ */
75
+ ensureProfileWorkspace(profileName, contextData = {}) {
76
+ const sanitized = sanitizeProfileName(profileName);
77
+ const cached = this._activeWorkspaces.get(sanitized);
78
+ if (cached) return cached;
79
+
80
+ const wsPath = path.join(this._rootPath, 'Workspace', sanitized);
81
+ fs.mkdirSync(wsPath, { recursive: true });
82
+
83
+ // Write/update CLAUDE.md with profile context
84
+ const claudeMdPath = path.join(wsPath, 'CLAUDE.md');
85
+ const content = buildClaudeMd(profileName, contextData);
86
+ fs.writeFileSync(claudeMdPath, content, 'utf-8');
87
+
88
+ this._activeWorkspaces.set(sanitized, wsPath);
89
+ return wsPath;
90
+ }
91
+
92
+ /**
93
+ * Get cached workspace path for a profile, or null if not yet created.
94
+ */
95
+ getWorkspaceForProfile(profileName) {
96
+ return this._activeWorkspaces.get(sanitizeProfileName(profileName)) || null;
97
+ }
98
+
99
+ /**
100
+ * Delete files in temp/ older than threshold (default: 24h)
101
+ */
102
+ cleanupTemp(maxAgeMs) {
103
+ const threshold = maxAgeMs ?? this._tempMaxAgeMs;
104
+ return cleanupDir(this.getTempPath(), threshold);
105
+ }
106
+
107
+ /**
108
+ * Delete files in downloads/ older than threshold (default: 7d)
109
+ */
110
+ cleanupDownloads(maxAgeMs) {
111
+ const threshold = maxAgeMs ?? this._downloadsMaxAgeMs;
112
+ return cleanupDir(this.getDownloadsPath(), threshold);
113
+ }
114
+ }
115
+
116
+ // =====================================================
117
+ // Helpers
118
+ // =====================================================
119
+
120
+ /**
121
+ * Sanitize profile name for use as a folder name.
122
+ * Replace non-alphanumeric (except - and _) with _, trim, max 50 chars.
123
+ */
124
+ function sanitizeProfileName(name) {
125
+ return (name || 'unknown')
126
+ .replace(/[^a-zA-Z0-9_-]/g, '_')
127
+ .replace(/_+/g, '_')
128
+ .replace(/^_|_$/g, '')
129
+ .substring(0, 50) || 'unknown';
130
+ }
131
+
132
+ /**
133
+ * Build CLAUDE.md content for a profile workspace.
134
+ */
135
+ function buildClaudeMd(profileName, contextData = {}) {
136
+ const timestamp = new Date().toISOString();
137
+ const lines = [
138
+ `# ${profileName} Workspace`,
139
+ '',
140
+ `Agent: ${profileName}`,
141
+ `Initialized: ${timestamp}`,
142
+ '',
143
+ ];
144
+
145
+ if (contextData.systemPrompt) {
146
+ lines.push('## Instructions', '', contextData.systemPrompt, '');
147
+ }
148
+
149
+ return lines.join('\n');
150
+ }
151
+
152
+ /**
153
+ * Delete files in a directory older than maxAgeMs.
154
+ * Skips subdirectories (only cleans top-level files).
155
+ * @returns {number} Number of files deleted
156
+ */
157
+ function cleanupDir(dirPath, maxAgeMs) {
158
+ if (!fs.existsSync(dirPath)) return 0;
159
+
160
+ let deleted = 0;
161
+ const now = Date.now();
162
+
163
+ try {
164
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
165
+ for (const entry of entries) {
166
+ if (!entry.isFile()) continue;
167
+ const fullPath = path.join(dirPath, entry.name);
168
+ try {
169
+ const stat = fs.statSync(fullPath);
170
+ if (now - stat.mtimeMs > maxAgeMs) {
171
+ fs.unlinkSync(fullPath);
172
+ deleted++;
173
+ }
174
+ } catch { /* skip files we can't stat/delete */ }
175
+ }
176
+ } catch { /* directory read failed, non-critical */ }
177
+
178
+ return deleted;
179
+ }
180
+
181
+ // =====================================================
182
+ // Singleton
183
+ // =====================================================
184
+
185
+ let _instance = null;
186
+
187
+ function getWorkspaceManager() {
188
+ return _instance;
189
+ }
190
+
191
+ function initWorkspaceManager(config) {
192
+ _instance = new WorkspaceManager(config);
193
+ return _instance;
194
+ }
195
+
196
+ module.exports = {
197
+ WorkspaceManager,
198
+ getWorkspaceManager,
199
+ initWorkspaceManager,
200
+ sanitizeProfileName,
201
+ };