SLOPSHOPPER

instradamento-modelli

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

newcommandagents
A shopper browsing a rack in a slop shop
README

discoteca-api

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.

Come funziona

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.

Stack tecnico

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.

Stato del progetto

È 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.

Source 1 files
hooks/register.js 532 lines
1/*
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