@ducci/jarvis 1.0.72 → 1.0.74
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 +15 -0
- package/docs/cli.md +4 -0
- package/docs/system-prompt.md +1 -1
- package/package.json +1 -1
- package/src/channels/telegram/index.js +26 -2
- package/src/index.js +14 -0
- package/src/scripts/onboarding.js +15 -0
- package/src/server/agent.js +86 -0
package/README.md
CHANGED
|
@@ -74,6 +74,21 @@ cd ui && npm run build # outputs to ui/dist/, served automatically by the serv
|
|
|
74
74
|
|
|
75
75
|
Jarvis is designed for **local or private server use only**. The API has no authentication — do not expose port `18008` to the public internet. The `exec` tool runs shell commands with the same permissions as the server process.
|
|
76
76
|
|
|
77
|
+
If you run Jarvis on a VPS, make sure your firewall only allows what's necessary. With `ufw`:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
ufw default deny incoming
|
|
81
|
+
ufw default allow outgoing
|
|
82
|
+
ufw allow 22/tcp # SSH
|
|
83
|
+
ufw enable
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Ports like `18008` stay closed to the outside world — access the UI via an SSH tunnel instead:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
ssh -L 18008:localhost:18008 user@your-vps
|
|
90
|
+
```
|
|
91
|
+
|
|
77
92
|
## Data
|
|
78
93
|
|
|
79
94
|
All runtime data lives in `~/.jarvis/` and is never stored in the repo:
|
package/docs/cli.md
CHANGED
|
@@ -33,6 +33,10 @@ Lifecycle management is handled by the CLI using the **programmatic PM2 API** fo
|
|
|
33
33
|
- The process is named `jarvis-server`.
|
|
34
34
|
- Enables `autorestart` on crash.
|
|
35
35
|
- Merges logs into a single file in the user's data directory.
|
|
36
|
+
- Calls `pm2 save` (`pm2.dump()`) after start to persist the process list for reboot recovery.
|
|
37
|
+
- Prints a tip to run `pm2 startup` once to register the PM2 boot hook with the OS init system.
|
|
38
|
+
|
|
39
|
+
> **Reboot persistence**: `pm2 save` persists the process list; `pm2 startup` (run once, may need sudo) installs the OS-level boot hook so PM2 — and therefore Jarvis — starts automatically after a system reboot.
|
|
36
40
|
|
|
37
41
|
### `jarvis stop`
|
|
38
42
|
- Stops the background process named `jarvis-server` using PM2.
|
package/docs/system-prompt.md
CHANGED
|
@@ -72,7 +72,7 @@ You have access to a set of tools. Each tool has a name and description that tel
|
|
|
72
72
|
- If a tool fails, record the error in `logSummary` and decide whether to retry with a corrected call or explain the failure to the user.
|
|
73
73
|
- Proactively save user facts with `save_user_info` when the user shares personal details (name, timezone, preferences) — even if not asked.
|
|
74
74
|
- Use `write_file` to create or overwrite files — never `exec` with echo/printf/heredoc (shell escaping silently corrupts content).
|
|
75
|
-
-
|
|
75
|
+
- Never use `&` to background a process. For any long-running or background process, use tmux: `tmux new-session -d -s jarvis-<purpose> "command"`. Always check first with `tmux has-session -t <name>` before starting. Read output with `tmux capture-pane -t <name> -p`. Stop with `tmux kill-session -t <name>`. Record active session names in checkpoint `state` (e.g. `{"serverSession": "jarvis-server"}`).
|
|
76
76
|
- Prefer using tools over making assumptions about the state of the system.
|
|
77
77
|
|
|
78
78
|
## Failure Recovery
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@ import fs from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { Bot } from 'grammy';
|
|
4
4
|
import { run } from '@grammyjs/runner';
|
|
5
|
-
import { handleChat } from '../../server/agent.js';
|
|
5
|
+
import { handleChat, requestAbort } from '../../server/agent.js';
|
|
6
6
|
import { loadSession } from '../../server/sessions.js';
|
|
7
7
|
import { PATHS } from '../../server/config.js';
|
|
8
8
|
import { load, save } from './sessions.js';
|
|
@@ -24,6 +24,12 @@ function escapeHtml(str) {
|
|
|
24
24
|
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
function stripHtml(text) {
|
|
28
|
+
return text
|
|
29
|
+
.replace(/<[^>]+>/g, '')
|
|
30
|
+
.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
|
31
|
+
}
|
|
32
|
+
|
|
27
33
|
function markdownToHtml(text) {
|
|
28
34
|
// 0. Sanitize unsupported Telegram HTML tags
|
|
29
35
|
// Headings → <b>
|
|
@@ -76,7 +82,7 @@ async function sendMessage(api, chatId, text, sessionId) {
|
|
|
76
82
|
description: e.description || e.message,
|
|
77
83
|
}) + '\n', 'utf8').catch(() => {});
|
|
78
84
|
}
|
|
79
|
-
await api.sendMessage(chatId, chunk);
|
|
85
|
+
await api.sendMessage(chatId, stripHtml(chunk));
|
|
80
86
|
} else {
|
|
81
87
|
throw e;
|
|
82
88
|
}
|
|
@@ -100,6 +106,7 @@ export async function startTelegramChannel(config) {
|
|
|
100
106
|
await bot.api.setMyCommands([
|
|
101
107
|
{ command: 'new', description: 'Start a fresh session' },
|
|
102
108
|
{ command: 'usage', description: 'Show token usage for the current session' },
|
|
109
|
+
{ command: 'stop', description: 'Stop the current run' },
|
|
103
110
|
]);
|
|
104
111
|
|
|
105
112
|
bot.command('usage', async (ctx) => {
|
|
@@ -131,6 +138,23 @@ export async function startTelegramChannel(config) {
|
|
|
131
138
|
);
|
|
132
139
|
});
|
|
133
140
|
|
|
141
|
+
bot.command('stop', async (ctx) => {
|
|
142
|
+
const userId = ctx.from?.id;
|
|
143
|
+
if (!allowedUserIds.includes(userId)) return;
|
|
144
|
+
|
|
145
|
+
const chatId = ctx.chat.id;
|
|
146
|
+
const sessionId = sessions[chatId];
|
|
147
|
+
|
|
148
|
+
if (!isRunning.has(chatId) || !sessionId) {
|
|
149
|
+
await ctx.reply('Nothing is currently running.');
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
requestAbort(sessionId);
|
|
154
|
+
await appendTelegramChatLog(chatId, sessionId, 'SYSTEM', '--- /stop requested ---');
|
|
155
|
+
await ctx.reply('Stopping current run... I\'ll send a summary when done.');
|
|
156
|
+
});
|
|
157
|
+
|
|
134
158
|
bot.command('new', async (ctx) => {
|
|
135
159
|
const userId = ctx.from?.id;
|
|
136
160
|
if (!allowedUserIds.includes(userId)) return;
|
package/src/index.js
CHANGED
|
@@ -95,6 +95,15 @@ function pm2Restart() {
|
|
|
95
95
|
});
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
function pm2Save() {
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
pm2.dump((err) => {
|
|
101
|
+
if (err) reject(err);
|
|
102
|
+
else resolve();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
98
107
|
const program = new Command();
|
|
99
108
|
|
|
100
109
|
program
|
|
@@ -154,7 +163,9 @@ program
|
|
|
154
163
|
return;
|
|
155
164
|
}
|
|
156
165
|
await pm2Start();
|
|
166
|
+
await pm2Save();
|
|
157
167
|
console.log('Jarvis server started.');
|
|
168
|
+
console.log('Tip: run `pm2 startup` once to make Jarvis survive system reboots.');
|
|
158
169
|
pm2.disconnect();
|
|
159
170
|
} catch (e) {
|
|
160
171
|
console.error('Failed to start Jarvis server:', e.message);
|
|
@@ -191,10 +202,13 @@ program
|
|
|
191
202
|
const isRunning = desc.length > 0 && desc[0].pm2_env?.status === 'online';
|
|
192
203
|
if (isRunning) {
|
|
193
204
|
await pm2Restart();
|
|
205
|
+
await pm2Save();
|
|
194
206
|
console.log('Jarvis server restarted.');
|
|
195
207
|
} else {
|
|
196
208
|
await pm2Start();
|
|
209
|
+
await pm2Save();
|
|
197
210
|
console.log('Jarvis server started.');
|
|
211
|
+
console.log('Tip: run `pm2 startup` once to make Jarvis survive system reboots.');
|
|
198
212
|
}
|
|
199
213
|
pm2.disconnect();
|
|
200
214
|
} catch (e) {
|
|
@@ -698,7 +698,22 @@ async function run() {
|
|
|
698
698
|
}
|
|
699
699
|
}
|
|
700
700
|
|
|
701
|
+
// --- TMUX CHECK ---
|
|
702
|
+
const tmuxCheck = spawnSync('which', ['tmux'], { stdio: 'pipe' });
|
|
703
|
+
if (tmuxCheck.status !== 0) {
|
|
704
|
+
console.log(chalk.blue('Installing tmux...'));
|
|
705
|
+
const hasBrew = spawnSync('which', ['brew'], { stdio: 'pipe' }).status === 0;
|
|
706
|
+
const hasApt = spawnSync('which', ['apt-get'], { stdio: 'pipe' }).status === 0;
|
|
707
|
+
if (hasBrew) spawnSync('brew', ['install', 'tmux'], { stdio: 'inherit' });
|
|
708
|
+
else if (hasApt) spawnSync('apt-get', ['install', '-y', 'tmux'], { stdio: 'inherit' });
|
|
709
|
+
else console.log(chalk.yellow('tmux not found. Install manually: apt-get install tmux / brew install tmux'));
|
|
710
|
+
}
|
|
711
|
+
|
|
701
712
|
console.log(chalk.green.bold('\nSetup complete!'));
|
|
713
|
+
console.log(chalk.cyan('\nNext steps:'));
|
|
714
|
+
console.log(' 1. Run ' + chalk.bold('jarvis start') + ' to launch the server.');
|
|
715
|
+
console.log(' 2. Run ' + chalk.bold('pm2 startup') + ' once to make Jarvis survive system reboots.');
|
|
716
|
+
console.log(' (Follow the command it prints — usually needs sudo on Linux.)');
|
|
702
717
|
}
|
|
703
718
|
|
|
704
719
|
run().catch(error => {
|
package/src/server/agent.js
CHANGED
|
@@ -53,6 +53,22 @@ function sanitizeJson(text) {
|
|
|
53
53
|
const CONSECUTIVE_FAILURE_THRESHOLD = 3;
|
|
54
54
|
const MAX_TOOL_RESULT = 4000;
|
|
55
55
|
|
|
56
|
+
const ABORT_NOTE = `[System: The user has requested an immediate stop. This is your final response for this run.
|
|
57
|
+
Respond with your normal JSON, but add a checkpoint field:
|
|
58
|
+
|
|
59
|
+
{
|
|
60
|
+
"response": "Brief message to the user acknowledging the stop and summarising what was completed.",
|
|
61
|
+
"logSummary": "Human-readable summary of what happened before the stop.",
|
|
62
|
+
"checkpoint": {
|
|
63
|
+
"progress": "What has been fully completed — only include items confirmed by tool output.",
|
|
64
|
+
"remaining": "What still needs to be done to finish the original task — as a plain text string, never an array or object.",
|
|
65
|
+
"failedApproaches": ["Concise description of each approach that failed. Leave as empty array if nothing failed."],
|
|
66
|
+
"state": {"factKey": "factValue — concrete facts confirmed by tool output: file paths, binary locations, config values. Use {} if nothing concrete was discovered."}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
The checkpoint will allow the task to be resumed later if needed.]`;
|
|
71
|
+
|
|
56
72
|
const WRAP_UP_NOTE = `[System: You have reached the iteration limit. This is your final response for this run.
|
|
57
73
|
Respond with your normal JSON, but add a checkpoint field:
|
|
58
74
|
|
|
@@ -74,6 +90,15 @@ The checkpoint field will be used to automatically resume the task in the next r
|
|
|
74
90
|
// queued request finishes).
|
|
75
91
|
const sessionQueues = new Map();
|
|
76
92
|
|
|
93
|
+
// Abort flags: set by requestAbort(), checked at each iteration boundary in
|
|
94
|
+
// runAgentLoop. Always cleared in _runHandleChat's finally block to prevent
|
|
95
|
+
// stale flags from killing subsequent runs.
|
|
96
|
+
const sessionAborts = new Map();
|
|
97
|
+
|
|
98
|
+
export function requestAbort(sessionId) {
|
|
99
|
+
sessionAborts.set(sessionId, true);
|
|
100
|
+
}
|
|
101
|
+
|
|
77
102
|
function accumulateUsage(accum, result) {
|
|
78
103
|
const u = result?.usage;
|
|
79
104
|
if (!u) return;
|
|
@@ -235,6 +260,48 @@ export async function runAgentLoop(client, config, session, prepareMessages, usa
|
|
|
235
260
|
while (iteration < config.maxIterations) {
|
|
236
261
|
iteration++;
|
|
237
262
|
|
|
263
|
+
// Check for user-requested stop. Do a wrap-up call so the user gets a
|
|
264
|
+
// meaningful summary and the session can be resumed later if needed.
|
|
265
|
+
if (sessionAborts.get(config._sessionId)) {
|
|
266
|
+
sessionAborts.delete(config._sessionId);
|
|
267
|
+
const abortMessages = [
|
|
268
|
+
...prepareMessages(session.messages),
|
|
269
|
+
{ role: 'user', content: ABORT_NOTE },
|
|
270
|
+
];
|
|
271
|
+
try {
|
|
272
|
+
const abortResult = await callModelWithFallback(client, config, abortMessages, []);
|
|
273
|
+
accumulateUsage(usageAccum, abortResult);
|
|
274
|
+
const abortContent = abortResult.choices[0]?.message?.content || '';
|
|
275
|
+
let parsedAbort = null;
|
|
276
|
+
try { parsedAbort = JSON.parse(sanitizeJson(abortContent)); } catch { /* use raw */ }
|
|
277
|
+
session.messages.push({ role: 'assistant', content: abortContent });
|
|
278
|
+
if (parsedAbort?.checkpoint) {
|
|
279
|
+
const cp = parsedAbort.checkpoint;
|
|
280
|
+
if (typeof cp.remaining !== 'string') cp.remaining = Array.isArray(cp.remaining) ? cp.remaining.map(String).join('\n') : cp.remaining != null ? JSON.stringify(cp.remaining) : '';
|
|
281
|
+
if (!Array.isArray(cp.failedApproaches)) cp.failedApproaches = [];
|
|
282
|
+
else cp.failedApproaches = cp.failedApproaches.map(i => typeof i === 'string' ? i : JSON.stringify(i));
|
|
283
|
+
if (typeof cp.state !== 'object' || cp.state === null || Array.isArray(cp.state)) cp.state = {};
|
|
284
|
+
}
|
|
285
|
+
return {
|
|
286
|
+
iteration,
|
|
287
|
+
response: parsedAbort?.response || abortContent || 'Run stopped.',
|
|
288
|
+
logSummary: parsedAbort?.logSummary || 'Run stopped by user request.',
|
|
289
|
+
status: 'aborted',
|
|
290
|
+
runToolCalls,
|
|
291
|
+
checkpoint: parsedAbort?.checkpoint || null,
|
|
292
|
+
};
|
|
293
|
+
} catch (e) {
|
|
294
|
+
return {
|
|
295
|
+
iteration,
|
|
296
|
+
response: 'Run stopped.',
|
|
297
|
+
logSummary: `Run stopped by user request. Wrap-up call failed: ${e.message}`,
|
|
298
|
+
status: 'aborted',
|
|
299
|
+
runToolCalls,
|
|
300
|
+
checkpoint: null,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
238
305
|
let modelResult;
|
|
239
306
|
const iterationsLeft = config.maxIterations - iteration + 1;
|
|
240
307
|
const base = prepareMessages(session.messages);
|
|
@@ -774,6 +841,21 @@ async function _runHandleChat(config, sessionId, userMessage, attachments = [],
|
|
|
774
841
|
// makes the next one more likely (especially on free models with small context
|
|
775
842
|
// windows). The synthetic note is sufficient context; tool results are preserved
|
|
776
843
|
// in the JSONL log and accessible via read_session_log.
|
|
844
|
+
// On abort: save checkpoint data so the task can be resumed later,
|
|
845
|
+
// same as the checkpoint_reached path does for handoff runs.
|
|
846
|
+
if (finalStatus === 'aborted' && run.checkpoint) {
|
|
847
|
+
if (run.checkpoint.failedApproaches?.length > 0) {
|
|
848
|
+
if (!session.metadata.failedApproaches) session.metadata.failedApproaches = [];
|
|
849
|
+
session.metadata.failedApproaches.push(...run.checkpoint.failedApproaches);
|
|
850
|
+
}
|
|
851
|
+
if (run.checkpoint.state && Object.keys(run.checkpoint.state).length > 0) {
|
|
852
|
+
session.metadata.checkpointState = { ...(session.metadata.checkpointState || {}), ...run.checkpoint.state };
|
|
853
|
+
}
|
|
854
|
+
if (run.checkpoint.remaining) {
|
|
855
|
+
session.metadata.lastCheckpointRemaining = run.checkpoint.remaining.trim();
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
777
859
|
if (finalStatus === 'model_error' || finalStatus === 'format_error') {
|
|
778
860
|
if (finalStatus === 'model_error' && isImageUnsupportedError(run.errorDetail)) {
|
|
779
861
|
finalResponse = 'This model does not support image input. Please switch to a multimodal model (e.g. claude-3.5-sonnet, gpt-4o) in settings.';
|
|
@@ -914,6 +996,10 @@ async function _runHandleChat(config, sessionId, userMessage, attachments = [],
|
|
|
914
996
|
});
|
|
915
997
|
throw e;
|
|
916
998
|
} finally {
|
|
999
|
+
// Clear any stale abort flag — prevents a flag set just as a run finished
|
|
1000
|
+
// from killing the next run.
|
|
1001
|
+
sessionAborts.delete(sessionId);
|
|
1002
|
+
|
|
917
1003
|
// Accumulate token usage into session metadata so /usage can read it
|
|
918
1004
|
if (!session.metadata.tokenUsage) session.metadata.tokenUsage = { prompt: 0, completion: 0, cacheRead: 0, cacheCreation: 0 };
|
|
919
1005
|
session.metadata.tokenUsage.prompt += usageAccum.prompt;
|