gigafibre-fsm/services/targo-hub/lib/staff-agent.js
louispaulb 0372176fc8 feat(assistant): vrai CHAT multi-tours — on peut répondre aux questions de l'assistant
Avant : l'assistant posait une question (« Souhaitez-vous que je crée… ? ») mais le dialogue était mono-coup — impossible
de répondre (pas de fil, chaque envoi repartait de zéro). Maintenant c'est une conversation :
- Backend staff-agent.plan(text, email, history) : accepte les tours précédents (user/assistant, 10 derniers) → contexte ;
  POST /staff-agent {text, history}.
- OrchestratorDialog : fil de bulles (user/assistant), le champ se vide après envoi pour RÉPONDRE, historique renvoyé à
  chaque tour, bouton « Envoyer » en mode conversation. Le plan (actions à confirmer) suit le dernier tour.
Vérifié : (backend) « vérifier signal C-LPB4 » → diagnostic (réparation, 12 techs/431h) ; « oui, crée » AVEC history →
propose create_job(C-LPB4, Réparation). (UI) 4 bulles [user,assistant,user,assistant], champ vidé, action « Créer un job »
affichée, 0 erreur console. Déployé.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 21:07:55 -04:00

365 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict'
/**
* staff-agent.js — Copilote OPS en langage naturel (Gemini function-calling), surface STAFF.
*
* BUT (parité UI↔NL) : le langage naturel peut faire ce que fait l'interface visuelle, en exposant
* les ACTIONS EXISTANTES comme « outils » function-calling. NL et UI partagent UNE SEULE couche
* d'action (les routes hub / fonctions que l'UI appelle déjà) → couverture(NL) = couverture(UI).
*
* RÉUTILISE l'existant (cf feedback_reuse_shared_components) :
* - registre d'outils = services/targo-hub/lib/agent-tools.json (audience 'staff'),
* - runtime Gemini function-calling = lib/ai.js `chat({tools, wantMessage})` (même boucle que
* lib/agent.js répondeur SMS et lib/roster-assistant.js copilote roster),
* - couche d'action = les routes hub EXISTANTES (roster.js/dispatch.js/conversation.js), appelées
* en LOOPBACK autorisé (jeton de service + identité x-authentik-email) — zéro logique réécrite.
*
* SÉCURITÉ (cf feedback_ppa_double_charge_incident — une action auto peut coûter cher) :
* - outils LECTURE : s'exécutent librement pendant le PLAN (résolution d'entités, aucun effet).
* - outils ÉCRITURE/conséquents : NE s'exécutent PAS via le modèle. Ils sont mis en attente
* (staged) avec un APERÇU. L'exécution réelle passe par POST /staff-agent/run APRÈS confirmation
* humaine explicite (le modèle propose ; l'humain dispose). Pas d'endpoint arbitraire : /run mappe
* type → exécuteur côté serveur (liste blanche), re-valide les permissions (parité usePermissions
* via auth.effectiveCapabilities) et agit AU NOM de l'utilisateur connecté (x-authentik-email).
*
* Routes :
* POST /staff-agent { text } → { ok, reply, actions:[{id,type,title,preview,params,permission,needs_confirmation}], transcript }
* POST /staff-agent/run { actions } → { ok, results:[{type,ok,message|error}] }
* GET /staff-agent/tools → { tools } (inventaire — parité/complétude)
*/
const cfg = require('./config')
const { log, json, parseBody } = require('./helpers')
const { toolsForModel, toolsByAudience } = require('./agent')
const erp = require('./erp')
// ── Registre : outils STAFF (audience 'staff') depuis le registre UNIQUE agent-tools.json ──
const STAFF_TOOLS_RAW = toolsByAudience('staff')
const STAFF_TOOLS = toolsForModel(STAFF_TOOLS_RAW) // envoyés au modèle : {type, function} seulement
const META = {} // name → { mode, permission, title }
for (const t of STAFF_TOOLS_RAW) META[t.function.name] = { mode: t.mode || 'read', permission: t.permission || null, title: t.title || t.function.name }
// require paresseux (évite les cycles + n'alourdit pas le boot) — mêmes modules que l'UI appelle.
const roster = () => require('./roster')
const dispatch = () => require('./dispatch')
function todayET () { return new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) }
// ── Loopback AUTORISÉ vers les routes hub EXISTANTES ─────────────────────────
// Réutilise EXACTEMENT les handlers que le front appelle (couche d'action unique). On imite le proxy
// nginx /ops/hub : Authorization = jeton de service + x-authentik-email = utilisateur connecté (→
// attribution, suivi, et vérifs d'identité des handlers). Pas d'endpoint fourni par le client : seuls
// les chemins codés dans EXECUTORS sont atteignables.
const PORT = cfg.PORT || 3300
async function hubCall (method, path, body, email) {
const headers = { 'Content-Type': 'application/json' }
if (process.env.HUB_SERVICE_TOKEN) headers.Authorization = 'Bearer ' + process.env.HUB_SERVICE_TOKEN
if (email) headers['x-authentik-email'] = email
const opt = { method, headers }
if (body != null && method !== 'GET') opt.body = JSON.stringify(body)
const r = await fetch(`http://127.0.0.1:${PORT}${path}`, opt)
let data = null
try { data = await r.json() } catch { /* corps non-JSON */ }
return { status: r.status, ok: r.ok, data: data || {} }
}
// Un résultat hub → { ok, message } normalisé (les routes renvoient {ok:false,error} ou {error}).
function hubResult (r, okMsg) {
const ok = r.ok && r.data && r.data.ok !== false && !r.data.error
return { ok, message: ok ? okMsg : ((r.data && r.data.error) || `échec (HTTP ${r.status})`), status: r.status, data: r.data }
}
async function resolveTechName (techId) {
try { const techs = await roster().fetchTechnicians(); const t = techs.find(x => x.id === techId || x.name === techId); return t ? t.name : techId }
catch { return techId }
}
// ── Outils LECTURE (exécutés pendant le PLAN — sans effet de bord) ───────────
async function read_list_technicians ({ query, skill } = {}) {
let techs
try { techs = await roster().fetchTechnicians() } catch (e) { return { error: 'techniciens indisponibles : ' + e.message } }
const q = String(query || '').trim().toLowerCase()
const sk = String(skill || '').trim().toLowerCase()
let out = techs || []
if (q) out = out.filter(t => String(t.name || '').toLowerCase().includes(q) || String(t.id || '').toLowerCase().includes(q))
if (sk) out = out.filter(t => (t.skills || []).some(s => String(s).toLowerCase().includes(sk)))
return { count: out.length, technicians: out.slice(0, 40).map(t => ({ id: t.id, name: t.name, status: t.status, skills: t.skills || [] })) }
}
async function read_get_job ({ job } = {}) {
if (!job) return { error: 'job requis' }
try {
const d = await erp.get('Dispatch Job', job, { fields: ['name', 'subject', 'status', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'priority', 'customer_name', 'service_location', 'job_type', 'booking_status'] })
if (!d || !d.name) return { error: 'job introuvable : ' + job }
return { job: d.name, subject: d.subject, status: d.status, assigned_tech: d.assigned_tech || null, scheduled_date: d.scheduled_date || null, start_time: d.start_time || null, duration_h: d.duration_h, priority: d.priority, customer_name: d.customer_name || '', service_location: d.service_location || '', job_type: d.job_type, booking_status: d.booking_status || '' }
} catch (e) { return { error: e.message } }
}
async function read_find_slot ({ duration_h, skill, after_date } = {}) {
try {
const slots = await dispatch().suggestSlots({ duration_h: Number(duration_h) || 1, skill: skill || '', after_date: after_date || todayET(), limit: 5 })
return { count: (slots || []).length, slots: (slots || []).slice(0, 5) }
} catch (e) { return { error: e.message } }
}
async function read_resolve_skill ({ text, department } = {}) {
try {
const r = await require('./skill-resolver').resolveSkills({ text: text || '', department: department || '', useAI: true })
const skills = r.skills || []
return { skills, primary: r.primary || '', confidence: r.confidence, reason: r.reason || '', note: skills.length ? ('Compétence(s) requise(s) : ' + skills.join(', ') + '. Enchaîne find_slot avec skill=' + (r.primary || skills[0]) + ' pour la disponibilité.') : 'Aucune compétence terrain — traitable à distance (tous les agents).' }
} catch (e) { return { error: e.message } }
}
async function read_resource_availability ({ skill, after_date, days } = {}) {
try {
const o = await dispatch().techOccupancy({ after_date: after_date || todayET(), days: Number(days) || 7, skill: skill || '' })
const techs = (o && o.techs) || []
const freeH = Math.round(techs.reduce((a, t) => a + (t.free_h || 0), 0) * 10) / 10
return { skill: skill || '(toute compétence)', window_days: (o && o.days) || 7, qualified_techs: techs.length, free_hours: freeH, techs: techs.slice(0, 8).map(t => ({ name: t.tech_name, free_h: Math.round((t.free_h || 0) * 10) / 10 })), note: techs.length ? (techs.length + ' tech(s) qualifié(s) « ' + (skill || 'tous') + ' » · ' + freeH + ' h libres sur ' + ((o && o.days) || 7) + ' jours') : 'Aucun tech qualifié disponible sur la période — élargis la fenêtre ou revois la compétence.' }
} catch (e) { return { error: e.message } }
}
const READERS = { list_technicians: read_list_technicians, get_job: read_get_job, find_slot: read_find_slot, resolve_skill: read_resolve_skill, resource_availability: read_resource_availability }
// ── Jours de la semaine — clés du weekly_schedule (= DOW_KEYS de roster.js) ──
const DAY_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
const DAY_FR = { mon: 'Lun', tue: 'Mar', wed: 'Mer', thu: 'Jeu', fri: 'Ven', sat: 'Sam', sun: 'Dim' }
const DAY_ALIASES = {
mon: 'mon', tue: 'tue', wed: 'wed', thu: 'thu', fri: 'fri', sat: 'sat', sun: 'sun',
monday: 'mon', tuesday: 'tue', wednesday: 'wed', thursday: 'thu', friday: 'fri', saturday: 'sat', sunday: 'sun',
lun: 'mon', mar: 'tue', mer: 'wed', jeu: 'thu', ven: 'fri', sam: 'sat', dim: 'sun',
lundi: 'mon', mardi: 'tue', mercredi: 'wed', jeudi: 'thu', vendredi: 'fri', samedi: 'sat', dimanche: 'sun',
}
function normDays (days) {
const set = new Set()
for (const d of (Array.isArray(days) ? days : [])) { const k = DAY_ALIASES[String(d || '').trim().toLowerCase()]; if (k) set.add(k) }
return DAY_KEYS.filter(k => set.has(k)) // ordre lun→dim, dédupliqué
}
function normTime (t, dflt = '') {
const m = String(t || '').trim().match(/^(\d{1,2})(?:\s*[:hH]\s*(\d{2}))?/) // "8", "8:00", "8h", "16h00"
if (!m) return dflt
return String(Math.min(23, parseInt(m[1], 10))).padStart(2, '0') + ':' + (m[2] || '00')
}
function buildWeeklySchedule (days, start, end) {
const st = normTime(start), en = normTime(end)
const sched = {}
for (const k of normDays(days)) sched[k] = { start: st, end: en }
return sched
}
// ── Outils ÉCRITURE — { permission, build(params)→{title,preview,params[,severity]}, exec(params,email) } ──
// build : mis en attente pendant le plan (aperçu). exec : exécution réelle après confirmation (via /run),
// chaque exec = fine enveloppe sur une route/fonction hub EXISTANTE.
const WRITES = {
create_job: {
permission: 'create_jobs',
async build (p) {
const params = { subject: String(p.subject || '').trim(), customer_id: p.customer_id || '', service_location: p.service_location || '', address: p.address || '', priority: p.priority || '', job_type: p.job_type || '', notes: p.notes || '', auto_assign: p.auto_assign !== false }
const preview = `« ${params.subject || 'Intervention'} »${params.job_type ? ' · ' + params.job_type : ''}${params.priority ? ' · priorité ' + params.priority : ''}${params.address ? ' · ' + params.address : ''}${params.customer_id ? ' · client ' + params.customer_id : ''} · ${params.auto_assign ? 'assignation auto' : 'non assigné'}`
return { title: META.create_job.title, preview, params }
},
async exec (p) {
const r = await dispatch().agentCreateDispatchJob({ customer_id: p.customer_id || '', service_location: p.service_location || '', address: p.address || '', subject: p.subject, priority: p.priority || undefined, job_type: p.job_type || undefined, notes: p.notes || '', auto_assign: p.auto_assign })
return { ok: !!(r && r.success), message: (r && r.message) || 'job créé', job_id: r && r.job_id, assigned_tech: r && r.assigned_tech }
},
},
assign_tech: {
permission: 'assign_jobs',
async build (p) {
const name = await resolveTechName(p.tech_id)
const params = { job: p.job, tech: p.tech_id, date: p.date || '', start: p.start || '' }
return { title: META.assign_tech.title, preview: `${p.job}${name} (${p.tech_id})${p.date ? ' · ' + p.date : ''}${p.start ? ' à ' + p.start : ''}`, params }
},
async exec (p, email) {
const body = { job: p.job, tech: p.tech }
if (p.date) body.date = p.date
if (p.start) body.start = p.start
return hubResult(await hubCall('POST', '/roster/assign-job', body, email), `${p.job} assigné à ${p.tech}`)
},
},
proposer_rdv_client: {
permission: 'assign_jobs',
async build (p) {
const ref = p.job || p.ticket || p.customer_id || 'le job ouvert du client'
const params = { job: p.job || '', ticket: p.ticket || '', customer: p.customer_id || '', message: p.message || '' }
return { title: META.proposer_rdv_client.title, preview: `Envoyer au client le lien de prise de RDV (il choisit un créneau réservé 5 min, ou propose 3 dispos) — ${ref}`, params }
},
async exec (p, email) {
const body = {}
if (p.job) body.job = p.job
if (p.ticket) body.ticket = p.ticket
if (p.customer) body.customer = p.customer
if (p.message) body.message = p.message
const r = await hubCall('POST', '/roster/book/propose', body, email)
const ok = r.ok && r.data && r.data.ok !== false && !r.data.error
const msg = ok
? (r.data.sms ? 'Lien de RDV envoyé au client par texto.' : ('Lien de RDV prêt : ' + (r.data.url || '') + (r.data.note ? ' — ' + r.data.note : '')))
: ((r.data && r.data.error) || `échec (HTTP ${r.status})`)
return { ok, message: msg, data: r.data }
},
},
add_assistant: {
permission: 'assign_jobs',
async build (p) {
const name = p.tech_name || await resolveTechName(p.tech_id)
return { title: META.add_assistant.title, preview: `Renfort sur ${p.job} : ${name} (${p.tech_id})`, params: { job: p.job, tech_id: p.tech_id, tech_name: name } }
},
async exec (p, email) {
return hubResult(await hubCall('POST', '/roster/job/team', { job: p.job, add: { tech_id: p.tech_id, tech_name: p.tech_name || '' } }, email), `Assistant ${p.tech_id} ajouté à ${p.job}`)
},
},
set_job_status: {
permission: 'assign_jobs',
async build (p) {
return { title: META.set_job_status.title, preview: `${p.job} → statut « ${p.status} »`, params: { job: p.job, status: p.status }, severity: p.status === 'Cancelled' ? 'high' : 'normal' }
},
async exec (p, email) {
return hubResult(await hubCall('POST', '/roster/job/update', { job: p.job, patch: { status: p.status } }, email), `${p.job}${p.status}`)
},
},
reschedule_job: {
permission: 'assign_jobs',
async build (p) {
return { title: META.reschedule_job.title, preview: `${p.job} reporté au ${p.date}${p.start ? ' à ' + p.start : ''}`, params: { job: p.job, date: p.date, start: p.start || '' } }
},
async exec (p, email) {
// Conserve le tech assigné si possible (re-place l'heure via /roster/assign-job) ; sinon change la date via /roster/job/update.
let tech = null
try { const d = await erp.get('Dispatch Job', p.job, { fields: ['assigned_tech'] }); tech = d && d.assigned_tech } catch { /* fallback ci-dessous */ }
if (tech) {
const body = { job: p.job, tech, date: p.date }
if (p.start) body.start = p.start
return hubResult(await hubCall('POST', '/roster/assign-job', body, email), `${p.job} reporté au ${p.date}`)
}
return hubResult(await hubCall('POST', '/roster/job/update', { job: p.job, patch: { scheduled_date: p.date } }, email), `${p.job} reporté au ${p.date}`)
},
},
create_recurring_shift: {
permission: 'assign_jobs',
async build (p) {
const days = normDays(p.days)
const schedule = buildWeeklySchedule(p.days, p.start, p.end)
const name = await resolveTechName(p.technicien_id)
const st = normTime(p.start), en = normTime(p.end)
const worked = days.map(k => DAY_FR[k]).join(', ') || '(aucun jour)'
const off = DAY_KEYS.filter(k => !days.includes(k)).map(k => DAY_FR[k]).join(', ')
const preview = `${name} (${p.technicien_id}) : ${st}${en} le ${worked}${off ? ` · repos ${off}` : ''}`
// schedule pré-calculé porté dans les params → /run le pousse tel quel sur la route weekly-schedule.
return { title: META.create_recurring_shift.title, preview, params: { technicien_id: p.technicien_id, schedule } }
},
async exec (p, email) {
if (!p.schedule || typeof p.schedule !== 'object' || !Object.keys(p.schedule).length) return { ok: false, message: 'horaire vide (aucun jour)' }
return hubResult(await hubCall('POST', `/roster/technician/${encodeURIComponent(p.technicien_id)}/weekly-schedule`, { schedule: p.schedule }, email), 'Horaire récurrent enregistré (quarts matérialisés au prochain cycle)')
},
},
follow_doc: {
permission: null, // suivi = personnel/bas risque → tout staff authentifié
async build (p) {
const follow = p.follow !== false
return { title: META.follow_doc.title, preview: `${follow ? 'Suivre' : 'Ne plus suivre'} ${p.doctype} ${p.name}`, params: { doctype: p.doctype, name: p.name, follow } }
},
async exec (p, email) {
return hubResult(await hubCall('POST', '/conversations/follow', { doctype: p.doctype, name: p.name, follow: p.follow !== false }, email), (p.follow !== false) ? 'Suivi activé' : 'Suivi retiré')
},
},
}
// ── Runtime Gemini (function-calling) — même config/boucle que lib/agent.js & roster-assistant.js ──
async function geminiChat (messages) {
const msg = await require('./ai').chat({ task: 'nl', messages, tools: STAFF_TOOLS, maxTokens: 900, temperature: 0.1, wantMessage: true })
return { choices: [{ message: msg }] }
}
function systemPrompt (email) {
return `Tu es le COPILOTE OPS (dispatch/planification) de Gigafibre/TARGO, fournisseur Internet/TV/téléphonie au Québec.
Aujourd'hui = ${todayET()}. Tu agis AU NOM de l'utilisateur connecté${email ? ` (${email})` : ''}.
Tu transformes une commande en langage naturel en APPELS D'OUTILS.
RÈGLES :
- Utilise d'abord les outils de LECTURE (list_technicians, get_job, resolve_skill, resource_availability, find_slot) pour RÉSOUDRE et DIAGNOSTIQUER. Résous toujours un technicien nommé (« Simon ») en tech_id via list_technicians avant d'agir.
- INTERVENTION à créer/assigner : procède par ÉTAPES conversationnelles, ne saute PAS à la création. (1) Cerne la RAISON (si vague ou absente, pose UNE question courte plutôt que deviner). (2) resolve_skill → compétence requise. (3) resource_availability (combien de techs qualifiés dispo + heures libres) puis find_slot (créneaux précis) → DISPONIBILITÉ réelle. (4) SEULEMENT ensuite propose create_job / assign_tech / proposer_rdv_client. Résume ton diagnostic (raison → compétence → ressource dispo) AVANT de proposer, et inclus l'adresse dans create_job si fournie.
- Les outils d'ÉCRITURE (create_job, assign_tech, proposer_rdv_client, add_assistant, set_job_status, reschedule_job, create_recurring_shift, follow_doc) NE S'EXÉCUTENT PAS tout de suite : ils sont PROPOSÉS puis CONFIRMÉS par l'utilisateur. Chaque appel te renvoie un APERÇU (staged=true). N'affirme JAMAIS qu'une action est faite ; annonce ce qui SERA fait après confirmation.
- Horaire/quart RÉCURRENT : days parmi mon,tue,wed,thu,fri,sat,sun. « jours de semaine » = mon,tue,wed,thu,fri ; retire les exceptions (« sauf le vendredi » → mon,tue,wed,thu). « fin de semaine » = sat,sun. Convertis « 8-16h » en start 08:00 / end 16:00. Il FAUT un technicien : si aucun n'est nommé, DEMANDE-le.
- S'il manque une information essentielle (quel job ? quel tech ? quelle date ?), pose UNE question courte au lieu d'appeler un outil d'écriture.
- Réponds en français, bref. Après avoir proposé des actions, résume-les en une phrase et invite à confirmer.`
}
async function execToolPlan (name, args, ctx) {
const meta = META[name]
if (!meta) return { error: 'outil inconnu : ' + name }
try {
if (meta.mode === 'read') { const fn = READERS[name]; return fn ? await fn(args || {}) : { error: 'lecteur manquant : ' + name } }
const w = WRITES[name]
if (!w) return { error: 'exécuteur manquant : ' + name }
const staged = await w.build(args || {}, ctx.email)
const id = 'a' + (ctx.planned.length + 1)
ctx.planned.push({ id, type: name, title: staged.title, preview: staged.preview, params: staged.params, permission: meta.permission || null, severity: staged.severity || 'normal', needs_confirmation: true })
return { staged: true, requires_confirmation: true, title: staged.title, preview: staged.preview, note: "Action PROPOSÉE, NON exécutée. L'utilisateur doit la confirmer. Ne dis pas qu'elle est faite." }
} catch (e) { return { error: String(e.message || e) } }
}
// PLAN : boucle function-calling. Les LECTURES s'exécutent (résolution), les ÉCRITURES sont mises en attente.
async function plan (text, email, history) {
if (!cfg.AI_API_KEY) return { ok: false, error: "Le service IA n'est pas configuré (AI_API_KEY manquante)." }
const planned = []
const ctx = { email, planned }
// Chat MULTI-TOURS : les tours précédents (user/assistant) donnent le contexte pour répondre à une question de
// clarification (« oui, crée l'intervention »). Bornage : 10 derniers tours, 4000 car. chacun.
const hist = Array.isArray(history) ? history.filter(m => m && (m.role === 'user' || m.role === 'assistant') && m.content).slice(-10).map(m => ({ role: m.role, content: String(m.content).slice(0, 4000) })) : []
const messages = [{ role: 'system', content: systemPrompt(email) }, ...hist, { role: 'user', content: String(text || '') }]
let response
try { response = await geminiChat(messages) } catch (e) { return { ok: false, error: 'IA : ' + e.message } }
const cur = [...messages]
for (let i = 0; i < 6; i++) {
const msg = response.choices?.[0]?.message
if (!msg) break
const calls = msg.tool_calls // signal OpenAI-compat des appels d'outils (indépendant de finish_reason)
if (!calls || !calls.length) break
cur.push(msg)
for (const tc of calls) {
const args = (() => { try { return JSON.parse(tc.function.arguments || '{}') } catch { return {} } })()
log(`[staff-agent] ${email || 'anon'}${tc.function.name}(${JSON.stringify(args)})`)
const result = await execToolPlan(tc.function.name, args, ctx)
cur.push({ role: 'tool', tool_call_id: tc.id, content: JSON.stringify(result).slice(0, 6000) })
}
try { response = await geminiChat(cur) } catch (e) { return { ok: false, error: 'IA : ' + e.message } }
}
const reply = response.choices?.[0]?.message?.content || (planned.length ? 'Plan préparé — confirme pour exécuter.' : 'Aucune action détectée — reformule.')
return { ok: true, reply, actions: planned, transcript: String(text || '') }
}
// RUN : exécute les actions CONFIRMÉES. type → exécuteur (liste blanche) + re-vérif permission + identité.
async function run (actions, email) {
const list = Array.isArray(actions) ? actions.slice(0, 12) : []
let caps = null
try { caps = await require('./auth').effectiveCapabilities(email) } catch (e) { log('[staff-agent] perms indispo : ' + e.message) }
const results = []
for (const a of list) {
const type = a && a.type
const w = WRITES[type]
if (!w) { results.push({ type, ok: false, error: 'action inconnue' }); continue }
// Permissions (parité usePermissions) : appliquées si Authentik est branché (prod). En dev/aperçu
// (pas de token → caps.configured=false) on n'enforce pas mais on journalise — le gate du hub
// (jeton de service) garantit déjà que seul un membre du staff authentifié atteint /staff-agent.
const need = w.permission
if (need && caps && caps.configured) {
const allowed = caps.is_superuser || (caps.capabilities && caps.capabilities[need] === true)
if (!allowed) { results.push({ type, ok: false, denied: true, error: `permission requise : ${need}` }); continue }
}
try { results.push({ type, ...(await w.exec(a.params || {}, email)) }) }
catch (e) { results.push({ type, ok: false, error: String(e.message || e) }) }
}
return { ok: results.length > 0 && results.every(r => r.ok), results }
}
async function handle (req, res, method, path) {
const email = String(req.headers['x-authentik-email'] || '').toLowerCase()
if (path === '/staff-agent' && method === 'POST') {
const b = await parseBody(req)
try { return json(res, 200, await plan(b.text || '', email, b.history)) }
catch (e) { return json(res, 200, { ok: false, error: 'Erreur copilote : ' + (e.message || e) }) }
}
if (path === '/staff-agent/run' && method === 'POST') {
const b = await parseBody(req)
try { return json(res, 200, await run(b.actions, email)) }
catch (e) { return json(res, 200, { ok: false, error: 'Erreur exécution : ' + (e.message || e) }) }
}
if (path === '/staff-agent/tools' && method === 'GET') {
return json(res, 200, { tools: STAFF_TOOLS_RAW.map(t => ({ name: t.function.name, mode: t.mode || 'read', permission: t.permission || null, title: t.title || t.function.name, description: t.function.description })) })
}
return json(res, 404, { error: 'staff-agent: route inconnue ' + path })
}
module.exports = { handle, plan, run, buildWeeklySchedule, normDays, normTime, STAFF_TOOLS_RAW }