πŸ”§ Tools Registry

51 tools
check_room_availabilityβœ“ handler

Controleer welke vergaderzalen vrij zijn tussen twee tijdstippen. Gebruik dit voor vragen als 'welke kamers zijn vrij van 10 tot 12', 'is er nog een zaal beschikbaar vandaag', 'welke ruimtes kan ik boeken'. Geeft een lijst van vrije en bezette kamers terug.

datestart_timeend_time
Handler source bekijken
export async function check_room_availability({ date, start_time, end_time } = {}) {
  if (!start_time || !end_time) return 'Geef een begin- en eindtijd op (bijv. 10:00 en 11:00).';
  const resolvedDate = resolveDate(date);
  const start = `${resolvedDate} ${start_time}`;
  const end = `${resolvedDate} ${end_time}`;
  try {
    const res = await fetch(`${GRAPH_LOCAL_URL}/api/rooms/availability`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ start, end }),
      signal: AbortSignal.timeout(15000),
    });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const data = await res.json();
    const rooms = data.rooms || [];
    const free = rooms.filter(r => r.available);
    const busy = rooms.filter(r => !r.available);

    const dateLabel = resolvedDate === new Date().toISOString().slice(0, 10) ? 'vandaag' : resolvedDate;
    let result = `Beschikbaarheid ${dateLabel} van ${start_time} tot ${end_time}:\n\n`;

    if (free.length) {
      result += `βœ… Vrij (${free.length}):\n`;
      result += free.map(r => `  - ${r.name}`).join('\n') + '\n\n';
    } else {
      result += `❌ Geen vrije zalen in dit tijdslot.\n\n`;
    }

    if (busy.length) {
      result += `πŸ”΄ Bezet (${busy.length}):\n`;
      result += busy.map(r => {
        const who = r.busyPeriods?.[0]?.subject ? ` (${r.busyPeriods[0].subject.trim()})` : '';
        return `  - ${r.name}${who}`;
      }).join('\n');
    }

    return result.trim();
  } catch (e) {
    return `Fout bij ophalen beschikbaarheid: ${e.message}`;
  }
}
book_roomβœ“ handler

Reserveer een vergaderzaal op een bepaalde datum en tijd. Gebruik dit als iemand een kamer wil boeken of reserveren. Na aanroep wordt om bevestiging gevraagd β€” gebruik confirm_room_booking om te bevestigen of cancel_room_booking om te annuleren.

datestart_timeend_timeroom_namesubject
Handler source bekijken
export async function book_room({ date, start_time, end_time, room_name, subject = 'Meeting' } = {}) {
  if (!start_time || !end_time) return 'Geef een begin- en eindtijd op.';
  if (!room_name) return 'Welke zaal wil je boeken?
confirm_room_bookingβœ“ handler

Bevestig de wachtende zaalreservering en voer hem daadwerkelijk uit. Gebruik dit nadat book_room is aangeroepen en de gebruiker bevestigt.

Handler source bekijken
export async function confirm_room_booking() {
  if (!existsSync(PENDING_BOOKING_FILE)) return 'Geen boeking in de wachtrij. Gebruik book_room om eerst een boeking klaar te zetten.';
  let pending;
  try { pending = JSON.parse(readFileSync(PENDING_BOOKING_FILE, 'utf-8')); } catch { return 'Fout bij lezen van de wachtende boeking.'; }

  try {
    const res = await fetch(`${GRAPH_LOCAL_URL}/api/rooms/book`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ roomEmail: pending.roomEmail, subject: pending.subject, start: pending.start, end: pending.end }),
      signal: AbortSignal.timeout(20000),
    });
    const data = await res.json();
    unlinkSync(PENDING_BOOKING_FILE);

    if (data.success) {
      let msg = `βœ… Geboekt: ${pending.roomName} van ${pending.start.split(' ')[1]} tot ${pending.end.split(' ')[1]}`;
      if (data.warning) msg += `. Let op: ${data.warning}`;
      return msg;
    } else {
      return `❌ Boeking mislukt: ${data.error || 'onbekende fout'}`;
    }
  } catch (e) {
    return `Fout bij uitvoeren boeking: ${e.message}`;
  }
}
cancel_room_bookingβœ“ handler

Annuleer de wachtende zaalreservering zonder hem uit te voeren. Gebruik dit als de gebruiker zegt dat hij de boeking niet wil of annuleert.

Handler source bekijken
export async function cancel_room_booking() {
  if (!existsSync(PENDING_BOOKING_FILE)) return 'Geen boeking in de wachtrij om te annuleren.';
  let pending;
  try { pending = JSON.parse(readFileSync(PENDING_BOOKING_FILE, 'utf-8')); } catch {}
  unlinkSync(PENDING_BOOKING_FILE);
  return `Boeking geannuleerd: ${pending?.roomName || 'zaal'} op ${pending?.start?.split(' ')[0] || 'onbekend'}.`;
}
get_system_statusβœ“ handler

Haal direct de huidige systeemstatus op: schijfruimte, geheugengebruik, aantal draaiende Docker containers, open poorten en uptime. Gebruik dit voor vragen over serverstatus, schijfruimte, RAM, of resources.

Handler source bekijken
export async function get_system_status() {
  try {
    const disk = execSync('df -h / /mnt/media 2>/dev/null || df -h /', { timeout: 3000 }).toString().trim();
    const dockerCount = execSync('docker ps -q 2>/dev/null | wc -l', { timeout: 3000 }).toString().trim();
    const portCount = execSync("ss -tlnp 2>/dev/null | grep LISTEN | wc -l", { timeout: 3000 }).toString().trim();
    const uptime = execSync('uptime -p 2>/dev/null', { timeout: 2000 }).toString().trim();
    const mem = execSync("free -h | awk '/^Mem/{print $3\"/\"$2}'", { timeout: 2000 }).toString().trim();

    const diskLines = disk.split('\n').slice(1).map(line => {
      const parts = line.split(/\s+/);
      return `${parts[5] || parts[0]}: ${parts[3]} vrij (${parts[4]} gebruikt)`;
    });

    return [
      `Schijfruimte: ${diskLines.join(', ')}`,
      `Docker containers actief: ${dockerCount.trim()}`,
      `Poorten in gebruik: ${portCount.trim()}`,
      `RAM gebruik: ${mem}`,
      `Uptime: ${uptime}`,
    ].join('\n');
  } catch (e) {
    return `Fout bij systeem status ophalen: ${e.message}`;
  }
}

// ─────────────────────────────────────────────────────────────
// TOOL: get_recent_downloads
// ─────────────────────────────────────────────────────────────
get_recent_downloadsβœ“ handler

Haal de meest recent gedownloade series (via Sonarr) en films (via Radarr) op. Gebruik dit voor vragen als 'wat is er gedownload', 'welke serie is nieuw', 'wat is er recent binnengekomen'.

typelimit
Handler source bekijken
export async function get_recent_downloads({ type = 'all', limit = 5 } = {}) {
  const results = [];

  try {
    if (type === 'all' || type === 'series' || type === 'tv') {
      const data = await fetchJson(
        `${SONARR_URL}/api/v3/history?pageSize=${limit * 3}&sortKey=date&sortDirection=descending`,
        { 'X-Api-Key': SONARR_KEY }
      );
      for (const r of (data.records || []).filter(r => r.eventType === 'downloadFolderImported').slice(0, limit)) {
        const title = r.sourceTitle?.replace(/\.(mkv|mp4|avi)$/i, '') || 'Onbekend';
        const date = r.date ? new Date(r.date).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' }) : '?';
        results.push(`πŸ“Ί ${title} β€” ${date}`);
      }
    }

    if (type === 'all' || type === 'films' || type === 'movies') {
      const data = await fetchJson(
        `${RADARR_URL}/api/v3/history?pageSize=${limit * 3}&sortKey=date&sortDirection=descending`,
        { 'X-Api-Key': RADARR_KEY }
      );
      for (const r of (data.records || []).filter(r => r.eventType === 'downloadFolderImported').slice(0, limit)) {
        const title = r.sourceTitle?.replace(/\.(mkv|mp4|avi)$/i, '') || r.movie?.title || 'Onbekend';
        const date = r.date ? new Date(r.date).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' }) : '?';
        results.push(`🎬 ${title} β€” ${date}`);
      }
    }

    if (!results.length) return 'Geen recente downloads gevonden.';
    return results.slice(0, limit).join('\n');
  } catch (e) {
    return `Fout bij downloads ophalen: ${e.message}`;
  }
}

// ─────────────────────────────────────────────────────────────
// TOOL: get_download_history
// ─────────────────────────────────────────────────────────────
get_download_historyβœ“ handler

Haal download geschiedenis op uit qBittorrent, inclusief bestandsnaam, grootte en HOE LANG de download duurde. Gebruik dit specifiek voor vragen over downloadtijden, snelheid, of hoe lang iets duurde.

limit
Handler source bekijken
export async function get_download_history({ limit = 5 } = {}) {
  try {
    const data = await fetchJson(
      `${QB_URL}/api/v2/torrents/info?limit=${limit}&sort=completion_on&reverse=true`
    );

    if (!Array.isArray(data) || !data.length) return 'Geen download geschiedenis gevonden.';

    const lines = data.slice(0, limit).map(t => {
      const name = t.name?.substring(0, 50) || 'Onbekend';
      const size = t.size ? `${(t.size / 1e9).toFixed(1)} GB` : '?';
      const durationSec = t.completion_on && t.added_on ? t.completion_on - t.added_on : null;
      const duration = durationSec
        ? durationSec < 60 ? `${durationSec}s`
          : durationSec < 3600 ? `${Math.round(durationSec / 60)}min`
          : `${(durationSec / 3600).toFixed(1)}u`
        : '?';
      const completed = t.completion_on ? new Date(t.completion_on * 1000).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' }) : '?';
      return `β€’ ${name} β€” ${size} in ${duration} (klaar: ${completed})`;
    });

    return `Recente downloads:\n${lines.join('\n')}`;
  } catch (e) {
    return `Fout bij download geschiedenis: ${e.message}`;
  }
}

// ─────────────────────────────────────────────────────────────
// TOOL: get_azure_costs
// ─────────────────────────────────────────────────────────────
get_azure_costsβœ“ handler

Schat de Azure AI kosten voor de huidige dag op basis van het aantal voice sessies en transcript lengte. Gebruik dit voor vragen over kosten, prijs, of hoeveel de AI gesprekken kosten.

Handler source bekijken
export async function get_azure_costs() {
  // Azure Cost Management API requires subscription ID + access token
  // For now: estimate based on session count and known pricing
  // gpt-realtime-mini pricing: ~$0.06/min audio input + $0.12/min audio output
  try {
    const transcriptDir = resolve(__dirname, '../transcripts');
    const files = (await import('fs')).readdirSync(transcriptDir).filter(f => f.endsWith('.txt') && f.startsWith(new Date().toISOString().slice(0, 10)));
    const sessionCount = files.length;

    // Count transcript lines for time estimation
    let totalLines = 0;
    for (const f of files) {
      const content = (await import('fs')).readFileSync(`${transcriptDir}/${f}`, 'utf-8');
      totalLines += content.split('\n').filter(l => l.includes('USER:') || l.includes('AI:')).length;
    }

    const estMinutes = Math.max(1, totalLines * 0.3); // ~18s per exchange
    const estCost = (estMinutes * 0.06 + estMinutes * 0.12).toFixed(2);

    return [
      `Vandaag: ${sessionCount} voice sessie(s)`,
      `Geschatte duur: ~${estMinutes.toFixed(0)} minuten`,
      `Geschatte kosten: ~$${estCost} USD (gpt-realtime-mini: $0.06/min input + $0.12/min output)`,
      `Deployment: ${ENV.A
get_jellyfin_historyβœ“ handler

Haal de meest recent bekeken films en series op uit Jellyfin. Gebruik dit voor vragen als 'wat heb ik gekeken', 'wat was de laatste film', 'mijn kijkgeschiedenis'.

limit
Handler source bekijken
export async function get_jellyfin_history({ limit = 5 } = {}) {
  try {
    const data = await fetchJson(
      `${JELLYFIN_URL}/Users/${JELLYFIN_USER}/Items?SortBy=DatePlayed&SortOrder=Descending&Filters=IsPlayed&Limit=${limit}&Recursive=true&Fields=Name,UserData,SeriesName,ProductionYear&IncludeItemTypes=Movie,Episode`,
      { 'X-MediaBrowser-Token': JELLYFIN_KEY }
    );
    const items = data.Items || [];
    if (!items.length) return 'Geen recent bekeken items gevonden in Jellyfin.';

    const lines = items.map(item => {
      const name = item.SeriesName ? `${item.SeriesName} β€” ${item.Name}` : item.Name;
      const year = item.ProductionYear ? ` (${item.ProductionYear})` : '';
      const played = item.UserData?.LastPlayedDate
        ? new Date(item.UserData.LastPlayedDate).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' })
        : '?';
      const type = item.Type === 'Movie' ? '🎬' : 'πŸ“Ί';
      return `${type} ${name}${year} β€” ${played}`;
    });

    return `Recent bekeken in Jellyfin:\n${lines.join('\n')}`;
  } catch (e) {
    return `Fout bij Jellyfin geschiedenis ophalen: ${e.message}`;
  }
}

// ─────────────────────────────────────────────────────────────
// TOOL: get_orchestration_schedules
// ─────────────────────────────────────────────────────────────
get_orchestration_schedulesβœ“ handler

Haal de lijst van ingeplande agents op uit orchestration.local. Gebruik dit voor vragen als 'wat staat er ingepland', 'welke agents draaien automatisch', 'wat is er geconfigureerd in orchestration'.

Handler source bekijken
export async function get_orchestration_schedules() {
  try {
    const schedules = await fetchJson(`${ORCHESTRATION_URL}/api/schedules`);
    if (!Array.isArray(schedules) || !schedules.length) return 'Geen geplande agents gevonden in orchestration.local.';

    const lines = schedules.map(s => {
      const status = s.enabled ? 'βœ…' : '⏸️';
      const lastRun = s.lastRunAt
        ? new Date(s.lastRunAt).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' })
        : 'nog niet gedraaid';
      return `${status} ${s.agentName} β€” ${s.recurrenceLabel} (laatste run: ${lastRun})`;
    });

    return `Ingeplande agents in orchestration.local:\n${lines.join('\n')}`;
  } catch (e) {
    return `Fout bij ophalen orchestration schedules: ${e.message}`;
  }
}

// ─────────────────────────────────────────────────────────────
// Handler map β€” used by relay to dispatch tool calls
// ─────────────────────────────────────────────────────────────
get_jellyfin_recent_watchedβœ“ handler

Haalt de recent bekeken items op in Jellyfin (laatste 10 films/series)

limit
Handler source bekijken
export async function get_jellyfin_recent_watched({ limit = 10 } = {}) {
  try {
    const data = await fetchJson(
      `${JELLYFIN_URL}/Users/${JELLYFIN_USER}/Items?SortBy=DatePlayed&SortOrder=Descending&Filters=IsPlayed&Limit=${limit}&Recursive=true&Fields=Name,UserData,SeriesName,ProductionYear&IncludeItemTypes=Movie,Episode`,
      { 'X-MediaBrowser-Token': JELLYFIN_KEY }
    );
    const items = data.Items || [];
    if (!items.length) return 'Geen recent bekeken items gevonden in Jellyfin.';
    const lines = items.map(item => {
      const name = item.SeriesName ? `${item.SeriesName} β€” ${item.Name}` : item.Name;
      const year = item.ProductionYear ? ` (${item.ProductionYear})` : '';
      const played = item.UserData?.LastPlayedDate
        ? new Date(item.UserData.LastPlayedDate).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' })
        : '?';
      return `${item.Type === 'Movie' ? '🎬' : 'πŸ“Ί'} ${name}${year} β€” ${played}`;
    });
    return `Recent bekeken in Jellyfin:\n${lines.join('\n')}`;
  } catch (e) {
    return `Fout bij ophalen Jellyfin kijkgeschiedenis: ${e.message}`;
  }
}
get_prowlarr_indexersβœ“ handler

Haalt de lijst van indexers op uit Prowlarr met status

Handler source bekijken
export async function get_prowlarr_indexers() {
  try {
    const data = await fetchJson(`${PROWLARR_URL}/api/v1/indexer`, { 'X-Api-Key': PROWLARR_KEY });
    if (!Array.isArray(data) || !data.length) return 'Geen indexers gevonden in Prowlarr.';
    const lines = data.map(i => `${i.enable ? 'βœ…' : '❌'} ${i.name} (${i.protocol})`);
    return `Prowlarr indexers:\n${lines.join('\n')}`;
  } catch (e) {
    return `Fout bij ophalen Prowlarr indexers: ${e.message}`;
  }
}
get_qbittorrent_recentβœ“ handler

Haalt recente torrents op met naam, status, grootte en download tijd

Handler source bekijken
export async function get_qbittorrent_recent() {
  try {
    const data = await fetchJson(`${QB_URL}/api/v2/torrents/info?filter=completed&limit=10&sort=completion_on&reverse=true`);
    if (!Array.isArray(data) || !data.length) return 'Geen recente torrents gevonden.';
    const lines = data.slice(0, 10).map(t => {
      const name = t.name?.substring(0, 50) || 'Onbekend';
      const size = t.size ? `${(t.size / 1e9).toFixed(1)} GB` : '?';
      const durationSec = t.completion_on && t.added_on ? t.completion_on - t.added_on : null;
      const duration = durationSec
        ? durationSec < 3600 ? `${Math.round(durationSec / 60)}min` : `${(durationSec / 3600).toFixed(1)}u`
        : '?';
      const completed = t.completion_on
        ? new Date(t.completion_on * 1000).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' })
        : '?';
      return `β€’ ${name} β€” ${size} in ${duration} (${completed})`;
    });
    return `Recente torrents:\n${lines.join('\n')}`;
  } catch (e) {
    return `Fout bij ophalen qBittorrent: ${e.message}`;
  }
}
get_radarr_recent_moviesβœ“ handler

Haalt de laatste 10 gedownloade films op uit Radarr

limit
Handler source bekijken
export async function get_radarr_recent_movies({ limit = 10 } = {}) {
  try {
    const data = await fetchJson(
      `${RADARR_URL}/api/v3/history?pageSize=${limit}&sortKey=date&sortDirection=descending&eventType=1`,
      { 'X-Api-Key': RADARR_KEY }
    );
    const records = data.records || [];
    if (!records.length) return 'Geen recente films gevonden.';
    const lines = records.map(r => {
      const title = r.movie?.title || r.sourceTitle || 'Onbekend';
      const year = r.movie?.year ? ` (${r.movie.year})` : '';
      const quality = r.quality?.quality?.name || '?';
      const date = r.date ? new Date(r.date).toLocaleString('nl-NL', { dateStyle: 'short' }) : '?';
      return `🎬 ${title}${year} β€” ${quality} (${date})`;
    });
    return `Recente films in Radarr:\n${lines.join('\n')}`;
  } catch (e) {
    return `Fout bij ophalen Radarr films: ${e.message}`;
  }
}
get_sonarr_recent_episodesβœ“ handler

Haalt de laatste 10 gedownloade afleveringen op uit Sonarr

limit
Handler source bekijken
export async function get_sonarr_recent_episodes({ limit = 10 } = {}) {
  try {
    const data = await fetchJson(
      `${SONARR_URL}/api/v3/history?pageSize=${limit}&sortKey=date&sortDirection=descending&eventType=1`,
      { 'X-Api-Key': SONARR_KEY }
    );
    const records = data.records || [];
    if (!records.length) return 'Geen recente afleveringen gevonden.';
    const lines = records.map(r => {
      const series = r.series?.title || 'Onbekend';
      const ep = r.episode
        ? `S${String(r.episode.seasonNumber).padStart(2, '0')}E${String(r.episode.episodeNumber).padStart(2, '0')}: ${r.episode.title}`
        : '';
      const quality = r.quality?.quality?.name || '?';
      const date = r.date ? new Date(r.date).toLocaleString('nl-NL', { dateStyle: 'short' }) : '?';
      return `πŸ“Ί ${series} ${ep} β€” ${quality} (${date})`;
    });
    return `Recente afleveringen in Sonarr:\n${lines.join('\n')}`;
  } catch (e) {
    return `Fout bij ophalen Sonarr afleveringen: ${e.message}`;
  }
}
get_orchestration_scheduled_jobsβœ“ handler

Haalt de ingeplande agent jobs op van orchestration.local

Handler source bekijken
export async function get_orchestration_scheduled_jobs() {
  try {
    const schedules = await fetchJson(`${ORCHESTRATION_URL}/api/schedules`);
    if (!Array.isArray(schedules) || !schedules.length) return 'Geen ingeplande jobs gevonden.';
    const lines = schedules.map(s => {
      const status = s.enabled ? 'βœ…' : '⏸️';
      const lastRun = s.lastRunAt
        ? new Date(s.lastRunAt).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' })
        : 'nog niet gedraaid';
      const runStatus = s.lastRunStatus ? ` [${s.lastRunStatus}]` : '';
      return `${status} ${s.agentName} β€” ${s.recurrenceLabel} (${lastRun}${runStatus})`;
    });
    return `Ingeplande jobs:\n${lines.join('\n')}`;
  } catch (e) {
    return `Fout bij ophalen orchestration jobs: ${e.message}`;
  }
}
get_docker_container_statsβœ“ handler

Haalt CPU en geheugen gebruik op van draaiende Docker containers

Handler source bekijken
export async function get_docker_container_stats() {
  try {
    const result = execSync(
      "docker stats --no-stream --format 'table {{.Name}}\\t{{.CPUPerc}}\\t{{.MemUsage}}' | head -n 20",
      { timeout: 10000 }
    ).toString().trim();
    return result || 'Geen container stats beschikbaar.';
  } catch (e) {
    return `Fout bij ophalen Docker stats: ${e.message}`;
  }
}


// AUTO-GENERATED by analyzer on 2026-02-25
get_azure_costs_date_rangeβœ“ handler

Haal geschatte Azure AI kosten op voor een specifieke datum of datumbereik

date
Handler source bekijken
export async function get_azure_costs_date_range({ date } = {}) {
  try {
    const transcriptDir = '/home/ubuntu/realtime-voice/transcripts';
    let targetDate;
    
    if (date === 'today') {
      targetDate = new Date().toISOString().split('T')[0];
    } else if (date === 'yesterday') {
      const yesterday = new Date();
      yesterday.setDate(yesterday.getDate() - 1);
      targetDate = yesterday.toISOString().split('T')[0];
    } else {
      targetDate = date;
    }
    
    const files = execSync(`find ${transcriptDir} -name "${targetDate}*.txt" -type f`, { encoding: 'utf-8' }).trim().split('\n').filter(Boolean);
    
    if (files.length === 0) {
      return `Geen transcripts gevonden voor ${targetDate}`;
    }
    
    let totalMinutes = 0;
    let sessionCount = files.length;
    
    for (const file of files) {
      const content = execSync(`cat "${file}"`, { encoding: 'utf-8' });
      const lines = content.split('\n').filter(l => l.includes('USER:') || l.includes('AI:'));
      const duration = lines.length * 0.5;
      totalMinutes += duration;
    }
    
    const inputCost = (totalMinutes * 60 * 0.06) / 1000000;
    const outputCost = (totalMinutes * 60 * 0.24) / 1000000;
    const totalCost = inputCost + outputCost;
    
    return `Datum: ${targetDate}\nSessies: ${sessionCount}\nGeschatte duur: ${Math.round(totalMinutes)} minuten\nGeschatte kosten: $${totalCost.toFixed(2)} USD`;
  } catch (e) {
    return 'Fout bij ophalen kosten: ' + e.message;
  }
}

// AUTO-GENERATED by analyzer on 2026-02-25
get_qbittorrent_torrent_detailsβœ“ handler

Haal gedetailleerde informatie op over recente torrents inclusief download tijd en snelheid

Handler source bekijken
export async function get_qbittorrent_torrent_details() {
  try {
    const torrents = await fetchJson(QB_URL + '/api/v2/torrents/info?filter=completed&limit=5');
    
    if (!torrents || torrents.length === 0) {
      return 'Geen voltooide torrents gevonden';
    }
    
    const result = torrents.map(t => {
      const completedOn = new Date(t.completion_on * 1000);
      const addedOn = new Date(t.added_on * 1000);
      const durationMs = completedOn - addedOn;
      const durationMin = Math.round(durationMs / 60000);
      const sizeGB = (t.size / (1024 ** 3)).toFixed(2);
      const avgSpeed = t.size / (durationMs / 1000) / (1024 ** 2);
      
      return `${t.name}\n  Grootte: ${sizeGB} GB\n  Duur: ${durationMin} minuten\n  Gem. snelheid: ${avgSpeed.toFixed(1)} MB/s\n  Voltooid: ${completedOn.toLocaleString('nl-NL')}`;
    }).join('\n\n');
    
    return result;
  } catch (e) {
    return 'Fout bij ophalen torrent details: ' + e.message;
  }
}

// AUTO-GENERATED by analyzer on 2026-02-25
compare_ai_hosting_costsβœ“ handler

Vergelijk kosten van verschillende AI hosting opties (OpenAI Realtime API vs GPU huren vs eigen hardware)

daily_minutes
Handler source bekijken
export async function compare_ai_hosting_costs({ daily_minutes } = {}) {
  try {
    const monthlyMinutes = daily_minutes * 30;
    const monthlyHours = monthlyMinutes / 60;
    
    const realtimeInputTokens = monthlyMinutes * 60 * 1000;
    const realtimeOutputTokens = monthlyMinutes * 60 * 1000;
    const realtimeCost = (realtimeInputTokens * 0.06 / 1000000) + (realtimeOutputTokens * 0.24 / 1000000);
    
    const t4HourlyCost = 0.40;
    const t4MonthlyCost = monthlyHours * t4HourlyCost;
    
    const t4PurchaseCost = 1500;
    const monthsToBreakEven = t4PurchaseCost / t4MonthlyCost;
    
    return `Kostenvergelijking voor ${daily_minutes} min/dag (${monthlyMinutes} min/maand):\n\nOpenAI Realtime API:\n  Maandelijks: $${realtimeCost.toFixed(2)}\n  Jaarlijks: $${(realtimeCost * 12).toFixed(2)}\n\nNVIDIA T4 huren (cloud):\n  Maandelijks: $${t4MonthlyCost.toFixed(2)}\n  Jaarlijks: $${(t4MonthlyCost * 12).toFixed(2)}\n\nNVIDIA T4 kopen (~$1500):\n  Break-even na: ${monthsToBreakEven.toFixed(1)} maanden\n  Stroom/onderhoud: ~$50/maand extra\n\nAanbeveling: ${realtimeCost < t4MonthlyCost ? 'OpenAI Realtime API (goedkoper)' : 'Eigen GPU huren of kopen (goedkoper)'}`;
  } catch (e) {
    return 'Fout bij kostenvergelijking: ' + e.message;
  }
}

// AUTO-GENERATED by analyzer on 2026-02-25
get_system_service_countβœ“ handler

Tel alle draaiende services (Docker containers, systemd units, open poorten)

Handler source bekijken
export async function get_system_service_count() {
  try {
    const dockerContainers = execSync('docker ps --format "{{.Names}}"', { encoding: 'utf-8' }).trim().split('\n').filter(Boolean);
    const systemdActive = execSync('systemctl list-units --type=service --state=running --no-pager --no-legend | wc -l', { encoding: 'utf-8' }).trim();
    const listeningPorts = execSync('ss -tuln | grep LISTEN | wc -l', { encoding: 'utf-8' }).trim();
    
    const dockerList = dockerContainers.join(', ');
    
    return `Services overzicht:\n\nDocker containers: ${dockerContainers.length}\n  ${dockerList}\n\nSystemd services: ${systemdActive}\nListening poorten: ${listeningPorts}`;
  } catch (e) {
    return 'Fout bij ophalen services: ' + e.message;
  }
}

// AUTO-GENERATED by analyzer on 2026-02-25
get_prowlarr_indexer_statsβœ“ handler

Haal Prowlarr indexer statistieken op (welke indexers actief zijn, success rate, etc.)

Handler source bekijken
export async function get_prowlarr_indexer_stats() {
  try {
    const indexers = await fetchJson(PROWLARR_URL + '/api/v1/indexer', { 'X-Api-Key': PROWLARR_KEY });
    
    if (!indexers || indexers.length === 0) {
      return 'Geen indexers gevonden in Prowlarr';
    }
    
    const active = indexers.filter(i => i.enable);
    const inactive = indexers.filter(i => !i.enable);
    
    const result = `Prowlarr Indexers:\n\nActief: ${active.length}\nInactief: ${inactive.length}\nTotaal: ${indexers.length}\n\nActieve indexers:\n` + 
      active.map(i => `  - ${i.name} (${i.protocol})`).join('\n');
    
    return result;
  } catch (e) {
    return 'Fout bij ophalen Prowlarr indexers: ' + e.message;
  }
}
get_notesβœ“ handler

Haal de recente notities op die zijn opgeslagen via de voice agent. Gebruik dit voor vragen als 'welke notities heb ik', 'wat heb ik genoteerd', 'laat mijn notities zien', of 'notities van vandaag'.

limitcategory
Handler source bekijken
export async function get_notes({ limit = 10, category } = {}) {
  try {
    const notesFile = resolve(__dirname, '../notes/notes.json');
    if (!existsSync(notesFile)) return 'Nog geen notities opgeslagen.';
    let notes = JSON.parse(readFileSync(notesFile, 'utf-8'));
    if (category) notes = notes.filter(n => n.category === category);
    notes = notes.slice(0, limit);
    if (!notes.length) return 'Geen notities gevonden' + (category ? ` voor categorie '${category}'` : '') + '.';
    return notes.map(n => {
      const date = new Date(n.timestamp).toLocaleString('nl-NL', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
      return `[${date}] (${n.category}) ${n.text}`;
    }).join('\n');
  } catch (e) {
    return 'Fout bij ophalen notities: ' + e.message;
  }
}
get_kanban_tasksβœ“ handler

Haal open taken op van het Kanban bord (tasks.local). Gebruik dit voor vragen als 'welke taken heb ik open staan', 'wat staat er op het bord', 'backlog', of 'to-do taken'.

columnlimit
Handler source bekijken
export async function get_kanban_tasks({ column = 'all', limit = 10 } = {}) {
  // Column mapping: graatje notes uses open/planning/bezig/klaar
  const colMap = { 'backlog': 'open', 'todo': 'planning', 'in-progress': 'bezig', 'done': 'klaar' };
  const resolvedCol = colMap[column] || column; // accept both old and new column names
  try {
    const url = resolvedCol !== 'all'
      ? `http://localhost:3031/api/notes?status=${resolvedCol}`
      : 'http://localhost:3031/api/notes';
    const data = await fetchJson(url);
    let notes = data.notes || [];
    // Default: exclude klaar unless specifically requested
    if (resolvedCol === 'all') notes = notes.filter(n => (n.status ?? 'open') !== 'klaar');
    notes = notes.slice(0, limit);
    if (!notes.length) return 'Geen taken gevonden' + (resolvedCol !== 'all' ? ` met status '${resolvedCol}'` : '') + '.';
    return notes.map(n => `[${(n.status ?? 'open').toUpperCase()}] ${n.text}${n.category ? ` (${n.category})` : ''}`).join('\n');
  } catch (e) {
    return 'Fout bij ophalen taken: ' + e.message;
  }
}

// AUTO-GENERATED by analyzer on 2026-02-26
get_whatsapp_recent_messagesβœ“ handler

Lees recente WhatsApp berichten uit de lokale database. Gebruik dit voor vragen als 'wat zijn mijn laatste WhatsApp berichten', 'heb ik nieuwe berichten', of 'wat staat er in WhatsApp'.

limit
Handler source bekijken
export async function get_whatsapp_recent_messages({ limit = 10 } = {}) {
  try {
    const db = '/home/ubuntu/whatsapp-bot/data/whatsapp.db';
    const result = execSync(
      `sqlite3 -separator '|' ${db} "SELECT sender_jid, chat_jid, content, push_name, from_me, timestamp FROM wa_messages ORDER BY timestamp DESC LIMIT ${limit};"`
      , { timeout: 5000 }
    ).toString().trim();
    if (!result) return 'Geen recente berichten gevonden.';
    return result.split('\n').map(line => {
      const [senderJid, chatJid, content, pushName, fromMe, ts] = line.split('|');
      const date = ts ? new Date(parseInt(ts) * 1000).toLocaleString('nl-NL', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : '?';
      const chatName = chatJid?.includes('g.us') ? '(groep)' : '(prive)';
      const who = fromMe === '1' ? 'Ik' : (pushName || senderJid?.split('@')[0] || '?');
      return `[${date}] ${chatName} ${who}: ${(content || '').slice(0, 120)}`;
    }).join('\n');
  } catch (e) {
    return 'Fout bij ophalen WhatsApp berichten: ' + e.message;
  }
}

// AUTO-GENERATED by analyzer on 2026-02-26
run_transcript_analyzerβœ“ handler

Start de transcript-analyzer die nieuwe gesprekken analyseert en automatisch tools genereert. Gebruik dit wanneer de gebruiker vraagt 'analyseer transcripts', 'genereer nieuwe tools', of 'trap de analyzer af'.

all
Handler source bekijken
export async function run_transcript_analyzer({ all = false } = {}) {
  try {
    mkdirSync(resolve(__dirname, '../jobs/logs'), { recursive: true });
    const jobId = Date.now().toString();
    const logFile = resolve(__dirname, `../jobs/logs/${jobId}-analyzer.log`);
    const cmd = `node ${resolve(__dirname, '../analyzer/analyze.mjs')}${all ? ' --all' : ''}`;
    const { spawn } = await import('child_process');
    const child = spawn('bash', ['-c', `${cmd} > ${logFile} 2>&1`], { detached: true, stdio: 'ignore' });
    child.unref();
    const jobs = existsSync(resolve(__dirname, '../jobs/jobs.json'))
      ? JSON.parse(readFileSync(resolve(__dirname, '../jobs/jobs.json'), 'utf-8')) : [];
    jobs.unshift({ id: jobId, name: 'Transcript Analyzer', command: cmd, status: 'running', startedAt: new Date().toISOString(), logFile });
    writeFileSync(resolve(__dirname, '../jobs/jobs.json'), JSON.stringify(jobs, null, 2));
    return `Transcript-analyzer gestart als achtergrondtaak (ID: ${jobId}). Gebruik check_background_task met dit ID om de status te zien. Log: ${logFile}`;
  } catch (e) {
    return 'Fout bij starten analyzer: ' + e.message;
  }
}

// AUTO-GENERATED by analyzer on 2026-02-26
get_available_toolsβœ“ handler

Geef direct de lijst van alle beschikbare tools/skills terug zonder de backend-agent. Gebruik dit voor vragen als 'welke tools heb je', 'wat kun je', 'lijst van skills', of 'welke tools zijn beschikbaar'.

search
Handler source bekijken
export async function get_available_tools({ search } = {}) {
  try {
    const registry = JSON.parse(readFileSync(resolve(__dirname, 'registry.json'), 'utf-8'));
    let tools = registry.filter(t => t.name && t.description);
    if (search) {
      const q = search.toLowerCase();
      tools = tools.filter(t => t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q));
    }
    if (!tools.length) return 'Geen tools gevonden' + (search ? ` voor "${search}"` : '') + '.';
    return `${tools.length} tools beschikbaar:\n` + tools.map(t => `β€’ ${t.name}: ${t.description.slice(0, 100)}`).join('\n');
  } catch (e) {
    return 'Fout bij ophalen tools: ' + e.message;
  }
}


// Fixed: wa-cli does not exist β€” use HTTP API, always send to self for testing
send_whatsapp_to_numberβœ“ handler

Verstuur WhatsApp bericht. Voor testdoeleinden wordt altijd naar jezelf (de eigenaar) gestuurd.

phone_numbermessage
Handler source bekijken
export async function send_whatsapp_to_number({ phone_number, message } = {}) {
  if (!message) return 'Fout: message is vereist';
  const selfJid = ENV.WHATSAPP_SELF_JID || '31657856919@s.whatsapp.net';
  try {
    const res = await fetch('http://localhost:3022/api/chat/send', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ jid: selfJid, text: message }),
      signal: AbortSignal.timeout(8000),
    });
    const result = await res.json();
    if (result?.sent) return `WhatsApp bericht verstuurd (test β†’ naar jezelf): "${message}"`;
    return `Fout bij versturen: ${JSON.stringify(result)}`;
  } catch (e) {
    return 'Fout bij versturen WhatsApp: ' + e.message;
  }
}

// Fixed: wa-cli does not exist β€” use HTTP API
search_whatsapp_contactsβœ“ handler

Zoek WhatsApp contacten op naam

search_term
Handler source bekijken
export async function search_whatsapp_contacts({ search_term } = {}) {
  if (!search_term) return 'Geef een zoekterm op.';
  try {
    const res = await fetch('http://localhost:3022/api/wa/contacts', { signal: AbortSignal.timeout(5000) });
    const contacts = await res.json();
    const matches = contacts.filter(c =>
      (c.name || '').toLowerCase().includes(search_term.toLowerCase()) ||
      (c.notify || '').toLowerCase().includes(search_term.toLowerCase()) ||
      (c.jid || '').includes(search_term)
    );
    if (!matches.length) return `Geen contacten gevonden voor: ${search_term}`;
    return matches.map(c => `${c.name || c.notify || 'Onbekend'} β€” ${c.jid}`).join('\n');
  } catch (e) {
    return 'Fout bij zoeken contacten: ' + e.message;
  }
}

// Fixed: wa-cli does not exist β€” use HTTP API
get_whatsapp_conversationsβœ“ handler

Haal recente WhatsApp conversaties op

limit
Handler source bekijken
export async function get_whatsapp_conversations({ limit = 10 } = {}) {
  try {
    const res = await fetch('http://localhost:3022/api/wa/chats', { signal: AbortSignal.timeout(5000) });
    const chats = await res.json();
    const top = Array.isArray(chats) ? chats.slice(0, limit) : [];
    if (!top.length) return 'Geen conversaties gevonden.';
    return top.map(c => `${c.name || c.jid} β€” ${c.unreadCount || 0} ongelezen`).join('\n');
  } catch (e) {
    return 'Fout bij ophalen conversaties: ' + e.message;
  }
}
get_torrent_download_statusβœ“ handler

Haalt de downloadvoortgang op van torrents in qBittorrent. Optioneel zoeken op naam. Geeft percentage, status en downloadsnelheid terug.

name
Handler source bekijken
export async function get_torrent_download_status({ name } = {}) {
  try {
    const data = await fetchJson(`${QB_URL}/api/v2/torrents/info`);
    if (!Array.isArray(data)) return 'Kon torrent lijst niet ophalen.';
    if (name) {
      const q = name.toLowerCase();
      const match = data.find(t => t.name?.toLowerCase().includes(q));
      if (!match) return `Geen torrent gevonden met naam "${name}". Actieve downloads: ${data.filter(t => t.progress < 1).map(t => t.name).join(', ') || 'geen'}`;
      const pct = Math.round((match.progress || 0) * 100);
      const state = match.state || 'onbekend';
      const speed = match.dlspeed ? `${(match.dlspeed / 1024 / 1024).toFixed(1)} MB/s` : 'n.v.t.';
      return `${match.name}: ${pct}% klaar (${state}), snelheid: ${speed}`;
    }
    const active = data.filter(t => t.progress < 1);
    if (!active.length) return 'Geen actieve downloads. ' + (data.length ? `${data.length} voltooide torrents aanwezig.` : '');
    return active.map(t => `${t.name}: ${Math.round((t.progress || 0) * 100)}% (${t.state || '?'})`).join('\n');
  } catch (e) {
    return `Fout bij ophalen torrents: ${e.message}`;
  }
}
get_analyzer_planβœ“ handler

Toont het huidige pending plan van de transcript analyzer: welke gaps gevonden zijn en welke nieuwe tools voorgesteld worden. Gebruik dit om te zien wat de analyzer wil toevoegen voordat je goedkeurt.

Handler source bekijken
export async function get_analyzer_plan() {
  const stateFile = resolve(__dirname, '../analyzer/state.json');
  try {
    const state = JSON.parse(readFileSync(stateFile, 'utf-8'));
    const plan = state.pendingPlan;
    if (!plan) return 'Geen pending analyzer plan gevonden.';
    if (plan.approvedAt) return `Plan al goedgekeurd op ${new Date(plan.approvedAt).toLocaleString('nl-NL')}. Voer analyze.mjs opnieuw uit voor een nieuw plan.`;

    const lines = [
      `πŸ“‹ Analyzer plan van ${new Date(plan.date).toLocaleString('nl-NL')}:`,
      ``,
      `Gaps gevonden: ${plan.gaps?.length || 0}`,
    ];
    for (const g of (plan.gaps || []).slice(0, 3)) {
      lines.push(`  - ${g.description}`);
    }
    lines.push(``, `Voorgestelde tools (${plan.new_tools?.length || 0}):`);
    for (const t of (plan.new_tools || [])) {
      lines.push(`  + ${t.name}: ${t.description?.slice(0, 70)}`);
    }
    lines.push(``, `Goedkeuren?
approve_analyzer_planβœ“ handler

Keurt het pending analyzer plan goed en voert het uit: past de voorgestelde tools toe op handlers.mjs en registry.json, en herstart de relay. Gebruik get_analyzer_plan eerst om te zien wat er wordt toegepast.

Handler source bekijken
export async function approve_analyzer_plan() {
  const analyzerScript = resolve(__dirname, '../analyzer/analyze.mjs');
  const handlersPath = resolve(__dirname, 'handlers.mjs');
  const serverPath = resolve(__dirname, '../server.mjs');
  const stateFile = resolve(__dirname, '../analyzer/state.json');
  try {
    const state = JSON.parse(readFileSync(stateFile, 'utf-8'));
    if (!state.pendingPlan?.new_tools?.length) return 'Geen pending plan om goed te keuren.';
    if (state.pendingPlan.approvedAt) return `Plan is al goedgekeurd op ${new Date(state.pendingPlan.approvedAt).toLocaleString('nl-NL')}.`;

    // Run analyze --apply, then syntax-check both key files, then restart relay
    const command = [
      `node ${analyzerScript} --apply`,
      `node --check ${handlersPath} && echo "βœ… handlers.mjs OK"`,
      `node --check ${serverPath} && echo "βœ… server.mjs OK"`,
      `pm2 restart realtime-voice`,
      `sleep 3`,
      `curl -sf http://127.0.0.1:3024/health | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf-8')); console.log('βœ… Health OK β€”', d.tools?.length ?? 0, 'tools')"`,
    ].join(' && ');

    return await start_background_task({
      name: 'Analyzer plan toepassen + valideren',
      command,
    });
  } catch (e) {
    return `Fout bij goedkeuren plan: ${e.message}`;
  }
}
execute_job_planβœ“ handler

Voert het opgeslagen plan van een plan-modus Copilot taak uit. Gebruik na start_copilot_task met plan_only:true β€” leest het plan van die taak en voert het stap voor stap uit.

job_id
Handler source bekijken
export async function execute_job_plan({ job_id } = {}) {
  if (!job_id?.trim()) return 'Geef een job ID op.';
  try {
    const jobs = JSON.parse(readFileSync(JOBS_FILE, 'utf-8'));
    const planJob = jobs.find(j => j.id === job_id || j.name?.toLowerCase().includes(job_id.toLowerCase()));
    if (!planJob) return `Geen taak gevonden met ID of naam "${job_id}".`;
    if (!planJob.planText) return `Taak "${planJob.name}" heeft geen plan opgeslagen. Was het een plan-modus taak?`;
    if (planJob.mode !== 'plan') return `Taak "${planJob.name}" is geen plan-modus taak (mode: ${planJob.mode || 'execute'}).`;

    const res = await fetch('http://localhost:3031/api/jobs/copilot', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        name: `Uitvoeren: ${planJob.name}`,
        prompt: planJob.prompt,
        dir: planJob.dir || '/home/ubuntu',
        model: planJob.model,
        executePlanJobId: planJob.id,
      }),
    });
    const data = await res.json();
    if (!res.ok) return `Fout bij starten uitvoering: ${data.error || res.status}`;
    return `Plan van "${planJob.name}" wordt nu uitgevoerd (ID: ${data.job.id}). Bekijk voortgang op graatje.local/jobs`;
  } catch (err) {
    return `Fout: ${err.message}`;
  }
}


// AUTO-GENERATED by analyzer on 2026-02-26
validate_relay_filesβœ“ handler

Valideert de syntax van server.mjs en handlers.mjs (node --check) en de JSON-structuur van registry.json. Gebruik dit na elke bestandswijziging om te controleren of alles correct is.

Handler source bekijken
export async function validate_relay_files() {
  const files = {
    'server.mjs': resolve(__dirname, '../server.mjs'),
    'handlers.mjs': resolve(__dirname, 'handlers.mjs'),
    'registry.json': resolve(__dirname, 'registry.json'),
  };
  const results = [];
  for (const [name, path] of Object.entries(files)) {
    try {
      if (name.endsWith('.json')) {
        JSON.parse(readFileSync(path, 'utf-8'));
        results.push(`βœ… ${name}: valid JSON`);
      } else {
        execSync(`node --check ${path}`, { stdio: 'pipe' });
        results.push(`βœ… ${name}: syntax OK`);
      }
    } catch (e) {
      const msg = e.stderr?.toString().trim() || e.message;
      results.push(`❌ ${name}: ${msg.split('\n')[0]}`);
    }
  }
  return results.join('\n');
}
ask_environment_agentβœ“ handler

Stel een complexe vraag aan de Copilot AI agent die volledige toegang heeft tot de linuxdev VM omgeving (kan bash uitvoeren, bestanden lezen, services beheren). Gebruik dit ALLEEN voor vragen die niet door de specifieke tools beantwoord kunnen worden, zoals configuraties, logs, projecten, of specifieke problemen.

query
Handler source bekijken
export async function ask_environment_agent({ query } = {}) {
  if (!query?.trim()) return 'Geef een vraag op.';
  return await start_copilot_task({ name: query.substring(0, 60), prompt: query, dir: '/home/ubuntu', model: 'gpt-4.1' });
}
send_whatsapp_messageβœ“ handler

Stuur een WhatsApp bericht naar de eigenaar of een specifiek nummer. Gebruik dit wanneer gevraagd om een WhatsApp te sturen, een reminder te sturen via WhatsApp, of iemand te berichten via WhatsApp.

messageto
Handler source bekijken
export async function send_whatsapp_message(args = {}) {
  const { message } = args;
  if (!message) return 'Fout: message parameter is vereist';
  // Always send to self for testing β€” never to third parties
  const selfJid = ENV.WHATSAPP_SELF_JID || '31657856919@s.whatsapp.net';
  try {
    const res = await fetch('http://localhost:3022/api/chat/send', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ jid: selfJid, text: message }),
      signal: AbortSignal.timeout(8000),
    });
    const result = await res.json();
    if (result?.sent) return `WhatsApp bericht verstuurd naar jezelf: "${message}"`;
    return `Fout bij versturen: ${JSON.stringify(result)}`;
  } catch (e) {
    return `Fout bij WhatsApp: ${e.message}`;
  }
}
get_proxmox_backup_infoβœ“ handler

Haal informatie op over Proxmox backup jobs: configuratie, schema's en status. Gebruik dit voor vragen over backups, backup schema's of backup status van de homelab server.

Handler source bekijken
export async function get_proxmox_backup_info(args = {}) {
  try {
    // Read Proxmox backup job config from local vzdump config
    const configPath = '/etc/pve/vzdump.cron';
    let backupJobs = 'Geen backup jobs gevonden';
    try {
      const config = readFileSync(configPath, 'utf-8');
      backupJobs = config.trim();
    } catch {
      // Try to get info via local Proxmox API (if running on PVE host)
      try {
        const result = execSync(
          'pvesh get /nodes/$(hostname)/backup --output-format json 2>/dev/null || echo "[]"',
          { timeout: 5000, encoding: 'utf-8' }
        );
        const jobs = JSON.parse(result.trim() || '[]');
        if (jobs.length) {
          backupJobs = `${jobs.length} backup job(s) gevonden:\n` +
            jobs.map(j => `  - ${j.id || j.vmid || 'job'}: ${j.schedule || 'geen schema'}`).join('\n');
        }
      } catch {
        backupJobs = 'Geen toegang tot Proxmox backup info (niet op PVE host)';
      }
    }
    return `Proxmox Backup Info:\n${backupJobs}`;
  } catch (e) {
    return `Fout bij ophalen backup info: ${e.message}`;
  }
}
add_noteβœ“ handler

Zet een notitie klaar voor bevestiging (slaat nog NIET op). Na add_note MOET je altijd confirm_note aanroepen nadat de gebruiker bevestigd heeft, of reject_note als ze annuleren.

textcategory
Handler source bekijken
export async function add_note({ text, category = 'algemeen' } = {}) {
  if (!text?.trim()) return 'Geen tekst opgegeven voor de notitie.';
  const pendingFile = resolve(__dirname, '../notes/pending_note.json');
  const pending = {
    text: text.trim(),
    category,
    stagedAt: new Date().toISOString(),
  };
  writeFileSync(pendingFile, JSON.stringify(pending, null, 2));
  const preview = text.trim().slice(0, 80) + (text.length > 80 ? '…' : '');
  return `Notitie klaarstaan: "${preview}" (categorie: ${category}). Bevestig je dat ik hem opslaat, of wil je hem annuleren?`;
}
start_background_taskβœ“ handler

Start een langlopende taak als achtergrondproces. Gebruik dit voor taken die meer dan 30 seconden kunnen duren. Geeft een job ID terug waarmee je later de status kunt opvragen.

namecommand
Handler source bekijken
export async function start_background_task({ name, command, task_id } = {}) {
  if (!name?.trim() || !command?.trim()) return 'Geef een naam en commando op.';
  mkdirSync(JOBS_LOGS, { recursive: true });
  const id = Date.now().toString();
  const logFile = join(JOBS_LOGS, `${id}.log`);

  const job = { id, name: name.trim(), command: command.trim(), type: 'bash', status: 'running', startedAt: new Date().toISOString(), ...(task_id ? { taskId: task_id } : {}) };
  const jobs = loadJobs();
  jobs.push(job);
  writeFileSync(JOBS_FILE, JSON.stringify(jobs, null, 2));

  // Spawn detached via bash -c, redirect output to log
  const escaped = command.replace(/'/g, "'\\''");
  const wrapper = `bash -c '${escaped}' > ${logFile} 2>&1`;
  const { spawn } = await import('child_process');
  const child = spawn('bash', ['-c', wrapper], { detached: true, stdio: 'ignore' });
  
  const pid = child.pid;
  child.unref();

  // Update pid
  const idx = jobs.findIndex(j => j.id === id);
  if (idx >= 0) { jobs[idx].pid = pid; writeFileSync(JOBS_FILE, JSON.stringify(jobs, null, 2)); }

  // Watch for completion β€” update status and fire notifications
  child.on('close', async (code) => {
    try {
      const current = JSON.parse(readFileSync(JOBS_FILE, 'utf-8'));
      const i = current.findIndex(j => j.id === id);
      if (i >= 0) {
        current[i].status = code === 0 ? 'done' : 'failed';
        current[i].exitCode = code;
        current[i].finishedAt = new Date().toISOString();
        writeFileSync(JOBS_FILE, JSON.stringify(current, null, 2));
      }
    } catch {}
    // Notify voice server
    try {
      await fetch('http://localhost:3024/api/notify', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ jobId: id, name: name.trim(), status: code === 0 ? 'done' : 'failed' }),
        signal: AbortSignal.timeout(2000),
      });
    } catch {}
    // Move linked graatje note to klaar/open
    if (task_id) {
      const noteStatus = code === 0 ? 'klaar' : 'open';
      try {
        await fetch(`http://localhost:3031/api/notes?id=${task_id}`, {
          method: 'PATCH',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ status: noteStatus }),
          signal: AbortSignal.timeout(2000),
        });
      } catch {}
    }
  });

  return `Taak "${name}" gestart (ID: ${id}). Bekijk voortgang op graatje.local/jobs`;
}
check_background_taskβœ“ handler

Controleer de status van een eerder gestarte achtergrondtaak. Geeft status (running/done/failed), looptijd en de laatste regels van het logbestand terug.

job_id
Handler source bekijken
export async function check_background_task({ job_id } = {}) {
  if (!job_id) return 'Geef een job ID of naam op.';
  const jobs = loadJobs();
  const job = jobs.find(j => j.id === job_id) ||
              jobs.find(j => j.name.toLowerCase().includes(job_id.toLowerCase()));
  if (!job) return `Geen taak gevonden met ID/naam "${job_id}". Gebruik list_background_tasks voor overzicht.`;

  const logFile = join(JOBS_LOGS, `${job.id}.log`);
  let logContent = '';
  if (existsSync(logFile)) {
    try {
      const raw = readFileSync(logFile, 'utf-8');
      // Return last 3000 chars β€” enough for most research outputs
      logContent = raw.length > 3000 ? '...[afgekapt]...\n' + raw.slice(-3000) : raw;
      logContent = logContent.trim();
    } catch {}
  }

  const durationMs = new Date(job.finishedAt ?? new Date()).getTime() - new Date(job.startedAt).getTime();
  const durationStr = durationMs < 60000 ? `${Math.round(durationMs/1000)}s` : `${Math.round(durationMs/60000)}m`;

  const parts = [
    `Taak: ${job.name}`,
    `Status: ${job.status === 'running' ? '🟑 Bezig' : job.status === 'done' ? 'βœ… Klaar' : '❌ Mislukt'}`,
    `Gestart: ${new Date(job.startedAt).toLocaleString('nl-NL')}`,
    job.finishedAt ? `Klaar: ${new Date(job.finishedAt).toLocaleString('nl-NL')}` : '',
    `Duur: ${durationStr}`,
  ];
  if (job.summary) parts.push(`\nSamenvatting: ${job.summary}`);
  if (logContent) parts.push(`\nOutput:\n${logContent}`);
  else parts.push('(geen output)');
  return parts.filter(Boolean).join('\n');
}
list_background_tasksβœ“ handler

Geef een overzicht van alle achtergrondtaken: lopende, voltooide en mislukte taken. Handig als de gebruiker vraagt naar eerdere taken.

Handler source bekijken
export async function list_background_tasks() {
  const jobs = loadJobs().reverse();
  if (!jobs.length) return 'Geen achtergrondtaken gevonden.';

  const running = jobs.filter(j => j.status === 'running');
  const done = jobs.filter(j => j.status === 'done');
  const failed = jobs.filter(j => j.status === 'failed');

  const fmt = (j) => {
    const durationMs = new Date(j.finishedAt ?? new Date()).getTime() - new Date(j.startedAt).getTime();
    const dur = durationMs < 60000 ? `${Math.round(durationMs/1000)}s` : `${Math.round(durationMs/60000)}m`;
    const icon = j.status === 'running' ? '🟑' : j.status === 'done' ? 'βœ…' : '❌';
    const typeIcon = j.type === 'copilot' ? 'πŸ€–' : 'βš™οΈ';
    return `  ${icon} ${typeIcon} ${j.name} (ID: ${j.id}, ${dur})`;
  };

  const lines = [`Achtergrondtaken (${jobs.length} totaal):`];
  if (running.length) { lines.push(`\nActief (${running.length}):`); running.forEach(j => lines.push(fmt(j))); }
  if (done.length) { lines.push(`\nKlaar (${done.length}):`); done.slice(0,5).forEach(j => lines.push(fmt(j))); }
  if (failed.length) { lines.push(`\nMislukt (${failed.length}):`); failed.forEach(j => lines.push(fmt(j))); }
  lines.push('\nBekijk details: graatje.local/jobs');
  return lines.join('\n');
}
start_copilot_taskβœ“ handler

Start een Copilot AI agent taak op de achtergrond. Gebruik voor complexe redeneer- of implementatietaken die meerdere stappen vereisen. Gebruik plan_only:true om eerst een plan te maken zonder iets te implementeren.

namepromptdirmodelplan_only
Handler source bekijken
export async function start_copilot_task({ name, prompt, dir, model, plan_only, task_id } = {}) {
  if (!name?.trim() || !prompt?.trim()) return 'Geef een naam en prompt op.';
  try {
    const res = await fetch('http://localhost:3031/api/jobs/copilot', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name: name.trim(), prompt: prompt.trim(), dir, model, planOnly: plan_only, taskId: task_id }),
    });
    const data = await res.json();
    if (!res.ok) return `Fout bij starten: ${data.error || res.status}`;
    const modeLabel = plan_only ? '(plan-modus β€” wacht op goedkeuring)' : '';
    const devUrl = data.job?.devLocalUrl ? ` Terminal: ${data.job.devLocalUrl}` : '';
    return `Copilot taak "${name}" gestart ${modeLabel}(ID: ${data.job.id}).${devUrl} Bekijk voortgang op graatje.local/jobs`;
  } catch (err) {
    return `Fout: ${err.message}`;
  }
}
read_job_outputβœ“ handler

Lees de volledige output van een voltooide achtergrondtaak. Gebruik dit als je de resultaten van een eerdere onderzoekstaak wilt gebruiken om een vraag te beantwoorden. Geeft de schone, leesbare output terug zonder interne metadata.

job_idmax_chars
Handler source bekijken
export async function read_job_output({ job_id, max_chars = 5000 } = {}) {
  if (!job_id) return 'Geef een job ID of naam op.';
  const jobs = loadJobs();
  const job = jobs.find(j => j.id === job_id) ||
              jobs.find(j => j.name.toLowerCase().includes(String(job_id).toLowerCase()));
  if (!job) return `Geen taak gevonden met ID/naam "${job_id}".`;

  if (job.status === 'running') return `Taak "${job.name}" is nog bezig. Wacht tot hij klaar is.`;

  const logFile = join(JOBS_LOGS, `${job.id}.log`);
  if (!existsSync(logFile)) return `Geen output gevonden voor taak "${job.name}".`;

  try {
    const raw = readFileSync(logFile, 'utf-8');
    // Strip internal copilot metadata lines, keep only real content
    const cleaned = raw
      .split('\n')
      .filter(line => !line.startsWith('[copilot]') && !line.startsWith('[tool]') && !line.startsWith('[permission]') && line !== '---')
      .join('\n')
      .trim();

    const output = cleaned.length > max_chars
      ? cleaned.slice(0, max_chars) + `\n...[${cleaned.length - max_chars} extra tekens niet getoond]`
      : cleaned;

    return `Output van taak "${job.name}" (${job.status}, ${new Date(job.startedAt).toLocaleString('nl-NL')}):\n\n${output}`;
  } catch (e) {
    return `Fout bij lezen output: ${e.message}`;
  }
}
web_searchβœ“ handler

Zoek op het web via SearXNG (self-hosted meta-zoekmachine). Gebruik dit om actuele informatie op te zoeken, nieuws te vinden, of iets op te zoeken dat je niet weet. Geeft een lijst van relevante URLs en beschrijvingen terug.

querymax_results
Handler source bekijken
export async function web_search({ query, max_results = 5 } = {}) {
  if (!query?.trim()) return 'Geef een zoekopdracht op.';
  try {
    const url = `http://localhost:8070/search?q=${encodeURIComponent(query)}&format=json&language=auto`;
    const res = await fetch(url, { signal: AbortSignal.timeout(15000) });
    if (!res.ok) throw new Error(`SearXNG HTTP ${res.status}`);
    const data = await res.json();
    const results = (data.results || []).slice(0, max_results);
    if (!results.length) return 'Geen zoekresultaten gevonden.';
    return results.map((r, i) => `${i + 1}. **${r.title}**\n   ${r.url}\n   ${r.content || ''}`).join('\n\n');
  } catch (e) {
    return `Fout bij zoeken: ${e.message}`;
  }
}
fetch_pageβœ“ handler

Haal de inhoud van een webpagina op als schone markdown tekst. Gebruik dit nadat je een URL hebt gevonden via web_search, om de volledige pagina-inhoud te lezen.

url
Handler source bekijken
export async function fetch_page({ url } = {}) {
  if (!url?.trim()) return 'Geef een URL op.';
  try {
    const res = await fetch(`https://r.jina.ai/${url}`, {
      headers: { 'Accept': 'application/json' },
      signal: AbortSignal.timeout(20000),
    });
    if (!res.ok) throw new Error(`Jina HTTP ${res.status}`);
    const data = await res.json();
    const content = (data.data?.content || data.content || '').substring(0, 4000);
    const title = data.data?.title || data.title || url;
    return `# ${title}\n\n${content}`;
  } catch (e) {
    return `Fout bij ophalen pagina: ${e.message}`;
  }
}
run_researchβœ“ handler

Start een deep research onderzoek naar een onderwerp. Gebruik dit wanneer de gebruiker vraagt 'doe research naar', 'zoek uit', 'onderzoek', 'research' of vergelijkbare formuleringen. Het rapport wordt automatisch gepubliceerd op research.local.

topic
Handler source bekijken
export async function run_research({ topic } = {}) {
  if (!topic?.trim()) return 'Geef een onderwerp op om te onderzoeken.';
  try {
    const slug = topic.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
    const date = new Date().toISOString().slice(0, 10);
    const outputFile = `/home/ubuntu/research/${date}-${slug}.md`;
    const prompt = `Doe uitgebreid research naar het volgende onderwerp: ${topic}

Gebruik Tavily en Brave Search om meerdere bronnen te raadplegen.
Schrijf een gestructureerd rapport en sla het op als: ${outputFile}

Het rapport moet bevatten:
- Samenvatting
- Belangrijkste bevindingen
- Bronnenlijst

Gebruik de bash tool om het bestand te schrijven naar: ${outputFile}`;

    const { start_copilot_task } = await import('./handlers.mjs');
    const result = await start_copilot_task({
      name: `research-${slug}`,
      prompt,
      dir: '/home/ubuntu',
      model: 'claude-sonnet-4.5',
    });
    return `Research gestart naar "${topic}". ${result}\n\nHet rapport wordt automatisch gepubliceerd op research.local zodra het klaar is.`;
  } catch (e) {
    return `Fout bij starten research: ${e.message}`;
  }
}

// ─────────────────────────────────────────────────────────────
// ROOM TOOLS
// ─────────────────────────────────────────────────────────────

const GRAPH_LOCAL_URL = 'http://localhost:3033';
const PENDING_BOOKING_FILE = resolve(__dirname, 'pending_booking.json');

// Known rooms for fuzzy matching
const KNOWN_ROOMS = [
  { name: 'OL MST VC 0.1', aliases: ['0.1', 'begane grond', 'beneden', 'bg'], email: 'meetingroom.beganegrond@openline.nl' },
  { name: 'OL MST VC 1.0', aliases: ['1.0', 'etage 1', '1e', 'eerste'], email: 'VideoConferenceRoom.etage1@openline.nl' },
  { name: 'OL MST VC 2.0', aliases: ['2.0', 'etage 2', '2e', 'tweede'], email: 'VideoConferenceRoom.etage2@openline.nl' },
  { name: 'OL MST VC 3.0', aliases: ['3.0', 'etage 3', '3e', 'derde', 'webex'], email: 'VideoConferenceRoom.etage3@openline.nl' },
  { name: 'OL MST VC 4.0', aliases: ['4.0', 'etage 4', '4e', 'vierde'], email: 'VideoConferenceRoom.etage4@openline.nl' },
];

function resolveDate(dateStr) {
  if (!dateStr || dateStr === 'vandaag') return new Date().toISOString().slice(0, 10);
  if (dateStr === 'morgen') {
    const d = new Date(); d.setDate(d.getDate() + 1); return d.toISOString().slice(0, 10);
  }
  return dateStr;
}

function fuzzyMatchRoom(name) {
  if (!name) return null;
  const q = name.toLowerCase();
  for (const room of KNOWN_ROOMS) {
    if (room.name.toLowerCase().includes(q)) return room;
    if (room.aliases.some(a => q.includes(a) || a.includes(q))) return room;
  }
  return null;
}
confirm_noteβœ“ handler

Sla de eerder klaargezette notitie definitief op (na bevestiging van de gebruiker). Gebruik dit alleen nadat add_note is aangeroepen EN de gebruiker ja heeft gezegd.

Handler source bekijken
export async function confirm_note() {
  const pendingFile = resolve(__dirname, '../notes/pending_note.json');
  if (!existsSync(pendingFile)) return 'Geen notitie in de wachtrij om op te slaan.';
  let pending;
  try { pending = JSON.parse(readFileSync(pendingFile, 'utf-8')); } catch { return 'Fout bij lezen van de wachtende notitie.'; }
  const notesFile = resolve(__dirname, '../notes/notes.json');
  let notes = [];
  try {
    if (existsSync(notesFile)) notes = JSON.parse(readFileSync(notesFile, 'utf-8'));
  } catch {}
  const note = {
    id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
    text: pending.text,
    category: pending.category,
    timestamp: new Date().toISOString(),
  };
  notes.unshift(note);
  writeFileSync(notesFile, JSON.stringify(notes, null, 2));
  unlinkSync(pendingFile);
  return `Notitie opgeslagen: "${pending.text.slice(0, 60)}${pending.text.length > 60 ? '…' : ''}"`;
}
reject_noteβœ“ handler

Annuleer en verwijder de eerder klaargezette notitie (als de gebruiker nee zegt of annuleert). Gebruik dit alleen nadat add_note is aangeroepen EN de gebruiker nee heeft gezegd.

Handler source bekijken
export async function reject_note() {
  const pendingFile = resolve(__dirname, '../notes/pending_note.json');
  if (!existsSync(pendingFile)) return 'Geen notitie in de wachtrij om te verwijderen.';
  let pending;
  try { pending = JSON.parse(readFileSync(pendingFile, 'utf-8')); } catch {}
  unlinkSync(pendingFile);
  return `Notitie geannuleerd: "${pending?.text?.slice(0, 60) || '(onbekend)'}".`;
}


const JOBS_DIR = resolve(__dirname, '../jobs');
const JOBS_FILE = join(JOBS_DIR, 'jobs.json');
const JOBS_LOGS = join(JOBS_DIR, 'logs');

function loadJobs() {
  if (!existsSync(JOBS_FILE)) return [];
  try { return JSON.parse(readFileSync(JOBS_FILE, 'utf-8')); } catch { return []; }
}
send_note_to_agentβœ“ handler

Stuur een bestaande notitie naar de Copilot agent als plan, research of autopilot taak. Gebruik note_id (van get_notes) of search (zoekterm in notitietekst). Mode: plan = alleen plannen, research = onderzoeken, autopilot = direct implementeren.

note_idsearchmode
Handler source bekijken
export async function send_note_to_agent({ note_id, search, mode = 'autopilot' } = {}) {
  if (!note_id && !search) return 'Geef een note_id of zoekterm op.';
  if (!['plan', 'research', 'autopilot'].includes(mode)) return 'Ongeldige mode. Kies plan, research of autopilot.';

  const notesFile = resolve(__dirname, '../notes/notes.json');
  if (!existsSync(notesFile)) return 'Nog geen notities opgeslagen.';
  let notes;
  try { notes = JSON.parse(readFileSync(notesFile, 'utf-8')); } catch { return 'Fout bij lezen van notities.'; }

  let note;
  if (note_id) {
    note = notes.find(n => n.id === note_id);
  } else {
    const term = search.toLowerCase();
    note = notes.find(n => n.text.toLowerCase().includes(term));
  }
  if (!note) return `Geen notitie gevonden${note_id ? ` met ID "${note_id}"` : ` voor zoekterm "${search}"`}.`;

  const prefixes = { plan: '[Plan]', research: '[Research]', autopilot: '[Autopilot]' };
  const suffixes = {
    plan: '\n\nAnalyseer dit en maak een stapsgewijs implementatieplan. Implementeer nog NIETS β€” alleen plannen.',
    research: '\n\nDoe research naar dit onderwerp. Onderzoek de huidige staat en geef een beknopte analyse. Maak geen code-wijzigingen.',
    autopilot: '\n\nVoer dit direct uit. Analyseer, plan, en implementeer in één sessie.',
  };
  const prompt = `${prefixes[mode]}\n${note.text}${suffixes[mode]}`;
  const name = `${prefixes[mode]} ${note.text.slice(0, 50)}${note.text.length > 50 ? '…' : ''}`;

  try {
    const res = await fetch('http://localhost:3031/api/jobs/copilot', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name, prompt, dir: '/home/ubuntu', planOnly: mode === 'plan' }),
    });
    const data = await res.json();
    if (!res.ok) return `Fout bij starten: ${data.error || res.status}`;
    const jobId = data.job?.id || data.jobId;
    if (jobId) {
      // Persist sentJobId/sentMode back to the note via graatje API
      await fetch(`http://localhost:3031/api/notes?id=${note.id}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ sentJobId: jobId, sentMode: mode }),
      }).catch(() => {});
    }
    const modeLabel = { plan: 'gepland', research: 'onderzoek gestart', autopilot: 'autopilot gestart' }[mode];
    const devUrl = data.job?.devLocalUrl ? ` Terminal: ${data.job.devLocalUrl}` : '';
    return `Notitie "${note.text.slice(0, 60)}…" β†’ ${modeLabel} (job ID: ${jobId}).${devUrl} Volg op graatje.local/jobs`;
  } catch (err) {
    return `Fout: ${err.message}`;
  }
}
get_whatsapp_last_by_unique_contactsβœ“ handler

Haal de laatste berichten op van N unieke contacten (laatst ontvangen bericht per persoon)

count
Handler source bekijken
export async function get_whatsapp_last_by_unique_contacts({ count = 5 } = {}) {
  try {
    const messages = await fetchJson('http://localhost:3007/api/whatsapp/recent');
    if (!messages || !Array.isArray(messages)) return 'Geen berichten beschikbaar';
    
    const uniqueContacts = new Map();
    for (const msg of messages) {
      if (!uniqueContacts.has(msg.from) && msg.direction === 'incoming') {
        uniqueContacts.set(msg.from, msg);
        if (uniqueContacts.size >= count) break;
      }
    }
    
    const result = Array.from(uniqueContacts.values()).map(msg => 
      `Van: ${msg.fromName || msg.from}\nBericht: ${msg.body}\nTijd: ${msg.timestamp}`
    ).join('\n\n');
    
    return result || 'Geen unieke contacten gevonden';
  } catch (e) {
    return 'Fout bij ophalen WhatsApp berichten: ' + e.message;
  }
}
🐟
Graatje
Klaar voor je
🐟
Hey man, wat kan ik voor je doen?
Stel een vraag, start een taak, of vraag om uitleg over een tool.
Enter = verstuur Β· Shift+Enter = nieuwe regel