Instrada i subagent meccanici su un modello piu' economico, regola lo sforzo di ragionamento e misura i token richiesta per richiesta, modello per modello.

Un piccolo backend TypeScript per una lista ospiti in stile discoteca: chi si registra riceve via email un codice QR personale da mostrare all'ingresso. Il progetto nasce come prova di concetto personale per l'idea, non come sistema in produzione.
Un unico endpoint POST accetta nome ed email, validati con Zod. Il controller cerca l'utente per email e, se non esiste, lo crea; genera poi un'immagine con codice QR che codifica id, nome ed email dell'utente (src/lib/qrcode.ts) e la invia all'ospite via email tramite Nodemailer (src/lib/mailer.ts), così che il QR possa essere scansionato all'ingresso. Un secondo endpoint elenca gli utenti registrati. Il modello dati (prisma/schema.prisma, SQLite) è volutamente minimale: un'unica tabella User, senza ruoli, eventi o gestione della capienza.
Esiste anche un frontend complementare in frontend/ (Vite, React, TypeScript) che consiste in poco più di un form di registrazione (RegisterForm.tsx) che chiama l'endpoint POST.
Backend: Express 5, TypeScript, Prisma su SQLite, libreria qrcode per la generazione dell'immagine, Nodemailer per l'invio email, Zod per la validazione dell'input. Frontend: Vite con React e TypeScript.
È una prova di concetto funzionante, non un servizio consolidato: non ci sono test automatizzati, lo schema non versiona eventi o liste ospiti distinte al di là della tabella utenti piatta, e in passato la cartella node_modules era stata committata per errore ed è stata aggiunta al .gitignore solo di recente.
hooks/register.js 532 lines1/*
2 * instradamento-modelli - register.js
3 *
4 * Mod di Claude Code che fa due cose, e la seconda e' la ragione della prima.
5 *
6 * MISURA. A ogni richiesta che parte verso il modello, l'hook turn.step legge
7 * i token che l'API riporta e li somma per modello: richieste, ingresso,
8 * uscita, cache letta e cache scritta. Il totale di sessione vive in memoria,
9 * quello cumulato nello store del plugin, che e' condiviso fra le sessioni
10 * della macchina. Senza questa misura l'instradamento sarebbe una speranza.
11 *
12 * INSTRADA. L'hook agent.spawn riscrive il modello del subagent che sta per
13 * partire, secondo una tabella che dice quale tipo di agente va su quale
14 * modello, e turn.step riscrive lo sforzo di ragionamento delle richieste di
15 * quel subagent. La conversazione principale si tocca solo se la
16 * configurazione lo chiede esplicitamente.
17 *
18 * TRE MODI, e il predefinito non instrada niente. In `osserva` la mod dichiara
19 * in trascrizione che cosa instraderebbe e lascia correre: e' il modo con cui
20 * si misura la linea di partenza, che e' la prima regola del settore. In
21 * `instrada` riscrive davvero. In `spento` non fa nulla, nemmeno la misura.
22 *
23 * FALLISCE APERTO, sempre. Qualunque errore di configurazione o di lettura
24 * lascia passare la chiamata intatta: una mod che instrada non e' una guardia
25 * di sicurezza, e un suo guasto non deve mai fermare il lavoro.
26 *
27 * La configurazione si legge per account da <CLAUDE_CONFIG_DIR>/instradamento-
28 * modelli.json, con un file di progetto che puo' sovrascriverla. Il perche'
29 * della forma e i limiti stanno nel README del pacchetto.
30 */
31
32const NOME_FILE = 'instradamento-modelli.json'
33const CHIAVE_CONTATORI = 'contatori'
34const MODI = ['spento', 'osserva', 'instrada']
35
36const CHIAVE_PROPOSTE = 'proposte'
37// Lo sforzo sta in un contatore suo perche' risponde a una domanda diversa dai
38// token: non quanto e' costato, ma se la riscrittura dello sforzo abbia davvero
39// raggiunto le richieste del subagent. Il segnale e' nel rapporto fra due numeri,
40// gli spawn instradati con una regola di sforzo e le richieste che quello sforzo
41// lo hanno ricevuto: se il primo cresce e il secondo resta a zero, la riscrittura
42// non arriva, e senza questi due conteggi la cosa non si vede da nessuna parte.
43const CHIAVE_SFORZO = 'sforzo'
44
45const PREDEFINITA = {
46 modo: 'osserva',
47 agenti: {},
48 conversazione: {},
49 rispettaModelloChiesto: true,
50 annuncia: true,
51 promemoriaDopo: 10,
52}
53
54let conf = PREDEFINITA
55let origini = []
56let avvisi = []
57let caricata = false
58let percorsoAccount = null
59
60const sforzoPerAgente = new Map()
61const tipiOfferti = new Set()
62let contatoriSessione = {}
63let instradati = 0
64let proposti = 0
65let propostiDaRiversare = 0
66let spawnConSforzo = 0
67let spawnConSforzoDaRiversare = 0
68let richiesteConSforzo = {}
69let richiesteConSforzoDaRiversare = {}
70
71function contatoriVuoti() {
72 return { richieste: 0, ingresso: 0, uscita: 0, cacheLetta: 0, cacheScritta: 0 }
73}
74
75function somma(contatori, modello, uso) {
76 const nome = modello || 'ignoto'
77 if (!contatori[nome]) contatori[nome] = contatoriVuoti()
78 const c = contatori[nome]
79 c.richieste += 1
80 if (!uso) return
81 c.ingresso += uso.input_tokens || 0
82 c.uscita += uso.output_tokens || 0
83 c.cacheLetta += uso.cache_read_input_tokens || 0
84 c.cacheScritta += uso.cache_creation_input_tokens || 0
85}
86
87function segnaSforzo(destinazione, applicato, entrante) {
88 const chiave = String(applicato)
89 if (!destinazione[chiave]) destinazione[chiave] = { totale: 0, da: {} }
90 const voce = destinazione[chiave]
91 voce.totale += 1
92 // Lo sforzo entrante e' quello che il motore aveva gia' deciso per la
93 // richiesta: serve a distinguere una riscrittura che cambia qualcosa da una
94 // che riscrive lo stesso valore, cioe' da un risparmio che non c'e'.
95 const da = entrante === undefined || entrante === null ? 'senza' : String(entrante)
96 voce.da[da] = (voce.da[da] || 0) + 1
97}
98
99function righeSforzo(spawn, richieste) {
100 const chiavi = Object.keys(richieste || {})
101 if (spawn === 0 && chiavi.length === 0) return []
102 const righe = []
103 const totale = chiavi.reduce(function (somma, k) { return somma + (richieste[k].totale || 0) }, 0)
104 righe.push('sforzo di ragionamento: ' + spawn + ' spawn instradati con una regola di sforzo, ' +
105 totale + ' richieste lo hanno ricevuto')
106 for (const chiave of chiavi.sort()) {
107 const voce = richieste[chiave]
108 const da = Object.keys(voce.da || {}).sort()
109 .map(function (k) { return k + ' ' + voce.da[k] }).join(', ')
110 righe.push(' ' + chiave + ': ' + voce.totale + (da ? ' (entranti: ' + da + ')' : ''))
111 }
112 // Il caso che questo conteggio esiste per rendere visibile: la regola e'
113 // scattata allo spawn e nessuna richiesta di quel subagent e' mai passata di
114 // qui, quindi lo sforzo non lo ha ricevuto nessuno. Senza questa riga si
115 // vedrebbe solo che il modello e' cambiato, e si darebbe per fatto il resto.
116 if (spawn > 0 && totale === 0) {
117 righe.push(' ATTENZIONE: nessuna richiesta ha ricevuto lo sforzo, la riscrittura non sta arrivando al subagent')
118 }
119 return righe
120}
121
122function fondiSforzi(destinazione, aggiunta) {
123 for (const chiave of Object.keys(aggiunta || {})) {
124 if (!destinazione[chiave]) destinazione[chiave] = { totale: 0, da: {} }
125 const a = aggiunta[chiave]
126 const d = destinazione[chiave]
127 d.totale += a.totale || 0
128 for (const nome of Object.keys(a.da || {})) {
129 d.da[nome] = (d.da[nome] || 0) + a.da[nome]
130 }
131 }
132 return destinazione
133}
134
135function fondiContatori(destinazione, aggiunta) {
136 for (const nome of Object.keys(aggiunta || {})) {
137 if (!destinazione[nome]) destinazione[nome] = contatoriVuoti()
138 const a = aggiunta[nome]
139 const d = destinazione[nome]
140 d.richieste += a.richieste || 0
141 d.ingresso += a.ingresso || 0
142 d.uscita += a.uscita || 0
143 d.cacheLetta += a.cacheLetta || 0
144 d.cacheScritta += a.cacheScritta || 0
145 }
146 return destinazione
147}
148
149/*
150 * Una regola si scrive in forma breve, "Explore": "haiku", oppure estesa,
151 * "Explore": { "modello": "haiku", "sforzo": "low" }. La forma breve copre il
152 * caso ordinario senza che nessuno debba ricordare lo schema.
153 */
154function regolaDi(valore) {
155 if (!valore) return null
156 if (typeof valore === 'string') return { modello: valore, sforzo: undefined }
157 if (typeof valore === 'object' && !Array.isArray(valore)) {
158 return { modello: valore.modello, sforzo: valore.sforzo }
159 }
160 return null
161}
162
163function fondi(base, aggiunta) {
164 const esito = Object.assign({}, base, aggiunta)
165 esito.agenti = Object.assign({}, base.agenti || {}, aggiunta.agenti || {})
166 esito.conversazione = Object.assign({}, base.conversazione || {}, aggiunta.conversazione || {})
167 return esito
168}
169
170function unisciPercorso(cartella, nome) {
171 if (!cartella) return null
172 return String(cartella).replace(/[\\/]+$/, '') + '/' + nome
173}
174
175async function leggiJson($, percorso) {
176 if (!percorso) return null
177 let testo
178 try {
179 testo = await $.fs.read(percorso)
180 } catch (errore) {
181 // File assente o illeggibile: e' il caso ordinario, non un problema da dichiarare.
182 return null
183 }
184 if (!testo || !String(testo).trim()) return null
185 try {
186 const dati = JSON.parse(testo)
187 if (!dati || typeof dati !== 'object' || Array.isArray(dati)) {
188 avvisi.push(percorso + ': non contiene un oggetto JSON, ignorato')
189 return null
190 }
191 return { percorso: percorso, dati: dati }
192 } catch (errore) {
193 avvisi.push(percorso + ': JSON non valido, ignorato (' + errore.message + ')')
194 return null
195 }
196}
197
198async function cartellaAccount($) {
199 let cartella = null
200 try {
201 cartella = await $.env.get('CLAUDE_CONFIG_DIR')
202 } catch (errore) {
203 cartella = null
204 }
205 if (cartella) return cartella
206 let casa = null
207 try {
208 casa = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME'))
209 } catch (errore) {
210 casa = null
211 }
212 return casa ? unisciPercorso(casa, '.claude') : null
213}
214
215async function carica($, cwd) {
216 origini = []
217 avvisi = []
218 let composta = PREDEFINITA
219
220 const account = await cartellaAccount($)
221 percorsoAccount = unisciPercorso(account, NOME_FILE)
222 const perAccount = await leggiJson($, percorsoAccount)
223 if (perAccount) {
224 composta = fondi(composta, perAccount.dati)
225 origini.push(perAccount.percorso)
226 }
227
228 const perProgetto = cwd ? await leggiJson($, unisciPercorso(cwd, '.claude/' + NOME_FILE)) : null
229 if (perProgetto) {
230 composta = fondi(composta, perProgetto.dati)
231 origini.push(perProgetto.percorso)
232 }
233
234 if (MODI.indexOf(composta.modo) === -1) {
235 avvisi.push('modo "' + composta.modo + '" sconosciuto, torno a osserva')
236 composta.modo = 'osserva'
237 }
238 conf = composta
239 caricata = true
240 return conf
241}
242
243function attivo() {
244 return conf.modo !== 'spento'
245}
246
247function instrada() {
248 return conf.modo === 'instrada'
249}
250
251async function annuncia($, testo) {
252 if (!conf.annuncia) return
253 try {
254 await $.ui.log(testo)
255 } catch (errore) {
256 // Una riga non scritta non vale il fallimento di uno spawn.
257 }
258}
259
260async function salvaContatori($) {
261 try {
262 const salvato = (await $.store.get(CHIAVE_CONTATORI)) || {}
263 const cumulato = fondiContatori(Object.assign({}, salvato), contatoriSessione)
264 await $.store.set(CHIAVE_CONTATORI, cumulato)
265 contatoriSessione = {}
266 if (propostiDaRiversare > 0) {
267 const proposteSalvate = (await $.store.get(CHIAVE_PROPOSTE)) || 0
268 await $.store.set(CHIAVE_PROPOSTE, proposteSalvate + propostiDaRiversare)
269 propostiDaRiversare = 0
270 }
271 if (spawnConSforzoDaRiversare > 0 || Object.keys(richiesteConSforzoDaRiversare).length > 0) {
272 const salvatoSforzo = (await $.store.get(CHIAVE_SFORZO)) || {}
273 const unito = {
274 spawn: (salvatoSforzo.spawn || 0) + spawnConSforzoDaRiversare,
275 richieste: fondiSforzi(Object.assign({}, salvatoSforzo.richieste || {}), richiesteConSforzoDaRiversare),
276 }
277 await $.store.set(CHIAVE_SFORZO, unito)
278 spawnConSforzoDaRiversare = 0
279 richiesteConSforzoDaRiversare = {}
280 }
281 } catch (errore) {
282 // Lo store e' una comodita': se non risponde, il totale di sessione resta
283 // in memoria e si somma al prossimo giro.
284 }
285}
286
287function tabellaContatori(contatori) {
288 const nomi = Object.keys(contatori || {}).sort()
289 if (nomi.length === 0) return [' (nessuna richiesta misurata)']
290 return nomi.map(function (nome) {
291 const c = contatori[nome]
292 return ' ' + nome + ': ' + c.richieste + ' richieste, ' + c.ingresso + ' in, ' +
293 c.uscita + ' out, cache ' + c.cacheLetta + ' letti / ' + c.cacheScritta + ' scritti'
294 })
295}
296
297async function resoconto($) {
298 const righe = []
299 righe.push('instradamento-modelli: modo ' + conf.modo)
300 righe.push('configurazione: ' + (origini.length ? origini.join(' poi ') : '(nessun file, valori predefiniti)'))
301 for (const avviso of avvisi) righe.push('avviso: ' + avviso)
302
303 const tipi = Object.keys(conf.agenti || {}).sort()
304 if (tipi.length === 0) {
305 righe.push('tabella agenti: vuota')
306 } else {
307 righe.push('tabella agenti:')
308 for (const tipo of tipi) {
309 const r = regolaDi(conf.agenti[tipo])
310 if (!r) continue
311 // Un nome che nessuno ha mai offerto e' quasi sempre un refuso, e un
312 // refuso qui non produce alcun errore: produce una regola che non scatta
313 // mai. Si dichiara, invece di lasciarlo scoprire a un conto che non cala.
314 const visto = tipiOfferti.size === 0 ? '' : (tipiOfferti.has(tipo) ? '' : ' [mai offerto in questa sessione: nome giusto?]')
315 righe.push(' ' + tipo + ' -> ' + (r.modello || 'eredita') + (r.sforzo ? ' (sforzo ' + r.sforzo + ')' : '') + visto)
316 }
317 }
318 const c = conf.conversazione || {}
319 righe.push('conversazione principale: ' +
320 (c.modello || c.sforzo ? (c.modello || 'modello invariato') + (c.sforzo ? ' / sforzo ' + c.sforzo : '') : 'non toccata'))
321 righe.push('spawn instradati in questa sessione: ' + instradati + ' su ' + proposti + ' proposti')
322 for (const riga of righeSforzo(spawnConSforzo, richiesteConSforzo)) righe.push(riga)
323 righe.push('token di questa sessione, per modello:')
324 for (const riga of tabellaContatori(contatoriSessione)) righe.push(riga)
325
326 let cumulato = null
327 try {
328 cumulato = await $.store.get(CHIAVE_CONTATORI)
329 } catch (errore) {
330 cumulato = null
331 }
332 righe.push('token cumulati sulla macchina, per modello:')
333 for (const riga of tabellaContatori(cumulato || {})) righe.push(riga)
334
335 let sforzoCumulato = null
336 try {
337 sforzoCumulato = await $.store.get(CHIAVE_SFORZO)
338 } catch (errore) {
339 sforzoCumulato = null
340 }
341 if (sforzoCumulato) {
342 for (const riga of righeSforzo(sforzoCumulato.spawn || 0, sforzoCumulato.richieste || {})) {
343 righe.push(riga.indexOf(' ') === 0 ? riga : 'cumulato, ' + riga)
344 }
345 }
346 return righe.join('\n')
347}
348
349/*
350 * Il promemoria esiste perche' il modo `osserva` ha un difetto che si produce
351 * da se': fa esattamente il suo mestiere, cioe' niente, e chi lo installa se ne
352 * dimentica. Una misura raccolta che nessuno guarda e' lavoro buttato, quindi
353 * quando le occasioni accumulate superano la soglia la mod lo dice da sola, una
354 * volta per sessione, e nomina il file da cambiare invece di dire di cambiarlo.
355 */
356async function promemoria($) {
357 const soglia = Number(conf.promemoriaDopo)
358 if (conf.modo !== 'osserva' || !soglia || soglia < 1) return
359 let proposte = 0
360 try {
361 proposte = (await $.store.get(CHIAVE_PROPOSTE)) || 0
362 } catch (errore) {
363 return
364 }
365 if (proposte < soglia) return
366 await annuncia($, 'instradamento-modelli: sei in modo osserva e ' + proposte +
367 ' spawn sarebbero stati instradati. Guarda i numeri con /instradamento, e se convincono metti "modo": "instrada" in ' +
368 (percorsoAccount || 'instradamento-modelli.json') + '.')
369}
370
371export function register(on) {
372 on('session.start', async ($, e, next) => {
373 try {
374 await carica($, e && e.cwd)
375 await promemoria($)
376 } catch (errore) {
377 avvisi.push('configurazione non letta: ' + errore.message)
378 }
379 try {
380 await $.command.register({
381 name: 'instradamento',
382 description: 'Instradamento dei modelli: stato, misura e cambio di modo',
383 argumentHint: '[instrada|osserva|spento|ricarica|azzera]',
384 })
385 } catch (errore) {
386 // Un nome gia' preso non deve impedire il resto della mod.
387 }
388 return next(e)
389 })
390
391 on('command.run', { command: 'instradamento' }, async ($, e) => {
392 const argomento = String((e && e.args) || '').trim().toLowerCase()
393 if (MODI.indexOf(argomento) !== -1) {
394 conf = Object.assign({}, conf, { modo: argomento })
395 await annuncia($, 'instradamento-modelli: modo ' + argomento + ' per questa sessione, il file non e stato toccato')
396 return {}
397 }
398 if (argomento === 'ricarica') {
399 let cwd = null
400 try {
401 cwd = await $.session.cwd()
402 } catch (errore) {
403 cwd = null
404 }
405 try {
406 await carica($, cwd)
407 } catch (errore) {
408 avvisi.push('ricarica fallita: ' + errore.message)
409 }
410 }
411 if (argomento === 'azzera') {
412 try {
413 await $.store.set(CHIAVE_CONTATORI, {})
414 await $.store.set(CHIAVE_SFORZO, { spawn: 0, richieste: {} })
415 } catch (errore) {
416 avvisi.push('azzeramento fallito: ' + errore.message)
417 }
418 contatoriSessione = {}
419 richiesteConSforzo = {}
420 richiesteConSforzoDaRiversare = {}
421 spawnConSforzo = 0
422 spawnConSforzoDaRiversare = 0
423 }
424 await annuncia($, await resoconto($))
425 return {}
426 })
427
428 // Serve solo a sapere quali nomi di agente esistono davvero in questa
429 // sessione, per smascherare un refuso nella tabella. Non decide niente.
430 on('agent.offer', async ($, e, next) => {
431 if (e && e.agent) tipiOfferti.add(e.agent)
432 return next(e)
433 })
434
435 /*
436 * Il modello di un subagent si decide qui, prima che parta. Quattro casi
437 * lasciano la chiamata intatta, e ciascuno per una ragione propria: il fork,
438 * che eredita per definizione e ignora il campo; il modello gia' chiesto dal
439 * chiamante, che e' una decisione presa da chi sa piu' di noi; il tipo di
440 * agente assente dalla tabella; e il modo diverso da `instrada`.
441 */
442 on('agent.spawn', async ($, e, next) => {
443 let regola = null
444 try {
445 if (!caricata) await carica($, null)
446 if (!attivo()) return next(e)
447 regola = regolaDi((conf.agenti || {})[e.subagentType])
448 } catch (errore) {
449 return next(e)
450 }
451 if (!regola || (!regola.modello && !regola.sforzo)) return next(e)
452 if (e.fork) return next(e)
453 if (e.model && conf.rispettaModelloChiesto) {
454 await annuncia($, 'instradamento-modelli: ' + e.subagentType + ' non instradato, il chiamante ha chiesto ' + e.model)
455 return next(e)
456 }
457
458 proposti += 1
459 propostiDaRiversare += 1
460 if (!instrada()) {
461 await annuncia($, 'instradamento-modelli (osserva): ' + e.subagentType + ' andrebbe su ' +
462 (regola.modello || 'modello invariato') + (regola.sforzo ? ' con sforzo ' + regola.sforzo : ''))
463 return next(e)
464 }
465
466 const evento = regola.modello ? Object.assign({}, e, { model: regola.modello }) : e
467 const esito = await next(evento)
468 if (esito && !esito.deny) {
469 instradati += 1
470 if (esito.agentId && regola.sforzo) {
471 sforzoPerAgente.set(esito.agentId, regola.sforzo)
472 spawnConSforzo += 1
473 spawnConSforzoDaRiversare += 1
474 }
475 await annuncia($, 'instradamento-modelli: ' + e.subagentType + ' su ' + (esito.model || regola.modello) +
476 (regola.sforzo ? ' con sforzo ' + regola.sforzo : ''))
477 }
478 return esito
479 }).catch(async ($, e, next) => {
480 // Fallimento aperto, dichiarato: se l'hook qui sopra va in errore o sfora
481 // il tempo, lo spawn parte come sarebbe partito senza la mod. Una mod che
482 // instrada non e' una guardia, e un suo guasto non deve fermare il lavoro.
483 return next(e)
484 })
485
486 /*
487 * Ogni richiesta passa di qui: e' il solo punto in cui si vedono i token
488 * davvero consumati, ed e' anche il punto in cui si regola lo sforzo, che
489 * agent.spawn non espone.
490 */
491 on('turn.step', async function* ($, e, next) {
492 let evento = e
493 try {
494 if (attivo() && instrada()) {
495 const sforzo = e.agentId
496 ? sforzoPerAgente.get(e.agentId)
497 : (conf.conversazione || {}).sforzo
498 const modello = e.agentId ? null : (conf.conversazione || {}).modello
499 if (modello) evento = Object.assign({}, evento, { model: modello })
500 if (sforzo) {
501 segnaSforzo(richiesteConSforzo, sforzo, e.effort)
502 segnaSforzo(richiesteConSforzoDaRiversare, sforzo, e.effort)
503 evento = Object.assign({}, evento, { effort: sforzo })
504 }
505 }
506 } catch (errore) {
507 evento = e
508 }
509 const esito = yield* next(evento)
510 try {
511 if (attivo()) {
512 const uso = esito && esito.usage
513 somma(contatoriSessione, (uso && uso.model) || evento.model, uso)
514 }
515 } catch (errore) {
516 // Una misura mancata non vale l'interruzione di una risposta.
517 }
518 return esito
519 })
520
521 on('turn.complete', async ($, e, next) => {
522 if (attivo()) await salvaContatori($)
523 if (e && e.agentId) sforzoPerAgente.delete(e.agentId)
524 return next(e)
525 })
526
527 on('session.end', async ($, e, next) => {
528 if (attivo()) await salvaContatori($)
529 return next(e)
530 })
531}
532