@ducci/jarvis 1.0.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/README.md +121 -0
- package/package.json +42 -0
- package/src/index.js +169 -0
- package/src/scripts/onboarding.js +239 -0
- package/src/server/agent.js +287 -0
- package/src/server/app.js +73 -0
- package/src/server/config.js +74 -0
- package/src/server/logging.js +9 -0
- package/src/server/sessions.js +31 -0
- package/src/server/tools.js +183 -0
package/README.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Jarvis
|
|
2
|
+
|
|
3
|
+
A fully automated agent system that lives on a server. Will always run, can be started, stopped, restarted. Autorestarts on crash and can be configured via a setup phase. This README is the entry point and links to focused docs for each major topic.
|
|
4
|
+
|
|
5
|
+
## Docs
|
|
6
|
+
|
|
7
|
+
- Onboarding and Configuration: [docs/setup.md](./docs/setup.md)
|
|
8
|
+
- CLI and Server Lifecycle: [docs/cli.md](./docs/cli.md)
|
|
9
|
+
- Agent system details: [docs/agent.md](./docs/agent.md)
|
|
10
|
+
- UI implementation: [docs/ui.md](./docs/ui.md)
|
|
11
|
+
- Evaluation guide: [docs/evaluation.md](./docs/evaluation.md)
|
|
12
|
+
|
|
13
|
+
## Principles (early draft)
|
|
14
|
+
|
|
15
|
+
- Minimal surface area in v1
|
|
16
|
+
- Clear defaults and predictable behavior
|
|
17
|
+
- Simple local data model
|
|
18
|
+
- No hidden automation
|
|
19
|
+
|
|
20
|
+
## end goal (how i wish the system will be onced finished)
|
|
21
|
+
- following what jarvis is doing is super easy to understand for a human
|
|
22
|
+
- everything that goes wrong e.g. failed tool calls, or errors from exec calls should be easy to understand for a human
|
|
23
|
+
- transparency is very important, without this we can not easily debug or improve the system
|
|
24
|
+
- it should work autonomously, i.e. it does not need any instructions from me on decicions but instead decide itself how to achieve whatever its doing
|
|
25
|
+
- when working autonomously on a task its given it should know when to stop (task is done in a good quality)
|
|
26
|
+
|
|
27
|
+
## Implementation Roadmap
|
|
28
|
+
|
|
29
|
+
To reach v1, we will follow this order:
|
|
30
|
+
|
|
31
|
+
1. **Phase 1: Project Skeleton [x]**
|
|
32
|
+
- Scaffolding (`package.json`, folder structure).
|
|
33
|
+
- Basic HTTP server on port `18008`.
|
|
34
|
+
2. **Phase 2: Onboarding & Config [x]**
|
|
35
|
+
- `jarvis setup` CLI command.
|
|
36
|
+
- Persistence for API keys (`.env`) and settings (`settings.json`).
|
|
37
|
+
3. **Phase 3: Core Agent Loop [x]**
|
|
38
|
+
- Request/Response flow with OpenRouter.
|
|
39
|
+
- Serial tool execution logic (`new Function`).
|
|
40
|
+
- Basic session persistence.
|
|
41
|
+
- Seed tool: `list_dir` (runs `ls -la`) to verify the full loop end-to-end.
|
|
42
|
+
4. **Phase 4: Lifecycle Management [x]**
|
|
43
|
+
- CLI `start/stop/status` using programmatic PM2.
|
|
44
|
+
- Pre-flight configuration checks.
|
|
45
|
+
5. **Phase 5: Tools & Refinement [x]**
|
|
46
|
+
- Implementation of built-in tools (`exec`, `user_info`).
|
|
47
|
+
- Standardized logging (JSONL).
|
|
48
|
+
6. **Phase 6: UI [x]**
|
|
49
|
+
- Vite + React + Tailwind chat interface in `ui/`.
|
|
50
|
+
- Server serves built UI as static files.
|
|
51
|
+
|
|
52
|
+
## Usage
|
|
53
|
+
|
|
54
|
+
### First-time setup
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
npm install
|
|
58
|
+
npm run setup
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
This prompts for your OpenRouter API key and model selection.
|
|
62
|
+
|
|
63
|
+
### Running in production (background via PM2)
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
npm start # start the server in the background (auto-restarts on crash)
|
|
67
|
+
npm run status # check if it's running (PID, uptime, restarts)
|
|
68
|
+
npm run stop # stop the background server
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The server runs on port `18008`. Open `http://localhost:18008` to use the chat UI.
|
|
72
|
+
|
|
73
|
+
Logs are written to `~/.jarvis/logs/server.log`.
|
|
74
|
+
|
|
75
|
+
### Running in development (foreground)
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
npm run dev # start the server with nodemon (auto-reload on file changes)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
To develop the UI with hot-reload:
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
cd ui
|
|
85
|
+
npm install # first time only
|
|
86
|
+
npm run dev # starts Vite on port 5173, proxies /api to localhost:18008
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
You need both the server (`npm run dev` in root) and the UI dev server (`npm run dev` in `ui/`) running at the same time. Open `http://localhost:5173` during UI development.
|
|
90
|
+
|
|
91
|
+
### Building the UI for production
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
cd ui
|
|
95
|
+
npm run build
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
This outputs to `ui/dist/`, which the Express server serves as static files automatically.
|
|
99
|
+
|
|
100
|
+
### Global install
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
npm i -g @ducci/jarvis
|
|
104
|
+
jarvis setup
|
|
105
|
+
jarvis start
|
|
106
|
+
jarvis stop
|
|
107
|
+
jarvis status
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Security & Local Usage
|
|
111
|
+
|
|
112
|
+
Jarvis is designed for **local use only**. There is no built-in authentication for the API. It is intended to be run on a trusted machine (e.g., your laptop or a private server) where the port is not exposed to the public internet.
|
|
113
|
+
|
|
114
|
+
## Current status, IMPORTANT instructions for LLMs:
|
|
115
|
+
- Phase 1 (Skeleton) is implemented.
|
|
116
|
+
- Phase 2 (Onboarding & Config) is implemented.
|
|
117
|
+
- Phase 3 (Core Agent Loop) is implemented.
|
|
118
|
+
- Phase 4 (Lifecycle Management) is implemented.
|
|
119
|
+
- Phase 5 (Tools & Refinement) is implemented.
|
|
120
|
+
- Phase 6 (UI) is implemented.
|
|
121
|
+
- the scope is only this jarvis folder and each file in it. no parent folders or any other outside of this
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ducci/jarvis",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A fully automated agent system that lives on a server.",
|
|
5
|
+
"main": "./src/index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"jarvis": "./src/index.js",
|
|
9
|
+
"jarvis-setup": "./src/scripts/onboarding.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src",
|
|
13
|
+
"ui/dist"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"start": "node ./src/index.js start",
|
|
17
|
+
"stop": "node ./src/index.js stop",
|
|
18
|
+
"status": "node ./src/index.js status",
|
|
19
|
+
"setup": "node ./src/scripts/onboarding.js",
|
|
20
|
+
"dev": "nodemon ./src/server/app.js",
|
|
21
|
+
"prepare": "cd ui && npm install && npm run build",
|
|
22
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
23
|
+
},
|
|
24
|
+
"keywords": ["agent", "ai", "openrouter", "cli", "server"],
|
|
25
|
+
"author": "ducci",
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18"
|
|
28
|
+
},
|
|
29
|
+
"license": "ISC",
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"chalk": "^5.6.2",
|
|
32
|
+
"commander": "^14.0.3",
|
|
33
|
+
"dotenv": "^17.3.1",
|
|
34
|
+
"express": "^5.2.1",
|
|
35
|
+
"inquirer": "^12.11.1",
|
|
36
|
+
"openai": "^6.22.0",
|
|
37
|
+
"pm2": "^6.0.14"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"nodemon": "^3.1.14"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { spawnSync } from 'child_process';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import os from 'os';
|
|
9
|
+
import pm2 from 'pm2';
|
|
10
|
+
|
|
11
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
12
|
+
const __dirname = path.dirname(__filename);
|
|
13
|
+
|
|
14
|
+
const JARVIS_DIR = path.join(os.homedir(), '.jarvis');
|
|
15
|
+
const ENV_FILE = path.join(JARVIS_DIR, '.env');
|
|
16
|
+
const SETTINGS_FILE = path.join(JARVIS_DIR, 'data', 'config', 'settings.json');
|
|
17
|
+
const SERVER_SCRIPT = path.join(__dirname, 'server', 'app.js');
|
|
18
|
+
const PROCESS_NAME = 'jarvis-server';
|
|
19
|
+
const LOG_FILE = path.join(JARVIS_DIR, 'logs', 'server.log');
|
|
20
|
+
|
|
21
|
+
function preflight() {
|
|
22
|
+
if (!fs.existsSync(ENV_FILE)) {
|
|
23
|
+
console.error('Error: .env not found. Please run `jarvis setup` first.');
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
if (!fs.existsSync(SETTINGS_FILE)) {
|
|
27
|
+
console.error('Error: settings.json not found. Please run `jarvis setup` first.');
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
// Ensure logs directory exists for PM2 output
|
|
31
|
+
fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function connectPm2() {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
pm2.connect((err) => {
|
|
37
|
+
if (err) reject(err);
|
|
38
|
+
else resolve();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function pm2Start() {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
pm2.start({
|
|
46
|
+
script: SERVER_SCRIPT,
|
|
47
|
+
name: PROCESS_NAME,
|
|
48
|
+
autorestart: true,
|
|
49
|
+
output: LOG_FILE,
|
|
50
|
+
error: LOG_FILE,
|
|
51
|
+
merge_logs: true,
|
|
52
|
+
}, (err, proc) => {
|
|
53
|
+
if (err) reject(err);
|
|
54
|
+
else resolve(proc);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function pm2Stop() {
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
pm2.stop(PROCESS_NAME, (err) => {
|
|
62
|
+
if (err) reject(err);
|
|
63
|
+
else resolve();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function pm2Delete() {
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
pm2.delete(PROCESS_NAME, (err) => {
|
|
71
|
+
if (err) reject(err);
|
|
72
|
+
else resolve();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function pm2Describe() {
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
pm2.describe(PROCESS_NAME, (err, desc) => {
|
|
80
|
+
if (err) reject(err);
|
|
81
|
+
else resolve(desc);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const program = new Command();
|
|
87
|
+
|
|
88
|
+
program
|
|
89
|
+
.name('jarvis')
|
|
90
|
+
.description('A fully automated agent system that lives on a server.')
|
|
91
|
+
.version('1.0.0');
|
|
92
|
+
|
|
93
|
+
program
|
|
94
|
+
.command('setup')
|
|
95
|
+
.description('Run interactive onboarding to configure API key and model.')
|
|
96
|
+
.action(() => {
|
|
97
|
+
const onboardingScript = path.join(__dirname, 'scripts', 'onboarding.js');
|
|
98
|
+
spawnSync('node', [onboardingScript], { stdio: 'inherit' });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
program
|
|
102
|
+
.command('start')
|
|
103
|
+
.description('Start the Jarvis server in the background.')
|
|
104
|
+
.action(async () => {
|
|
105
|
+
preflight();
|
|
106
|
+
try {
|
|
107
|
+
await connectPm2();
|
|
108
|
+
// Check if already running
|
|
109
|
+
const desc = await pm2Describe().catch(() => []);
|
|
110
|
+
if (desc.length > 0 && desc[0].pm2_env?.status === 'online') {
|
|
111
|
+
console.log('Jarvis server is already running.');
|
|
112
|
+
pm2.disconnect();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
await pm2Start();
|
|
116
|
+
console.log('Jarvis server started.');
|
|
117
|
+
pm2.disconnect();
|
|
118
|
+
} catch (e) {
|
|
119
|
+
console.error('Failed to start Jarvis server:', e.message);
|
|
120
|
+
pm2.disconnect();
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
program
|
|
126
|
+
.command('stop')
|
|
127
|
+
.description('Stop the Jarvis server.')
|
|
128
|
+
.action(async () => {
|
|
129
|
+
try {
|
|
130
|
+
await connectPm2();
|
|
131
|
+
await pm2Stop();
|
|
132
|
+
await pm2Delete();
|
|
133
|
+
console.log('Jarvis server stopped.');
|
|
134
|
+
pm2.disconnect();
|
|
135
|
+
} catch (e) {
|
|
136
|
+
console.error('Failed to stop Jarvis server:', e.message);
|
|
137
|
+
pm2.disconnect();
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
program
|
|
143
|
+
.command('status')
|
|
144
|
+
.description('Display the status of the Jarvis server.')
|
|
145
|
+
.action(async () => {
|
|
146
|
+
try {
|
|
147
|
+
await connectPm2();
|
|
148
|
+
const desc = await pm2Describe().catch(() => []);
|
|
149
|
+
if (desc.length === 0) {
|
|
150
|
+
console.log('Jarvis server is not running.');
|
|
151
|
+
} else {
|
|
152
|
+
const proc = desc[0];
|
|
153
|
+
const env = proc.pm2_env || {};
|
|
154
|
+
console.log(`Name: ${proc.name}`);
|
|
155
|
+
console.log(`Status: ${env.status}`);
|
|
156
|
+
console.log(`PID: ${proc.pid}`);
|
|
157
|
+
console.log(`Uptime: ${env.pm_uptime ? new Date(env.pm_uptime).toISOString() : 'N/A'}`);
|
|
158
|
+
console.log(`Restarts: ${env.restart_time || 0}`);
|
|
159
|
+
console.log(`Log file: ${LOG_FILE}`);
|
|
160
|
+
}
|
|
161
|
+
pm2.disconnect();
|
|
162
|
+
} catch (e) {
|
|
163
|
+
console.error('Failed to get status:', e.message);
|
|
164
|
+
pm2.disconnect();
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
program.parse();
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
import inquirer from 'inquirer';
|
|
7
|
+
import chalk from 'chalk';
|
|
8
|
+
|
|
9
|
+
const jarvisDir = path.join(os.homedir(), '.jarvis');
|
|
10
|
+
const envFile = path.join(jarvisDir, '.env');
|
|
11
|
+
const configDir = path.join(jarvisDir, 'data', 'config');
|
|
12
|
+
const logsDir = path.join(jarvisDir, 'logs');
|
|
13
|
+
const settingsFile = path.join(configDir, 'settings.json');
|
|
14
|
+
|
|
15
|
+
function ensureDirectories() {
|
|
16
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
17
|
+
fs.mkdirSync(logsDir, { recursive: true });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function loadEnv() {
|
|
21
|
+
if (fs.existsSync(envFile)) {
|
|
22
|
+
const content = fs.readFileSync(envFile, 'utf8');
|
|
23
|
+
const match = content.match(/^OPENROUTER_API_KEY=(.*)$/m);
|
|
24
|
+
if (match) {
|
|
25
|
+
return match[1].trim();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function saveEnv(apiKey) {
|
|
32
|
+
let content = '';
|
|
33
|
+
if (fs.existsSync(envFile)) {
|
|
34
|
+
content = fs.readFileSync(envFile, 'utf8');
|
|
35
|
+
if (content.match(/^OPENROUTER_API_KEY=/m)) {
|
|
36
|
+
content = content.replace(/^OPENROUTER_API_KEY=.*$/m, `OPENROUTER_API_KEY=${apiKey}`);
|
|
37
|
+
} else {
|
|
38
|
+
content += `\nOPENROUTER_API_KEY=${apiKey}\n`;
|
|
39
|
+
}
|
|
40
|
+
} else {
|
|
41
|
+
content = `OPENROUTER_API_KEY=${apiKey}\n`;
|
|
42
|
+
}
|
|
43
|
+
fs.writeFileSync(envFile, content.trim() + '\n', 'utf8');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function loadSettings() {
|
|
47
|
+
if (fs.existsSync(settingsFile)) {
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
|
|
50
|
+
} catch (e) {
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return {};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function saveSettings(settings) {
|
|
58
|
+
fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2), 'utf8');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function fetchModels(apiKey) {
|
|
62
|
+
console.log(chalk.blue('Fetching models from OpenRouter...'));
|
|
63
|
+
try {
|
|
64
|
+
const response = await fetch('https://openrouter.ai/api/v1/models', {
|
|
65
|
+
headers: {
|
|
66
|
+
'Authorization': `Bearer ${apiKey}`
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
if (!response.ok) {
|
|
70
|
+
throw new Error(`HTTP error! status: ${response.status}`);
|
|
71
|
+
}
|
|
72
|
+
const data = await response.json();
|
|
73
|
+
return data.data;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
console.error(chalk.red('Failed to fetch models:'), error.message);
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function run() {
|
|
81
|
+
ensureDirectories();
|
|
82
|
+
|
|
83
|
+
console.log(chalk.green.bold('\n=== Jarvis Setup ===\n'));
|
|
84
|
+
|
|
85
|
+
// --- API KEY STEP ---
|
|
86
|
+
let existingKey = loadEnv();
|
|
87
|
+
let apiKey = existingKey;
|
|
88
|
+
|
|
89
|
+
if (existingKey) {
|
|
90
|
+
const { keepKey } = await inquirer.prompt([
|
|
91
|
+
{
|
|
92
|
+
type: 'confirm',
|
|
93
|
+
name: 'keepKey',
|
|
94
|
+
message: 'An OPENROUTER_API_KEY is already configured. Do you want to keep it?',
|
|
95
|
+
default: true
|
|
96
|
+
}
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
if (!keepKey) {
|
|
100
|
+
const { newKey } = await inquirer.prompt([
|
|
101
|
+
{
|
|
102
|
+
type: 'password',
|
|
103
|
+
name: 'newKey',
|
|
104
|
+
message: 'Enter your OpenRouter API key:',
|
|
105
|
+
validate: (input) => input.length >= 10 || 'API key must be at least 10 characters long.'
|
|
106
|
+
}
|
|
107
|
+
]);
|
|
108
|
+
apiKey = newKey;
|
|
109
|
+
saveEnv(apiKey);
|
|
110
|
+
console.log(chalk.green('API key updated.'));
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
const { newKey } = await inquirer.prompt([
|
|
114
|
+
{
|
|
115
|
+
type: 'password',
|
|
116
|
+
name: 'newKey',
|
|
117
|
+
message: 'Enter your OpenRouter API key:',
|
|
118
|
+
validate: (input) => input.length >= 10 || 'API key must be at least 10 characters long.'
|
|
119
|
+
}
|
|
120
|
+
]);
|
|
121
|
+
apiKey = newKey;
|
|
122
|
+
saveEnv(apiKey);
|
|
123
|
+
console.log(chalk.green('API key saved.'));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// --- MODEL SELECTION STEP ---
|
|
127
|
+
let settings = loadSettings();
|
|
128
|
+
let selectedModel = settings.selectedModel;
|
|
129
|
+
|
|
130
|
+
if (selectedModel) {
|
|
131
|
+
const { keepModel } = await inquirer.prompt([
|
|
132
|
+
{
|
|
133
|
+
type: 'list',
|
|
134
|
+
name: 'keepModel',
|
|
135
|
+
message: `Current model is ${chalk.yellow(selectedModel)}. Do you want to keep it or change it?`,
|
|
136
|
+
choices: [
|
|
137
|
+
{ name: 'Keep current model', value: true },
|
|
138
|
+
{ name: 'Change model', value: false }
|
|
139
|
+
]
|
|
140
|
+
}
|
|
141
|
+
]);
|
|
142
|
+
|
|
143
|
+
if (!keepModel) {
|
|
144
|
+
selectedModel = null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!selectedModel) {
|
|
149
|
+
const { modelSelectionMethod } = await inquirer.prompt([
|
|
150
|
+
{
|
|
151
|
+
type: 'list',
|
|
152
|
+
name: 'modelSelectionMethod',
|
|
153
|
+
message: 'How would you like to select a model?',
|
|
154
|
+
choices: [
|
|
155
|
+
{ name: 'Browse OpenRouter models', value: 'browse' },
|
|
156
|
+
{ name: 'Enter model ID manually', value: 'manual' }
|
|
157
|
+
]
|
|
158
|
+
}
|
|
159
|
+
]);
|
|
160
|
+
|
|
161
|
+
if (modelSelectionMethod === 'manual') {
|
|
162
|
+
const { manualModel } = await inquirer.prompt([
|
|
163
|
+
{
|
|
164
|
+
type: 'input',
|
|
165
|
+
name: 'manualModel',
|
|
166
|
+
message: 'Enter OpenRouter model ID (e.g., anthropic/claude-3.5-sonnet):',
|
|
167
|
+
validate: (input) => input.trim().length > 0 || 'Model ID cannot be empty.'
|
|
168
|
+
}
|
|
169
|
+
]);
|
|
170
|
+
selectedModel = manualModel.trim();
|
|
171
|
+
} else {
|
|
172
|
+
const models = await fetchModels(apiKey);
|
|
173
|
+
if (models.length === 0) {
|
|
174
|
+
console.log(chalk.yellow('Falling back to manual entry due to fetch failure.'));
|
|
175
|
+
const { manualModel } = await inquirer.prompt([
|
|
176
|
+
{
|
|
177
|
+
type: 'input',
|
|
178
|
+
name: 'manualModel',
|
|
179
|
+
message: 'Enter OpenRouter model ID:',
|
|
180
|
+
validate: (input) => input.trim().length > 0 || 'Model ID cannot be empty.'
|
|
181
|
+
}
|
|
182
|
+
]);
|
|
183
|
+
selectedModel = manualModel.trim();
|
|
184
|
+
} else {
|
|
185
|
+
// Sort models: free first, then alphabetical
|
|
186
|
+
models.sort((a, b) => {
|
|
187
|
+
const isFreeA = a.pricing && parseFloat(a.pricing.prompt) === 0 && parseFloat(a.pricing.completion) === 0;
|
|
188
|
+
const isFreeB = b.pricing && parseFloat(b.pricing.prompt) === 0 && parseFloat(b.pricing.completion) === 0;
|
|
189
|
+
|
|
190
|
+
if (isFreeA && !isFreeB) return -1;
|
|
191
|
+
if (!isFreeA && isFreeB) return 1;
|
|
192
|
+
return a.id.localeCompare(b.id);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const choices = models.map(m => {
|
|
196
|
+
const isFree = m.pricing && parseFloat(m.pricing.prompt) === 0 && parseFloat(m.pricing.completion) === 0;
|
|
197
|
+
return {
|
|
198
|
+
name: `${m.id} ${isFree ? chalk.green('(Free)') : ''}`,
|
|
199
|
+
value: m.id
|
|
200
|
+
};
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const { browsedModel } = await inquirer.prompt([
|
|
204
|
+
{
|
|
205
|
+
type: 'list',
|
|
206
|
+
name: 'browsedModel',
|
|
207
|
+
message: 'Select a model:',
|
|
208
|
+
choices: choices,
|
|
209
|
+
pageSize: 20
|
|
210
|
+
}
|
|
211
|
+
]);
|
|
212
|
+
selectedModel = browsedModel;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
settings.selectedModel = selectedModel;
|
|
218
|
+
if (!settings.fallbackModel) {
|
|
219
|
+
settings.fallbackModel = 'openrouter/free';
|
|
220
|
+
}
|
|
221
|
+
if (settings.maxIterations === undefined) {
|
|
222
|
+
settings.maxIterations = 10;
|
|
223
|
+
}
|
|
224
|
+
if (settings.maxHandoffs === undefined) {
|
|
225
|
+
settings.maxHandoffs = 5;
|
|
226
|
+
}
|
|
227
|
+
if (settings.port === undefined) {
|
|
228
|
+
settings.port = 18008;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
saveSettings(settings);
|
|
232
|
+
console.log(chalk.green(`\nModel ${chalk.bold(selectedModel)} saved to settings.`));
|
|
233
|
+
console.log(chalk.green.bold('Setup complete! You can now start the Jarvis server.'));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
run().catch(error => {
|
|
237
|
+
console.error(chalk.red('Setup failed:'), error);
|
|
238
|
+
process.exit(1);
|
|
239
|
+
});
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import OpenAI from 'openai';
|
|
3
|
+
import { loadSystemPrompt, resolveSystemPrompt } from './config.js';
|
|
4
|
+
import { loadSession, saveSession, createSession } from './sessions.js';
|
|
5
|
+
import { loadTools, getToolDefinitions, executeTool } from './tools.js';
|
|
6
|
+
import { appendLog } from './logging.js';
|
|
7
|
+
|
|
8
|
+
const WRAP_UP_NOTE = `[System: You have reached the iteration limit. This is your final response for this run.
|
|
9
|
+
Respond with your normal JSON, but add a checkpoint field:
|
|
10
|
+
|
|
11
|
+
{
|
|
12
|
+
"response": "Brief message to the user that the task is still in progress.",
|
|
13
|
+
"logSummary": "Human-readable summary of what happened in this run.",
|
|
14
|
+
"checkpoint": {
|
|
15
|
+
"progress": "What has been fully completed so far.",
|
|
16
|
+
"remaining": "What still needs to be done to finish the task."
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
The checkpoint field will be used to automatically resume the task in the next run.]`;
|
|
21
|
+
|
|
22
|
+
async function callModel(client, model, messages, tools) {
|
|
23
|
+
const params = { model, messages };
|
|
24
|
+
if (tools && tools.length > 0) {
|
|
25
|
+
params.tools = tools;
|
|
26
|
+
}
|
|
27
|
+
return await client.chat.completions.create(params);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function callModelWithFallback(client, config, messages, tools) {
|
|
31
|
+
try {
|
|
32
|
+
return await callModel(client, config.selectedModel, messages, tools);
|
|
33
|
+
} catch (primaryErr) {
|
|
34
|
+
try {
|
|
35
|
+
return await callModel(client, config.fallbackModel, messages, tools);
|
|
36
|
+
} catch (fallbackErr) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`Both primary (${config.selectedModel}) and fallback (${config.fallbackModel}) models failed. Last error: ${fallbackErr.message}`
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Runs a single agent loop up to maxIterations.
|
|
46
|
+
* Returns { iteration, response, logSummary, status, runToolCalls, checkpoint }.
|
|
47
|
+
*/
|
|
48
|
+
async function runAgentLoop(client, config, session, tools, toolDefs, prepareMessages) {
|
|
49
|
+
let iteration = 0;
|
|
50
|
+
const runToolCalls = [];
|
|
51
|
+
let done = false;
|
|
52
|
+
let response = '';
|
|
53
|
+
let logSummary = '';
|
|
54
|
+
let status = 'ok';
|
|
55
|
+
|
|
56
|
+
while (iteration < config.maxIterations) {
|
|
57
|
+
iteration++;
|
|
58
|
+
|
|
59
|
+
let modelResult;
|
|
60
|
+
try {
|
|
61
|
+
modelResult = await callModelWithFallback(client, config, prepareMessages(session.messages), toolDefs);
|
|
62
|
+
} catch (e) {
|
|
63
|
+
return {
|
|
64
|
+
iteration,
|
|
65
|
+
response: e.message,
|
|
66
|
+
logSummary: `Model error on iteration ${iteration}: ${e.message}`,
|
|
67
|
+
status: 'model_error',
|
|
68
|
+
runToolCalls,
|
|
69
|
+
checkpoint: null,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const assistantMessage = modelResult.choices[0].message;
|
|
74
|
+
|
|
75
|
+
// Tool calls present — execute serially and continue loop
|
|
76
|
+
if (assistantMessage.tool_calls && assistantMessage.tool_calls.length > 0) {
|
|
77
|
+
session.messages.push({
|
|
78
|
+
role: 'assistant',
|
|
79
|
+
content: assistantMessage.content || null,
|
|
80
|
+
tool_calls: assistantMessage.tool_calls,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
for (const toolCall of assistantMessage.tool_calls) {
|
|
84
|
+
const toolName = toolCall.function.name;
|
|
85
|
+
let toolArgs;
|
|
86
|
+
try {
|
|
87
|
+
toolArgs = JSON.parse(toolCall.function.arguments || '{}');
|
|
88
|
+
} catch {
|
|
89
|
+
toolArgs = {};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let result;
|
|
93
|
+
let toolStatus = 'ok';
|
|
94
|
+
try {
|
|
95
|
+
result = await executeTool(tools, toolName, toolArgs);
|
|
96
|
+
} catch (e) {
|
|
97
|
+
result = { status: 'error', error: e.message };
|
|
98
|
+
toolStatus = 'error';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const resultStr = typeof result === 'string' ? result : JSON.stringify(result);
|
|
102
|
+
runToolCalls.push({ name: toolName, args: toolArgs, status: toolStatus, result: resultStr });
|
|
103
|
+
|
|
104
|
+
session.messages.push({
|
|
105
|
+
role: 'tool',
|
|
106
|
+
tool_call_id: toolCall.id,
|
|
107
|
+
content: resultStr,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// No tool calls — final response
|
|
115
|
+
const content = assistantMessage.content || '';
|
|
116
|
+
session.messages.push({ role: 'assistant', content });
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const parsed = JSON.parse(content);
|
|
120
|
+
response = parsed.response || content;
|
|
121
|
+
logSummary = parsed.logSummary || '';
|
|
122
|
+
} catch {
|
|
123
|
+
response = content;
|
|
124
|
+
logSummary = 'Model returned non-JSON final response.';
|
|
125
|
+
status = 'format_error';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
done = true;
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Hit iteration limit without completing — wrap-up call
|
|
133
|
+
if (!done) {
|
|
134
|
+
const wrapUpMessages = [
|
|
135
|
+
...prepareMessages(session.messages),
|
|
136
|
+
{ role: 'user', content: WRAP_UP_NOTE },
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
let wrapUpResult;
|
|
140
|
+
try {
|
|
141
|
+
wrapUpResult = await callModelWithFallback(client, config, wrapUpMessages, []);
|
|
142
|
+
} catch (e) {
|
|
143
|
+
return {
|
|
144
|
+
iteration,
|
|
145
|
+
response: 'Agent reached iteration limit and wrap-up call also failed.',
|
|
146
|
+
logSummary: `Iteration limit reached. Wrap-up failed: ${e.message}`,
|
|
147
|
+
status: 'model_error',
|
|
148
|
+
runToolCalls,
|
|
149
|
+
checkpoint: null,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const wrapUpContent = wrapUpResult.choices[0].message.content || '';
|
|
154
|
+
// Store the wrap-up response (but NOT the temporary system note)
|
|
155
|
+
session.messages.push({ role: 'assistant', content: wrapUpContent });
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const parsed = JSON.parse(wrapUpContent);
|
|
159
|
+
response = parsed.response || '';
|
|
160
|
+
logSummary = parsed.logSummary || '';
|
|
161
|
+
|
|
162
|
+
if (parsed.checkpoint) {
|
|
163
|
+
return {
|
|
164
|
+
iteration,
|
|
165
|
+
response,
|
|
166
|
+
logSummary,
|
|
167
|
+
status: 'checkpoint_reached',
|
|
168
|
+
runToolCalls,
|
|
169
|
+
checkpoint: parsed.checkpoint,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
} catch {
|
|
173
|
+
response = wrapUpContent;
|
|
174
|
+
logSummary = 'Wrap-up response was not valid JSON.';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
status = 'checkpoint_reached';
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { iteration, response, logSummary, status, runToolCalls, checkpoint: null };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Main entry point: handles a single POST /api/chat request.
|
|
185
|
+
* Manages the handoff loop across multiple agent runs.
|
|
186
|
+
*/
|
|
187
|
+
export async function handleChat(config, requestSessionId, userMessage) {
|
|
188
|
+
const client = new OpenAI({
|
|
189
|
+
baseURL: 'https://openrouter.ai/api/v1',
|
|
190
|
+
apiKey: config.apiKey,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const systemPromptTemplate = loadSystemPrompt();
|
|
194
|
+
const sessionId = requestSessionId || crypto.randomUUID();
|
|
195
|
+
let session = loadSession(sessionId);
|
|
196
|
+
|
|
197
|
+
if (!session) {
|
|
198
|
+
session = createSession(systemPromptTemplate);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Append user message and reset handoff counter
|
|
202
|
+
session.messages.push({ role: 'user', content: userMessage });
|
|
203
|
+
session.metadata.handoffCount = 0;
|
|
204
|
+
|
|
205
|
+
const tools = loadTools();
|
|
206
|
+
const toolDefs = getToolDefinitions(tools);
|
|
207
|
+
|
|
208
|
+
// Resolves {{user_info}} in system prompt at runtime (never persisted)
|
|
209
|
+
function prepareMessages(messages) {
|
|
210
|
+
return messages.map((msg, i) => {
|
|
211
|
+
if (i === 0 && msg.role === 'system') {
|
|
212
|
+
return { ...msg, content: resolveSystemPrompt(msg.content) };
|
|
213
|
+
}
|
|
214
|
+
return msg;
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const allToolCalls = [];
|
|
219
|
+
let finalResponse = '';
|
|
220
|
+
let finalLogSummary = '';
|
|
221
|
+
let finalStatus = 'ok';
|
|
222
|
+
|
|
223
|
+
// Handoff loop
|
|
224
|
+
while (true) {
|
|
225
|
+
const run = await runAgentLoop(client, config, session, tools, toolDefs, prepareMessages);
|
|
226
|
+
allToolCalls.push(...run.runToolCalls);
|
|
227
|
+
|
|
228
|
+
if (run.status !== 'checkpoint_reached') {
|
|
229
|
+
finalResponse = run.response;
|
|
230
|
+
finalLogSummary = run.logSummary;
|
|
231
|
+
finalStatus = run.status;
|
|
232
|
+
|
|
233
|
+
appendLog(sessionId, {
|
|
234
|
+
iteration: run.iteration,
|
|
235
|
+
model: config.selectedModel,
|
|
236
|
+
userInput: userMessage,
|
|
237
|
+
toolCalls: allToolCalls,
|
|
238
|
+
response: finalResponse,
|
|
239
|
+
logSummary: finalLogSummary,
|
|
240
|
+
status: finalStatus,
|
|
241
|
+
});
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Checkpoint reached — log this run
|
|
246
|
+
appendLog(sessionId, {
|
|
247
|
+
iteration: run.iteration,
|
|
248
|
+
model: config.selectedModel,
|
|
249
|
+
userInput: userMessage,
|
|
250
|
+
toolCalls: run.runToolCalls,
|
|
251
|
+
response: run.response,
|
|
252
|
+
logSummary: run.logSummary,
|
|
253
|
+
status: 'checkpoint_reached',
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// Check handoff limit
|
|
257
|
+
session.metadata.handoffCount++;
|
|
258
|
+
if (session.metadata.handoffCount > config.maxHandoffs) {
|
|
259
|
+
finalResponse = run.response;
|
|
260
|
+
finalLogSummary = run.logSummary;
|
|
261
|
+
finalStatus = 'intervention_required';
|
|
262
|
+
|
|
263
|
+
appendLog(sessionId, {
|
|
264
|
+
iteration: 0,
|
|
265
|
+
model: config.selectedModel,
|
|
266
|
+
userInput: userMessage,
|
|
267
|
+
toolCalls: [],
|
|
268
|
+
response: finalResponse,
|
|
269
|
+
logSummary: 'Max handoffs exceeded. Human intervention required.',
|
|
270
|
+
status: 'intervention_required',
|
|
271
|
+
});
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Resume with checkpoint.remaining as new prompt
|
|
276
|
+
session.messages.push({ role: 'user', content: run.checkpoint.remaining });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
saveSession(sessionId, session);
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
sessionId,
|
|
283
|
+
response: finalResponse,
|
|
284
|
+
logSummary: finalLogSummary,
|
|
285
|
+
toolCalls: allToolCalls,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { loadConfig, ensureDirectories } from './config.js';
|
|
5
|
+
import { seedTools } from './tools.js';
|
|
6
|
+
import { handleChat } from './agent.js';
|
|
7
|
+
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
const __dirname = path.dirname(__filename);
|
|
10
|
+
|
|
11
|
+
const app = express();
|
|
12
|
+
app.use(express.json());
|
|
13
|
+
|
|
14
|
+
// Serve the built UI as static files
|
|
15
|
+
const uiDist = path.join(__dirname, '..', '..', 'ui', 'dist');
|
|
16
|
+
app.use(express.static(uiDist));
|
|
17
|
+
|
|
18
|
+
app.get('/health', (req, res) => {
|
|
19
|
+
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
app.post('/api/chat', async (req, res) => {
|
|
23
|
+
const { sessionId, message } = req.body;
|
|
24
|
+
|
|
25
|
+
if (!message || typeof message !== 'string' || message.trim().length === 0) {
|
|
26
|
+
return res.status(400).json({ error: 'message is required', status: 'format_error' });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const result = await handleChat(app.locals.config, sessionId || null, message.trim());
|
|
31
|
+
res.json(result);
|
|
32
|
+
} catch (e) {
|
|
33
|
+
console.error('Chat error:', e);
|
|
34
|
+
res.status(500).json({ error: e.message, status: 'model_error' });
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// SPA fallback: serve index.html for non-API routes
|
|
39
|
+
app.use((req, res, next) => {
|
|
40
|
+
if (req.method === 'GET' && !req.path.startsWith('/api') && !req.path.startsWith('/health')) {
|
|
41
|
+
res.sendFile(path.join(uiDist, 'index.html'), (err) => {
|
|
42
|
+
if (err) next();
|
|
43
|
+
});
|
|
44
|
+
} else {
|
|
45
|
+
next();
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
function startServer() {
|
|
50
|
+
let config;
|
|
51
|
+
try {
|
|
52
|
+
config = loadConfig();
|
|
53
|
+
} catch (e) {
|
|
54
|
+
console.error(e.message);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
ensureDirectories();
|
|
59
|
+
seedTools();
|
|
60
|
+
|
|
61
|
+
app.locals.config = config;
|
|
62
|
+
|
|
63
|
+
const PORT = config.port;
|
|
64
|
+
app.listen(PORT, () => {
|
|
65
|
+
console.log(`Jarvis server listening on port ${PORT}`);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
70
|
+
startServer();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export { app, startServer };
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import dotenv from 'dotenv';
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = path.dirname(__filename);
|
|
9
|
+
|
|
10
|
+
const JARVIS_DIR = path.join(os.homedir(), '.jarvis');
|
|
11
|
+
|
|
12
|
+
export const PATHS = {
|
|
13
|
+
jarvisDir: JARVIS_DIR,
|
|
14
|
+
envFile: path.join(JARVIS_DIR, '.env'),
|
|
15
|
+
configDir: path.join(JARVIS_DIR, 'data', 'config'),
|
|
16
|
+
settingsFile: path.join(JARVIS_DIR, 'data', 'config', 'settings.json'),
|
|
17
|
+
conversationsDir: path.join(JARVIS_DIR, 'data', 'conversations'),
|
|
18
|
+
toolsDir: path.join(JARVIS_DIR, 'data', 'tools'),
|
|
19
|
+
toolsFile: path.join(JARVIS_DIR, 'data', 'tools', 'tools.json'),
|
|
20
|
+
logsDir: path.join(JARVIS_DIR, 'logs'),
|
|
21
|
+
userInfoFile: path.join(JARVIS_DIR, 'data', 'user-info.json'),
|
|
22
|
+
systemPromptFile: path.join(__dirname, '..', '..', 'docs', 'system-prompt.md'),
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function ensureDirectories() {
|
|
26
|
+
for (const dir of [PATHS.configDir, PATHS.conversationsDir, PATHS.toolsDir, PATHS.logsDir]) {
|
|
27
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function loadConfig() {
|
|
32
|
+
dotenv.config({ path: PATHS.envFile });
|
|
33
|
+
|
|
34
|
+
const apiKey = process.env.OPENROUTER_API_KEY;
|
|
35
|
+
if (!apiKey) {
|
|
36
|
+
throw new Error('OPENROUTER_API_KEY not found. Run `jarvis setup` first.');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!fs.existsSync(PATHS.settingsFile)) {
|
|
40
|
+
throw new Error('settings.json not found. Run `jarvis setup` first.');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const settings = JSON.parse(fs.readFileSync(PATHS.settingsFile, 'utf8'));
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
apiKey,
|
|
47
|
+
selectedModel: settings.selectedModel,
|
|
48
|
+
fallbackModel: settings.fallbackModel || 'openrouter/free',
|
|
49
|
+
maxIterations: settings.maxIterations || 10,
|
|
50
|
+
maxHandoffs: settings.maxHandoffs || 5,
|
|
51
|
+
port: settings.port || 18008,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function loadSystemPrompt() {
|
|
56
|
+
const content = fs.readFileSync(PATHS.systemPromptFile, 'utf8');
|
|
57
|
+
const match = content.match(/```\n([\s\S]*?)```/);
|
|
58
|
+
if (!match) throw new Error('Could not parse system prompt from docs/system-prompt.md');
|
|
59
|
+
return match[1].trim();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function resolveSystemPrompt(promptTemplate) {
|
|
63
|
+
let userInfo = '(none yet)';
|
|
64
|
+
try {
|
|
65
|
+
const raw = fs.readFileSync(PATHS.userInfoFile, 'utf8');
|
|
66
|
+
const { items } = JSON.parse(raw);
|
|
67
|
+
if (items && items.length > 0) {
|
|
68
|
+
userInfo = items.map(i => `- ${i.key}: ${i.value}`).join('\n');
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// File doesn't exist yet
|
|
72
|
+
}
|
|
73
|
+
return promptTemplate.replace('{{user_info}}', userInfo);
|
|
74
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { PATHS } from './config.js';
|
|
4
|
+
|
|
5
|
+
export function appendLog(sessionId, entry) {
|
|
6
|
+
const logFile = path.join(PATHS.logsDir, `session-${sessionId}.jsonl`);
|
|
7
|
+
const line = JSON.stringify({ ts: new Date().toISOString(), sessionId, ...entry }) + '\n';
|
|
8
|
+
fs.appendFileSync(logFile, line, 'utf8');
|
|
9
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { PATHS } from './config.js';
|
|
4
|
+
|
|
5
|
+
export function loadSession(sessionId) {
|
|
6
|
+
const filePath = path.join(PATHS.conversationsDir, `${sessionId}.json`);
|
|
7
|
+
try {
|
|
8
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
9
|
+
} catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function saveSession(sessionId, session) {
|
|
15
|
+
session.metadata.updatedAt = new Date().toISOString();
|
|
16
|
+
const filePath = path.join(PATHS.conversationsDir, `${sessionId}.json`);
|
|
17
|
+
fs.writeFileSync(filePath, JSON.stringify(session, null, 2), 'utf8');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createSession(systemPromptTemplate) {
|
|
21
|
+
return {
|
|
22
|
+
metadata: {
|
|
23
|
+
handoffCount: 0,
|
|
24
|
+
createdAt: new Date().toISOString(),
|
|
25
|
+
updatedAt: new Date().toISOString(),
|
|
26
|
+
},
|
|
27
|
+
messages: [
|
|
28
|
+
{ role: 'system', content: systemPromptTemplate },
|
|
29
|
+
],
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import { createRequire } from 'module';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { PATHS } from './config.js';
|
|
5
|
+
|
|
6
|
+
const _require = createRequire(import.meta.url);
|
|
7
|
+
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
|
8
|
+
|
|
9
|
+
const SEED_TOOLS = {
|
|
10
|
+
list_dir: {
|
|
11
|
+
definition: {
|
|
12
|
+
type: 'function',
|
|
13
|
+
function: {
|
|
14
|
+
name: 'list_dir',
|
|
15
|
+
description: 'List directory contents (similar to ls -la). Use this to explore the filesystem and see what files and directories exist at a given path.',
|
|
16
|
+
parameters: {
|
|
17
|
+
type: 'object',
|
|
18
|
+
properties: {
|
|
19
|
+
path: {
|
|
20
|
+
type: 'string',
|
|
21
|
+
description: 'Directory path to list. Defaults to the current working directory if omitted.',
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
required: [],
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
code: 'const targetPath = args.path || process.cwd(); const resolved = path.resolve(targetPath); const { execSync } = require("child_process"); const output = execSync(`ls -la "${resolved}"`, { encoding: "utf8" }); return { status: "ok", path: resolved, output };',
|
|
29
|
+
},
|
|
30
|
+
exec: {
|
|
31
|
+
definition: {
|
|
32
|
+
type: 'function',
|
|
33
|
+
function: {
|
|
34
|
+
name: 'exec',
|
|
35
|
+
description: 'Execute an arbitrary shell command on the server. Returns stdout, stderr, and exit code. Use this for any system operation: running scripts, installing packages, managing files, etc.',
|
|
36
|
+
parameters: {
|
|
37
|
+
type: 'object',
|
|
38
|
+
properties: {
|
|
39
|
+
cmd: {
|
|
40
|
+
type: 'string',
|
|
41
|
+
description: 'The shell command to execute.',
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
required: ['cmd'],
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
code: 'const { execSync } = require("child_process"); try { const stdout = execSync(args.cmd, { encoding: "utf8", timeout: 60000 }); return { status: "ok", exitCode: 0, stdout, stderr: "" }; } catch (e) { return { status: "error", exitCode: e.status || 1, stdout: e.stdout || "", stderr: e.stderr || e.message }; }',
|
|
49
|
+
},
|
|
50
|
+
save_user_info: {
|
|
51
|
+
definition: {
|
|
52
|
+
type: 'function',
|
|
53
|
+
function: {
|
|
54
|
+
name: 'save_user_info',
|
|
55
|
+
description: 'Persist facts about the user (e.g., name, timezone, preferences). Items with the same key are overwritten. Use this whenever the user shares personal information worth remembering.',
|
|
56
|
+
parameters: {
|
|
57
|
+
type: 'object',
|
|
58
|
+
properties: {
|
|
59
|
+
items: {
|
|
60
|
+
type: 'array',
|
|
61
|
+
items: {
|
|
62
|
+
type: 'object',
|
|
63
|
+
properties: {
|
|
64
|
+
key: { type: 'string', description: 'Fact identifier (e.g., "timezone", "name").' },
|
|
65
|
+
value: { type: 'string', description: 'The fact value.' },
|
|
66
|
+
},
|
|
67
|
+
required: ['key', 'value'],
|
|
68
|
+
},
|
|
69
|
+
description: 'Array of key-value facts to save.',
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
required: ['items'],
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
code: `const filePath = path.join(process.env.HOME, '.jarvis/data/user-info.json'); const raw = await fs.promises.readFile(filePath, 'utf8').catch(() => '{"items":[]}'); const data = JSON.parse(raw); const items = args.items || []; for (const item of items) { const idx = data.items.findIndex(i => i.key === item.key); const entry = { key: item.key, value: item.value, ts: new Date().toISOString() }; if (idx >= 0) data.items[idx] = entry; else data.items.push(entry); } await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); await fs.promises.writeFile(filePath, JSON.stringify(data, null, 2), 'utf8'); return { status: 'ok', saved: items.length };`,
|
|
77
|
+
},
|
|
78
|
+
read_user_info: {
|
|
79
|
+
definition: {
|
|
80
|
+
type: 'function',
|
|
81
|
+
function: {
|
|
82
|
+
name: 'read_user_info',
|
|
83
|
+
description: 'Read all stored user facts. Returns the full list of known user information. Use this when you need to recall something about the user.',
|
|
84
|
+
parameters: {
|
|
85
|
+
type: 'object',
|
|
86
|
+
properties: {},
|
|
87
|
+
required: [],
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
code: `const filePath = path.join(process.env.HOME, '.jarvis/data/user-info.json'); const raw = await fs.promises.readFile(filePath, 'utf8').catch(() => '{"items":[]}'); const { items } = JSON.parse(raw); return { status: 'ok', items };`,
|
|
92
|
+
},
|
|
93
|
+
get_recent_sessions: {
|
|
94
|
+
definition: {
|
|
95
|
+
type: 'function',
|
|
96
|
+
function: {
|
|
97
|
+
name: 'get_recent_sessions',
|
|
98
|
+
description: 'Returns the most recent sessions with their titles and timestamps. Use this to find previous conversations.',
|
|
99
|
+
parameters: {
|
|
100
|
+
type: 'object',
|
|
101
|
+
properties: {
|
|
102
|
+
limit: {
|
|
103
|
+
type: 'number',
|
|
104
|
+
description: 'Number of recent sessions to return. Defaults to 2.',
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
required: [],
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
code: `const logsDir = path.join(process.env.HOME, '.jarvis/logs'); const limit = args.limit || 2; const files = await fs.promises.readdir(logsDir).catch(() => []); const sessionFiles = files.filter(f => f.startsWith('session-') && f.endsWith('.jsonl')); const sessions = []; for (const file of sessionFiles) { const sessionId = file.replace('session-', '').replace('.jsonl', ''); const content = await fs.promises.readFile(path.join(logsDir, file), 'utf8'); const lines = content.trim().split('\\n').filter(Boolean); if (lines.length === 0) continue; const firstEntry = JSON.parse(lines[0]); const lastEntry = JSON.parse(lines[lines.length - 1]); sessions.push({ sessionId, title: (firstEntry.logSummary || '').substring(0, 80), lastTs: lastEntry.ts }); } sessions.sort((a, b) => new Date(b.lastTs) - new Date(a.lastTs)); return { status: 'ok', sessions: sessions.slice(0, limit) };`,
|
|
112
|
+
},
|
|
113
|
+
read_session_log: {
|
|
114
|
+
definition: {
|
|
115
|
+
type: 'function',
|
|
116
|
+
function: {
|
|
117
|
+
name: 'read_session_log',
|
|
118
|
+
description: 'Read JSONL log entries for a given session. Use this to inspect what happened in a previous run, including tool calls, errors, and summaries.',
|
|
119
|
+
parameters: {
|
|
120
|
+
type: 'object',
|
|
121
|
+
properties: {
|
|
122
|
+
sessionId: {
|
|
123
|
+
type: 'string',
|
|
124
|
+
description: 'The session ID to read logs for.',
|
|
125
|
+
},
|
|
126
|
+
limit: {
|
|
127
|
+
type: 'number',
|
|
128
|
+
description: 'Maximum number of entries to return (from the end). Defaults to 20.',
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
required: ['sessionId'],
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
code: `const logsDir = path.join(process.env.HOME, '.jarvis/logs'); const logFile = path.join(logsDir, 'session-' + args.sessionId + '.jsonl'); const content = await fs.promises.readFile(logFile, 'utf8').catch(() => ''); const lines = content.trim().split('\\n').filter(Boolean); const limit = args.limit || 20; const entries = lines.slice(-limit).map(line => JSON.parse(line)); return { status: 'ok', entries };`,
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export function seedTools() {
|
|
140
|
+
let existing = {};
|
|
141
|
+
try {
|
|
142
|
+
existing = JSON.parse(fs.readFileSync(PATHS.toolsFile, 'utf8'));
|
|
143
|
+
} catch {
|
|
144
|
+
// File doesn't exist yet
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let changed = false;
|
|
148
|
+
for (const [name, tool] of Object.entries(SEED_TOOLS)) {
|
|
149
|
+
if (!existing[name]) {
|
|
150
|
+
existing[name] = tool;
|
|
151
|
+
changed = true;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (changed) {
|
|
156
|
+
fs.mkdirSync(PATHS.toolsDir, { recursive: true });
|
|
157
|
+
fs.writeFileSync(PATHS.toolsFile, JSON.stringify(existing, null, 2), 'utf8');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return existing;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function loadTools() {
|
|
164
|
+
try {
|
|
165
|
+
return JSON.parse(fs.readFileSync(PATHS.toolsFile, 'utf8'));
|
|
166
|
+
} catch {
|
|
167
|
+
return {};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function getToolDefinitions(tools) {
|
|
172
|
+
return Object.values(tools).map(t => t.definition);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function executeTool(tools, name, toolArgs) {
|
|
176
|
+
const tool = tools[name];
|
|
177
|
+
if (!tool) {
|
|
178
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const fn = new AsyncFunction('args', 'fs', 'path', 'process', 'require', tool.code);
|
|
182
|
+
return await fn(toolArgs, fs, path, process, _require);
|
|
183
|
+
}
|