SLOPSHOPPER

rec

Modalità registrazione: nasconde a schermo chiavi, dati personali, importi e clienti mentre lavori in Claude Code, e tiene chiusi i file privati. /rec la…

newbandspinnerrowsguardcommand
v1.0.2MITupdated 2026-10-07andreabrugnoli/mods/rec
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · rec
› fix the failing auth test and add an audit log call ╭─────────────╮ │ rec │ ⏺ Read(src/auth.ts) │ rec acceso. │ ⎿ Read 6 lines ╰─────────────╯ ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /rec ⎿ rec: rec · acceso ● REC ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
● REC ⟨Claude Code's own drawing⟩
README

mods

License: MIT Claude Code Mods

Un marketplace di mod per Claude Code, pensato per capire e controllare una sessione: barra della cache e dei consumi, registro di ciò che viene scritto fuori dalla repo, conferma delle proposte, raccolta degli inciampi, modalità rec per le registrazioni e inbox con bozze, etichette e task. Ogni mod è un plugin indipendente, installabile singolarmente con un comando.


Le sei mod

Ogni mod si accende e si spegne con un comando /..., che funziona in terminale, nell'app Desktop e da iPad. Lo stato si ricorda tra una sessione e l'altra.

⏳ barra-cache (/barra) — tre righe sopra il prompt. La prima è la memoria della chat: una barra con i token del contesto occupato e quanto è costata la sessione a listino (verde fino al 60%, gialla fino all'80%, rossa oltre, con il suggerimento Nuova chat). La seconda mostra l'*uso delle 5 ore* e l'*uso della settimana* dell'abbonamento, solo dove il motore riporta le cifre. La terza dice da quanto è aperta la sessione e quanto resta di cache calda: la prompt cache dura un'ora dall'ultima richiesta della conversazione principale (le richieste dei subagent non la rinnovano), poi la richiesta successiva riscrive tutto il contesto a prezzo pieno. Con la cache scaduta e almeno 20mila token di contesto compare un avviso rosso. Sotto, una riga con branch, file modificati e commit da pubblicare (letta da git, solo dove $.process esiste: nel cloud non compare) e i bottoni: Commit e push (tasto g), Push (tasto p, compare solo quando ci sono commit da pubblicare e nessuna modifica da committare, senza token) e Handoff (tasto n), che fa scrivere al modello un riassunto di ripartenza in ~/.claude/handoffs/, svuota la chat con /clear e riparte da quel file. Dove la banda non viene disegnata (ad esempio l'app su iPad) restano /cache, che mostra minuti rimasti, contesto, costo dell'ultimo turno e quota di cache letta, /push e /handoff (alias /nuova). I comandi sono anche file statici in barra-cache/commands/, perché l'elenco dello slash dell'app non mostra quelli registrati a runtime; l'hook risponde prima del modello, quindi non costano token.

📒 scritture-esterne (/scritture) — a fine turno elenca tutto ciò che il turno ha scritto fuori dalla repo: Notion, Postpickr, Spreaker, Gmail, Calendar, Drive, git push, gh, curl -X POST e gli script con --applica o --elimina. Ogni riga ha servizio, azione, bersaglio, un link apri se la risposta ne contiene uno, e una croce rossa se la chiamata è fallita o negata. Le letture non compaiono. Il registro sparisce all'inizio del turno successivo. Riconosce dal nome i connettori più comuni; per quelli con id opaco serve l'opzione servizi del plugin.

✅ conferma-proposte (/conferma) — quando l'ultima risposta di Claude chiude con una proposta o una domanda di conferma (una domanda, oppure formule come "procedo", "vuoi che", "confermi"), mostra sopra il prompt il passaggio che propone e due bottoni: Sì, procedi (tasto s) e No, fermati (tasto x). Il bottone invia la risposta come se l'avessi scritta tu. Non usa il modello, quindi non consuma token. Funziona anche nel cloud e da iPad.

🛠 correggi (/correggi) — durante la sessione annota gli inciampi: tool che falliscono, azioni negate dal sistema di permessi e le tue correzioni ("non vedo", "hai sbagliato", "riprova"). Con almeno due inciampi compare una riga con il conteggio, le skill usate e il bottone Proponi correzione (tasto l), che chiede a Claude di individuare la causa e proporre la modifica esatta alla skill o al file di istruzioni, senza applicarla. /correggi elenco mostra gli inciampi raccolti. Consuma token solo quando premi il bottone.

🔴 rec (/rec) — la modalità per registrare un video o lavorare in una sessione live con ospiti. Maschera a schermo chiavi e valori dei .env, email, nomi, telefoni, indirizzi, codice fiscale, partita IVA, IBAN, importi in euro e cifre vicino a parole come fatturato, margine, compenso, preventivo. I risultati di posta, chat, task, file, calendario, Notion e strumenti di pagamento si disegnano nascosti. Claude continua a lavorare sui dati reali: cambia solo ciò che si vede (se modifica un file da 29 a 39 euro, il file cambia davvero, lo schermo no). Tiene chiusi i file privati (.env, credenziali, fatture, contratti, preventivi, buste paga) e gli strumenti di pagamento, e ogni prompt porta una nota nascosta che chiede a Claude di usare segnaposto al posto di nomi e cifre. Un ● REC rosso sopra il prompt e nel piè di pagina ricorda che è acceso. /rec alterna, /rec rigoroso maschera anche ogni cifra grande, /rec off spegne, /rec config crea ~/.claude/mods-data/rec/config.json per il tuo nome (nomiVisibili), le persone da nascondere (nomiNascosti), le cartelle private (percorsiPrivati) e gli strumenti extra (strumentiAffari, strumentiChiusi). Limiti: cambia ciò che è disegnato, non ciò che è memorizzato; il riconoscimento per pattern non prende tutto quello che è scritto a parole, quindi riguarda il girato prima di pubblicarlo; i titoli delle chat nella barra laterale dell'app Desktop non si possono mascherare. Adattata da recording-mode di Nate Herk (MIT, vedi rec/NOTICE.md).

📬 posta (posta o /posta) — apre un pannello con la inbox Gmail: l'elenco delle ultime 12 mail per account (una finestra di 5 righe che scorre con j e k), i bottoni e, sotto, il testo intero della mail selezionata (tutti i messaggi del thread, dal più recente, con allegati), letto da Gmail senza modello. I bottoni sono Bozza (b), Label (l), Bozza+Label (m), Task (t), più Aggiorna (r) e Chiudi (x). Le quattro azioni girano dentro la mod, senza subagent: il modello riceve il thread senza alcun tool e sceglie solo i contenuti, mentre le scritture su Gmail e Notion le fa il codice dopo averle validate (la chat resta pulita: l'esito compare nel pannello e in un avviso). Bozza scrive con le regole di ~/.claude/mods-data/posta/sistematore.md e crea una bozza di risposta (la mail non viene mai inviata). Label fa scegliere al modello economico un'etichetta tra quelle esistenti e la applica solo se il nome coincide (non ne crea). Task fa proporre al modello economico titolo, scadenza, urgenza, importanza, impegno e contesto, li riporta ai valori ammessi (scadenza non valida o passata: domani alle 09:00) e crea la pagina nel database Tasks di Notion con il link alla mail. Solo Label e Bozza+Label tolgono la mail dall'inbox (finisce nella cartella dell'etichetta); Bozza e Task la lasciano in inbox. I lavori in corso mostrano uno spinner, quelli riusciti spariscono dopo 8 secondi e quelli falliti si rilanciano con Riprova (y). Nel dettaglio compare l'ultimo messaggio del thread; i precedenti sono righe ripiegate e si aprono con Mostra precedenti (p). Scrivendo posta senza barra il pannello si apre senza avviso dell'app e senza token; posta off lo chiude. Gli account sono in DEFAULT_ACCOUNTS e si sostituiscono con la chiave accounts dello store. In modalità Auto anche le chiamate della mod passano dal classificatore, che sulle scritture non dà verdetto: vanno autorizzate in permissions.allow le letture Gmail (search_threads, get_thread, list_labels) e le quattro scritture (create_draft, label_thread, unlabel_thread di Gmail, notion-create-pages di Notion), con il prefisso mcp__<id connettore>__. Un errore resta visibile solo sulla mail a cui appartiene e sparisce quando la stessa azione riesce.


Prerequisiti

  • Claude Code 2.1.287 o successivo: le mod sono attive di default da questa versione. Verifica con claude --version.
  • Terminale o tab Code dell'app Desktop: le sessioni WSL nell'app Desktop non eseguono le mod.

Una mod è codice eseguito con i tuoi permessi: può leggere e scrivere file, avviare processi e vedere prompt e tool call della sessione. Il sorgente di ogni mod è in hooks/: leggilo prima dell'installazione.


Installazione

  1. Aggiungi il marketplace a Claude Code:
   claude plugin marketplace add andreabrugnoli/mods
  1. Installa le mod che ti interessano, una per comando:
   claude plugin install barra-cache@andrea-mods
   claude plugin install scritture-esterne@andrea-mods
   claude plugin install conferma-proposte@andrea-mods
   claude plugin install correggi@andrea-mods
   claude plugin install rec@andrea-mods
   claude plugin install posta@andrea-mods
  1. Avvia una nuova sessione con claude.

Verifica: apri /plugin e controlla che le mod risultino attive. Per provare una mod in una sola sessione senza installarla, clona la repo e avvia claude --plugin-dir ./mods/barra-cache. Per disattivarla, usa il tab Installed di /plugin.


Uso in cloud e da iPad

Le sessioni cloud (claude.ai/code, app per iPad) non leggono le tue impostazioni locali: partono da un contenitore pulito e caricano i plugin dichiarati nella repo su cui lavori. Per attivare una mod in una repo, aggiungi a .claude/settings.json di quella repo:

{
  "extraKnownMarketplaces": {
    "andrea-mods": { "source": { "source": "github", "repo": "andreabrugnoli/mods" } }
  },
  "enabledPlugins": { "barra-cache@andrea-mods": true, "rec@andrea-mods": true }
}

Lo fa per te scripts/abilita-cloud.sh <percorso-repo> [mod ...], che unisce la voce alle impostazioni esistenti. Se il tuo gitignore (anche globale) esclude .claude/settings.json, aggiungilo con git add -f .claude/settings.json, poi commit e push. La sessione cloud legge il file dal branch.

Su iPad la banda sopra il prompt può non essere disegnata: restano /cache, /push e /nuova, e tutti i comandi on/off (/barra, /scritture, /conferma, /correggi, /rec) funzionano uguale. Lo stato ($.store) e i riassunti in ~/.claude/handoffs/ vivono nel contenitore della sessione e si perdono alla sua chiusura.


Comandi disponibili

Accendere e spegnere (senza argomento alternano; accettano anche on e off):

  • /barra: la barra sopra il prompt.
  • /scritture: il registro delle scritture esterne.
  • /conferma: i bottoni Sì e No sulle proposte.
  • /correggi: la raccolta degli inciampi (/correggi elenco li mostra).
  • /rec: la modalità registrazione (/rec rigoroso, /rec off, /rec config).
  • posta o /posta: la inbox con i bottoni Bozza, Label e Task (posta aggiorna, posta off).

Altri comandi di barra-cache:

  • /cache: minuti di cache rimasti, contesto occupato, costo dell'ultimo turno e quota di cache letta.
  • /push: pubblica i commit del branch corrente.
  • /nuova: riassume la sessione in un file e riparte da una chat pulita.

Personalizzazione

Durata della cache in barra-cache: parte da un'ora (TTL_MS) e si corregge da sola. Dopo una pausa di almeno 6 minuti guarda la quota di cache letta: se la cache è stata riscritta, passa a 5 minuti e lo ricorda tra le sessioni; se è stata letta, conferma l'ora.

Soglie dei colori in barra-cache: CONTEXT_WARN, CONTEXT_BAD, COST_WARN, CACHE_OK e CACHE_BAD, in cima allo stesso file.

Nomi dei connettori in scritture-esterne: opzione servizi del plugin.

Per modificare una mod, clona la repo e caricala con claude --plugin-dir: ogni salvataggio ricarica il modulo nella sessione aperta.


Struttura del progetto

mods/
├── .claude-plugin/marketplace.json   # elenco delle mod installabili
├── barra-cache/
├── scritture-esterne/
├── conferma-proposte/
├── correggi/
├── rec/
├── posta/
└── scripts/abilita-cloud.sh

Ogni cartella di mod è un plugin completo: .claude-plugin/plugin.json, hooks/hooks.json, hooks/register.js e tests/ (eseguibili con claude plugin test ./<mod>). claude plugin validate ./<mod> elenca gli eventi intercettati e le chiamate al motore.


Licenza: MIT.

Source 2 files
hooks/register.js 283 lines
1// rec: /rec prima di registrare un video o lavorare in una sessione live con ospiti.
2// Adattato da recording-mode di Nate Herk (MIT, vedi NOTICE.md).
3// Finché è acceso, in ogni sessione di Claude Code su questa macchina:
4// - chiavi, valori dei .env, email, nomi, telefoni, indirizzi, codice fiscale, partita IVA, IBAN,
5//   importi e cifre d'affari vengono mascherati dove la chat disegna testo
6// - i risultati di posta, chat, task, file, calendario e strumenti di pagamento si disegnano nascosti
7// - Claude non può aprire i file privati (.env, credenziali, memoria di Claude, fatture, contratti,
8//   preventivi e quanto elenchi nel file di configurazione) né gli strumenti di pagamento
9// - ogni prompt porta una nota nascosta che chiede a Claude di tenere nomi e cifre fuori dalle risposte
10// - un ● REC rosso sopra il prompt e nel piè di pagina ricorda che è acceso
11// Claude lavora sempre sui dati reali: cambia solo ciò che si vede a schermo.
12// /rec rigoroso maschera anche ogni cifra grande. /rec off spegne tutto.
13// /rec config crea ~/.claude/mods-data/rec/config.json per il tuo nome, le persone da nascondere,
14// le cartelle private e gli strumenti extra.
15
16import { makeMasker, deepMask, hideText, privatePathHit, toolCallTargets, secretValuesFromEnv, businessSource, financeTool } from './privacy.js'
17
18const FLAG_PATH = '/.claude/mods-data/rec/stato.json'
19const CONFIG_PATH = '/.claude/mods-data/rec/config.json'
20
21const CONFIG_TEMPLATE = {
22  nomiVisibili: ['Andrea Brugnoli', 'abcomunica'],
23  nomiNascosti: ['Un cliente', 'Un collaboratore'],
24  percorsiPrivati: ['clienti/', 'trattative/', 'appunti-privati.md'],
25  strumentiAffari: [],
26  strumentiChiusi: [],
27}
28
29const CONFIG_HELP = [
30  'nomiVisibili: il tuo nome e il tuo marchio, mostrati anche durante la registrazione.',
31  'nomiNascosti: persone sempre mascherate (clienti, collaboratori). I nomi nelle email e nei campi contatto si imparano da soli.',
32  'percorsiPrivati: cartelle o file che Claude non può aprire durante la registrazione. Vale qualsiasi parte del percorso, maiuscole comprese.',
33  'strumentiAffari: nomi di tool o comandi (anche solo una parte) i cui risultati si disegnano nascosti, oltre all\'elenco già previsto.',
34  'strumentiChiusi: nomi di tool che Claude non può chiamare durante la registrazione, oltre agli strumenti di pagamento già previsti.',
35]
36
37// Un punto rosso che pulsa e la scritta REC. Si disegna come immagine semplice: un Svg interattivo
38// finisce in un iframe che nel tema scuro dipinge un riquadro bianco e si ricarica a ogni ridisegno.
39const REC_RED = '#e5484d'
40const REC_SVG =
41  '<svg xmlns="http://www.w3.org/2000/svg" width="54" height="18" viewBox="0 0 54 18">' +
42  `<circle cx="8" cy="9" r="5" fill="${REC_RED}"><animate attributeName="opacity" values="1;0.35;1" dur="1.6s" repeatCount="indefinite" calcMode="spline" keyTimes="0;0.5;1" keySplines="0.45 0 0.55 1;0.45 0 0.55 1"/></circle>` +
43  `<text x="19" y="13.2" font-family="-apple-system,'Segoe UI',system-ui,sans-serif" font-size="12" font-weight="700" letter-spacing="0.8" fill="${REC_RED}">REC</text>` +
44  '</svg>'
45
46let home = ''
47let cwd = ''
48let rec = { on: false, strict: false, since: 0 }
49// I valori dei .env: restano solo in memoria, non vengono scritti da nessuna parte
50let values = []
51let config = { nomiVisibili: [], nomiNascosti: [], percorsiPrivati: [], strumentiAffari: [], strumentiChiusi: [] }
52let mask = (s) => s
53let blocked = 0
54let noteSent = false
55// Gli id delle chiamate i cui risultati sono dati d'affari
56const businessCalls = new Set()
57
58function strings(v) {
59  return Array.isArray(v) ? v.filter((x) => typeof x === 'string' && x.trim()).map((x) => x.trim()).slice(0, 500) : []
60}
61
62// La nota che Claude legge accanto a ogni prompt mentre si registra
63export function noteOn(keepNames = []) {
64  const keep = keepNames.length ? ` (a parte ${keepNames.join(', ')})` : ''
65  return (
66    `Modalità rec attiva: lo schermo è in registrazione per un video pubblico o una sessione live con ospiti. Nelle risposte e nei comandi lascia fuori nomi di persone${keep}, email, telefoni, indirizzi, numeri di conto, importi, fatturato, utili, prezzi, compensi e altre cifre d'affari, e piani interni, strategie, trattative e questioni di clienti. ` +
67    'Scrivi segnaposto come [nome], [importo] o [piano interno] e tieni i riassunti generici. I file privati e gli strumenti di pagamento restano chiusi finché la registrazione non finisce.'
68  )
69}
70const NOTE_OFF = 'La modalità rec è finita. La nota precedente sulla registrazione non vale più.'
71
72function apply() {
73  mask = rec.on ? makeMasker({ strict: rec.strict, values, names: config.nomiNascosti, keepNames: config.nomiVisibili }) : (s) => s
74}
75
76async function load($) {
77  await loadEnvValues($)
78  await loadConfig($)
79}
80
81function unload() {
82  values = []
83  businessCalls.clear()
84}
85
86async function loadConfig($) {
87  try {
88    const path = home + CONFIG_PATH
89    if (!(await $.fs.exists(path))) return
90    const raw = JSON.parse(await $.fs.read(path))
91    config = {
92      nomiVisibili: strings(raw.nomiVisibili),
93      nomiNascosti: strings(raw.nomiNascosti),
94      percorsiPrivati: strings(raw.percorsiPrivati),
95      strumentiAffari: strings(raw.strumentiAffari),
96      strumentiChiusi: strings(raw.strumentiChiusi),
97    }
98  } catch {
99    // file di configurazione rotto: valgono le regole già previste
100  }
101}
102
103function parentDirs(path, levels) {
104  const parts = String(path || '').replace(/\\/g, '/').split('/')
105  const out = []
106  for (let i = parts.length; i > 0 && out.length <= levels; i--) out.push(parts.slice(0, i).join('/'))
107  return out.filter(Boolean)
108}
109
110async function loadEnvValues($) {
111  const found = []
112  for (const dir of parentDirs(cwd, 3)) {
113    for (const name of ['.env', '.env.local']) {
114      const p = dir + '/' + name
115      try {
116        if (await $.fs.exists(p)) found.push(...secretValuesFromEnv(await $.fs.read(p)))
117      } catch {
118        // illeggibile: i pattern coprono comunque i formati di chiave più comuni
119      }
120    }
121  }
122  values = [...new Set(found)].sort((a, b) => b.length - a.length)
123}
124
125async function readFlag($) {
126  try {
127    const path = home + FLAG_PATH
128    if (!(await $.fs.exists(path))) return { on: false, strict: false, since: 0 }
129    const flag = JSON.parse(await $.fs.read(path))
130    return { on: !!flag.on, strict: !!flag.strict, since: flag.since || 0 }
131  } catch {
132    return { on: false, strict: false, since: 0 }
133  }
134}
135
136// Un'altra sessione può aver acceso o spento la registrazione
137async function syncFlag($) {
138  const flag = await readFlag($)
139  if (flag.on === rec.on && flag.strict === rec.strict) return
140  rec = flag
141  if (rec.on) await load($)
142  else unload()
143  apply()
144  $.ui.invalidate('ui.render')
145}
146
147async function setFlag($, on, strict) {
148  rec = { on, strict: on && strict, since: on ? await $.clock.now() : 0 }
149  try {
150    await $.fs.write(home + FLAG_PATH, JSON.stringify(rec))
151  } catch {
152    // dove il file non si scrive la modalità vale solo per questa sessione
153  }
154  if (rec.on) await load($)
155  else unload()
156  apply()
157  $.ui.invalidate('ui.render')
158}
159
160// /rec config: crea il file la prima volta, poi dice dov'è e cosa contiene
161async function configText($) {
162  const path = home + CONFIG_PATH
163  let made = false
164  try {
165    if (!(await $.fs.exists(path))) {
166      await $.fs.write(path, JSON.stringify(CONFIG_TEMPLATE, null, 2) + '\n')
167      made = true
168    }
169  } catch {
170    return `Non riesco a scrivere ${path}. Crealo a mano con questa forma:\n${JSON.stringify(CONFIG_TEMPLATE, null, 2)}`
171  }
172  await loadConfig($)
173  apply()
174  return [
175    `${made ? 'Creato' : 'Il tuo'} file di configurazione di rec: ${path}`,
176    made
177      ? 'Contiene valori d\'esempio. Sostituiscili con i tuoi, salva e lancia di nuovo /rec.'
178      : `Caricati: ${config.nomiVisibili.length} nomi visibili, ${config.nomiNascosti.length} nomi nascosti, ${config.percorsiPrivati.length} percorsi privati.`,
179    ...CONFIG_HELP,
180  ].join('\n')
181}
182
183// /rec alterna, /rec rigoroso accende anche la maschera sulle cifre grandi, /rec off spegne, /rec config crea la configurazione
184async function runRec($, e) {
185  const arg = String(e.args || '').trim().toLowerCase()
186  if (arg === 'config') return { text: await configText($) }
187  if (arg === 'off' || arg === 'spento' || (arg === '' && rec.on)) {
188    await setFlag($, false, false)
189    $.ui.toast('rec spento.' + (blocked ? ` Ha tenuto chiusi ${blocked} file privati.` : ''))
190    blocked = 0
191    return { text: 'rec · spento' }
192  }
193  const strict = arg === 'rigoroso' || arg === 'strict'
194  await setFlag($, true, strict)
195  $.ui.toast(`rec acceso${strict ? ' (rigoroso)' : ''}.`, { timeoutMs: 3000 })
196  return { text: 'rec · acceso' + (strict ? ' (rigoroso)' : '') }
197}
198
199export function register(on) {
200  on('session.start', async ($, e, next) => {
201    home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || ''
202    cwd = await $.session.cwd()
203    await $.command.register({
204      name: 'rec',
205      description: 'Accende o spegne la modalità rec: nasconde chiavi, dati personali, importi e file privati a schermo',
206      argumentHint: '[rigoroso|off|config]',
207      immediate: true,
208    })
209    await loadConfig($)
210    await syncFlag($)
211    $.clock.every(3000, () => syncFlag($).catch(() => {}))
212    return next(e)
213  })
214
215  on('command.run', { command: 'rec' }, runRec)
216  // Il comando statico del plugin si chiama anche rec:registra
217  on('command.run', { command: 'rec:registra' }, runRec)
218
219  // Claude legge una nota accanto a ogni prompt mentre si registra, e una in più quando finisce
220  on('prompt.submit', async ($, e, next) => {
221    const note = rec.on ? noteOn(config.nomiVisibili) : noteSent ? NOTE_OFF : null
222    if (!note) return next(e)
223    noteSent = rec.on
224    return next({ ...e, context: [...(e.context ?? []), note] })
225  })
226
227  // File privati e strumenti di pagamento restano chiusi mentre si registra
228  on('tool.call', async ($, e, next) => {
229    if (!rec.on) return next(e)
230    const hit = financeTool(e.tool, config.strumentiChiusi) ? 'strumenti di pagamento' : privatePathHit(toolCallTargets(e).join('\n'), config.percorsiPrivati)
231    if (!hit) {
232      if (businessSource(e.tool, e, config.strumentiAffari)) businessCalls.add(e.tool_use_id)
233      return next(e)
234    }
235    blocked += 1
236    return {
237      deny: `La modalità rec è attiva, quindi "${hit}" resta chiuso finché lo schermo è in registrazione. Continua senza, oppure chiedi all'utente di lanciare prima /rec off.`,
238    }
239  })
240
241  // Righe di testo: prompt, risposte, output dei comandi
242  on('ui.render', { component: ['UserMessage', 'AssistantMessage', 'CommandOutput'] }, async ($, e, next) => {
243    if (!rec.on || typeof e.props.text !== 'string') return next(e)
244    return next({ ...e, props: { ...e.props, text: mask(e.props.text) } })
245  })
246
247  // Righe dei tool: l'input si maschera, il risultato di un tool d'affari si nasconde per intero
248  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
249    if (!rec.on) return next(e)
250    const business = businessSource(e.props.tool, e.props.input, config.strumentiAffari) || businessCalls.has(e.props.tool_use_id)
251    if (business) businessCalls.add(e.props.tool_use_id)
252    const props = { ...e.props, input: deepMask(e.props.input, mask) }
253    if (e.props.output !== undefined) props.output = deepMask(e.props.output, business ? hideText : mask)
254    return next({ ...e, props })
255  })
256
257  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
258    if (!rec.on) return next(e)
259    const business = businessSource(e.props.tool, null, config.strumentiAffari) || businessCalls.has(e.props.tool_use_id)
260    return next({ ...e, props: { ...e.props, output: deepMask(e.props.output, business ? hideText : mask) } })
261  })
262
263  // Solo l'indicatore: un punto rosso che pulsa e REC
264  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
265    const below = await next(e)
266    if (e.props && e.props.hasSurvey) return below
267    if (!rec.on) return below
268    const el = $.ui.resolve(e)
269    const line =
270      e.surface === 'terminal' || !el.Svg
271        ? el.Text({ color: 'red', bold: true, children: ['● REC'] })
272        : el.Svg({ source: REC_SVG, alt: 'REC', width: 54, height: 18 })
273    return el.Box({ flexDirection: 'column', children: below ? [line, below] : [line] })
274  })
275
276  // L'etichetta nel piè di pagina si vede anche con la banda chiusa
277  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
278    if (!rec.on) return next(e)
279    const modes = Array.isArray(e.props && e.props.modes) ? e.props.modes : []
280    return next({ ...e, props: { ...e.props, modes: ['● REC', ...modes] } })
281  })
282}
283
hooks/privacy.js 392 lines
1// Maschera chiavi, dati personali e cifre d'affari per le registrazioni a schermo.
2// Funzioni pure, nessuna chiamata all'API delle mod. Adattato da recording-mode di Nate Herk (MIT):
3// pattern italiani per importi, codice fiscale, partita IVA, telefoni, indirizzi e parole del fatturato.
4
5const DOTS = '••••••••'
6const HIDE = '•••'
7const HIDDEN_OUTPUT = '••• nascosto durante la registrazione'
8
9const SECRET_PATTERNS = [
10  /sk-ant-[A-Za-z0-9_-]{16,}/g,
11  /\bsk-(?:proj-|live-|test-|svcacct-)?[A-Za-z0-9_-]{20,}/g,
12  /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}/g,
13  /\bgithub_pat_[A-Za-z0-9_]{20,}/g,
14  /\bxox[abprs]-[A-Za-z0-9-]{10,}/g,
15  /\bAIza[0-9A-Za-z_-]{30,}/g,
16  /\bAKIA[0-9A-Z]{16}\b/g,
17  /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
18  /\b(?:hf|r8|pk_live|sk_live|rk_live|whsec|pat|key)_[A-Za-z0-9]{20,}/g,
19  /\bBearer\s+[A-Za-z0-9._~+/-]{20,}=*/gi,
20]
21
22// NOME=valore oppure "nome": "valore" dove il nome sembra una credenziale
23const ASSIGNMENT =
24  /\b([A-Za-z0-9_.-]*(?:API[_-]?KEY|SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE[_-]?KEY|ACCESS[_-]?KEY|CLIENT[_-]?SECRET|AUTH)[A-Za-z0-9_.-]*)(["']?\s*[=:]\s*["']?)([^\s"'`,;}{]{6,})/gi
25
26const EMAIL_SRC = '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}'
27const EMAIL = new RegExp(`\\b${EMAIL_SRC}\\b`, 'g')
28
29// Persone: "Mario Rossi <mario@x.it>", "displayName": "Mario Rossi", righe Da:/A:/Cliente:
30const NAME_WORD = "[A-Z][\\p{L}'’.-]*"
31const DISPLAY_NAME = new RegExp(`(["']?)(${NAME_WORD}(?:[ \\t]+${NAME_WORD}){0,3})\\1([ \\t]*<[ \\t]*${EMAIL_SRC}[ \\t]*>)`, 'gu')
32const PERSON_KEY =
33  /("(?:displayName|display_name|fullName|full_name|firstName|first_name|lastName|last_name|givenName|given_name|familyName|family_name|real_name|realName|senderName|sender_name|authorName|author_name|username|user_name|cognome|referente|cliente)"\s*:\s*")([^"]{1,80})(")/g
34const PERSON_LINE =
35  /^([ \t>*-]*(?:From|To|Cc|Bcc|Reply-To|Attendees?|Organizer|Invitees?|Guests?|Assignees?|Sender|Owner|Da|A|Ccn|Rispondi a|Partecipanti|Organizzatore|Invitati|Assegnatari?|Mittente|Destinatario|Cliente|Referente|Contatto)[ \t]*:[ \t]*)(\S.*)$/gm
36
37// Dati personali
38const PHONE = /(?<![\w.])(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}(?![\w.])/g
39const PHONE_INTL = /(?<![\w.])\+\d{1,3}[\s.-]?\(?\d{1,4}\)?(?:[\s.-]?\d{2,4}){2,4}(?![\w.])/g
40// Cellulari italiani senza prefisso: 347 123 4567, 347.1234567 (solo con separatore, per non toccare numeri qualunque)
41const PHONE_IT_MOBILE = /(?<![\w.])3\d{2}[\s.-]\d{3}[\s.-]?\d{3,4}(?![\w.])/g
42// Fissi italiani: 045 1234567, 02-123456
43const PHONE_IT_LAND = /(?<![\w.])0\d{1,3}[\s.-]\d{5,8}(?![\w.])/g
44const SSN = /(?<![\w-])\d{3}-\d{2}-\d{4}(?![\w-])/g
45const EIN = /(?<![\w-])\d{2}-\d{7}(?![\w-])/g
46const CARD = /(?<![\w-])(?:4\d{3}|5[1-5]\d{2}|2[2-7]\d{2}|3[47]\d{2}|6(?:011|5\d{2}))(?:[ -]?\d{2,4}){3,4}(?![\w-])/g
47const IBAN = /\b[A-Z]{2}\d{2}(?: ?[A-Z0-9]{4}){3,7}(?: ?[A-Z0-9]{1,3})?\b/g
48const ACCOUNT =
49  /\b((?:routing|account|acct|a\/c|aba|swift|bic|iban|passport|driver'?s license|conto(?: corrente)?|c\/c|passaporto|patente|carta d'identit[àa])(?:\s*(?:number|no\.?|num|n\.?|numero|#))?\s*[:#]?\s*)(\d[\d -]{3,}\d)/gi
50const CARD_ENDING = /\b((?:ending(?:\s+in)?|last\s+(?:4|four)(?:\s+digits)?|termina(?:nte)?\s+(?:con|in)|ultime\s+4(?:\s+cifre)?)\s*[:#]?\s*)(\d{4})\b/gi
51// Codice fiscale e partita IVA
52const CODICE_FISCALE = /\b[A-Za-z]{6}\d{2}[A-EHLMPR-Ta-ehlmpr-t]\d{2}[A-Za-z]\d{3}[A-Za-z]\b/g
53const PARTITA_IVA = /\b((?:P\.?\s?IVA|partita\s+iva|VAT)\s*[:.]?\s*(?:IT)?\s*)(\d{11})\b/gi
54const IT_VAT = /\bIT\d{11}\b/g
55// Indirizzi italiani: Via Roma 12, Piazza dei Signori, 3, e CAP con città e provincia
56const VIA = /\b(?:Via|Viale|Piazza|Piazzale|Corso|Largo|Vicolo|Lungotevere|Lungarno|Strada|Contrada|Salita|Borgo)\s+(?:(?:d[eia]l?l?[ao]?|dei|degli|delle)\s+|[dD]'|S\.\s|San\s|Santa\s)?[\p{L}'.]+(?:\s+[\p{L}'.]+){0,2},?\s*(?:n\.?\s*)?\d{1,4}[A-Za-z]?\b/gu
57const CAP_CITTA = /\b\d{5}\s+[A-Z][\p{L}'-]+(?:\s+[A-Z][\p{L}'-]+)?\s*\([A-Z]{2}\)/gu
58const CAP = /\b(CAP\s*[:.]?\s*)(\d{5})\b/gi
59const STREET =
60  /\b\d{1,6}\s+(?:[NSEW]\.?\s+)?(?:[A-Z][\p{L}'.-]*\s+){1,3}(?:Street|St|Avenue|Ave|Road|Rd|Boulevard|Blvd|Drive|Dr|Lane|Ln|Way|Court|Ct|Place|Pl|Parkway|Pkwy|Highway|Hwy|Circle|Cir|Terrace|Ter|Trail|Trl|Square|Sq)\b\.?(?:,?\s+(?:Apt|Apartment|Suite|Ste|Unit|#)\.?\s*[\w-]+)?/gu
61const PO_BOX = /\bP\.?\s?O\.?\s+Box\s+\d+/gi
62const STATE_ZIP =
63  /(,\s*(?:A[KLRZ]|C[AOT]|D[CE]|FL|GA|HI|I[ADLN]|K[SY]|LA|M[ADEINOST]|N[CDEHJMVY]|O[HKR]|PA|RI|S[CD]|T[NX]|UT|V[AT]|W[AIVY]))\s+\d{5}(?:-\d{4})?\b/g
64const BIRTH = /\b((?:DOB|D\.O\.B\.|date of birth|birth\s?date|birthday|born(?: on)?|nato il|nata il|data di nascita|nascita)\s*[:-]?\s*)([A-Za-z0-9 ,/.-]{4,20}\d)/gi
65const IPV4 = /(?<![\w.])((?:25[0-5]|2[0-4]\d|1?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3})(?![\w.])/g
66
67// Importi: 1.250,00 €, € 1.250, 1250 euro, 3,4 milioni di euro, $29
68const SCALE = '(?:\\s?(?:[kKmMbB]|bn|million|thousand|billion|mila|milioni|miliardi|mln|mld)\\b)?'
69const MONEY = new RegExp(`(?:US|CA|AU)?[$€£¥]\\s?\\d[\\d.,]*${SCALE}`, 'g')
70const MONEY_AFTER = new RegExp(`(?<![\\w.,])\\d[\\d.,]*${SCALE}(?:\\s?(?:di\\s)?(?:€|\\$|£|euro\\b|eur\\b|usd\\b|dollari\\b|dollars\\b|bucks\\b))`, 'gi')
71const MONEY_CODE = new RegExp(`(?<![\\w.])(?:USD|EUR|GBP|CAD|AUD)\\s?\\d[\\d.,]*${SCALE}`, 'g')
72
73// Cifre vicino a parole del fatturato: "il fatturato è 84mila", "margine al 40%", "compenso 500"
74const FIN_WORD = new RegExp(
75  '\\b(?:' +
76    [
77      // italiano
78      'fatturato', 'ricav[oi]', 'util[ei]', 'margin[ei]', 'redditi?o', 'guadagn[oi]', 'stipendi?o?', 'compens[oi]', 'parcell[ae]', 'onorari?o?', 'tariff[ae]',
79      'prezz[oi]', 'preventiv[oi]', 'fattur[ae]', 'bonific[oi]', 'iva', 'imposte', 'tasse', 'sald[oi]', 'budget', 'spes[ae]', 'cost[oi]', 'incass[oi]',
80      'pagat[oi]', 'pagament[oi]', 'rat[ae]', 'canone', 'abbonament[oi]', 'debit[oi]', 'prestit[oi]', 'finanziament[oi]', 'investiment[oi]',
81      'commission[ei]', 'rimbors[oi]', 'acconto', 'caparra', 'sconto', 'nett[oi]', 'lord[oi]', 'dividend[oi]', 'trattativ[ae]', 'liquidit[àa]', 'ordin[ei]',
82      // inglese
83      'revenues?', 'mrr', 'arr', 'gmv', 'profits?', 'profit share', 'margins?', 'income', 'earnings', 'payroll', 'salar(?:y|ies)', 'wages?', 'compensation', 'bonus(?:es)?',
84      'equity', 'stakes?', 'valuation', 'runway', 'burn(?: rate)?', 'cash(?:flow)?', 'balances?', 'budgets?', 'spend(?:ing)?', 'pric(?:e|es|ing)', 'fees?', 'invoices?',
85      'payouts?', 'dividends?', 'tax(?:es)?', 'sales', 'sponsor(?:s|ships?)?', 'retainers?', 'royalt(?:y|ies)', 'refunds?', 'expenses?', 'debts?', 'loans?', 'funding',
86      'investments?', 'deals?', 'net', 'gross', 'paid', 'pays?', 'owed?', 'costs?',
87    ].join('|') +
88    ')\\b',
89  'gi',
90)
91const FIGURE = /(?<![\w.])\d+(?:[.,]\d+)*(?:\s?(?:%|per\s?cento\b|percent\b|[kKmMbB]\b|bn\b|mila\b|milioni\b|miliardi\b|mln\b|mld\b|million\b|thousand\b|billion\b|x\b))?/g
92const BIG_FIGURE = /(?<![\w.])(?:\d{1,3}(?:[.,]\d{3})+(?:[.,]\d+)?|\d+(?:[.,]\d+)?\s?(?:%|per\s?cento\b|[kKmMbB]\b|mila\b|milioni\b|miliardi\b|mln\b|mld\b|percent\b|million\b|thousand\b|billion\b))/g
93
94// Nomi singoli che sono anche parole comuni, e nomi che restano visibili (i tuoi)
95const COMMON_WORDS = new Set(
96  (
97    'Will Mark Grant Bill Rich Art Hope Faith Joy May June April August Summer Rose Page Chase Hunter Max Ray Dawn Sky Brook Lane Dean Gene Guy Frank Sterling Major Price Young King Long Little Wood Hill Stone Field Ford Hall Bell Rice Banks Case Cook Fox Gray Green Brown White Black Day Lee West North South Love Church Park Street Bishop Mason Miller Baker Carter Cole Wells Hart Moon Star Van Von De Del La Le Da Di St Mr Mrs Ms Dr Jr Sr ' +
98    'Claude Code Team Support Admin Info Hello The And Ai Slack Gmail Google Calendar Meeting Sync Notes Update Review Weekly Daily ' +
99    'Monday Tuesday Wednesday Thursday Friday Saturday Sunday Lunedì Martedì Mercoledì Giovedì Venerdì Sabato Domenica ' +
100    'Gennaio Febbraio Marzo Aprile Maggio Giugno Luglio Agosto Settembre Ottobre Novembre Dicembre ' +
101    'Il Lo Gli Un Una Ciao Notion Calendario Riunione Note Aggiornamento Settimanale Giornaliero Via Viale Piazza Corso San Santa Don Dott Sig Prof Avv Ing Geom Rag Italia Rosa Fiore Gioia Speranza Pace Felice Bianco Neri Rossi'
102  ).split(' '),
103)
104const ROLE_LOCAL_PARTS =
105  /^(?:info|hello|hi|ciao|team|support|supporto|assistenza|help|admin|noreply|no-reply|donotreply|contact|contatti|sales|vendite|billing|fatture|fatturazione|amministrazione|segreteria|ufficio|ordini|accounts?|contabilita|notifications?|news|newsletter|marketing|office|mail|posta|security|privacy|legal|press|jobs|careers|hr|ops|dev|bot|alerts?|updates?|calendar|invites?)$/i
106
107// File che restano chiusi durante la registrazione. Aggiungi cartelle e nomi tuoi in
108// ~/.claude/mods-data/rec/config.json ("percorsiPrivati"); /rec config crea il file.
109const PRIVATE_PATHS = [
110  /(^|[\\/\s"'`])\.env(\.[A-Za-z0-9_-]+)?(?=$|[\s"'`;|&)])/i,
111  /\.credentials\.json/i,
112  /[\\/]\.claude\.json\b/i,
113  /\.claude[\\/]projects[\\/][^\\/\s"'`]+[\\/]memory/i,
114  /(^|[\\/\s"'`])\.(?:ssh|aws|gnupg)(?=$|[\\/])/i,
115  /\bid_(?:rsa|ed25519|ecdsa)\b/i,
116  /(^|[\\/\s"'`])\.(?:netrc|npmrc|pypirc)\b/i,
117  /\.(?:pem|p12|pfx)\b/i,
118  /taxes?[-_ ]?20\d\d/i,
119  /[\\/_-](?:payroll|invoices?|contracts?|financials?|finances?|budgets?|forecasts?|cap[-_ ]?table|term[-_ ]?sheets?|offer[-_ ]?letters?|business[-_ ]?plans?)(?=$|[\\/._\s"'`;|&)-])/i,
120  /(^|[\s"'`])(?:payroll|invoices?|contracts?|financials?|finances?|budgets?|forecasts?|business[-_ ]?plans?)(?=[\\/.])/i,
121  // italiano
122  /[\\/_-](?:fatture?|contratti?|preventivi?|bilanci?|buste[-_ ]?paga|cedolini|stipendi|f24|modello[-_ ]?unico|dichiarazion[ei]|piano[-_ ]?industriale|offert[ae]|lettere?[-_ ]?d[-_ ]?incarico)(?=$|[\\/._\s"'`;|&)-])/i,
123  /(^|[\s"'`])(?:fatture?|contratti?|preventivi?|bilanci?|buste[-_ ]?paga|cedolini|stipendi)(?=[\\/.])/i,
124  /\b730[-_ ]?20\d\d/i,
125]
126
127// Tool i cui risultati sono dati d'affari: disegnati nascosti durante la registrazione
128const BUSINESS_TOOL = new RegExp(
129  '(?:^|__|_)(?:clickup|slack|gmail|outlook|notion|asana|linear|jira|clay|qbo|quickbooks|xero|fireflies|hubspot|salesforce|stripe|calendar|gcal|google_drive|drive|sheets|spreaker|postpickr)' +
130    '|search_threads|get_thread|get_message|list_drafts|get_draft|read_file_content|download_file_content|search_files|list_recent_files|get_file_metadata' +
131    '|profit_loss|cash_flow|balance_sheet|payroll|session_transcripts|export_transcript' +
132    '|list_events|get_event|search_events|list_calendars|get_values|get_spreadsheet|get_meeting|search_meetings|list_upcoming_meetings|get_scratchpad_note|search_scratchpad_notes|get_posts|get_scheduled_posts|get_drafts',
133  'i',
134)
135const BUSINESS_COMMAND = /\bgws(?:\.cmd|\.exe)?\b|fireflies|quickbooks|\bnotion-[a-z-]+/i
136// I tool di pagamento restano chiusi durante la registrazione
137const FINANCE_TOOL = /__(?:qbo_|quickbooks|profit_loss|cash_flow|balance_sheet|benchmarking_quickbooks|money_onboarding|company_info|stripe_)/i
138
139function escapeRegExp(s) {
140  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
141}
142
143// I valori dei file .env: abbastanza lunghi da essere segreti, e non parole o numeri semplici
144export function secretValuesFromEnv(text) {
145  const values = []
146  for (const line of String(text || '').split(/\r?\n/)) {
147    const m = line.match(/^\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=\s*(.*)\s*$/)
148    if (!m) continue
149    let v = m[1].trim()
150    if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1)
151    if (v.length < 8) continue
152    if (/^(true|false|null|none|yes|no|si|sì)$/i.test(v)) continue
153    if (/^\d+(\.\d+)?$/.test(v)) continue
154    if (/your[-_ ]?(key|token|secret)|here$|^<.*>$|^xxx/i.test(v)) continue
155    values.push(v)
156  }
157  return [...new Set(values)].sort((a, b) => b.length - a.length)
158}
159
160// Le forme del nome di una persona che vengono mascherate: nome completo, slug e ogni parte che non sia una parola comune
161function nameForms(full, keep) {
162  const clean = String(full || '').replace(/\s+/g, ' ').trim()
163  if (!clean || clean.length > 60 || keep.has(clean)) return []
164  const forms = new Set()
165  const parts = clean.split(/[ -]/).filter((p) => /^[A-Z][\p{L}'’.]*$/u.test(p) && p.length >= 2)
166  if (parts.length >= 2) {
167    forms.add(clean)
168    forms.add(clean.toLowerCase())
169    forms.add(clean.toLowerCase().replace(/ /g, '-'))
170    forms.add(clean.toLowerCase().replace(/ /g, '_'))
171  }
172  for (const p of parts) if (!COMMON_WORDS.has(p) && !keep.has(p)) forms.add(p)
173  return [...forms]
174}
175
176function namesFromEmail(email) {
177  const local = email.split('@')[0]
178  if (ROLE_LOCAL_PARTS.test(local)) return null
179  const parts = local.split(/[._+-]/).filter((p) => /^[a-z]{2,}$/i.test(p))
180  if (parts.length < 2 || parts.length > 4) return null
181  return parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase()).join(' ')
182}
183
184// Maschera i numeri nella stessa frase di una parola del fatturato
185function maskFigures(text, strict) {
186  if (strict) text = text.replace(BIG_FIGURE, HIDE)
187  const ranges = []
188  for (const m of text.matchAll(FIN_WORD)) {
189    const s = m.index
190    const e = s + m[0].length
191    let a = Math.max(0, s - 30)
192    let b = Math.min(text.length, e + 45)
193    const before = text.slice(a, s)
194    const stop = Math.max(before.lastIndexOf('\n'), before.lastIndexOf(';'), before.search(/[.!?]\s[^.!?]*$/))
195    if (stop >= 0) a += stop + 1
196    const after = text.slice(e, b)
197    const end = after.search(/\n|;|[.!?](?:\s|$)/)
198    if (end >= 0) b = e + end
199    ranges.push([a, b])
200  }
201  if (!ranges.length) return text
202  return text.replace(FIGURE, (fig, offset) => {
203    if (/^(?:19|20)\d\d$/.test(fig)) return fig // un anno
204    return ranges.some(([a, b]) => offset >= a && offset < b) ? HIDE : fig
205  })
206}
207
208function luhn(digits) {
209  let sum = 0
210  for (let i = 0; i < digits.length; i++) {
211    let d = Number(digits[digits.length - 1 - i])
212    if (i % 2 === 1) {
213      d *= 2
214      if (d > 9) d -= 9
215    }
216    sum += d
217  }
218  return sum % 10 === 0
219}
220
221function publicIp(ip) {
222  const [a, b] = ip.split('.').map(Number)
223  if (a === 10 || a === 127 || a === 0 || ip === '255.255.255.255') return false
224  if (a === 192 && b === 168) return false
225  if (a === 172 && b >= 16 && b <= 31) return false
226  if (a === 169 && b === 254) return false
227  return true
228}
229
230// Il tuo nome e le sue parti restano visibili
231function keepSet(keepNames) {
232  const keep = new Set()
233  for (const n of keepNames || []) {
234    const clean = String(n || '').replace(/\s+/g, ' ').trim()
235    if (!clean) continue
236    keep.add(clean)
237    keep.add(clean.toLowerCase())
238    keep.add(clean.toLowerCase().replace(/ /g, '-'))
239    for (const part of clean.split(/[ -]/)) if (part) keep.add(part)
240  }
241  return keep
242}
243
244// strict: maschera anche ogni cifra grande (1.250 / 18% / 40k) ovunque compaia
245// names: persone da mascherare; keepNames: nomi che restano visibili (i tuoi)
246export function makeMasker({ strict = false, values = [], names = [], keepNames = [] } = {}) {
247  const KEEP_NAMES = keepSet(keepNames)
248  const valueRe = values.length ? new RegExp(values.map(escapeRegExp).join('|'), 'g') : null
249  // Nomi: l'elenco passato più quelli appresi da email e campi contatto. Solo in memoria.
250  const people = new Set()
251  let nameRe = null
252  let dirty = false
253  const learn = (full) => {
254    if (people.size > 2000) return
255    for (const f of nameForms(full, KEEP_NAMES)) {
256      if (!people.has(f)) {
257        people.add(f)
258        dirty = true
259      }
260    }
261  }
262  for (const n of names) learn(n)
263  const nameRegex = () => {
264    if (dirty) {
265      const list = [...people].sort((a, b) => b.length - a.length).map(escapeRegExp)
266      nameRe = list.length ? new RegExp(`(?<![\\p{L}\\p{N}_])(?:${list.join('|')})(?:['’]s)?(?![\\p{L}\\p{N}_])`, 'gu') : null
267      dirty = false
268    }
269    return nameRe
270  }
271
272  return function mask(text) {
273    if (typeof text !== 'string' || text.length === 0) return text
274    let out = text
275    if (valueRe) out = out.replace(valueRe, DOTS)
276    for (const re of SECRET_PATTERNS) out = out.replace(re, DOTS)
277    out = out.replace(ASSIGNMENT, (_, name, sep) => name + sep + DOTS)
278
279    // persone: si imparano prima delle email che le nominano
280    for (const m of out.matchAll(EMAIL)) {
281      const n = namesFromEmail(m[0])
282      if (n) learn(n)
283    }
284    out = out.replace(DISPLAY_NAME, (_, q, name, addr) => {
285      learn(name)
286      return KEEP_NAMES.has(name) ? q + name + q + addr : HIDE + addr
287    })
288    out = out.replace(PERSON_KEY, (_, head, value, tail) => {
289      learn(value)
290      return head + (KEEP_NAMES.has(value) ? value : HIDE) + tail
291    })
292    out = out.replace(PERSON_LINE, (line, label, value) => (KEEP_NAMES.has(value.trim()) ? line : label + HIDE))
293    out = out.replace(EMAIL, '•••@•••')
294    const re = nameRegex()
295    if (re) out = out.replace(re, HIDE)
296
297    // dati personali
298    out = out.replace(CARD, (m) => {
299      const digits = m.replace(/\D/g, '')
300      return digits.length >= 13 && digits.length <= 19 && luhn(digits) ? '•••• •••• •••• ••••' : m
301    })
302    out = out.replace(SSN, '•••-••-••••')
303    out = out.replace(EIN, '••-•••••••')
304    out = out.replace(CODICE_FISCALE, '••••••••••••••••')
305    out = out.replace(PARTITA_IVA, (_, head) => head + HIDE)
306    out = out.replace(IT_VAT, HIDE)
307    out = out.replace(IBAN, HIDE)
308    out = out.replace(ACCOUNT, (_, head) => head + HIDE)
309    out = out.replace(CARD_ENDING, (_, head) => head + '••••')
310    out = out.replace(BIRTH, (_, head) => head + HIDE)
311    out = out.replace(VIA, HIDE)
312    out = out.replace(CAP_CITTA, HIDE)
313    out = out.replace(CAP, (_, head) => head + '•••••')
314    out = out.replace(STREET, HIDE)
315    out = out.replace(PO_BOX, HIDE)
316    out = out.replace(STATE_ZIP, (_, head) => head + ' •••••')
317    out = out.replace(PHONE_INTL, '+•• •••')
318    out = out.replace(PHONE, '•••-•••-••••')
319    out = out.replace(PHONE_IT_MOBILE, '••• •••••••')
320    out = out.replace(PHONE_IT_LAND, '••• •••••')
321    out = out.replace(IPV4, (ip) => (publicIp(ip) ? '•••.•••.•••.•••' : ip))
322
323    // importi e cifre d'affari
324    out = out.replace(MONEY, HIDE + ' €')
325    out = out.replace(MONEY_AFTER, HIDE + ' €')
326    out = out.replace(MONEY_CODE, HIDE)
327    out = maskFigures(out, strict)
328    return out
329  }
330}
331
332// Maschera ogni stringa dentro dati semplici, mantenendo la forma.
333export function deepMask(value, mask, depth = 0) {
334  if (depth > 12) return value
335  if (typeof value === 'string') return mask(value)
336  if (Array.isArray(value)) return value.map((v) => deepMask(v, mask, depth + 1))
337  if (value && typeof value === 'object') {
338    const proto = Object.getPrototypeOf(value)
339    if (proto !== Object.prototype && proto !== null) return value
340    const out = {}
341    for (const [k, v] of Object.entries(value)) out[k] = deepMask(v, mask, depth + 1)
342    return out
343  }
344  return value
345}
346
347// Nasconde il testo leggibile dentro il risultato di un tool d'affari, tenendo le etichette brevi (type: 'text') perché la riga si disegni.
348export function hideText(s) {
349  if (typeof s !== 'string' || s.length === 0) return s
350  return s.length > 24 || /\s/.test(s) ? HIDDEN_OUTPUT : s
351}
352
353// extra: cartelle o nomi di file dal file di configurazione, cercati in qualsiasi punto del percorso, senza badare alle maiuscole
354export function privatePathHit(text, extra = []) {
355  const s = String(text || '')
356  for (const re of PRIVATE_PATHS) {
357    const m = s.match(re)
358    if (m) return m[0].trim().replace(/^["'`\\/_-]/, '')
359  }
360  const flat = s.replace(/\\/g, '/').toLowerCase()
361  for (const item of extra) {
362    const needle = String(item || '').replace(/\\/g, '/').toLowerCase().trim()
363    if (needle && flat.includes(needle)) return String(item)
364  }
365  return null
366}
367
368// Le stringhe negli argomenti di una chiamata che possono nominare un file o contenere un comando.
369export function toolCallTargets(e) {
370  const keys = ['file_path', 'path', 'pattern', 'glob', 'command', 'notebook_path', 'url']
371  const out = []
372  for (const k of keys) if (typeof e?.[k] === 'string') out.push(e[k])
373  return out
374}
375
376// Se il risultato di un tool è dato d'affari (posta, chat, task, file, calendario, finanza)
377// extra: altri nomi di tool o comandi (anche solo una parte) i cui risultati si disegnano nascosti
378export function businessSource(tool, input, extra = []) {
379  const name = String(tool || '')
380  if (BUSINESS_TOOL.test(name)) return true
381  const cmd = input && typeof input.command === 'string' ? input.command : ''
382  const hay = (name + ' ' + cmd).toLowerCase()
383  if (extra.some((x) => x && hay.includes(String(x).toLowerCase()))) return true
384  return cmd ? BUSINESS_COMMAND.test(cmd) : false
385}
386
387// extra: altri nomi di tool (anche solo una parte) che restano chiusi durante la registrazione
388export function financeTool(tool, extra = []) {
389  const name = String(tool || '')
390  return FINANCE_TOOL.test(name) || extra.some((x) => x && name.toLowerCase().includes(String(x).toLowerCase()))
391}
392