@ducci/jarvis 1.0.78 → 1.0.80

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ducci/jarvis",
3
- "version": "1.0.78",
3
+ "version": "1.0.80",
4
4
  "description": "A fully automated agent system that lives on a server.",
5
5
  "main": "./src/index.js",
6
6
  "type": "module",
@@ -1,6 +1,12 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- import { Bot } from 'grammy';
3
+ import { exec } from 'child_process';
4
+ import { promisify } from 'util';
5
+ const execAsync = promisify(exec);
6
+ import { createRequire } from 'module';
7
+ const _require = createRequire(import.meta.url);
8
+ const { version: JARVIS_VERSION } = _require('../../../package.json');
9
+ import { Bot, InlineKeyboard } from 'grammy';
4
10
  import { run } from '@grammyjs/runner';
5
11
  import { handleChat, requestAbort } from '../../server/agent.js';
6
12
  import { loadSession } from '../../server/sessions.js';
@@ -90,6 +96,33 @@ async function sendMessage(api, chatId, text, sessionId) {
90
96
  }
91
97
  }
92
98
 
99
+ // Known model context windows in tokens. Used by /context command.
100
+ // Partial match: checked with model.includes(key) so short keys like 'gpt-4o' match 'openrouter/gpt-4o'.
101
+ const MODEL_CONTEXT_WINDOWS = {
102
+ 'claude': 200000, // all claude models (opus, sonnet, haiku)
103
+ 'gpt-4o': 128000,
104
+ 'gpt-4-turbo': 128000,
105
+ 'gpt-3.5': 16385,
106
+ 'gemini-1.5-pro': 1000000,
107
+ 'gemini-1.5-flash': 1000000,
108
+ 'gemini-2': 1000000,
109
+ 'llama-3.3': 128000,
110
+ 'llama-3.1': 128000,
111
+ 'mistral': 32000,
112
+ 'deepseek': 64000,
113
+ 'glm-5': 200000,
114
+ 'glm-4': 128000,
115
+ };
116
+
117
+ function lookupContextWindow(model) {
118
+ if (!model) return null;
119
+ const m = model.toLowerCase();
120
+ for (const [key, size] of Object.entries(MODEL_CONTEXT_WINDOWS)) {
121
+ if (m.includes(key)) return size;
122
+ }
123
+ return null;
124
+ }
125
+
93
126
  export async function startTelegramChannel(config) {
94
127
  const { token, allowedUserIds } = config.telegram;
95
128
 
@@ -160,11 +193,14 @@ export async function startTelegramChannel(config) {
160
193
  // --- Commands ---
161
194
 
162
195
  await bot.api.setMyCommands([
163
- { command: 'new', description: 'Reset the active slot (fresh session)' },
164
- { command: 'usage', description: 'Token usage for the active slot' },
165
- { command: 'stop', description: 'Stop the running agent on the active slot' },
166
- { command: 'slots', description: 'Show all slots and their status' },
167
- { command: 'slot', description: 'Switch or delete a slot: /slot 2 or /slot del 2' },
196
+ { command: 'new', description: 'Reset the active slot (fresh session)' },
197
+ { command: 'usage', description: 'Token usage for the active slot' },
198
+ { command: 'context', description: 'Estimated context size vs model limit' },
199
+ { command: 'stop', description: 'Stop the running agent on the active slot' },
200
+ { command: 'slots', description: 'Show all slots and their status' },
201
+ { command: 'version', description: 'Show Jarvis version' },
202
+ { command: 'update', description: 'Update Jarvis to the latest version' },
203
+ { command: 'restart', description: 'Restart Jarvis' },
168
204
  ]);
169
205
 
170
206
  bot.command('usage', async (ctx) => {
@@ -197,6 +233,75 @@ export async function startTelegramChannel(config) {
197
233
  );
198
234
  });
199
235
 
236
+ bot.command('version', async (ctx) => {
237
+ const userId = ctx.from?.id;
238
+ if (!allowedUserIds.includes(userId)) return;
239
+ await ctx.reply(`Jarvis v${JARVIS_VERSION}`);
240
+ });
241
+
242
+ bot.command('update', async (ctx) => {
243
+ const userId = ctx.from?.id;
244
+ if (!allowedUserIds.includes(userId)) return;
245
+ await ctx.reply('Updating Jarvis...');
246
+ try {
247
+ const { stdout, stderr } = await execAsync('npm install -g @ducci/jarvis@latest', { timeout: 120000 });
248
+ const out = (stdout + stderr).trim().slice(-1000) || 'Done.';
249
+ await ctx.api.sendMessage(ctx.chat.id, `Update complete:\n${out}`);
250
+ } catch (e) {
251
+ await ctx.api.sendMessage(ctx.chat.id, `Update failed:\n${e.message.slice(0, 1000)}`);
252
+ }
253
+ });
254
+
255
+ bot.command('restart', async (ctx) => {
256
+ const userId = ctx.from?.id;
257
+ if (!allowedUserIds.includes(userId)) return;
258
+ await ctx.reply('Restarting Jarvis...');
259
+ // Fire and forget — process will exit before a response could be sent
260
+ setTimeout(() => execAsync('jarvis restart').catch(() => {}), 500);
261
+ });
262
+
263
+ bot.command('context', async (ctx) => {
264
+ const userId = ctx.from?.id;
265
+ if (!allowedUserIds.includes(userId)) return;
266
+
267
+ const chatId = ctx.chat.id;
268
+ const slot = getActiveSlot(chatId);
269
+ const sessionId = getSessionId(chatId, slot);
270
+ if (!sessionId) {
271
+ await ctx.reply('No active session. Send a message to start one.');
272
+ return;
273
+ }
274
+
275
+ const session = await loadSession(sessionId);
276
+ if (!session) {
277
+ await ctx.reply('Could not load session.');
278
+ return;
279
+ }
280
+
281
+ const msgCount = Math.max(0, session.messages.length - 1); // exclude system prompt
282
+ const estimatedTokens = Math.round(JSON.stringify(session.messages).length / 4);
283
+ const model = config.selectedModel || 'unknown';
284
+ const contextWindow = config.modelContextWindow || lookupContextWindow(model);
285
+
286
+ let lines = [
287
+ `<b>Context — Slot ${slot}</b>`,
288
+ `Model: <code>${escapeHtml(model)}</code>`,
289
+ `Messages in history: ${msgCount}`,
290
+ `Estimated tokens: ~${estimatedTokens.toLocaleString()}`,
291
+ ];
292
+
293
+ if (contextWindow) {
294
+ const pct = Math.round((estimatedTokens / contextWindow) * 100);
295
+ const bar = pct >= 90 ? '🔴' : pct >= 70 ? '🟡' : '🟢';
296
+ lines.push(`Model context window: ${contextWindow.toLocaleString()}`);
297
+ lines.push(`Usage: ${bar} ~${pct}%`);
298
+ } else {
299
+ lines.push(`Model context window: unknown`);
300
+ }
301
+
302
+ await ctx.reply(lines.join('\n'), { parse_mode: 'HTML' });
303
+ });
304
+
200
305
  bot.command('stop', async (ctx) => {
201
306
  const userId = ctx.from?.id;
202
307
  if (!allowedUserIds.includes(userId)) return;
@@ -234,11 +339,7 @@ export async function startTelegramChannel(config) {
234
339
  await ctx.reply(`New session started on slot ${slot}.`);
235
340
  });
236
341
 
237
- bot.command('slots', async (ctx) => {
238
- const userId = ctx.from?.id;
239
- if (!allowedUserIds.includes(userId)) return;
240
-
241
- const chatId = ctx.chat.id;
342
+ function buildSlotsDisplay(chatId) {
242
343
  const d = sessions[chatId];
243
344
  const activeSlot = getActiveSlot(chatId);
244
345
 
@@ -250,7 +351,10 @@ export async function startTelegramChannel(config) {
250
351
  }
251
352
 
252
353
  const slotNums = [...new Set(['1', ...Object.keys(slotsMap)])].sort((a, b) => Number(a) - Number(b));
354
+ const maxSlot = Math.max(...slotNums.map(Number));
355
+ const nextSlot = maxSlot + 1;
253
356
 
357
+ // Status text
254
358
  const lines = ['<b>Slots:</b>'];
255
359
  for (const sn of slotNums) {
256
360
  const n = Number(sn);
@@ -275,64 +379,93 @@ export async function startTelegramChannel(config) {
275
379
  }
276
380
  lines.push(`Slot ${n}: ${statusIcon}${activeMarker}`);
277
381
  }
278
-
279
- // Always show one empty slot beyond the highest existing one
280
- const maxSlot = Math.max(...slotNums.map(Number));
281
- const nextSlot = maxSlot + 1;
282
382
  if (!isRunning.has(slotKey(chatId, nextSlot)) && !slotsMap[String(nextSlot)]) {
283
383
  lines.push(`Slot ${nextSlot}: ➕ leer`);
284
384
  }
285
385
 
286
- await ctx.reply(lines.join('\n'), { parse_mode: 'HTML' });
287
- });
386
+ // Inline keyboard
387
+ const kb = new InlineKeyboard();
388
+ for (const sn of slotNums) {
389
+ const n = Number(sn);
390
+ const sid = slotsMap[sn] ?? null;
391
+ const key = slotKey(chatId, n);
392
+ const running = isRunning.has(key);
393
+ if (n === activeSlot) {
394
+ kb.text(`✓ Slot ${n} (aktiv)`, `slots_noop`);
395
+ } else {
396
+ kb.text(`↩️ Slot ${n}`, `slots_switch_${n}`);
397
+ }
398
+ if (sid && !running) {
399
+ kb.text(`🗑️`, `slots_del_${n}`);
400
+ }
401
+ kb.row();
402
+ }
403
+ // Button for the next empty slot
404
+ kb.text(`➕ Slot ${nextSlot} (neu)`, `slots_switch_${nextSlot}`);
405
+
406
+ return { text: lines.join('\n'), keyboard: kb };
407
+ }
288
408
 
289
- bot.command('slot', async (ctx) => {
409
+ bot.command('slots', async (ctx) => {
290
410
  const userId = ctx.from?.id;
291
411
  if (!allowedUserIds.includes(userId)) return;
292
412
 
293
413
  const chatId = ctx.chat.id;
294
- const args = (ctx.match || '').trim().split(/\s+/).filter(Boolean);
414
+ const { text, keyboard } = buildSlotsDisplay(chatId);
415
+ await ctx.reply(text, { parse_mode: 'HTML', reply_markup: keyboard });
416
+ });
295
417
 
296
- // /slot del N
297
- if (args[0] === 'del') {
298
- const n = parseInt(args[1], 10);
299
- if (!n || n < 1) { await ctx.reply('Usage: /slot del <number>'); return; }
300
- const key = slotKey(chatId, n);
301
- if (isRunning.has(key) || pendingMessages.has(key)) {
302
- await ctx.reply(`Slot ${n} ist gerade aktiv. Erst /stop, dann löschen.`);
303
- return;
304
- }
305
- const oldSid = getSessionId(chatId, n);
306
- if (oldSid) {
307
- await appendTelegramChatLog(chatId, oldSid, 'SYSTEM', `--- /slot del ${n} ---`);
308
- }
309
- setSessionId(chatId, n, null);
310
- pendingMessages.delete(key);
311
- runStartTimes.delete(key);
312
- if (getActiveSlot(chatId) === n) {
313
- setActiveSlot(chatId, 1);
314
- await ctx.reply(`Slot ${n} gelöscht. Zu Slot 1 gewechselt.`);
315
- } else {
316
- await ctx.reply(`Slot ${n} gelöscht.`);
317
- }
318
- return;
319
- }
418
+ bot.callbackQuery(/^slots_switch_(\d+)$/, async (ctx) => {
419
+ const userId = ctx.from?.id;
420
+ if (!allowedUserIds.includes(userId)) { await ctx.answerCallbackQuery(); return; }
320
421
 
321
- // /slot N — switch active slot
322
- const n = parseInt(args[0], 10);
323
- if (!n || n < 1) { await ctx.reply('Usage: /slot <number> oder /slot del <number>'); return; }
422
+ const chatId = ctx.chat.id;
423
+ const n = parseInt(ctx.match[1], 10);
324
424
  setActiveSlot(chatId, n);
325
- const sid = getSessionId(chatId, n);
326
425
  const key = slotKey(chatId, n);
426
+ const sid = getSessionId(chatId, n);
327
427
  let status;
328
- if (isRunning.has(key)) {
329
- status = '🟢 läuft';
330
- } else if (sid) {
331
- status = '💬 bereit (vorhandene Session)';
332
- } else {
333
- status = '➕ leer (neue Session beim nächsten Message)';
428
+ if (isRunning.has(key)) status = '🟢 läuft';
429
+ else if (sid) status = '💬 bereit';
430
+ else status = '➕ leer (neue Session beim nächsten Message)';
431
+
432
+ const { text, keyboard } = buildSlotsDisplay(chatId);
433
+ await ctx.editMessageText(text, { parse_mode: 'HTML', reply_markup: keyboard });
434
+ await ctx.answerCallbackQuery(`Slot ${n} aktiv — ${status}`);
435
+ });
436
+
437
+ bot.callbackQuery(/^slots_del_(\d+)$/, async (ctx) => {
438
+ const userId = ctx.from?.id;
439
+ if (!allowedUserIds.includes(userId)) { await ctx.answerCallbackQuery(); return; }
440
+
441
+ const chatId = ctx.chat.id;
442
+ const n = parseInt(ctx.match[1], 10);
443
+ const key = slotKey(chatId, n);
444
+
445
+ if (isRunning.has(key) || pendingMessages.has(key)) {
446
+ await ctx.answerCallbackQuery(`Slot ${n} läuft gerade — erst /stop`);
447
+ return;
334
448
  }
335
- await ctx.reply(`Slot ${n} ist jetzt aktiv. Status: ${status}`);
449
+
450
+ const oldSid = getSessionId(chatId, n);
451
+ if (oldSid) {
452
+ await appendTelegramChatLog(chatId, oldSid, 'SYSTEM', `--- slot del ${n} (via keyboard) ---`);
453
+ }
454
+ setSessionId(chatId, n, null);
455
+ pendingMessages.delete(key);
456
+ runStartTimes.delete(key);
457
+
458
+ if (getActiveSlot(chatId) === n) {
459
+ setActiveSlot(chatId, 1);
460
+ }
461
+
462
+ const { text, keyboard } = buildSlotsDisplay(chatId);
463
+ await ctx.editMessageText(text, { parse_mode: 'HTML', reply_markup: keyboard });
464
+ await ctx.answerCallbackQuery(`Slot ${n} gelöscht`);
465
+ });
466
+
467
+ bot.callbackQuery('slots_noop', async (ctx) => {
468
+ await ctx.answerCallbackQuery();
336
469
  });
337
470
 
338
471
  // Runs one or more batches until the pending queue is drained.
@@ -72,6 +72,7 @@ export function loadConfig() {
72
72
  maxIterations: settings.maxIterations || 20,
73
73
  maxHandoffs: settings.maxHandoffs || 3,
74
74
  contextWindow: settings.contextWindow || 100,
75
+ modelContextWindow: settings.modelContextWindow || null,
75
76
  port: settings.port || 18008,
76
77
  telegram: {
77
78
  token: process.env.TELEGRAM_BOT_TOKEN || null,
@@ -45,6 +45,15 @@ export async function runCron(entry, config) {
45
45
  const failedApproaches = [];
46
46
  const checkpointState = {};
47
47
 
48
+ // Compact tool call representation for logs: "tool_name first_arg_value"
49
+ function compactToolCalls(toolCalls) {
50
+ return (toolCalls || []).map(tc => {
51
+ const firstVal = Object.values(tc.args || {})[0];
52
+ const argStr = firstVal !== undefined ? ' ' + String(firstVal).slice(0, 80) : '';
53
+ return `${tc.name}${argStr}`;
54
+ });
55
+ }
56
+
48
57
  try {
49
58
  while (true) {
50
59
  const runStartIndex = session.messages.length;
@@ -53,9 +62,25 @@ export async function runCron(entry, config) {
53
62
  run = await runAgentLoop(client, config, session, prepareMessages, usageAccum);
54
63
  } catch (e) {
55
64
  run = { status: 'error', response: e.message, logSummary: e.message, runToolCalls: [] };
65
+ await appendCronLog(entry.id, {
66
+ cronName: entry.name,
67
+ handoff: handoffCount + 1,
68
+ status: run.status,
69
+ logSummary: run.logSummary,
70
+ toolCalls: [],
71
+ }).catch(e => console.error(`[cron] log error: ${e.message}`));
56
72
  break;
57
73
  }
58
74
 
75
+ await appendCronLog(entry.id, {
76
+ cronName: entry.name,
77
+ handoff: handoffCount + 1,
78
+ status: run.status,
79
+ logSummary: run.logSummary,
80
+ ...(run.status !== 'checkpoint_reached' && { response: run.response }),
81
+ toolCalls: compactToolCalls(run.runToolCalls),
82
+ }).catch(e => console.error(`[cron] log error: ${e.message}`));
83
+
59
84
  if (run.status !== 'checkpoint_reached') break;
60
85
 
61
86
  if (run.checkpoint.failedApproaches?.length > 0) {
@@ -103,14 +128,6 @@ export async function runCron(entry, config) {
103
128
  run = { status: 'error', response: e.message, logSummary: e.message, runToolCalls: [] };
104
129
  }
105
130
 
106
- // Log to cron JSONL
107
- await appendCronLog(entry.id, {
108
- cronName: entry.name,
109
- status: run.status,
110
- response: run.response,
111
- logSummary: run.logSummary,
112
- }).catch(e => console.error(`[cron] log error: ${e.message}`));
113
-
114
131
  // once: true — delete after firing
115
132
  if (entry.once) {
116
133
  try {
@@ -571,12 +571,13 @@ const SEED_TOOLS = {
571
571
  type: 'function',
572
572
  function: {
573
573
  name: 'read_cron_log',
574
- description: 'Read cron execution logs. Without id: returns recent runs across all crons (last 8 most recently active cron files, 5 entries each). With id: returns runs for that specific cron.',
574
+ description: 'Read cron execution logs. Without id: returns recent runs across all crons (last 8 most recently active cron files, 5 entries each). With id: returns runs for that specific cron. Each cron execution logs one entry per handoff. Use verbose:true to include tool call details for debugging.',
575
575
  parameters: {
576
576
  type: 'object',
577
577
  properties: {
578
578
  id: { type: 'string', description: 'The cron id. Omit to get an overview across all crons.' },
579
579
  limit: { type: 'number', description: 'Max entries to return when reading a specific cron. Defaults to 20.' },
580
+ verbose: { type: 'boolean', description: 'Include toolCalls array in each entry. Default false.' },
580
581
  },
581
582
  required: [],
582
583
  },
@@ -584,6 +585,12 @@ const SEED_TOOLS = {
584
585
  },
585
586
  code: `
586
587
  const logsDir = path.join(process.env.HOME, '.jarvis/logs');
588
+ const verbose = !!args.verbose;
589
+ function strip(entry) {
590
+ if (verbose) return entry;
591
+ const { toolCalls, ...rest } = entry;
592
+ return rest;
593
+ }
587
594
  if (!args.id) {
588
595
  const files = await fs.promises.readdir(logsDir).catch(() => []);
589
596
  const cronFiles = files.filter(f => f.startsWith('cron-') && f.endsWith('.jsonl'));
@@ -596,7 +603,7 @@ const SEED_TOOLS = {
596
603
  for (const { file } of withMtime.slice(0, 8)) {
597
604
  const content = await fs.promises.readFile(path.join(logsDir, file), 'utf8').catch(() => '');
598
605
  const lines = content.trim().split('\\n').filter(Boolean);
599
- allEntries.push(...lines.slice(-5).map(line => JSON.parse(line)));
606
+ allEntries.push(...lines.slice(-5).map(line => strip(JSON.parse(line))));
600
607
  }
601
608
  allEntries.sort((a, b) => new Date(b.ts) - new Date(a.ts));
602
609
  return { status: 'ok', entries: allEntries };
@@ -604,7 +611,7 @@ const SEED_TOOLS = {
604
611
  const logFile = path.join(logsDir, 'cron-' + args.id + '.jsonl');
605
612
  const content = await fs.promises.readFile(logFile, 'utf8').catch(() => '');
606
613
  const lines = content.trim().split('\\n').filter(Boolean);
607
- const entries = lines.slice(-(args.limit || 20)).map(line => JSON.parse(line));
614
+ const entries = lines.slice(-(args.limit || 20)).map(line => strip(JSON.parse(line)));
608
615
  return { status: 'ok', entries };
609
616
  `,
610
617
  },