serpentstack 0.2.5 → 0.2.9
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/bin/serpentstack.js +76 -29
- package/lib/commands/persistent.js +318 -286
- package/lib/commands/skills-init.js +61 -23
- package/lib/commands/skills-update.js +10 -8
- package/lib/commands/stack-new.js +56 -21
- package/lib/utils/agent-utils.js +1 -1
- package/lib/utils/config.js +14 -7
- package/lib/utils/fs-helpers.js +1 -1
- package/lib/utils/models.js +181 -0
- package/lib/utils/ui.js +70 -49
- package/package.json +3 -3
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
2
3
|
import { downloadFile } from '../utils/github.js';
|
|
3
4
|
import { safeWrite } from '../utils/fs-helpers.js';
|
|
4
|
-
import {
|
|
5
|
+
import { readConfig, writeConfig, detectTemplateDefaults, defaultAgentConfig } from '../utils/config.js';
|
|
6
|
+
import { parseAgentMd, discoverAgents } from '../utils/agent-utils.js';
|
|
7
|
+
import { info, success, warn, error, spinner, bold, dim, green, cyan, divider, printBox, printPrompt, printHeader, fileStatus } from '../utils/ui.js';
|
|
5
8
|
|
|
6
9
|
const SKILLS_FILES = [
|
|
10
|
+
'.skills/auth/SKILL.md',
|
|
11
|
+
'.skills/db-migrate/SKILL.md',
|
|
12
|
+
'.skills/deploy/SKILL.md',
|
|
13
|
+
'.skills/dev-server/SKILL.md',
|
|
7
14
|
'.skills/find-skills/SKILL.md',
|
|
8
15
|
'.skills/generate-skills/SKILL.md',
|
|
9
16
|
'.skills/git-workflow/SKILL.md',
|
|
10
17
|
'.skills/model-routing/SKILL.md',
|
|
18
|
+
'.skills/scaffold/SKILL.md',
|
|
19
|
+
'.skills/test/SKILL.md',
|
|
11
20
|
];
|
|
12
21
|
|
|
13
22
|
const OPENCLAW_FILES = [
|
|
@@ -26,13 +35,9 @@ const MANIFEST = [...SKILLS_FILES, ...OPENCLAW_FILES, ...DOCS_FILES];
|
|
|
26
35
|
export async function skillsInit({ force = false } = {}) {
|
|
27
36
|
printHeader();
|
|
28
37
|
|
|
29
|
-
|
|
30
|
-
console.log(` ${bold('Step 1/2')} ${dim('\u2014 Downloading skills + persistent agent configs')}`);
|
|
31
|
-
console.log();
|
|
32
|
-
|
|
33
|
-
const results = { created: 0, skipped: 0, overwritten: 0, failed: 0 };
|
|
38
|
+
const results = { created: 0, skipped: 0, overwritten: 0, unchanged: 0, failed: 0 };
|
|
34
39
|
const logs = [];
|
|
35
|
-
const spin = spinner('
|
|
40
|
+
const spin = spinner('Slithering through GitHub...');
|
|
36
41
|
|
|
37
42
|
try {
|
|
38
43
|
for (const repoPath of MANIFEST) {
|
|
@@ -52,23 +57,26 @@ export async function skillsInit({ force = false } = {}) {
|
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
// Group output by section
|
|
55
|
-
|
|
60
|
+
divider('Skills');
|
|
56
61
|
for (let i = 0; i < SKILLS_FILES.length; i++) logs[i] && console.log(logs[i]);
|
|
57
62
|
console.log();
|
|
58
|
-
|
|
63
|
+
|
|
64
|
+
divider('Persistent Agents');
|
|
59
65
|
for (let i = SKILLS_FILES.length; i < SKILLS_FILES.length + OPENCLAW_FILES.length; i++) logs[i] && console.log(logs[i]);
|
|
60
66
|
console.log();
|
|
61
|
-
|
|
67
|
+
|
|
68
|
+
divider('Docs');
|
|
62
69
|
for (let i = SKILLS_FILES.length + OPENCLAW_FILES.length; i < logs.length; i++) logs[i] && console.log(logs[i]);
|
|
63
70
|
console.log();
|
|
64
71
|
|
|
65
72
|
// Summary line
|
|
66
73
|
const parts = [];
|
|
67
74
|
if (results.created > 0) parts.push(green(`${results.created} created`));
|
|
68
|
-
if (results.overwritten > 0) parts.push(`${results.overwritten} updated`);
|
|
75
|
+
if (results.overwritten > 0) parts.push(cyan(`${results.overwritten} updated`));
|
|
76
|
+
if (results.unchanged > 0) parts.push(`${results.unchanged} up to date`);
|
|
69
77
|
if (results.skipped > 0) parts.push(`${results.skipped} skipped`);
|
|
70
78
|
if (results.failed > 0) parts.push(`${results.failed} failed`);
|
|
71
|
-
console.log(` ${parts.join(dim('
|
|
79
|
+
console.log(` ${parts.join(dim(' · '))}`);
|
|
72
80
|
|
|
73
81
|
if (results.failed === MANIFEST.length) {
|
|
74
82
|
console.log();
|
|
@@ -76,14 +84,43 @@ export async function skillsInit({ force = false } = {}) {
|
|
|
76
84
|
return;
|
|
77
85
|
}
|
|
78
86
|
|
|
79
|
-
//
|
|
87
|
+
// Generate default config.json if it doesn't exist
|
|
88
|
+
const projectDir = process.cwd();
|
|
89
|
+
if (!readConfig(projectDir)) {
|
|
90
|
+
const templateDefaults = detectTemplateDefaults(projectDir) || {};
|
|
91
|
+
const config = {
|
|
92
|
+
project: {
|
|
93
|
+
name: templateDefaults.name || '',
|
|
94
|
+
language: templateDefaults.language || '',
|
|
95
|
+
framework: templateDefaults.framework || '',
|
|
96
|
+
devCmd: templateDefaults.devCmd || '',
|
|
97
|
+
testCmd: templateDefaults.testCmd || '',
|
|
98
|
+
conventions: templateDefaults.conventions || '',
|
|
99
|
+
},
|
|
100
|
+
agents: {},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// Populate agent defaults from downloaded AGENT.md files
|
|
104
|
+
const agents = discoverAgents(projectDir);
|
|
105
|
+
for (const agent of agents) {
|
|
106
|
+
try {
|
|
107
|
+
const agentMd = parseAgentMd(agent.agentMdPath);
|
|
108
|
+
config.agents[agent.name] = defaultAgentConfig(agentMd.meta);
|
|
109
|
+
} catch { /* skip invalid agents */ }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
writeConfig(projectDir, config);
|
|
113
|
+
console.log(` ${fileStatus('.openclaw/config.json', 'created')}`);
|
|
114
|
+
results.created++;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Next steps
|
|
80
118
|
console.log();
|
|
81
|
-
|
|
119
|
+
divider('What\'s next');
|
|
82
120
|
console.log();
|
|
83
|
-
console.log(` ${dim('Open your IDE agent (Claude Code, Cursor, Copilot, etc.)')}`);
|
|
84
|
-
console.log(` ${dim('and paste
|
|
85
|
-
console.log(` ${dim('
|
|
86
|
-
console.log(` ${dim('.skills/ directory tailored to your project.')}`);
|
|
121
|
+
console.log(` ${dim('Open your IDE agent (Claude Code, Cursor, Copilot, Gemini CLI, etc.)')}`);
|
|
122
|
+
console.log(` ${dim('and paste this prompt. It reads your codebase and generates')}`);
|
|
123
|
+
console.log(` ${dim('project-specific skills tailored to your stack.')}`);
|
|
87
124
|
|
|
88
125
|
printPrompt([
|
|
89
126
|
`Read .skills/generate-skills/SKILL.md and follow its instructions`,
|
|
@@ -98,11 +135,12 @@ export async function skillsInit({ force = false } = {}) {
|
|
|
98
135
|
`Reference SKILL-AUTHORING.md for the format.`,
|
|
99
136
|
]);
|
|
100
137
|
|
|
101
|
-
printBox('
|
|
102
|
-
`${dim('$')} ${bold('serpentstack persistent')}
|
|
138
|
+
printBox('Want persistent background agents too?', [
|
|
139
|
+
`${dim('$')} ${bold('serpentstack persistent')}`,
|
|
103
140
|
'',
|
|
104
|
-
`${dim('
|
|
105
|
-
`${dim('
|
|
106
|
-
`${dim('
|
|
141
|
+
`${dim('Agents that watch your dev server, run tests, and keep')}`,
|
|
142
|
+
`${dim('skills fresh — each in its own terminal. Pick which to')}`,
|
|
143
|
+
`${dim('run and choose local or cloud models.')}`,
|
|
107
144
|
]);
|
|
145
|
+
console.log();
|
|
108
146
|
}
|
|
@@ -2,13 +2,19 @@ import { join } from 'node:path';
|
|
|
2
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { downloadFile } from '../utils/github.js';
|
|
4
4
|
import { safeWrite } from '../utils/fs-helpers.js';
|
|
5
|
-
import { info, success, warn, error, spinner, bold, dim, green, printHeader, fileStatus } from '../utils/ui.js';
|
|
5
|
+
import { info, success, warn, error, spinner, bold, dim, green, cyan, printHeader, fileStatus } from '../utils/ui.js';
|
|
6
6
|
|
|
7
7
|
const MANIFEST = [
|
|
8
|
+
'.skills/auth/SKILL.md',
|
|
9
|
+
'.skills/db-migrate/SKILL.md',
|
|
10
|
+
'.skills/deploy/SKILL.md',
|
|
11
|
+
'.skills/dev-server/SKILL.md',
|
|
8
12
|
'.skills/find-skills/SKILL.md',
|
|
9
13
|
'.skills/generate-skills/SKILL.md',
|
|
10
14
|
'.skills/git-workflow/SKILL.md',
|
|
11
15
|
'.skills/model-routing/SKILL.md',
|
|
16
|
+
'.skills/scaffold/SKILL.md',
|
|
17
|
+
'.skills/test/SKILL.md',
|
|
12
18
|
'.openclaw/SOUL.md',
|
|
13
19
|
'.openclaw/agents/log-watcher/AGENT.md',
|
|
14
20
|
'.openclaw/agents/test-runner/AGENT.md',
|
|
@@ -27,11 +33,8 @@ const CUSTOMIZABLE = new Set([
|
|
|
27
33
|
export async function skillsUpdate({ force = false, all = false } = {}) {
|
|
28
34
|
printHeader();
|
|
29
35
|
|
|
30
|
-
info(`Checking for updates in ${bold(process.cwd())}`);
|
|
31
|
-
console.log();
|
|
32
|
-
|
|
33
36
|
const results = { updated: 0, skipped: 0, unchanged: 0, failed: 0 };
|
|
34
|
-
const spin = spinner('
|
|
37
|
+
const spin = spinner('Checking for updates...');
|
|
35
38
|
const logs = [];
|
|
36
39
|
|
|
37
40
|
try {
|
|
@@ -83,12 +86,11 @@ export async function skillsUpdate({ force = false, all = false } = {}) {
|
|
|
83
86
|
if (results.unchanged > 0) parts.push(`${results.unchanged} up to date`);
|
|
84
87
|
if (results.skipped > 0) parts.push(`${results.skipped} skipped`);
|
|
85
88
|
if (results.failed > 0) parts.push(`${results.failed} failed`);
|
|
86
|
-
console.log(` ${parts.join(dim('
|
|
89
|
+
console.log(` ${parts.join(dim(' · '))}`);
|
|
87
90
|
|
|
88
91
|
if (results.updated > 0) {
|
|
89
92
|
console.log();
|
|
90
|
-
info(`${dim('
|
|
91
|
-
info(`${dim('Only the base SerpentStack files were updated.')}`);
|
|
93
|
+
info(`${dim('Only base SerpentStack files were updated — your custom skills are untouched.')}`);
|
|
92
94
|
}
|
|
93
95
|
console.log();
|
|
94
96
|
}
|
|
@@ -3,7 +3,7 @@ import { rm } from 'node:fs/promises';
|
|
|
3
3
|
import { join, resolve } from 'node:path';
|
|
4
4
|
import { execFile } from 'node:child_process';
|
|
5
5
|
import { cloneRepo, checkGit } from '../utils/github.js';
|
|
6
|
-
import { info, success, warn, error, spinner, bold, dim, green, cyan, printBox, printPrompt, printHeader } from '../utils/ui.js';
|
|
6
|
+
import { info, success, warn, error, spinner, bold, dim, green, yellow, cyan, divider, printBox, printPrompt, printHeader } from '../utils/ui.js';
|
|
7
7
|
|
|
8
8
|
const CLEANUP_PATHS = [
|
|
9
9
|
'cli',
|
|
@@ -26,6 +26,22 @@ function checkCommand(cmd) {
|
|
|
26
26
|
});
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
function getCommandVersion(cmd, args = ['--version']) {
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
execFile(cmd, args, { timeout: 5000 }, (err, stdout) => {
|
|
32
|
+
if (err) resolve(null);
|
|
33
|
+
else resolve(stdout.trim());
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseVersion(versionStr) {
|
|
39
|
+
if (!versionStr) return null;
|
|
40
|
+
const match = versionStr.match(/(\d+)\.(\d+)/);
|
|
41
|
+
if (!match) return null;
|
|
42
|
+
return { major: parseInt(match[1]), minor: parseInt(match[2]) };
|
|
43
|
+
}
|
|
44
|
+
|
|
29
45
|
export async function stackNew(name) {
|
|
30
46
|
const nameError = validateName(name);
|
|
31
47
|
if (nameError) {
|
|
@@ -44,25 +60,43 @@ export async function stackNew(name) {
|
|
|
44
60
|
printHeader();
|
|
45
61
|
|
|
46
62
|
// Step 1: Check prerequisites
|
|
47
|
-
|
|
63
|
+
divider('Prerequisites');
|
|
48
64
|
console.log();
|
|
49
65
|
|
|
50
66
|
const checks = [
|
|
51
67
|
{ cmd: 'git', label: 'git', url: 'https://git-scm.com/downloads' },
|
|
52
|
-
{ cmd: 'python3', label: 'Python 3.12+', url: 'https://python.org/downloads' },
|
|
53
|
-
{ cmd: 'node', label: 'Node 22+', url: 'https://nodejs.org' },
|
|
68
|
+
{ cmd: 'python3', label: 'Python 3.12+', url: 'https://python.org/downloads', minMajor: 3, minMinor: 12 },
|
|
69
|
+
{ cmd: 'node', label: 'Node 22+', url: 'https://nodejs.org', minMajor: 22, minMinor: 0 },
|
|
54
70
|
{ cmd: 'docker', label: 'Docker', url: 'https://docker.com/get-started' },
|
|
55
71
|
{ cmd: 'uv', label: 'uv (Python package manager)', url: 'https://docs.astral.sh/uv' },
|
|
56
72
|
];
|
|
57
73
|
|
|
58
|
-
|
|
59
|
-
|
|
74
|
+
// Run all checks in parallel
|
|
75
|
+
const results = await Promise.all(checks.map(async ({ cmd, label, url, minMajor, minMinor }) => {
|
|
60
76
|
const found = await checkCommand(cmd);
|
|
61
|
-
if (found) {
|
|
62
|
-
|
|
77
|
+
if (!found) return { label, url, found: false };
|
|
78
|
+
|
|
79
|
+
// Version check if required
|
|
80
|
+
if (minMajor != null) {
|
|
81
|
+
const vStr = await getCommandVersion(cmd);
|
|
82
|
+
const ver = parseVersion(vStr);
|
|
83
|
+
if (ver && (ver.major < minMajor || (ver.major === minMajor && ver.minor < minMinor))) {
|
|
84
|
+
return { label, url, found: true, versionOk: false, version: vStr };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { label, found: true, versionOk: true };
|
|
88
|
+
}));
|
|
89
|
+
|
|
90
|
+
let missing = [];
|
|
91
|
+
for (const r of results) {
|
|
92
|
+
if (!r.found) {
|
|
93
|
+
console.log(` ${dim('○')} ${dim(r.label)} ${dim(`— install: ${r.url}`)}`);
|
|
94
|
+
missing.push(r.label);
|
|
95
|
+
} else if (r.versionOk === false) {
|
|
96
|
+
console.log(` ${yellow('△')} ${r.label} ${dim(`— found ${r.version}, need newer version`)}`);
|
|
97
|
+
missing.push(r.label);
|
|
63
98
|
} else {
|
|
64
|
-
console.log(`
|
|
65
|
-
missing.push(label);
|
|
99
|
+
console.log(` ${green('✓')} ${r.label}`);
|
|
66
100
|
}
|
|
67
101
|
}
|
|
68
102
|
console.log();
|
|
@@ -79,13 +113,14 @@ export async function stackNew(name) {
|
|
|
79
113
|
}
|
|
80
114
|
|
|
81
115
|
// Step 2: Clone template
|
|
82
|
-
|
|
116
|
+
divider('Clone');
|
|
83
117
|
console.log();
|
|
84
118
|
|
|
85
|
-
const spin = spinner(
|
|
119
|
+
const spin = spinner('Slithering through GitHub...');
|
|
86
120
|
try {
|
|
87
121
|
await cloneRepo(dest);
|
|
88
|
-
spin.stop(
|
|
122
|
+
spin.stop();
|
|
123
|
+
success(`Template cloned into ${green(name)}/`);
|
|
89
124
|
} catch (err) {
|
|
90
125
|
spin.stop();
|
|
91
126
|
error(err.message);
|
|
@@ -94,7 +129,7 @@ export async function stackNew(name) {
|
|
|
94
129
|
|
|
95
130
|
// Step 3: Clean up repo-specific files
|
|
96
131
|
console.log();
|
|
97
|
-
|
|
132
|
+
divider('Prepare');
|
|
98
133
|
console.log();
|
|
99
134
|
|
|
100
135
|
for (const p of CLEANUP_PATHS) {
|
|
@@ -115,16 +150,16 @@ export async function stackNew(name) {
|
|
|
115
150
|
|
|
116
151
|
// Step 4: Summary
|
|
117
152
|
console.log();
|
|
118
|
-
|
|
153
|
+
divider('Ready 🐍');
|
|
119
154
|
console.log();
|
|
120
155
|
|
|
121
156
|
console.log(` ${dim('Your project includes:')}`);
|
|
122
|
-
console.log(`
|
|
123
|
-
console.log(`
|
|
124
|
-
console.log(`
|
|
125
|
-
console.log(`
|
|
126
|
-
console.log(`
|
|
127
|
-
console.log(`
|
|
157
|
+
console.log(` ${green('✓')} FastAPI backend ${dim('(async SQLAlchemy, JWT auth, ownership)')}`);
|
|
158
|
+
console.log(` ${green('✓')} React frontend ${dim('(TypeScript, Vite, shadcn/ui)')}`);
|
|
159
|
+
console.log(` ${green('✓')} PostgreSQL + Redis ${dim('(Docker Compose)')}`);
|
|
160
|
+
console.log(` ${green('✓')} Terraform infrastructure ${dim('(AWS App Runner, RDS, ECR)')}`);
|
|
161
|
+
console.log(` ${green('✓')} 10 agent skills ${dim('(.skills/)')}`);
|
|
162
|
+
console.log(` ${green('✓')} Persistent agent configs ${dim('(.openclaw/)')}`);
|
|
128
163
|
console.log();
|
|
129
164
|
|
|
130
165
|
printBox('Get started', [
|
package/lib/utils/agent-utils.js
CHANGED
|
@@ -164,7 +164,7 @@ function generateHeartbeat(schedule) {
|
|
|
164
164
|
}
|
|
165
165
|
|
|
166
166
|
function generateAgentsConfig(meta) {
|
|
167
|
-
const model = meta.model || '
|
|
167
|
+
const model = meta.model || 'ollama/llama3.2';
|
|
168
168
|
const tools = meta.tools || ['file-system', 'shell', 'git'];
|
|
169
169
|
|
|
170
170
|
return [
|
package/lib/utils/config.js
CHANGED
|
@@ -3,12 +3,16 @@ import { join } from 'node:path';
|
|
|
3
3
|
|
|
4
4
|
const CONFIG_PATH = '.openclaw/config.json';
|
|
5
5
|
|
|
6
|
+
// Default model for persistent agents — local first, always.
|
|
7
|
+
// Cloud models cost tokens per heartbeat and require API keys.
|
|
8
|
+
const DEFAULT_MODEL = 'ollama/llama3.2';
|
|
9
|
+
|
|
6
10
|
/**
|
|
7
11
|
* Default config structure:
|
|
8
12
|
* {
|
|
9
13
|
* project: { name, language, framework, devCmd, testCmd, conventions },
|
|
10
14
|
* agents: {
|
|
11
|
-
* "log-watcher": { enabled: true, model: "
|
|
15
|
+
* "log-watcher": { enabled: true, model: "ollama/llama3.2" },
|
|
12
16
|
* ...
|
|
13
17
|
* }
|
|
14
18
|
* }
|
|
@@ -22,7 +26,10 @@ export function readConfig(projectDir) {
|
|
|
22
26
|
if (!existsSync(configPath)) return null;
|
|
23
27
|
try {
|
|
24
28
|
return JSON.parse(readFileSync(configPath, 'utf8'));
|
|
25
|
-
} catch {
|
|
29
|
+
} catch (err) {
|
|
30
|
+
if (err instanceof SyntaxError) {
|
|
31
|
+
console.error(` \x1b[33m\u25B3\x1b[0m .openclaw/config.json has invalid JSON — ignoring. Fix it or delete it to regenerate.`);
|
|
32
|
+
}
|
|
26
33
|
return null;
|
|
27
34
|
}
|
|
28
35
|
}
|
|
@@ -46,9 +53,9 @@ export function detectTemplateDefaults(projectDir) {
|
|
|
46
53
|
|
|
47
54
|
try {
|
|
48
55
|
const content = readFileSync(makefile, 'utf8');
|
|
49
|
-
// SerpentStack Makefile
|
|
50
|
-
if (
|
|
51
|
-
if (!content.includes('uv run')
|
|
56
|
+
// SerpentStack Makefile: must have a 'verify:' target AND 'uv run' (Python tooling)
|
|
57
|
+
if (!/^verify:/m.test(content)) return null;
|
|
58
|
+
if (!content.includes('uv run')) return null;
|
|
52
59
|
|
|
53
60
|
// It's a SerpentStack template project — return smart defaults
|
|
54
61
|
const defaults = {
|
|
@@ -80,7 +87,7 @@ export function detectTemplateDefaults(projectDir) {
|
|
|
80
87
|
export function defaultAgentConfig(meta) {
|
|
81
88
|
return {
|
|
82
89
|
enabled: true,
|
|
83
|
-
model:
|
|
90
|
+
model: DEFAULT_MODEL,
|
|
84
91
|
};
|
|
85
92
|
}
|
|
86
93
|
|
|
@@ -91,7 +98,7 @@ export function getEffectiveModel(agentName, agentMeta, config) {
|
|
|
91
98
|
if (config?.agents?.[agentName]?.model) {
|
|
92
99
|
return config.agents[agentName].model;
|
|
93
100
|
}
|
|
94
|
-
return
|
|
101
|
+
return DEFAULT_MODEL;
|
|
95
102
|
}
|
|
96
103
|
|
|
97
104
|
/**
|
package/lib/utils/fs-helpers.js
CHANGED
|
@@ -19,7 +19,7 @@ export function safeWrite(destPath, content, { force = false } = {}) {
|
|
|
19
19
|
// Don't overwrite if content is identical
|
|
20
20
|
try {
|
|
21
21
|
const existing = readFileSync(destPath, 'utf8');
|
|
22
|
-
if (existing === content) return '
|
|
22
|
+
if (existing === content) return 'unchanged';
|
|
23
23
|
} catch { /* proceed with overwrite */ }
|
|
24
24
|
|
|
25
25
|
writeFileSync(destPath, content, 'utf8');
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Detect all available models: local (Ollama) and cloud (via OpenClaw auth).
|
|
5
|
+
* Local models are preferred for persistent agents — they're free and fast.
|
|
6
|
+
* Cloud models require API keys and cost money per token.
|
|
7
|
+
*
|
|
8
|
+
* Returns { local: [...], cloud: [...], hasApiKey: bool }
|
|
9
|
+
*/
|
|
10
|
+
export async function detectModels() {
|
|
11
|
+
const [ollamaModels, openclawInfo] = await Promise.all([
|
|
12
|
+
detectOllamaModels(),
|
|
13
|
+
detectOpenClawAuth(),
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
local: ollamaModels,
|
|
18
|
+
cloud: openclawInfo.models,
|
|
19
|
+
hasApiKey: openclawInfo.hasApiKey,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Detect locally installed Ollama models via the REST API.
|
|
25
|
+
* GET http://localhost:11434/api/tags returns structured JSON with real
|
|
26
|
+
* parameter counts, sizes, and quantization levels — no CLI parsing needed.
|
|
27
|
+
*/
|
|
28
|
+
async function detectOllamaModels() {
|
|
29
|
+
try {
|
|
30
|
+
const response = await fetchWithTimeout('http://localhost:11434/api/tags', 3000);
|
|
31
|
+
if (!response.ok) return [];
|
|
32
|
+
|
|
33
|
+
const data = await response.json();
|
|
34
|
+
if (!data.models || !Array.isArray(data.models)) return [];
|
|
35
|
+
|
|
36
|
+
return data.models.map(m => {
|
|
37
|
+
const name = (m.name || '').replace(':latest', '');
|
|
38
|
+
if (!name) return null;
|
|
39
|
+
|
|
40
|
+
// Use the real parameter count from the API when available
|
|
41
|
+
const details = m.details || {};
|
|
42
|
+
const params = formatParamCount(details.parameter_size) || guessParamsFromSize(m.size);
|
|
43
|
+
const quant = details.quantization_level || '';
|
|
44
|
+
const sizeStr = formatBytes(m.size);
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
id: `ollama/${name}`,
|
|
48
|
+
name,
|
|
49
|
+
params,
|
|
50
|
+
quant,
|
|
51
|
+
size: sizeStr,
|
|
52
|
+
tier: 'local',
|
|
53
|
+
};
|
|
54
|
+
}).filter(Boolean);
|
|
55
|
+
} catch {
|
|
56
|
+
// Ollama not running or not installed
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Format parameter count from Ollama API (e.g., "7B", "3.2B", "70B").
|
|
63
|
+
* The API returns strings like "7B", "3.21B", etc. in details.parameter_size.
|
|
64
|
+
*/
|
|
65
|
+
function formatParamCount(paramSize) {
|
|
66
|
+
if (!paramSize) return '';
|
|
67
|
+
// Already formatted like "7B" or "3.2B"
|
|
68
|
+
const s = String(paramSize).trim();
|
|
69
|
+
if (/^\d+\.?\d*[BbMm]$/i.test(s)) return s.toUpperCase();
|
|
70
|
+
return s;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Fallback: estimate parameter count from file size.
|
|
75
|
+
* Rough heuristic: ~0.5GB per billion parameters for Q4 quantization.
|
|
76
|
+
*/
|
|
77
|
+
function guessParamsFromSize(bytes) {
|
|
78
|
+
if (!bytes || bytes <= 0) return '';
|
|
79
|
+
const gb = bytes / (1024 ** 3);
|
|
80
|
+
const billions = Math.round(gb * 2); // Q4 ≈ 0.5GB/B
|
|
81
|
+
if (billions > 0) return `~${billions}B`;
|
|
82
|
+
return '';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Format bytes into human-readable size (e.g., "4.7 GB").
|
|
87
|
+
*/
|
|
88
|
+
function formatBytes(bytes) {
|
|
89
|
+
if (!bytes || bytes <= 0) return '';
|
|
90
|
+
const gb = bytes / (1024 ** 3);
|
|
91
|
+
if (gb >= 1) return `${gb.toFixed(1)} GB`;
|
|
92
|
+
const mb = bytes / (1024 ** 2);
|
|
93
|
+
return `${Math.round(mb)} MB`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Fetch with timeout using AbortController (Node 18+).
|
|
98
|
+
*/
|
|
99
|
+
function fetchWithTimeout(url, timeoutMs) {
|
|
100
|
+
const controller = new AbortController();
|
|
101
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
102
|
+
return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(timeout));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Check OpenClaw for configured models and API key status.
|
|
107
|
+
*/
|
|
108
|
+
async function detectOpenClawAuth() {
|
|
109
|
+
const result = { models: [], hasApiKey: false };
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
// Check if any API key is configured via openclaw models status
|
|
113
|
+
const status = await execAsync('openclaw', ['models', 'status']);
|
|
114
|
+
|
|
115
|
+
// Look for "api_key" or "configured" in the output
|
|
116
|
+
if (status.includes('api_key') || status.includes('configured')) {
|
|
117
|
+
result.hasApiKey = true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Get the model catalog for cloud options
|
|
121
|
+
const list = await execAsync('openclaw', ['models', 'list', '--json']);
|
|
122
|
+
try {
|
|
123
|
+
const models = JSON.parse(list);
|
|
124
|
+
if (Array.isArray(models)) {
|
|
125
|
+
result.models = models
|
|
126
|
+
.filter(m => m.available && !m.local)
|
|
127
|
+
.map(m => ({
|
|
128
|
+
id: m.key || m.name,
|
|
129
|
+
name: modelShortName(m.key || m.name),
|
|
130
|
+
provider: (m.key || '').split('/')[0] || 'unknown',
|
|
131
|
+
tier: 'cloud',
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
134
|
+
} catch {
|
|
135
|
+
// Fall back to text parsing
|
|
136
|
+
const text = await execAsync('openclaw', ['models', 'list']);
|
|
137
|
+
const lines = text.trim().split('\n').filter(l => l.trim() && !l.startsWith('Model'));
|
|
138
|
+
result.models = lines.map(l => {
|
|
139
|
+
const id = l.trim().split(/\s+/)[0];
|
|
140
|
+
if (!id || id.length < 3) return null;
|
|
141
|
+
return { id, name: modelShortName(id), provider: id.split('/')[0], tier: 'cloud' };
|
|
142
|
+
}).filter(Boolean);
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
// OpenClaw not installed or no models configured — use defaults
|
|
146
|
+
result.models = [
|
|
147
|
+
{ id: 'anthropic/claude-haiku-4-20250414', name: 'Haiku', provider: 'anthropic', tier: 'cloud' },
|
|
148
|
+
{ id: 'anthropic/claude-sonnet-4-20250514', name: 'Sonnet', provider: 'anthropic', tier: 'cloud' },
|
|
149
|
+
];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Short display name for a model ID.
|
|
157
|
+
*/
|
|
158
|
+
export function modelShortName(model) {
|
|
159
|
+
if (!model) return 'unknown';
|
|
160
|
+
// Anthropic models
|
|
161
|
+
if (model.startsWith('anthropic/')) {
|
|
162
|
+
if (model.includes('haiku')) return 'Haiku';
|
|
163
|
+
if (model.includes('sonnet')) return 'Sonnet';
|
|
164
|
+
if (model.includes('opus')) return 'Opus';
|
|
165
|
+
return model.slice('anthropic/'.length);
|
|
166
|
+
}
|
|
167
|
+
// Ollama models
|
|
168
|
+
if (model.startsWith('ollama/')) return model.slice('ollama/'.length);
|
|
169
|
+
// Other: strip provider prefix
|
|
170
|
+
if (model.includes('/')) return model.split('/').pop();
|
|
171
|
+
return model;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function execAsync(cmd, args) {
|
|
175
|
+
return new Promise((resolve, reject) => {
|
|
176
|
+
execFile(cmd, args, { timeout: 5000 }, (err, stdout) => {
|
|
177
|
+
if (err) reject(err);
|
|
178
|
+
else resolve(stdout);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
}
|