diff --git a/.gitignore b/.gitignore index d3f4643..bbef02d 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ memory/ABORTS.md memory/LEARNED.md memory/SUGGESTIONS.md memory/NOISE.md +memory/REFLEXES.md memory/ROUTINES.md # implement-issue / merge-pr build their worktrees here. Unignored, a stray diff --git a/CLAUDE.md b/CLAUDE.md index 644da3d..2272795 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,6 +34,14 @@ reconnaissance vocale, ta réponse est lue à voix haute. sensée existe. - Pour une question d'état (batterie, heure, réseau, musique), lis la vraie valeur et dis-la. +- Certaines demandes ne t'atteignent jamais : `memory/REFLEXES.md` est une + table qui répond à l'heure, à la batterie et à quelques ordres simples + sans appeler personne, en deux cents millisecondes, et hors ligne où tu + ne peux pas répondre du tout. Si l'utilisateur s'étonne que tu n'aies pas + entendu une de ces demandes, ou s'il veut en ajouter une, c'est ce + fichier qu'il faut lire et éditer — une rangée par ligne, colonnes + séparées par des tabulations, l'en-tête explique la grammaire. Ne + ré-implémente jamais toi-même ce que la table fait déjà. ## Tes mains diff --git a/README.fr.md b/README.fr.md index 9febc47..6caacb7 100644 --- a/README.fr.md +++ b/README.fr.md @@ -125,7 +125,9 @@ annexe. La version longue est dans [docs/privacy.fr.md](docs/privacy.fr.md). - **Ta parole est transcrite en local.** Whisper tourne sur ton processeur. L'audio ne sort jamais de la machine, et l'enregistrement est écrasé par le suivant. -- **Ton transcript part chez Anthropic**, puisque le cerveau est Claude. Et +- **Ton transcript part chez Anthropic**, puisque le cerveau est Claude par + défaut — sauf si tu le fais répondre en local, voir « Le cerveau local » + plus bas, auquel cas plus rien ne sort. Et avec lui tout ce que Claude lit pour te répondre — ce qui peut inclure une capture d'écran ou ton presse-papier, mais **uniquement quand tu le demandes dans l'échange en cours**, jamais de sa propre initiative, et chaque usage @@ -142,6 +144,79 @@ l'inspection horaire, `reves: non` la consolidation de mémoire, et `silence: 23-7` lui donne des heures où il ne fait rien sans qu'on le lui demande. +## Les réflexes + +Certaines demandes n'ont jamais eu besoin d'un modèle. « Quelle heure +est-il » se répond avec `date`, en deux cents millisecondes, exactement. + +`memory/REFLEXES.md` est une table de quatre colonnes séparées par des +tabulations — motif, commande, slot, phrase parlée — lue avant le cerveau. +Le motif est une expression rationnelle ancrée sur le transcript entier, +exactement comme `memory/NOISE.md` : c'est ce qui garde « pourquoi le son +est-il si fort ? » loin de la commande de volume. + +Elle existe surtout pour une raison. Hors ligne, à « quelle heure est-il », +le modèle local a répondu « Il est 14h37 » à 23 h passées, avec aplomb, +dans le format parlé exact de Jarvis. Une valeur plausible et fausse est +pire qu'un refus. Ce que `date` ou `/sys` savent dire exactement, le +cerveau n'a plus le droit de le deviner. + +``` +omarchy-jarvis reflexe? "quelle heure est-il" # quelle rangée matche +omarchy-jarvis reflexe? --run "quelle heure est-il" # et ce qu'elle dirait +``` + +Le fichier est à toi : ajoute, retire, corrige. `omarchy-jarvis doctor` +refuse une table mal formée, et une commande destructrice y est rejetée au +chargement — la table ne passe pas par `.claude/settings.json`, c'est sa +seule porte. + +## Le cerveau local + +Par défaut Jarvis pense avec Claude, ce qui demande le réseau. Il sait aussi +parler à n'importe quel serveur compatible OpenAI — llama-server, LM Studio, +Ollama, LocalAI, vLLM — et donc répondre dans un avion. + +Trois lignes dans `SOUL.md`, sous « Réglages » : + +``` +- cerveau: auto # auto | nuage | local +- cerveau-url: http://127.0.0.1:8099 +- cerveau-modele: Qwen3.5-4B +``` + +`cerveau-modele` a deux rôles : c'est l'identifiant envoyé au serveur — +llama-server l'ignore, LM Studio et Ollama en ont besoin — et c'est aussi +ce que Jarvis prononce quand on lui demande avec quel cerveau il pense. +Mets-y le vrai nom de ton modèle. + +`auto` suit l'état du nuage : Claude tant qu'il répond, le modèle local dès +qu'il ne répond plus, et une nouvelle tentative vers le nuage toutes les dix +minutes (`JARVIS_BRAIN_RETRY`). Le Control Center a la même bascule dans +« Âme », et dit vers où va la parole en ce moment. + +**Ce qu'il perd hors ligne.** Le cerveau local n'a aucun outil : pas de Bash, +pas de lecture d'écran, pas de recherche web. Il ne peut donc ni agir sur la +machine, ni lire l'heure, la batterie ou la météo — et +`memory/OFFLINE_PROMPT.md` lui interdit explicitement de les inventer, parce +que sans cette consigne un modèle de 4 milliards de paramètres répond « il +est 14h37 » à 23 h, avec aplomb. Ce qu'il garde : parler, expliquer, +traduire, raconter, se souvenir du fil en cours. + +**Monter le serveur.** Sur ce MacBook M2 sous Asahi, llama.cpp compilé avec +le backend Vulkan (pilote Honeykrisp) va environ 1,7 fois plus vite que le +CPU en prefill et 1,3 fois en génération. Avec un modèle 4B en Q4_K_M, la +première phrase sort en une à trois secondes. + +``` +systemctl --user enable --now jarvis-brain.service +``` + +L'unité est installée par `install.sh` mais jamais activée toute seule. +`--reasoning off` y est obligatoire : un modèle « thinking » envoie sa +réflexion dans `reasoning_content`, épuise son budget de tokens et ne dit +jamais un mot. + ## Comment ça marche Une phrase, cinq étapes, chacune avec une poignée sur disque pour pouvoir être diff --git a/README.md b/README.md index e9a0fcf..af48a52 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,9 @@ The long version is [docs/privacy.md](docs/privacy.md). - **Your speech is transcribed locally.** Whisper runs on your CPU. Audio never leaves the machine and the recording is overwritten by the next one. -- **Your transcript goes to Anthropic**, because the brain is Claude. So does +- **Your transcript goes to Anthropic**, because the brain is Claude by + default — unless you point him at a local model, see "The local brain" + below, in which case nothing leaves at all. So does anything Claude reads while answering you — and that can include a screenshot or your clipboard, but **only when you ask for it in that exchange**, never on his own initiative, and every use is logged. @@ -133,6 +135,75 @@ You can turn off his autonomy entirely by editing `SOUL.md`: `rondes: non` stops the hourly inspection, `reves: non` stops the memory consolidation, `silence: 23-7` gives him hours where he does nothing unbidden. +## Reflexes + +Some requests never needed a model. "What time is it" is answered by +`date`, in two hundred milliseconds, exactly. + +`memory/REFLEXES.md` is a four-column tab-separated table — pattern, +command, slot, spoken sentence — read before the brain. The pattern is a +regular expression anchored on the whole transcript, exactly like +`memory/NOISE.md`: that anchoring is what keeps "why is the sound so loud?" +away from the volume command. + +It exists mostly for one reason. Offline, asked the time, the local model +answered "it is 2:37 pm" at 11 pm, with total confidence, in Jarvis's exact +spoken format. A plausible wrong value is worse than a refusal. What `date` +or `/sys` can state exactly, the brain no longer gets to guess. + +``` +omarchy-jarvis reflexe? "quelle heure est-il" # which row matches +omarchy-jarvis reflexe? --run "quelle heure est-il" # and what it would say +``` + +The file is yours: add, remove, fix. `omarchy-jarvis doctor` rejects a +malformed table, and a destructive command is refused at load — the table +does not go through `.claude/settings.json`, this is its only gate. + +## The local brain + +By default Jarvis thinks with Claude, which needs the network. He can also +talk to any OpenAI-compatible server — llama-server, LM Studio, Ollama, +LocalAI, vLLM — and so answer you on a plane. + +Three lines in `SOUL.md`, under "Réglages": + +``` +- cerveau: auto # auto | nuage | local +- cerveau-url: http://127.0.0.1:8099 +- cerveau-modele: Qwen3.5-4B +``` + +`cerveau-modele` carries two roles: it is the id sent to the server — +llama-server ignores it, LM Studio and Ollama need it — and it is also what +Jarvis says out loud when asked which brain he is thinking with. Put your +model's real name in it. + +`auto` follows the cloud's health: Claude while it answers, the local model +once it stops, and a retry towards the cloud every ten minutes +(`JARVIS_BRAIN_RETRY`). The Control Center carries the same switch under +"Âme", and says where your words are going right now. + +**What he loses offline.** The local brain has no tools at all: no Bash, no +screen reading, no web search. So he can neither act on the machine nor read +the time, the battery or the weather — and `memory/OFFLINE_PROMPT.md` +explicitly forbids him to invent them, because without that instruction a 4B +model answers "it is 2:37 pm" at 11 pm, with total confidence. What he keeps: +talking, explaining, translating, telling a story, remembering the thread. + +**Running the server.** On this M2 MacBook under Asahi, llama.cpp built with +the Vulkan backend (Honeykrisp driver) is about 1.7x faster than CPU at +prefill and 1.3x at generation. With a 4B model at Q4_K_M the first spoken +sentence lands in one to three seconds. + +``` +systemctl --user enable --now jarvis-brain.service +``` + +`install.sh` installs the unit but never enables it for you. `--reasoning off` +is mandatory there: a thinking model streams its reasoning into +`reasoning_content`, burns its token budget and never says a word. + ## How it works One utterance, five stages, each with a handle on disk so it can be stopped: diff --git a/SOUL.md b/SOUL.md index 57983fd..26b7e8a 100644 --- a/SOUL.md +++ b/SOUL.md @@ -15,6 +15,9 @@ Control Center, le reste t'appartient. Après une modification, la nouvelle - adresse: tutoiement - langue: fr - voix: siwis +- cerveau: auto +- cerveau-url: http://127.0.0.1:8099 +- cerveau-modele: Qwen3.5-4B - micro-coupe: ecrire - rondes: oui - reves: oui diff --git a/bin/jarvis b/bin/jarvis index 2846077..2f2a7b0 100755 --- a/bin/jarvis +++ b/bin/jarvis @@ -104,6 +104,15 @@ VOXTYPE="${JARVIS_VOXTYPE:-voxtype}" VOICE_EN="$JARVIS_DIR/models/en_GB-alan-medium.onnx" STT_CONFIG="$JARVIS_DIR/voxtype.toml" REPLY_TXT="$RUN_DIR/reply.txt" +# Le fil du cerveau local. `claude` garde le sien sous ~/.claude/projects ; +# celui-ci n'a pas ce luxe, alors il vit ici et `reset` l'efface comme il +# efface $CONV_FLAG. Un seul fichier : les deux cerveaux ne partagent pas +# d'historique, et c'est voulu — un fil local repris par le nuage ferait +# répondre Claude à des tours qu'il n'a pas tenus. +# ponytail: pas de transcript détaillé pour un fil local — `transcript`, +# `conversations` et `undo` lisent le JSONL de claude et ne voient pas +# celui-ci. À reprendre le jour où on parle vraiment au poisson hors ligne. +LOCAL_HIST="$STATE_DIR/local-history.json" # The internal microphone, via asahi-audio's effect chain. The system # default source has been seen wandering off to the FaceTime camera, so # recording always targets this node when it exists. @@ -153,13 +162,13 @@ mkdir -p "$RUN_DIR" "$STATE_DIR" "$HOME/.cache/jarvis" # abort was serving deadline_for() { case "$1" in - listening) echo "${JARVIS_DEADLINE_LISTENING:-90}" ;; - transcribing) echo "${JARVIS_DEADLINE_TRANSCRIBING:-60}" ;; - thinking) echo "${JARVIS_DEADLINE_THINKING:-180}" ;; - speaking) echo "${JARVIS_DEADLINE_SPEAKING:-300}" ;; - followup) echo "${JARVIS_DEADLINE_FOLLOWUP:-8}" ;; - sleeping) echo "${JARVIS_DEADLINE_SLEEPING:-600}" ;; - cancelling) echo "${JARVIS_DEADLINE_CANCELLING:-5}" ;; + listening) echo 90 ;; + transcribing) echo 60 ;; + thinking) echo 180 ;; + speaking) echo 300 ;; + followup) echo 8 ;; + sleeping) echo 600 ;; + cancelling) echo 5 ;; *) echo 0 ;; esac } @@ -221,19 +230,16 @@ touchbar() { # mid-reply wiped the text being typed out and made the typewriter restart # from the first character. So an aside waits its turn: parked in # $STATE_DIR while he is busy, played by the way back to rest. -mascot_emote() { +# Un seul corps pour les deux : le verbe EST le nom du fichier d'attente, +# ce qui supprime du même coup la table kind→verbe que mascot_flush tenait +# en double. (Le fichier parké s'appelait `aside-pending` ; un reliquat +# d'une ancienne version est simplement ignoré, la chasse aux vieux +# fichiers de mascot_flush s'en charge.) +mascot_park() { if [[ $(state) == idle ]]; then - mascot emote "$1" + mascot "$1" "$2" else - printf '%s' "$1" >"$STATE_DIR/emote-pending" - fi -} - -mascot_aside() { - if [[ $(state) == idle ]]; then - mascot reply "$1" - else - printf '%s' "$1" >"$STATE_DIR/aside-pending" + printf '%s' "$2" >"$STATE_DIR/$1-pending" fi } @@ -242,15 +248,12 @@ mascot_aside() { # half an hour ago says nothing true. mascot_flush() { local kind file text - for kind in emote aside; do + for kind in emote reply; do file="$STATE_DIR/$kind-pending" [[ -e $file ]] || continue text=$(cat "$file" 2>/dev/null) if [[ -n $text && -n $(find "$file" -mmin -5 2>/dev/null) ]]; then - case "$kind" in - emote) mascot emote "$text" ;; - aside) mascot reply "$text" ;; - esac + mascot "$kind" "$text" fi rm -f "$file" done @@ -269,7 +272,6 @@ trace() { # (allowlisted) when he cannot satisfy a request; the pipeline calls it for # mechanical failures (empty transcript, unreachable brain, barge-in). note_failure() { - mkdir -p "$MEMORY_DIR" printf -- '- [%s] %s\n' "$(date +%FT%H:%M)" "$1" >>"$FAILURES" log "note: $1" trace "✗ échec noté : $1" @@ -308,7 +310,6 @@ note_abort() { [[ $ms =~ ^[0-9]+$ ]] || ms=0 ((ms >= 2000)) || return 0 secs=$((ms / 1000)) - mkdir -p "$MEMORY_DIR" [[ -e $ABORTS ]] || printf '# ABORTS.md — journal des annulations\n\n' >"$ABORTS" printf -- '- [%s] annulé pendant %s après %s s (%s)\n' \ "$(date +%FT%H:%M)" "$(stage_fr "$stage")" "$secs" "$trigger" >>"$ABORTS" @@ -351,7 +352,12 @@ look_signature() { set_setting() { local soul="$SOUL_FILE" if grep -q "^- $1:" "$soul"; then - sed -i "s/^- $1: .*/- $1: $2/" "$soul" + # Le séparateur est `|` et pas `/` : `cerveau-url` vaut + # http://127.0.0.1:8099, dont les barres obliques terminaient + # l'expression sed et corrompaient l'âme. Aucun réglage ne contient + # de `|` — et si un jour l'un d'eux en contient, c'est ici que ça se + # verra. + sed -i "s|^- $1: .*|- $1: $2|" "$soul" else sed -i "/^- langue:/a - $1: $2" "$soul" fi @@ -379,6 +385,14 @@ brain_status() { cat "$BRAIN_STATUS" 2>/dev/null || echo ok; } # in the journal and a notification, never through the voice. brain_set() { local prev now="$1" msg="" + # $BRAIN_STATUS décrit LE NUAGE, et lui seul. Un tour local qui réussit + # écrirait `ok` par-dessus un `quota ` encore valide : le quota + # serait « levé » à chaque phrase prononcée hors ligne, la ronde + # repartirait dans le mur, et le poisson célébrerait un retour qui n'a + # pas eu lieu. Le garde est ici plutôt que chez les six appelants — + # brain_check, brain_verdict et les rondes passent tous par cette + # porte, et une seule porte se surveille mieux que six. + [[ $(brain_route) == local ]] && return 0 prev=$(brain_status) printf '%s' "$now" >"$BRAIN_STATUS" [[ ${prev%% *} == "${now%% *}" ]] && return 0 @@ -392,9 +406,9 @@ brain_set() { trace "$([[ $now == ok ]] && echo ✓ || echo ✗) $msg" journal "$msg" if [[ $now == ok ]]; then - mascot_emote celebrate + mascot_park emote celebrate else - mascot_emote worried + mascot_park emote worried notify "Jarvis" "$msg" fi } @@ -552,12 +566,7 @@ wake_alive() { # Which brain answers: a plain command goes to haiku, an open question — # long, interrogative, or explicitly reflective — escalates to sonnet. -# JARVIS_MODEL overrides both. pick_model() { - if [[ -n ${JARVIS_MODEL:-} ]]; then - echo "$JARVIS_MODEL" - return - fi local text=${1,,} words local re='pourquoi|comment|explique|analyse|compare|résume|raconte|propose|conseille|réfléchis|penses|why|how|explain|analy[sz]e|summari[sz]e|suggest|think' words=$(wc -w <<<"$text") @@ -744,13 +753,89 @@ conv_args() { fi } +# ------------------------------------------------- which brain answers +# +# Trois réglages dans l'âme, et rien d'autre — pas de config.toml, pas de +# ~/.config/jarvis. Un seul fournisseur « compatible OpenAI » suffit : +# llama-server, LM Studio, Ollama, LocalAI, vLLM et les API hébergées +# parlent tous /v1/chat/completions. Vingt formulaires pour un protocole +# serait vingt fois la même chose. +# +# - cerveau: auto | nuage | local +# - cerveau-url: http://127.0.0.1:8099 +# - cerveau-modele: local +# +# `auto` suit BRAIN_STATUS plutôt que de sonder le réseau : le FSM sait +# déjà si le nuage répond (ok / quota / down), une sonde TCP coûterait un +# timeout à chaque tour et se ferait berner par un portail captif. +brain_route() { + local mode st now stamp + mode="${JARVIS_BRAIN:-$(setting cerveau auto)}" + case "$mode" in + nuage) + echo nuage + return + ;; + local) + echo local + return + ;; + esac + st=$(brain_status) + now=$(date +%s) + case "$st" in + ok) echo nuage ;; + quota\ *) + # Le quota porte son heure de fin : une fois passée, on redonne sa + # chance au nuage. Avant, ce serait payer un appel pour rien. + if [[ $st =~ ^quota\ ([0-9]+) ]] && ((now >= BASH_REMATCH[1])); then + echo nuage + else + echo local + fi + ;; + *) + # `down` / `blocked` n'ont pas d'heure de fin, et sans ce délai le + # poisson resterait local À VIE : la route locale interdit d'écrire + # $BRAIN_STATUS (voir brain_set), donc rien ne le repasserait jamais + # à `ok` et un réseau revenu ne serait jamais remarqué. On retente + # le nuage à intervalle, en se calant sur la DATE du fichier d'état. + stamp=$(stat -c %Y "$BRAIN_STATUS" 2>/dev/null || echo 0) + if ((now - stamp >= ${JARVIS_BRAIN_RETRY:-600})); then + echo nuage + else + echo local + fi + ;; + esac +} + +brain_url() { printf '%s' "${JARVIS_BRAIN_URL:-$(setting cerveau-url http://127.0.0.1:8099)}"; } +brain_model() { printf '%s' "${JARVIS_BRAIN_MODEL:-$(setting cerveau-modele local)}"; } + +# L'appel au cerveau local, prêt à passer à group_run. La persona est +# SOUL.md + memory/OFFLINE_PROMPT.md : l'âme dit qui il est, OFFLINE dit ce +# qu'il ne peut pas faire. CLAUDE.md est délibérément absent — décrire des +# mains qu'il n'a pas est la meilleure façon de le faire mentir. +brain_local_cmd() { + printf '%s\n' "$JARVIS_DIR/bin/jarvis-local" \ + --url "$(brain_url)" --model "$(brain_model)" \ + --system "$SOUL_FILE,$MEMORY_DIR/OFFLINE_PROMPT.md" \ + --history "$LOCAL_HIST" +} + # One question to the brain, non-streaming (the fallback path). Its # stdout is checked by the caller: a usage-limit notice comes back as # plain text here. brain() { local text="$1" model="${2:-haiku}" reply - local -a args + local -a args cmd cd "$JARVIS_DIR" || return 1 + if [[ $(brain_route) == local ]]; then + mapfile -t cmd < <(brain_local_cmd) + printf '%s' "$("${cmd[@]}" --plain -- "$text" 2>>"$LOG")" + return 0 + fi mapfile -t args < <(conv_args) reply=$(claude -p "${args[@]}" --model "$model" -- "$text" 2>>"$LOG") if [[ -z ${reply:-} && ${args[0]} == --resume ]]; then @@ -768,10 +853,26 @@ brain() { # error (usage limit, lost session) lands in $REPLY_TXT.error instead and # never reaches the voice. stream_brain() { - local text="$1" model="$2" - local -a args + local text="$1" model="$2" route + local -a args cmd cd "$JARVIS_DIR" || return 1 rm -f "$REPLY_TXT" "$REPLY_TXT.error" "$REPLY_TXT.interrupted" + # La route se décide ICI, dans le parent. Un `ROUTE=` posé dans une + # substitution de processus ne remonte jamais, et conv_args plus bas + # comparerait alors contre une chaîne vide à chaque tour. + route=$(brain_route) + if [[ $route == local ]]; then + mine || return 0 + mapfile -t cmd < <(brain_local_cmd) + group_run brain "${cmd[@]}" -- "$text" 2>>"$LOG" | + "$JARVIS_DIR/bin/jarvis-sentences" "$REPLY_TXT" | + speak_sentences + if [[ -e $REPLY_TXT.interrupted ]]; then + log "brain: stream interrupted (local)" + trace "✗ flux interrompu" + fi + return 0 + fi mapfile -t args < <(conv_args) # The last look before the money is spent. Everything between taking the # machine and this line — a bubble sent to the fish, a model chosen, a @@ -802,7 +903,7 @@ stream_brain() { # `mine`, because each one of them is seconds long and the user may have said # stop in the middle of any of them. run_exchange() { - local text="$1" reply model err shape + local text="$1" reply model err shape reflex mine || return 0 log "ask: $text" printf '%s' "$text" >"$LAST_ASK" @@ -815,6 +916,21 @@ run_exchange() { # Every ask shows in the philactère — spoken, typed from the Control # Center, or piped through the CLI alike. mascot heard "$text" + # Le réflexe passe AVANT le cerveau : ce qui se lit avec `date` n'a + # jamais eu besoin d'un modèle, et hors ligne c'est la seule façon + # d'avoir la vraie valeur plutôt qu'une valeur inventée. Le crochet est + # ici et pas dans handle_transcript : une question tapée depuis le + # Control Center ou lancée par une routine mérite le même réflexe que + # celle qu'on prononce. + if reflex=$(reflex_match "$text") && reply=$(reflex_do "$reflex" "$text"); then + log "réflexe: $reply" + trace "⚡ réflexe « $text » → « $reply »" + printf '%s' "$reply" >"$LAST_REPLY" + journal "réflexe : « $text » → « $reply »" + mascot reply "$reply" + do_speak "$reply" followup + return 0 + fi model=$(pick_model "$text") log "model: $model" trace "ask($model) « $text »" @@ -991,6 +1107,107 @@ is_noise() { grep -qiE -f <(printf '%s\n' "$pats") <<<"$1" } +# --------------------------------------------------------- les réflexes +# +# Certaines demandes n'ont jamais eu besoin d'un modèle. « Quelle heure +# est-il » se répond avec `date`, en deux cents millisecondes, exactement. +# Le cerveau, lui, met deux secondes et — mesuré le 2026-09-05 sur le modèle +# local — répond « Il est 14h37 » à 23 h passées, avec aplomb, dans le +# format parlé exact de Jarvis. La table n'est donc pas une optimisation : +# c'est ce qui empêche le poisson de mentir sur ce qu'il ne peut pas lire. +# +# Une rangée : motif commande slot phrase-fr | phrase-en. +# Le motif est ancré ICI en ^(motif)$ et jamais dans le fichier — c'est la +# même mécanique que is_noise(), et c'est ce qui garde « pourquoi le son +# est-il si fort ? » loin de la commande de volume. + +# Les rangées utilisables, commentaires et lignes vides ôtés. Le filtre de +# sécurité est ici, au chargement, et pas à l'exécution : ce chemin ne passe +# PAS par .claude/settings.json — le cerveau y a sa liste d'autorisations, +# la table n'a que cette porte-ci. Une rangée refusée le dit dans le log +# plutôt que de disparaître en silence. +reflex_rows() { + local file="$MEMORY_DIR/REFLEXES.md" line cmd + [[ -e $file ]] || return 0 + sed '//d' "$file" 2>/dev/null | + grep -v '^[[:space:]]*$' | grep -v '^[[:space:]]*#' | + while IFS= read -r line; do + [[ $line == *$'\t'* ]] || continue + cmd=${line#*$'\t'} + cmd=${cmd%%$'\t'*} + if grep -qE '(^|[;&|`$([:space:]])(rm|sudo|pkexec|shutdown|reboot|systemctl|kill|pkill|dd|mkfs|chmod|chown)([[:space:]]|$)|omarchy[[:space:]]+(system|update|reinstall)' <<<"$cmd"; then + log "réflexe refusé (commande interdite) : $cmd" + continue + fi + printf '%s\n' "$line" + done +} + +# La première rangée dont le motif couvre TOUT le transcript, ou rien. La +# table est lue dans l'ordre : le plus spécifique doit donc être écrit en +# premier, et memory/REFLEXES.md le dit à qui l'édite. +reflex_match() { + local text="$1" line pat + # La couture des tests, comme JARVIS_BRAIN ou JARVIS_WAKE : la table est + # de la POLITIQUE, elle grandira, et un test du chemin vocal qui casse + # parce qu'on a ajouté une rangée finit par être désactivé. Les tests du + # cerveau tournent donc réflexes coupés, et ceux des réflexes les + # rallument explicitement. En vrai usage la table est toujours active : + # un réglage « réflexes: non » serait de la configuration pour une + # valeur qui ne change jamais. + [[ ${JARVIS_REFLEX:-1} == 0 ]] && return 1 + while IFS= read -r line; do + pat=${line%%$'\t'*} + [[ -n $pat ]] || continue + if grep -qiE "^($pat)\$" <<<"$text" 2>/dev/null; then + printf '%s\n' "$line" + return 0 + fi + done < <(reflex_rows) + return 1 +} + +# Exécuter une rangée et rendre la phrase à prononcer. `{n}` est le slot +# numérique, `{}` la sortie de la commande. +# +# Le slot est BORNÉ 0–100 avant toute substitution, et il ne peut être qu'un +# entier : c'est ce qui autorise le `eval` en dessous. Le seul texte qui +# entre dans la commande vient de la table, que l'utilisateur écrit lui-même +# — jamais du transcript, dont un `; rm -rf` mal entendu ferait sinon une +# journée mémorable. Il n'y a volontairement pas de slot `texte`. +reflex_do() { + local line="$1" text="$2" cmd slot say n="" out fr en + IFS=$'\t' read -r _ cmd slot say <<<"$line" + if [[ $slot == num ]]; then + [[ $text =~ ([0-9]{1,3}) ]] || return 1 + n=${BASH_REMATCH[1]} + n=$((10#$n)) + ((n > 100)) && n=100 + fi + cmd=${cmd//\{n\}/$n} + # Une commande qui échoue n'a pas de réponse honnête à donner : on rend + # la main au cerveau plutôt que de prononcer une phrase toute faite + # au-dessus d'un échec. Le test est dans le `if` et non sur $? plus bas : + # la première ligne qu'on glisserait entre les deux emporterait le code + # de sortie sans rien dire. + if ! out=$(eval "$cmd" 2>>"$LOG"); then + log "réflexe : la commande a échoué — $cmd" + return 1 + fi + # Une sortie multi-lignes rendrait une phrase illisible : on garde la + # première ligne, débarrassée de ses blancs. + out=$(head -1 <<<"$out" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//') + fr=${say%%|*} + en=${say#*|} + [[ $en == "$say" ]] && en=$fr + fr=$(sed 's/^[[:space:]]*//; s/[[:space:]]*$//' <<<"$fr") + en=$(sed 's/^[[:space:]]*//; s/[[:space:]]*$//' <<<"$en") + say=$(fixed "$fr" "$en") + say=${say//\{\}/$out} + say=${say//\{n\}/$n} + printf '%s' "$say" +} + # « Oublie ce que je viens de dire » is not a question for the brain: the # brain is what would be asked to forget, from inside the very turn to be # forgotten. The machine answers it itself, before any exchange opens. @@ -1004,35 +1221,62 @@ is_undo() { # forgotten ask on stdout. Returns 1, with the reason on stderr, when there # is nothing to forget. forget_last() { - local id out ask day file + local id out ask file + mkdir -p "$STATE_DIR/undo" + # Le cerveau local ne tient pas de session claude : son fil est un JSON + # de tours dans $LOCAL_HIST, et oublier le dernier échange, c'est en + # retirer la dernière paire. Sans cette branche « Oublier » échouait sur + # toute la route locale — silencieusement, puisque le panneau appelle + # `undo --quiet`. + if [[ $(brain_route) == local ]]; then + cp -f "$LOCAL_HIST" "$STATE_DIR/undo/local-$(date +%s).json" 2>/dev/null + ask=$(python3 -c ' +import json, sys +try: + h = json.load(open(sys.argv[1])) +except (OSError, ValueError): + sys.exit(1) +while h and h[-1].get("role") != "user": + h.pop() +if not h: + sys.exit(1) +q = h.pop()["content"] +json.dump(h, open(sys.argv[1], "w"), ensure_ascii=False) +print(q) +' "$LOCAL_HIST" 2>>"$LOG") || { + echo "aucune conversation en cours" >&2 + return 1 + } + forget_tail "$ask" + return 0 + fi id=$(cat "$CONV_FLAG" 2>/dev/null) if [[ -z $id ]]; then echo "aucune conversation en cours" >&2 return 1 fi - mkdir -p "$STATE_DIR/undo" cp -f "$CLAUDE_PROJECTS/$(realpath "$JARVIS_DIR" | tr / -)/$id.jsonl" "$STATE_DIR/undo/$id-$(date +%s).jsonl" 2>/dev/null out=$("$JARVIS_DIR/bin/jarvis-transcript" undo --projects "$CLAUDE_PROJECTS" --dir "$JARVIS_DIR" --session "$id" 2>>"$LOG") || { echo "rien à oublier" >&2 return 1 } ask=$(printf '%s' "$out" | python3 -c 'import json,sys; print(json.load(sys.stdin)["forgotten"]["ask"])') + forget_tail "$ask" +} + +# Ce que les deux routes font pareil une fois la question connue : la ligne +# de journal, la carte du panneau, les traces. +forget_tail() { + local ask="$1" file # The journal line of that exchange, and only the last one that matches: # the same question asked twice in a day loses one line, not two. - day=$(date +%F) - file="$MEMORY_DIR/journal/$day.md" - if [[ -e $file ]]; then - python3 - "$file" "$ask" <<-'PY' - import sys - path, ask = sys.argv[1], sys.argv[2] - lines = open(path, encoding="utf-8").read().split("\n") - for i in range(len(lines) - 1, -1, -1): - if "échange : « " + ask + " »" in lines[i]: - del lines[i] - break - open(path, "w", encoding="utf-8").write("\n".join(lines)) - PY - fi + # À l'envers, on supprime la PREMIÈRE correspondance, puis on remet à + # l'endroit : c'est la dernière du fichier. ENVIRON plutôt que `-v`, + # qui interpréterait une contre-oblique tombée dans la question. + file="$MEMORY_DIR/journal/$(date +%F).md" + [[ -e $file ]] && ASK="échange : « $ask »" tac "$file" | + awk '!d && index($0, ENVIRON["ASK"]) { d = 1; next } 1' | tac >"$file.tmp" && + mv "$file.tmp" "$file" # The panel shows nothing after an undo. It used to step back to the # previous exchange, and that read as « nothing happened, the last # message is still there » (2026-09-02 14:05): a forgotten exchange @@ -1152,7 +1396,7 @@ do_finish() { if is_noise "$text"; then log "noise: $text" transition rest - mascot_emote curious + mascot_park emote curious return 0 fi if is_undo "$text"; then @@ -1303,7 +1547,16 @@ cancel) do_cancel "$@" ;; reset) - rm -f "$CONV_FLAG" + # Les deux fils, pas un seul : oublier le nuage en gardant l'historique + # local ferait resurgir, au prochain vol, une conversation que + # l'utilisateur croit close. + # + # Et la carte du panneau AVEC : elle continuait d'afficher la dernière + # question et sa réponse après « Nouvelle conversation », ce qui se lit + # exactement comme « il ne s'est rien passé ». Le fil est clos, la carte + # aussi — c'est déjà ce que fait forget_tail après un oubli. + rm -f "$CONV_FLAG" "$LOCAL_HIST" "$LAST_ASK" "$LAST_REPLY" + mascot heard "" notify "Jarvis" "Nouvelle conversation — l'âme a été relue." ;; settle) @@ -1382,7 +1635,7 @@ watchdog) # mind — so it belongs in the journal a dream reads for root causes. note_failure "chien de garde : figé pendant $(stage_fr "$stuck") depuis $age s ($why), retour au repos" transition rest - mascot_emote worried + mascot_park emote worried notify "Jarvis" "Jarvis s'était figé pendant $(stage_fr "$stuck") ; il est revenu au repos." ;; dispatch) @@ -1425,13 +1678,13 @@ dispatch) if [[ -z ${summary// /} ]]; then summary="La mission a échoué — voyez le journal des missions." note_failure "mission échouée dans $dir : « $task » ($mission)" - mascot_emote worried + mascot_park emote worried else - mascot_emote celebrate + mascot_park emote celebrate fi journal "mission terminée : $summary" trace "mission terminée : $summary" - mascot_aside "$summary" + mascot_park reply "$summary" notify "Jarvis — mission terminée" "$summary" [[ $(state) == idle ]] && ! quiet_now && "$JARVIS_BIN" say "$summary" ) >/dev/null 2>&1 & @@ -1469,11 +1722,54 @@ note) # n'a dit ça ». It is how the whole of memory/NOISE.md — the credits it must # catch AND the real requests it must never eat — is held by tests/run in # milliseconds, with no audio, no model and no real time. +# Le préchauffage du cerveau local, lancé par jarvis-brain.service juste +# après le serveur. Il envoie EXACTEMENT la persona que l'échange enverra — +# c'est tout l'intérêt : un préfixe différent d'un octet ne réchauffe rien. +# La première mesure l'a prouvé, un préchauffage à « bonjour » sans prompt +# système laissait quand même 23 s au premier « raconte-moi une blague ». +# L'historique est délibérément vide : réchauffer ne doit pas ouvrir un fil. +warmup) + url=$(brain_url) + for _ in $(seq 60); do + curl -sf -m 2 "$url/health" >/dev/null 2>&1 && break + sleep 2 + done + mapfile -t cmd < <(brain_local_cmd) + # --history vidé par l'expansion de tableau : réchauffer ne doit pas + # ouvrir un fil. + "${cmd[@]//"$LOCAL_HIST"/}" --plain -- "bonjour" >/dev/null 2>&1 + log "cerveau local préchauffé" + ;; noise\?) shift is_noise "$*" exit $? ;; +# La table des réflexes, assertable sans micro et sans rien exécuter : elle +# imprime le NOM de la rangée qui attrape le transcript, et sort 1 si aucune +# ne l'attrape. C'est ce qui permet aux tests de tenir les motifs — ceux qui +# doivent mordre ET, surtout, les vraies questions qu'ils ne doivent jamais +# manger — sans jouer une seule commande. `--run` exécute pour de bon et +# imprime la phrase, pour vérifier une rangée à la main. +reflexe\?) + shift + run=0 + if [[ ${1:-} == --run ]]; then + run=1 + shift + fi + row=$(reflex_match "$*") || exit 1 + if ((run)); then + reflex_do "$row" "$*" || exit 1 + echo + else + # Le motif seul : c'est lui l'identité de la rangée, et une commande + # imprimée ici finirait recopiée dans un test qui casserait au + # premier changement de commande. + printf '%s\n' "${row%%$'\t'*}" + fi + exit 0 + ;; undo\?) shift is_undo "$*" @@ -1494,7 +1790,16 @@ undo) ;; esac if ! ask=$(forget_last); then - ((quiet)) || { claim; stage_guard; do_speak "$(fixed "Il n'y a rien à oublier." "There is nothing to forget.")"; } + # Le panneau appelle `undo --quiet` : sans voix, un échec ne laissait + # AUCUNE trace, et le bouton avait l'air cassé. La notification est + # son seul canal de retour. + if ((quiet)); then + notify "Jarvis" "Rien à oublier." + else + claim + stage_guard + do_speak "$(fixed "Il n'y a rien à oublier." "There is nothing to forget.")" + fi exit 1 fi notify "Jarvis" "Oublié : « $ask »" @@ -1507,7 +1812,6 @@ routines) routine) # routine on|off|delete — routine add "" shift - memory_init if [[ ${1:-} == add ]]; then shift "$JARVIS_DIR/bin/jarvis-routines" --file "$ROUTINES" add "$*" @@ -1519,7 +1823,11 @@ transcript) # The conversation as it was lived: the last N exchanges with what he # thought and did between the question and the answer. shift - exec "$JARVIS_DIR/bin/jarvis-transcript" show --projects "$CLAUDE_PROJECTS" --dir "$JARVIS_DIR" --current "$(cat "$CONV_FLAG" 2>/dev/null)" "$@" + # --journal est le repli quand il n'y a pas de session claude à lire, + # c'est-à-dire toute la route locale : le journal du jour porte l'heure + # et la paire question/réponse, et il est écrit par les deux cerveaux. + exec "$JARVIS_DIR/bin/jarvis-transcript" show --projects "$CLAUDE_PROJECTS" --dir "$JARVIS_DIR" \ + --current "$(cat "$CONV_FLAG" 2>/dev/null)" --journal "$MEMORY_DIR/journal/$(date +%F).md" "$@" ;; conversations) shift @@ -1566,7 +1874,7 @@ dream) [[ -n ${summary:-} ]] && journal "rêve : $summary" notify "Jarvis" "${summary:-Le rêve a tourné court. Voyez le journal.}" if [[ -n ${summary:-} ]]; then - mascot_aside "$summary" + mascot_park reply "$summary" (( quiet )) || do_speak "$summary" fi if mine; then @@ -1592,8 +1900,8 @@ heartbeat) brain_verdict "$RUN_DIR/heartbeat.txt" ronde || exit 1 if [[ -n ${report:-} && $report != "RAS" && ${report:0:4} != "RAS." ]]; then journal "ronde : $report" - mascot_emote worried - mascot_aside "$report" + mascot_park emote worried + mascot_park reply "$report" notify "Jarvis — ronde" "$report" fi ;; @@ -1685,11 +1993,8 @@ digest) -- "$(cat "$MEMORY_DIR/DIGEST_PROMPT.md")"$'\n\n'"Fichier : $f"$'\n'"Fiche : $fiche" 2>>"$LOG" | "$JARVIS_DIR/bin/jarvis-sentences" --quiet "$RUN_DIR/digest.txt" summary=$(cat "$RUN_DIR/digest.txt" 2>/dev/null) - if brain_verdict "$RUN_DIR/digest.txt"; then - brain_ok=1 - else - brain_ok=0 - fi + brain_verdict "$RUN_DIR/digest.txt" && brain_ok=1 || brain_ok=0 + if ((brain_ok)) && [[ -s $fiche ]]; then dest="$inbox/.archive/$base" [[ -e $dest ]] && dest="$inbox/.archive/$(date +%s)-$base" @@ -1698,7 +2003,7 @@ digest) journal "digestion : $base → knowledge/inbox/$(basename "$fiche")" trace "✓ digéré : $base → $(basename "$fiche")" digested=1 - mascot_emote curious + mascot_park emote curious notify "Jarvis — document digéré" "${summary:-$base}" [[ $(state) == idle ]] && ! quiet_now && "$JARVIS_BIN" say "$summary" elif ((brain_ok)); then @@ -1785,7 +2090,7 @@ save) fi fi log "save: $what" - mascot_emote curious + mascot_park emote curious notify "Jarvis" "Je digère $what." ;; greet) @@ -1809,7 +2114,7 @@ greet) ' "$MEMORY_DIR/journal/$(date +%F).md" 2>/dev/null | tail -4) [[ -n $lines ]] || exit 0 summary=$(printf '%s' "$lines" | sed 's/^[a-zé ]* : //' | awk '{ printf "%s%s", (NR > 1 ? " " : ""), $0 }' | cut -c1-240) - mascot_aside "Pendant ton absence : $summary" + mascot_park reply "Pendant ton absence : $summary" log "greet: $summary" ;; look) @@ -1928,13 +2233,13 @@ tick) stamp="$STATE_DIR/emote-disk" if (($(df --output=pcent / 2>/dev/null | tail -1 | tr -dc 0-9) >= 90)) && [[ ! -e $stamp || -z $(find "$stamp" -mmin -1440 2>/dev/null) ]]; then touch "$stamp" - mascot_emote worried + mascot_park emote worried notify "Jarvis" "Le disque est presque plein." fi stamp="$STATE_DIR/emote-updates" if [[ ! -e $stamp || -z $(find "$stamp" -mmin -360 2>/dev/null) ]]; then touch "$stamp" - (exec 7>&-; omarchy-update-available >/dev/null 2>&1 && mascot_emote curious) & + (exec 7>&-; omarchy-update-available >/dev/null 2>&1 && mascot_park emote curious) & fi # Documents parked in the inbox while the brain was out: retry now. if [[ -n $(find "$BOX_DIR/input/.pending" -maxdepth 1 -type f ! -name '.*' -print -quit 2>/dev/null) ]]; then @@ -2054,6 +2359,36 @@ doctor) # arguments or through the enclosing scope, never interpolated into a # quoted string: a space in $HOME is not supposed to become a bug in a # diagnostic tool. + + # Une table mal formée ne casse rien : les rangées sans tabulation sont + # simplement ignorées, et le poisson repart vers le cerveau comme avant. + # Ce silence EST le problème — une rangée qu'on croit écrite et qui ne + # mord jamais est invisible. Le docteur la rend visible. + check_reflexes() { + local line pat bad=0 n=0 + [[ -e $MEMORY_DIR/REFLEXES.md ]] || return 0 + while IFS= read -r line; do + n=$((n + 1)) + pat=${line%%$'\t'*} + # grep sort 1 quand rien ne matche et 2 quand le MOTIF est + # invalide. Sur /dev/null tout motif correct sort 1 : tester + # « échec » ici déclarait donc toute la table cassée. + grep -qE "^($pat)$" /dev/null + if (($? > 1)); then + printf 'motif invalide : %s\n' "$pat" >&2 + bad=1 + fi + done < <(reflex_rows) + # Le vrai piège : un fichier bien présent dont AUCUNE ligne n'est + # lisible, parce qu'il a été écrit avec des espaces au lieu de + # tabulations. Sans ce test, personne ne l'apprendrait jamais. + if ((n == 0)); then + printf 'aucune rangée lisible (des tabulations, pas des espaces ?)\n' >&2 + return 1 + fi + return $bad + } + check_piper() { local out rc size out=$(mktemp) || return 1 @@ -2145,6 +2480,7 @@ doctor) chk "boîte $BOX_DIR" test -d "$BOX_DIR/input/.work" chk "symlink memory" test "$(readlink -f "$BOX_DIR/memory" 2>/dev/null)" = "$(readlink -f "$MEMORY_DIR")" chk "settings.json valide" python3 -c "import json;json.load(open('$JARVIS_DIR/.claude/settings.json'))" + chk "table des réflexes bien formée" check_reflexes chk "prompts (ronde, rêve, digestion)" bash -c "test -f '$MEMORY_DIR/HEARTBEAT_PROMPT.md' -a -f '$MEMORY_DIR/DREAM_PROMPT.md' -a -f '$MEMORY_DIR/DIGEST_PROMPT.md'" chk "connaissance à jour (omarchy)" test "$(omarchy version 2>/dev/null)" = "$(cat "$MEMORY_DIR/knowledge/VERSION" 2>/dev/null)" # The one line with a sentence instead of a label, because the answer is @@ -2181,6 +2517,24 @@ status) ((next_hb < now)) && next_hb=$now printf 'state=%s\n' "$(state)" printf 'brain=%s\n' "$(brain_status)" + # `cerveau` est le réglage, `route` ce qu'il donne maintenant : en + # `auto` les deux diffèrent dès que le nuage tombe, et le panneau doit + # pouvoir dire « auto — local en ce moment » plutôt que juste « auto ». + route=$(brain_route) + # La joignabilité n'est sondée QUE si la route est locale : le panneau + # rafraîchit toutes les trois secondes, et un curl inutile toutes les + # trois secondes est un curl de trop. En route nuage, `brain=` dit déjà + # tout ce qu'il y a à dire. + joignable=- + if [[ $route == local ]]; then + if curl -sf -m 1 "$(brain_url)/health" >/dev/null 2>&1; then + joignable=oui + else + joignable=non + fi + fi + printf 'cerveau=%s\nroute=%s\ncerveau_url=%s\ncerveau_modele=%s\ncerveau_joignable=%s\n' \ + "$(setting cerveau auto)" "$route" "$(brain_url)" "$(brain_model)" "$joignable" printf 'quiet=%s\n' "$(quiet_now && echo oui || echo non)" printf 'rondes=%s\nreves=%s\nsilence=%s\n' "$(setting rondes oui)" "$(setting reves oui)" "$(setting silence 23-7)" # The key carries a hyphen in the soul and an underscore here: these @@ -2189,17 +2543,16 @@ status) # is already an identifier and consistency is worth more than the hyphen. printf 'micro_coupe=%s\n' "$(setting micro-coupe ecrire)" printf 'corps=%s\noeil=%s\ncriniere=%s\nqueue=%s\ncouleur=%s\n' "$(setting corps B1)" "$(setting oeil E1)" "$(setting criniere M1)" "$(setting queue T1)" "$(setting couleur or)" - printf 'now=%s\nlast_ask_at=%s\nlast_heartbeat=%s\nnext_heartbeat=%s\nlast_dream=%s\n' \ - "$now" "$last_ask" "$last_hb" "$next_hb" "$(stat -c %Y "$STATE_DIR/last-dream" 2>/dev/null || echo 0)" + printf 'last_heartbeat=%s\nnext_heartbeat=%s\n' "$last_hb" "$next_hb" printf 'last_ask=%s\n' "$(tr '\n' ' ' 2>/dev/null <"$LAST_ASK")" printf 'last_reply=%s\n' "$(tr '\n' ' ' 2>/dev/null <"$LAST_REPLY")" printf 'failures=%s\n' "$(grep '^- \[' "$FAILURES" 2>/dev/null | wc -l)" printf 'lessons=%s\n' "$(grep '^- ' "$MEMORY_DIR/LEARNED.md" 2>/dev/null | wc -l)" printf 'suggestions=%s\n' "$(grep '^- \[' "$SUGGESTIONS" 2>/dev/null | wc -l)" - # Active routines, and the soonest of them. + # Combien de routines actives. La PROCHAINE était calculée ici aussi, + # et le panneau ne l'a jamais lue. "$JARVIS_DIR/bin/jarvis-routines" --file "$ROUTINES" list 2>/dev/null | - awk -F'\t' '$2 == "on" { n++; if ($7 > 0 && (soon == 0 || $7 < soon)) soon = $7 } - END { printf "routines=%d\nnext_routine=%d\n", n, soon }' + awk -F'\t' '$2 == "on" { n++ } END { print "routines=" n + 0 }' ;; suggestions) # The dream's proposals, one per line: `\t\t` with the @@ -2236,7 +2589,7 @@ suggestion) fi ;; *) - echo "usage: jarvis [press|cancel [--say] [--from ]|reset|settle|watchdog|ask [--quiet] |say |state|status|dispatch [--dir ] |dream [--quiet]|maybe-dream|heartbeat|tick|digest|doctor [--quiet] [--mic]|rotate|look [random]|lastcmd|missed [min]|save|greet|index|note |noise? |suggestions|suggestion accept|reject]" >&2 + echo "usage: jarvis [press|cancel [--say] [--from ]|reset|settle|watchdog|ask [--quiet] |say |state|status|dispatch [--dir ] |dream [--quiet]|maybe-dream|heartbeat|tick|digest|doctor [--quiet] [--mic]|rotate|look [random]|lastcmd|missed [min]|save|greet|index|note |noise? |reflexe? [--run] |warmup|suggestions|suggestion accept|reject]" >&2 exit 1 ;; esac diff --git a/bin/jarvis-local b/bin/jarvis-local new file mode 100755 index 0000000..70caea7 --- /dev/null +++ b/bin/jarvis-local @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +"""jarvis-local — le cerveau hors ligne, à la place de `claude -p`. + +Parle à n'importe quel serveur compatible OpenAI (`/v1/chat/completions`) : +llama-server, LM Studio, Ollama, LocalAI, vLLM, ou une API hébergée. Un seul +protocole les couvre tous ; c'est pourquoi il n'y a pas vingt fournisseurs +ici, seulement une URL. + +Il n'écrit PAS sa propre sortie : il imite exactement les événements que +`claude -p --output-format stream-json --include-partial-messages` produit, +parce que `bin/jarvis-sentences` les lit déjà et découpe les phrases pour +piper. Le chemin de la voix ne sait pas quel cerveau lui parle, et c'est ce +qui rend l'échange possible en une dizaine de lignes dans bin/jarvis. + +Ce qu'il n'a pas, et n'aura jamais : les outils. Pas de Bash, pas de Read, +pas de WebSearch. Un modèle local qui pilote un shell est un risque que ce +fichier refuse de prendre ; ce qu'il sait faire, c'est parler. Ce qu'il ne +peut pas lire — l'heure, la batterie, la météo — memory/OFFLINE.md lui +interdit de l'inventer, et la table des réflexes intercepte ces demandes +avant même qu'elles arrivent ici. + + jarvis-local --url URL --model M --history F --system F1[,F2...] -- "texte" +""" + +import argparse +import json +import os +import sys +import urllib.error +import urllib.request + +# Une réponse parlée fait une ou deux phrases. Le plafond n'est pas un +# réglage : c'est une ceinture contre un modèle qui part en digression et +# fait parler le poisson pendant deux minutes. +MAX_TOKENS = 300 +TIMEOUT = 120 +# Les tours gardés du fil : le contexte d'un modèle local est petit et une +# conversation sans fin le fait déborder. Pas un réglage non plus — le seul +# constructeur de la commande (brain_local_cmd) ne posait jamais --keep. +KEEP = 20 + + +def emit(obj): + """Une ligne JSON sur stdout, poussée tout de suite — le tube en aval + découpe des phrases pour les faire parler, donc bufferiser reviendrait + à retenir la voix jusqu'à la fin de la réponse.""" + sys.stdout.write(json.dumps(obj, ensure_ascii=False) + "\n") + sys.stdout.flush() + + +def block_start(): + emit({"type": "stream_event", "event": { + "type": "content_block_start", "content_block": {"type": "text"}}}) + + +def block_delta(text): + emit({"type": "stream_event", "event": { + "type": "content_block_delta", + "delta": {"type": "text_delta", "text": text}}}) + + +def block_stop(): + emit({"type": "stream_event", "event": {"type": "content_block_stop"}}) + + +def result_ok(full): + emit({"type": "result", "subtype": "success", + "is_error": False, "result": full}) + + +def result_error(msg): + """Le FSM lit ça dans .error et met BRAIN_STATUS à `down` : un + serveur local éteint doit ressembler à un cerveau injoignable, pas à une + réponse vide — sans quoi run_exchange repose la même question. + + Toujours doublé sur stderr : en mode --plain les événements sont muets, + et une panne qui ne laisse aucune trace dans le log est une panne qu'on + cherche deux heures.""" + print(f"jarvis-local: {msg}", file=sys.stderr) + emit({"type": "result", "subtype": "error", + "is_error": True, "result": msg}) + + +def read_system(paths): + parts = [] + for p in paths.split(","): + p = p.strip() + if not p: + continue + try: + with open(os.path.expanduser(p)) as f: + parts.append(f.read().strip()) + except OSError: + # Une âme illisible n'est pas une raison de se taire : le poisson + # répond sans elle plutôt que de ne pas répondre. + print(f"jarvis-local: {p} illisible", file=sys.stderr) + return "\n\n".join(parts) + + +def load_history(path, keep): + try: + with open(path) as f: + msgs = json.load(f) + return msgs[-keep:] if keep else msgs + except (OSError, ValueError): + return [] + + +def save_history(path, msgs, keep): + if not path: + return + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as f: + json.dump(msgs[-keep:] if keep else msgs, f, ensure_ascii=False) + except OSError as e: + print(f"jarvis-local: historique non écrit ({e})", file=sys.stderr) + + +def stream(url, model, messages): + """Envoie et rend le texte complet. Lève une exception si le serveur ne + répond pas — l'appelant la traduit en événement `result` en erreur.""" + body = json.dumps({ + "model": model, + "stream": True, + "max_tokens": MAX_TOKENS, + "messages": messages, + }).encode() + req = urllib.request.Request( + url.rstrip("/") + "/v1/chat/completions", body, + {"Content-Type": "application/json"}) + + full = "" + opened = False + with urllib.request.urlopen(req, timeout=TIMEOUT) as resp: + for raw in resp: + line = raw.decode("utf-8", "replace").strip() + if not line.startswith("data: "): + continue + payload = line[6:] + if payload == "[DONE]": + break + try: + delta = json.loads(payload)["choices"][0].get("delta", {}) + except (ValueError, KeyError, IndexError): + continue + # `reasoning_content` est la réflexion d'un modèle « thinking ». + # Elle ne se prononce pas : mesurée sur Qwen3.5-4B, elle brûlait + # tout le budget en anglais et la réponse n'arrivait jamais. + # Le serveur doit tourner avec --reasoning off ; si le drapeau + # manque, on jette ici plutôt que de faire lire ça à voix haute. + chunk = delta.get("content") or "" + if not chunk: + continue + if not opened: + block_start() + opened = True + block_delta(chunk) + full += chunk + if opened: + block_stop() + return full + + +def main(): + ap = argparse.ArgumentParser(add_help=False) + ap.add_argument("--url", required=True) + ap.add_argument("--model", default="local") + ap.add_argument("--system", default="") + ap.add_argument("--history", default="") + # Le chemin non-streamé de bin/jarvis veut du texte, pas des événements. + # Un drapeau ici coûte quatre lignes ; extraire le texte du JSON en sed + # côté bash coûterait un bug d'échappement le jour où le poisson dirait + # un guillemet. + ap.add_argument("--plain", action="store_true") + ap.add_argument("text", nargs="+") + args = ap.parse_args() + + global emit + if args.plain: + emit = lambda _obj: None + + text = " ".join(args.text) + messages = [] + system = read_system(args.system) if args.system else "" + if system: + messages.append({"role": "system", "content": system}) + messages += load_history(args.history, KEEP) if args.history else [] + messages.append({"role": "user", "content": text}) + + try: + full = stream(args.url, args.model, messages) + except urllib.error.HTTPError as e: + result_error(f"cerveau local : le serveur a répondu {e.code}") + return 1 + except (urllib.error.URLError, OSError) as e: + reason = getattr(e, "reason", e) + result_error(f"cerveau local injoignable sur {args.url} ({reason})") + return 1 + except BrokenPipeError: + # L'utilisateur a coupé la parole : la voix a fermé le tube. Ce n'est + # pas une panne, et surtout pas une erreur de session — sortir en + # silence laisse jarvis-sentences poser son marqueur .interrupted. + return 0 + + if not full.strip(): + result_error("cerveau local : réponse vide") + return 1 + + if args.history: + hist = load_history(args.history, 0) + hist += [{"role": "user", "content": text}, + {"role": "assistant", "content": full}] + save_history(args.history, hist, KEEP) + + if args.plain: + sys.stdout.write(full) + sys.stdout.flush() + else: + result_ok(full) + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + sys.exit(130) diff --git a/bin/jarvis-transcript b/bin/jarvis-transcript index fb088ea..f558221 100755 --- a/bin/jarvis-transcript +++ b/bin/jarvis-transcript @@ -25,11 +25,12 @@ alone does not do it. """ import glob +import argparse import json import os +import re import sys -import time -from datetime import datetime, timezone +from datetime import datetime THOUGHT = 300 RESULT = 200 @@ -41,25 +42,21 @@ def die(msg, code=1): def parse_args(argv): - opts = {"json": False, "session": None, "projects": None, "dir": None, - "current": None, "n": None, "cmd": None} - it = iter(argv) - for a in it: - if a == "--json": - opts["json"] = True - elif a in ("--session", "--projects", "--dir", "--current"): - opts[a[2:]] = next(it, None) - elif a.startswith("--"): - die(f"unknown option {a}") - elif opts["cmd"] is None: - opts["cmd"] = a - elif a.isdigit(): - opts["n"] = int(a) - else: - die(f"unexpected argument {a}") - if opts["cmd"] not in ("list", "show", "undo"): - die("usage: jarvis-transcript list|show|undo [options]", 2) - return opts + # argparse tient la validation de la commande, le N positionnel qui + # arrive APRÈS les options, et le message d'usage — le tout que cette + # fonction faisait à la main. + ap = argparse.ArgumentParser(prog="jarvis-transcript") + ap.add_argument("cmd", choices=("list", "show", "undo")) + ap.add_argument("n", nargs="?", type=int) + ap.add_argument("--json", action="store_true") + for opt in ("session", "projects", "dir", "current", "journal"): + ap.add_argument(f"--{opt}") + # parse_intermixed_args et pas parse_args : le N positionnel arrive APRÈS + # les options (`show --projects … --json 30`), et parse_args regroupe les + # positionnels au premier cluster — il voit « show », remplit cmd, et + # déclare le 30 final « unrecognized ». Le comportement varie selon la + # version de Python : vert en local, rouge en CI. + return vars(ap.parse_intermixed_args(argv)) def project_dir(opts): @@ -68,20 +65,13 @@ def project_dir(opts): return os.path.join(projects, work.replace("/", "-")) -def local_hhmm(ts): +def local(ts, fmt): + """L'horodatage ISO du transcript, dans le fuseau d'ici.""" try: d = datetime.fromisoformat(ts.replace("Z", "+00:00")) except (ValueError, AttributeError): return "" - return d.astimezone().strftime("%H:%M") - - -def local_stamp(ts): - try: - d = datetime.fromisoformat(ts.replace("Z", "+00:00")) - except (ValueError, AttributeError): - return "" - return d.astimezone().strftime("%Y-%m-%d %H:%M") + return d.astimezone().strftime(fmt) def squash(value, limit): @@ -194,19 +184,45 @@ def session_path(opts, sid): def public(x): - return {"at": local_hhmm(x["at"]), "ask": x["ask"], "reply": x["reply"], + return {"at": local(x["at"], "%H:%M"), "ask": x["ask"], "reply": x["reply"], "thoughts": x["thoughts"], "tools": x["tools"]} +def from_journal(path): + """Les échanges du jour lus dans memory/journal/.md. + + Le cerveau local ne tient pas de session claude : son fil vit dans un + JSON de tours sans horodatage, et l'onglet « Aujourd'hui » restait vide + alors que les échanges avaient bien eu lieu. Le journal, lui, porte + l'heure ET la paire, et il est écrit par les DEUX routes — c'est donc + la seule source qui les voit toutes. Ce qu'il n'a pas : les pensées et + les outils, que le modèle local n'a de toute façon pas. + """ + line = re.compile(r"^- (\d{2}:\d{2}) échange : « (.*) » → « (.*) »\s*$") + out = [] + try: + for raw in open(path, encoding="utf-8"): + m = line.match(raw.rstrip("\n")) + if m: + out.append({"at": m[1], "ask": m[2], "reply": m[3], + "thoughts": [], "tools": []}) + except OSError: + pass + return out + + def cmd_show(opts): sid = opts["session"] or opts["current"] - if not sid: + n = opts["n"] or 5 + path = session_path(opts, sid) if sid else "" + if sid and os.path.exists(path): + xs = [public(x) for x in exchanges(read_lines(path))][-n:] + elif opts["journal"]: + xs = from_journal(opts["journal"])[-n:] + elif not sid: die("no session (--session or --current)", 3) - path = session_path(opts, sid) - if not os.path.exists(path): + else: die(f"no transcript for {sid}", 3) - n = opts["n"] or 5 - xs = [public(x) for x in exchanges(read_lines(path))][-n:] if opts["json"]: print(json.dumps(xs, ensure_ascii=False)) return @@ -238,7 +254,7 @@ def cmd_list(opts): # conversation starts with a sentence. kind = "conversation" if len(xs[0]["ask"]) < 300 else "tâche" rows.append({"id": sid, "kind": kind, "current": sid == opts["current"], - "started": local_stamp(xs[0]["at"]), "ended": local_stamp(xs[-1]["at"]), + "started": local(xs[0]["at"], "%Y-%m-%d %H:%M"), "ended": local(xs[-1]["at"], "%Y-%m-%d %H:%M"), "exchanges": len(xs), "first": squash(xs[0]["ask"], 120), "mtime": os.path.getmtime(path)}) rows.sort(key=lambda r: r["mtime"], reverse=True) diff --git a/bootstrap.sh b/bootstrap.sh index 2d94882..ba60123 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -9,7 +9,6 @@ # running it twice is a no-op — it only fetches what is missing. # # ./bootstrap.sh the two voices Jarvis actually speaks -# ./bootstrap.sh --all-voices plus the two French alternates # ./bootstrap.sh --skip-wake no « Hey Jarvis » (skips a 200 MB venv) # ./bootstrap.sh --skip-whisper you already have a Whisper model # @@ -21,14 +20,13 @@ PIPER_VERSION="v1.2.0" WHISPER_MODEL="ggml-small.bin" # matches `model = "small"` in voxtype.toml WHISPER_DIR="$HOME/.local/share/voxtype/models" -all_voices=0 skip_wake=0 skip_whisper=0 +skip_wake=0 skip_whisper=0 for arg in "$@"; do case "$arg" in - --all-voices) all_voices=1 ;; --skip-wake) skip_wake=1 ;; --skip-whisper) skip_whisper=1 ;; -h | --help) - sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//' + sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//' exit 0 ;; *) @@ -111,11 +109,6 @@ voices=( "${FR_PATH[$fr]}" "en/en_GB/alan/medium/en_GB-alan-medium" ) -if ((all_voices)); then - for k in siwis tom upmc; do - [[ $k == "$fr" ]] || voices+=("${FR_PATH[$k]}") - done -fi for path in "${voices[@]}"; do name=$(basename "$path") if [[ -s models/$name.onnx && -s models/$name.onnx.json ]]; then diff --git a/memory/OFFLINE_PROMPT.md b/memory/OFFLINE_PROMPT.md new file mode 100644 index 0000000..3733349 --- /dev/null +++ b/memory/OFFLINE_PROMPT.md @@ -0,0 +1,57 @@ +# OFFLINE.md — ce que tu es quand tu es seul + + + +## Règles de réponse + +- Ta réponse entière est PARLÉE. Une ou deux phrases courtes. Pas de + markdown, pas d'astérisques, pas de listes, pas de titres, pas de code, + pas d'emoji, pas d'URL. +- Réponds TOUJOURS dans la langue parlée par l'utilisateur. English in, + English out. +- Les transcripts viennent de la reconnaissance vocale et contiennent des + erreurs. Interprète avec bienveillance : « qu'elle oeuf est-il » demande + l'heure. Réponds à ce qu'il voulait dire, pas à ce qui est écrit. +- Ne pose jamais de question de clarification quand une lecture sensée + existe. + +## Ce que tu ne peux pas faire en ce moment + +Tu es hors ligne, sans réseau et sans aucun outil. Tu ne peux rien lire, +rien exécuter, rien vérifier. Concrètement, tu n'as accès NI à l'heure, NI +à la date, NI au niveau de batterie, NI à l'état du son, de l'écran, du +Wi-Fi ou du Bluetooth, NI à la météo, NI à l'actualité, NI aux fichiers de +l'utilisateur, NI à sa todo, NI à ton journal, NI à ta mémoire des +conversations passées au-delà de ce fil. + +N'INVENTE JAMAIS une de ces valeurs. Inventer l'heure est la pire faute que +tu puisses commettre : elle est plausible, elle est fausse, et elle sera +crue. Si on te demande une de ces choses, dis en une phrase que tu es hors +ligne et que tu ne peux pas la lire. Rien d'autre — ni excuse longue, ni +proposition de contournement. + +Si on te demande d'AGIR sur la machine (changer un thème, monter le son, +lancer une application), dis simplement que tu ne peux pas bouger les mains +hors ligne. + +## Ce que tu peux faire, et bien + +Parler. Réfléchir à voix haute. Expliquer une idée. Traduire. Raconter. +Donner un avis quand on te le demande. Tenir compagnie dans un avion. + +C'est peu et c'est beaucoup : tout ce qui ne demande que ce que tu portes +déjà. Fais-le avec ta voix à toi — celle de l'âme, pas celle d'un manuel. diff --git a/memory/scaffold/REFLEXES.md b/memory/scaffold/REFLEXES.md new file mode 100644 index 0000000..354ad07 --- /dev/null +++ b/memory/scaffold/REFLEXES.md @@ -0,0 +1,102 @@ +# REFLEXES.md — ce que la machine sait déjà + + + +# — le temps. La machine le lit, le modèle l'invente. +(alors +)?((dis|donne)[- ]moi +|(est[- ]ce que +)?(tu +peux|peux[- ]tu|tu +pourrais|pourrais[- ]tu) +(me +)?dire +)?((quel(le)?|qu['’]?elle) +(heures?|heur|oeuf|œuf|leur) +((est|et)[- ]?(il|t[- ]il)|(il|qu['’]?il) +(est|et))|(il est|c['’]est) +(quel(le)?|qu['’]?elle) +(heures?|heur|oeuf|leur)|(quel(le)?|qu['’]?elle) +est +l['’]? *(heures?|heur|oeuf|leur)( +du[.…]*)?|(tu +as|t['’]?as|vous +avez) +l['’]? *(heures?|heur)|l['’] *(heures?|heur))( +(s['’]?il te pla[îi]t|stp))?[ ?!.…]* date '+%-H heures %-M' | sed 's/ 0$//; s/^0 heures/minuit/; s/^1 heures/1 heure/' - Il est {}. | It's {}. +((dis|donne)[- ]moi +|rappelle[- ]moi +)?((on +est|nous +sommes) +(quel +jour|quelle +date|le +combien)( +(d['’])?aujourd['’]?hui)?|(quel +jour|quelle +date) +(on +est|sommes[- ]nous|est[- ]on|c['’]est)( +(d['’])?aujourd['’]?hui)?|(c['’]est +)?quel +jour( +on +est)? +(d['’])?aujourd['’]?hui|(c['’]est +)?quoi +la +date( +du +jour| +(d['’])?aujourd['’]?hui)?|quelle +est +la +date( +du +jour| +(d['’])?aujourd['’]?hui)?|(dis|donne)[- ]moi +la +date|la +date +(du +jour|(d['’])?aujourd['’]?hui))[ ?!.…]* date '+%u %-d %m' | awk '{split("lundi mardi mercredi jeudi vendredi samedi dimanche",j," ");split("janvier février mars avril mai juin juillet août septembre octobre novembre décembre",m," ");print j[$1], ($2==1?"premier":$2), m[$3+0]}' - On est {}. | It's {}. + +# — l'état de la machine. Deux lectures, et un état inconnu doit +# ÉCHOUER : le cerveau dira « je ne sais pas », la table ne le peut pas. +(quel(le)? est (l['’]|le |la )?(pourcentage|niveau|[ée]tat) (de |d['’])(la )?batteries?|(il (me )?reste |j['’]ai )?combien de batteries?|combien (il (me )?reste )?de batteries?|combien de batteries? (il )?(me )?reste([- ]t[- ]il)?|la batterie est [àa] combien|niveau de batteries?|battery level|how much battery( is)? left|what['’]?s the battery( level)?)[ ?!.…]* cat /sys/class/power_supply/macsmc-battery/capacity - La batterie est à {} pour cent. | Battery is at {} percent. +(est-ce que )?(le |mon )?(mode )?ne pas d[ée]ranger,? (est|et)([- ]il)? (actif|activ[ée]|allum[ée]|en marche)[ ?!.…]* case "$(omarchy-shell notifications dndState)" in on) echo actif;; off) echo inactif;; *) exit 1;; esac - Le mode ne pas déranger est {}. | Do not disturb is {}. + +# — quel cerveau répond. Le modèle ne peut pas le savoir : il n'a pas +# accès à son propre routage, et interrogé là-dessus il répond de +# mémoire. Il a eu juste une fois ; c'est la même faute que l'heure. +(est-ce que )?((quel|quelle) (cerveau|mod[èe]le|ia|llm|l\\.?l\\.?m) (tu utilises|utilises[- ]tu|est[- ]ce que tu utilises)|tu (utilises|tournes (sur|avec)) (quel|quelle) (cerveau|mod[èe]le|ia|llm|l\\.?l\\.?m)|(sur )?(quel|quelle) (cerveau|mod[èe]le|ia|llm|l\\.?l\\.?m) (tu tournes|tournes[- ]tu|tu es)|tu (es|penses) (en )?(local|hors ligne|en ligne)|(which|what) (brain|model|llm) (are you|do you) (using|use|run on))[ ?!.…]* r=$(omarchy-jarvis status); case "$(sed -n 's/^route=//p' <<<"$r")" in nuage) echo Claude;; local) sed -n 's/^cerveau_modele=//p' <<<"$r";; *) exit 1;; esac - Je pense avec {}, en ce moment. | I'm thinking with {} right now. + +# — le son. La rangée à chiffre passe AVANT les rangées ± : « mets le +# son à 30 » ne doit pas tomber dans « monte le son ». +((est-ce que )?(tu peux|peux-tu) )?((mets|met|mais|mettre|r[èe]gl(e|er)|pass(e|er)|mont(e|er)|baiss(e|er))[- ]?(moi )?)?(le )?(son|volume) (a|à|sur) [0-9]{1,3}( ?%| pour ?cents?| pourcents?)?( s['’]?il te pla[iî]t)?[ !.…]* wpctl set-mute @DEFAULT_AUDIO_SINK@ 0; wpctl set-volume @DEFAULT_AUDIO_SINK@ {n}% num Son à {n} pour cent. | Volume at {n} percent. +((est-ce que )?(tu peux|peux-tu) )?(((mets|met|mais|mettre) )?(le (son|volume) )?(un peu )?plus fort|(mont(e|er)|augment(e|er)|remont(e|er))( un peu)? (le son|le volume)( un peu)?)( s['’]?il te pla[iî]t)?[ !.…]* omarchy audio output volume raise - Plus fort. | Louder. +((est-ce que )?(tu peux|peux-tu) )?(((mets|met|mais|mettre) )?(le (son|volume) )?(un peu )?moins fort|(baiss(e|er)|diminu(e|er)|r[ée]dui(s|re))( un peu)? (le son|le volume)( un peu)?)( s['’]?il te pla[iî]t)?[ !.…]* omarchy audio output volume lower - Moins fort. | Quieter. +((est-ce que )?(tu peux|peux-tu) )?((coup(e|er)|[ée]tein(s|dre))[- ]?(moi )?(le son|le volume|la musique)|(mets|met|mais|mettre) (le son |[çc]a )?en sourdine|mute)( s['’]?il te pla[iî]t)?[ !.…]* wpctl set-mute @DEFAULT_AUDIO_SINK@ 1 - Son coupé. | Sound off. +((est-ce que )?(tu peux|peux-tu) )?((remets|remet|remettre|r[ée]tabli(s|r)|r[ée]activ(e|er)|redonn(e|er))[- ]?(moi )?(le son|le volume)|(enl[èe]v(e|er)|coup(e|er)) la sourdine)( s['’]?il te pla[iî]t)?[ !.…]* wpctl set-mute @DEFAULT_AUDIO_SINK@ 0 - Son rétabli. | Sound back. + +# — les bascules du bureau. Chaque paire est complète : allumer sans +# pouvoir éteindre est un piège quand le cerveau est hors ligne. +(est-ce que )?((tu peux|peux-tu) (mettre|passer|basculer|activer)|mets?|met|mais|passe|bascule|active|remets)?(-| )?(moi )?(le |en |au |sur le |dans le )?(mode|th[èe]me) clair( s['’]il te pla[îi]t)?[ ?!.…]* omarchy theme set apple-glass-light - Thème clair activé, ça change ! | Light theme on. +(est-ce que )?((tu peux|peux-tu) (mettre|passer|basculer|activer)|mets?|met|mais|passe|bascule|active|remets)?(-| )?(moi )?(le |en |au |sur le |dans le )?(mode|th[èe]me) (sombre|fonc[ée])( s['’]il te pla[îi]t)?[ ?!.…]* omarchy theme set apple-glass - Thème sombre activé, c'est plus reposant. | Dark theme on. +(est-ce que )?((tu peux|peux-tu) (lancer|relancer|d[ée]marrer|allumer|activer|remettre|ouvrir)|r?allume|(re)?lance|red[ée]marre|d[ée]marre|active|remets|mets|ouvre)(-| )?(moi )?(l['’])?aquarium( s['’]il te pla[îi]t)?[ ?!.…]*|aquarium (on|en marche)[ ?!.…]* omarchy-aquarium-toggle on - L'aquarium est lancé. | The aquarium is running. +(est-ce que )?((tu peux|peux-tu) (couper|arr[êe]ter|[ée]teindre|d[ée]sactiver|fermer|stopper|enlever)|coupe|arr[êe]te|stoppe|[ée]teins|d[ée]sactive|ferme|enl[èe]ve)(-| )?(moi )?(l['’])?aquarium( s['’]il te pla[îi]t)?[ ?!.…]*|aquarium (of|off)[ ?!.…]* omarchy-aquarium-toggle off - Aquarium coupé. | Aquarium off. + +# — la seule demande du corpus que la permission avait refusée. +((est-ce que )?(tu peux|peux-tu) (verrouiller|v[ée]rouiller)|verrouille|verrouilles|v[ée]rouille)(-| )?(moi )?(l['’]|le |la |mon |ma )([ée]cran|ordi|ordinateur|mac|machine|session|portable)( s['’]il te pla[îi]t)?[ ?!.…]* omarchy-system-lock - Je verrouille, à tout de suite. | Locking, see you soon. diff --git a/plugin/ControlCenterModule.qml b/plugin/ControlCenterModule.qml index 28a5525..fea989d 100644 --- a/plugin/ControlCenterModule.qml +++ b/plugin/ControlCenterModule.qml @@ -23,9 +23,11 @@ import "components" Item { id: mod - // ------------------------------------------------------------- contract - property var bar: null - property var manifest: null + // ------------------------------------------------------------- contrat + // + // `bar` et `manifest` ne sont pas ici : l'hôte les écrit sous garde + // (BarWidget.qml `if ("bar" in item)`) et ce module ne les a jamais lus, + // donc les déclarer revenait à demander deux affectations pour rien. property bool panelOpen: false property bool pageShowing: false // Set false by the shell while the finger is still moving. @@ -58,6 +60,16 @@ Item { property string lang: "fr" property bool wake: false property string muteMode: "ecrire" + // Quel cerveau répond. `cerveau` est le réglage, `route` ce qu'il donne + // en ce moment : en « Auto » les deux diffèrent dès que le nuage tombe, + // et une pastille qui dit « Auto » sans dire « local là, maintenant » + // laisse l'utilisateur croire qu'il parle à Claude. + property string cerveau: "auto" + property string route: "nuage" + property string cerveauUrl: "" + property string cerveauModele: "" + // `-` quand la question ne se pose pas (route nuage), `oui`/`non` sinon. + property string cerveauJoignable: "-" readonly property var tones: ["majordome", "complice", "laconique"] readonly property var toneOptions: [ @@ -74,6 +86,13 @@ Item { readonly property var muteModes: [ { value: "ecrire", label: "Clavier" }, { value: "prevenir", label: "Prévenir" }, { value: "reactiver", label: "Réactiver" } ] + // Un seul « fournisseur » local, pas vingt : llama-server, LM Studio, + // Ollama, LocalAI et vLLM parlent tous /v1/chat/completions. Ce qui + // change d'un serveur à l'autre est l'URL, et une URL se saisit une fois + // dans l'âme — pas dans un panneau qu'on ouvre tous les jours. + readonly property var cerveaux: [ + { value: "auto", label: "Auto" }, { value: "nuage", label: "Nuage" }, { value: "local", label: "Local" } + ] // The fish's look: one soul setting per part (sprites/parts.py). property string corps: "B1" @@ -178,6 +197,15 @@ Item { return last + " · prochaine dans ~" + mins + " min" } + // Ce qu'on lit sans déplier. Un serveur injoignable doit se voir d'ici : + // c'est la panne qui rend Jarvis muet sans rien expliquer. + function brainSummary() { + if (cerveau === "nuage") return "Claude" + if (route === "local") + return cerveauJoignable === "non" ? "local — injoignable" : "local · " + cerveauModele + return cerveau === "auto" ? "auto · nuage" : "nuage" + } + function soulSummary() { return tone.charAt(0).toUpperCase() + tone.slice(1) + " · " + lang } @@ -256,12 +284,26 @@ Item { setSetting("micro-coupe", value) } + // Le `reset` n'est pas une politesse : un fil ouvert chez Claude et repris + // par le modèle local ferait répondre le second à des tours qu'il n'a pas + // tenus. On change de cerveau, on change de conversation. + function setCerveau(value) { + cerveau = value + Quickshell.execDetached(["bash", "-c", + 'grep -q "^- cerveau:" "$2" && sed -i "s/^- cerveau: .*/- cerveau: $1/" "$2" || sed -i "/^- langue:/a - cerveau: $1" "$2"; omarchy-jarvis reset', + "--", value, soulPath]) + recheck.restart() + } + // The automations live in SOUL.md's « Réglages » like the tone does, but // they are read live by the FSM on every tick — no reset needed. An older // soul without the line gets it appended after `langue`. function setSetting(key, value) { Quickshell.execDetached(["bash", "-c", - 'grep -q "^- $1:" "$3" && sed -i "s/^- $1: .*/- $1: $2/" "$3" || sed -i "/^- langue:/a - $1: $2" "$3"', + // Séparateur `|` et non `/` : cerveau-url vaut http://127.0.0.1:8099, + // dont les barres obliques terminaient l'expression sed et + // corrompaient l'âme. Même correctif que set_setting dans bin/jarvis. + 'grep -q "^- $1:" "$3" && sed -i "s|^- $1: .*|- $1: $2|" "$3" || sed -i "/^- langue:/a - $1: $2" "$3"', "--", key, value, soulPath]) recheck.restart() } @@ -388,6 +430,11 @@ Item { mod.reves = kv.reves !== "non" mod.silence = kv.silence || "23-7" mod.muteMode = kv.micro_coupe || "ecrire" + mod.cerveau = kv.cerveau || "auto" + mod.route = kv.route || "nuage" + mod.cerveauUrl = kv.cerveau_url || "" + mod.cerveauModele = kv.cerveau_modele || "" + mod.cerveauJoignable = kv.cerveau_joignable || "-" mod.corps = kv.corps || "B1" mod.oeil = kv.oeil || "E1" mod.criniere = kv.criniere || "M1" @@ -633,6 +680,105 @@ Item { } // ------------------------------------------------ what is set once + // Le cerveau n'est pas la personnalité : l'âme dit QUI il est, ceci dit + // AVEC QUOI il pense. Un point de terminaison compatible OpenAI couvre + // llama-server, LM Studio, Ollama, LocalAI et vLLM — c'est pourquoi il + // y a une URL et pas une liste de fournisseurs. + Disclosure { + Layout.fillWidth: true + title: "Cerveau" + summary: mod.brainSummary() + + PillRow { + Layout.fillWidth: true + options: mod.cerveaux + value: mod.cerveau + foreground: Color.popups.text + fontSize: Style.font.caption + onChanged: function(v) { mod.setCerveau(v) } + } + + // Ce que la pastille ne peut pas dire : où va la parole en ce moment. + // En « Auto » les deux diffèrent dès que le nuage tombe, et croire + // qu'on parle à Claude alors qu'un 4B répond est le pire malentendu + // que ce panneau puisse laisser s'installer. + Text { + Layout.fillWidth: true + visible: mod.cerveau !== "nuage" + text: mod.route === "local" + ? (mod.cerveauJoignable === "non" + ? "Local — serveur injoignable sur " + mod.cerveauUrl + : "Répond en local — " + mod.cerveauModele) + : "Répond par le nuage" + color: mod.cerveauJoignable === "non" + ? Color.urgent : Util.alpha(Color.popups.text, 0.45) + font.family: Style.font.family + font.pixelSize: Style.font.caption + wrapMode: Text.WordWrap + } + + // Les deux champs ne servent qu'au rang local : les montrer en + // « Nuage » serait offrir de régler une chose qui n'a aucun effet. + RowLayout { + Layout.fillWidth: true + visible: mod.cerveau !== "nuage" + spacing: Style.space(8) + + Text { + Layout.preferredWidth: Style.space(74) + text: "Adresse" + color: Util.alpha(Color.popups.text, 0.55) + font.family: Style.font.family + font.pixelSize: Style.font.caption + } + + TextField { + Layout.fillWidth: true + text: mod.cerveauUrl + placeholderText: "http://127.0.0.1:8099" + foreground: Color.popups.text + font.pixelSize: Style.font.caption + // On n'écrit qu'à la validation, jamais à la frappe : sauver à + // chaque caractère réécrirait l'âme vingt fois et ferait pointer + // Jarvis sur « http:/ » en cours de route. + onAccepted: mod.setSetting("cerveau-url", text.trim()) + } + } + + RowLayout { + Layout.fillWidth: true + visible: mod.cerveau !== "nuage" + spacing: Style.space(8) + + Text { + Layout.preferredWidth: Style.space(74) + text: "Modèle" + color: Util.alpha(Color.popups.text, 0.55) + font.family: Style.font.family + font.pixelSize: Style.font.caption + } + + TextField { + Layout.fillWidth: true + text: mod.cerveauModele + placeholderText: "Qwen3.5-4B" + foreground: Color.popups.text + font.pixelSize: Style.font.caption + onAccepted: mod.setSetting("cerveau-modele", text.trim()) + } + } + + Text { + Layout.fillWidth: true + visible: mod.cerveau !== "nuage" + text: "Le modèle est l'identifiant envoyé au serveur, et ce qu'il dit tout haut quand on lui demande avec quoi il pense. Entrée pour enregistrer." + color: Util.alpha(Color.popups.text, 0.4) + font.family: Style.font.family + font.pixelSize: Style.font.caption + wrapMode: Text.WordWrap + } + } + Disclosure { Layout.fillWidth: true title: "Âme" diff --git a/plugin/Service.qml b/plugin/Service.qml index dd2e37e..927e94d 100644 --- a/plugin/Service.qml +++ b/plugin/Service.qml @@ -420,7 +420,6 @@ Item { function show(): void { service.shown = true } function hide(): void { service.shown = false } - function toggle(): void { service.shown = !service.shown } function isShown(): string { return service.shown ? "on" : "off" } // Punctual emotion over the idle body (celebrate, worried, tired, dnd). @@ -440,9 +439,8 @@ Item { // The Journal: what he remembers, on paper (today's exchanges with what // went on in his head, past conversations, routines). `journal` - // toggles, `journalTab conversations|routines|today` opens on a tab. + // bascule, `journalTab conversations|routines|today` ouvre sur un onglet. function journal(): void { service.journalOpen = !service.journalOpen } - function journalClose(): void { service.journalOpen = false } function journalTab(name: string): void { journalPage.setTab(String(name)) service.journalOpen = true @@ -458,7 +456,6 @@ Item { function swim(): void { if (service.mood === "idle" && !excursion.running) excursion.start() } - function ping(): string { return "ok" } } // ------------------------------------------------------ the prompt bar @@ -533,24 +530,24 @@ Item { // Escape, once it has the keyboard, puts it away. property bool journalOpen: false - PanelWindow { + // Une vraie fenêtre, pas une surface de couche : Hyprland la tuile avec + // les autres et l'écran se partage, au lieu de la faire flotter par-dessus + // le travail en cours. La couche donnait un panneau qu'il fallait fermer + // pour lire ce qu'il cachait ; une fenêtre se range, se déplace et se + // redimensionne avec les raccourcis que l'utilisateur connaît déjà. + // + // Le clavier vient du compositeur, comme pour n'importe quelle + // application : toute la danse WlrKeyboardFocus disparaît avec la couche. + // C'est l'idiome du greffon dev-gallery du shell. + FloatingWindow { id: journalWin visible: service.journalOpen - screen: Quickshell.screens.length > 0 ? Quickshell.screens[0] : null + title: "Jarvis — journal" color: "transparent" - - WlrLayershell.namespace: "macarchy-jarvis-journal" - WlrLayershell.layer: WlrLayer.Top - WlrLayershell.keyboardFocus: service.journalOpen ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None - // Respect the bar's reserved edge (so "top" means under it), reserve - // nothing ourselves. - exclusionMode: ExclusionMode.Normal - exclusiveZone: 0 - - anchors { top: true; right: true } - margins { top: Style.space(8); right: Style.space(8) } implicitWidth: Style.space(520) - implicitHeight: Math.min(Style.space(700), (screen ? screen.height : 900) - Style.space(60)) + implicitHeight: Style.space(700) + minimumSize: Qt.size(Style.space(360), Style.space(280)) + // The page's frame is the bubble's own stepped border, without a tail. PixelBubble { @@ -576,7 +573,15 @@ Item { } } - onVisibleChanged: if (visible) Qt.callLater(function() { journalPage.forceActiveFocus() }) + // Deux choses au même endroit, parce que QML ne garde qu'un gestionnaire + // par signal : le focus à l'ouverture, et l'état à la fermeture. Fermée + // par le gestionnaire de fenêtres (croix, raccourci), `journalOpen` + // doit suivre — sinon le prochain appui sur `journal` ne fait rien, + // le service la croyant déjà ouverte. + onVisibleChanged: { + if (visible) Qt.callLater(function() { journalPage.forceActiveFocus() }) + else service.journalOpen = false + } } PanelWindow { @@ -724,7 +729,6 @@ Item { Behavior on xScale { NumberAnimation { duration: 90 } } }, Translate { - id: swim x: service.swimX y: 0 } diff --git a/plugin/components/Journal.qml b/plugin/components/Journal.qml index 10a5af7..b5e96cb 100644 --- a/plugin/components/Journal.qml +++ b/plugin/components/Journal.qml @@ -52,18 +52,13 @@ FocusScope { } // ---------------------------------------------------------------- data - property var today: [] // [{at, ask, reply, thoughts, tools}] - property bool todayLoaded: false - property var conversations: [] // [{id, kind, current, started, ended, exchanges, first}] - property bool convLoaded: false + // today: [{at, ask, reply, thoughts, tools}] — alias, plus bas. + // conversations: [{id, kind, current, started, ended, exchanges, first}] property bool showTasks: false // A past conversation opened from the list: its row, and its exchanges. property string session: "" property var sessionMeta: null - property var sessionExchanges: [] - property bool sessionLoaded: false - property var routines: [] // [{n, on, when, days, once, verb, text, next, line}] - property bool routinesLoaded: false + // routines: [{n, on, when, days, once, verb, text, next, line}] // One line of feedback under the tabs (« Rien à oublier »), gone after 4 s. property string notice: "" @@ -115,56 +110,44 @@ FocusScope { onTriggered: root.refresh() } - Process { - id: todayProbe - command: ["omarchy-jarvis", "transcript", "--json", "30"] - stdout: StdioCollector { - waitForEnd: true - onStreamFinished: { - var list = root.parseList(text) || [] - if (!root.same(list, root.today)) root.today = list - root.todayLoaded = true - } - } - } - - Process { - id: sessionProbe - stdout: StdioCollector { - waitForEnd: true - onStreamFinished: { - var list = root.parseList(text) || [] - if (!root.same(list, root.sessionExchanges)) root.sessionExchanges = list - root.sessionLoaded = true - } - } - } - - Process { - id: convProbe - command: ["omarchy-jarvis", "conversations", "--json"] + // Quatre sondes, un seul corps : même StdioCollector, même parseList, + // même garde `same()` qui évite de réassigner une liste identique. Seule + // la commande change, et celle de la session est posée à la volée + // (refresh(), plus haut) — d'où son `command` absent ici. + // + // `owner` est passé explicitement : un `component` en ligne a sa propre + // portée et ne voit pas l'`id: root` du fichier. + component ListProbe: Process { + id: probe + required property var owner + property var rows: [] + property bool loaded: false stdout: StdioCollector { waitForEnd: true onStreamFinished: { - var list = root.parseList(text) || [] - if (!root.same(list, root.conversations)) root.conversations = list - root.convLoaded = true + var list = probe.owner.parseList(text) || [] + if (!probe.owner.same(list, probe.rows)) probe.rows = list + probe.loaded = true } } } - Process { - id: routinesProbe - command: ["omarchy-jarvis", "routines", "--json"] - stdout: StdioCollector { - waitForEnd: true - onStreamFinished: { - var list = root.parseList(text) || [] - if (!root.same(list, root.routines)) root.routines = list - root.routinesLoaded = true - } - } - } + ListProbe { id: todayProbe; owner: root; command: ["omarchy-jarvis", "transcript", "--json", "30"] } + ListProbe { id: sessionProbe; owner: root } + ListProbe { id: convProbe; owner: root; command: ["omarchy-jarvis", "conversations", "--json"] } + ListProbe { id: routinesProbe; owner: root; command: ["omarchy-jarvis", "routines", "--json"] } + + // Les huit noms publics sont des alias sur ces quatre sondes : tous les + // lecteurs de la page n'ont pas bougé, et sessionExchanges reste + // écrivable (onSessionChanged le remet à []). + property alias today: todayProbe.rows + property alias todayLoaded: todayProbe.loaded + property alias sessionExchanges: sessionProbe.rows + property alias sessionLoaded: sessionProbe.loaded + property alias conversations: convProbe.rows + property alias convLoaded: convProbe.loaded + property alias routines: routinesProbe.rows + property alias routinesLoaded: routinesProbe.loaded // ---------------------------------------------------------------- actions // @@ -228,17 +211,12 @@ FocusScope { if (m[1] + "-" + m[2] + "-" + m[3] === todayStamp()) return m[4] return m[3] + "/" + m[2] + " " + m[4] } - // The gutter is five characters wide: the hour today, the day otherwise. - function stampText(s) { - var m = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}:\d{2})$/.exec(String(s || "")) - if (!m) return String(s || "") - if (m[1] + "-" + m[2] + "-" + m[3] === todayStamp()) return m[4] - return m[3] + "/" + m[2] - } - function timeOf(s) { - var m = /(\d{2}:\d{2})$/.exec(String(s || "")) - return m ? m[1] : "" - } + // La gouttière fait cinq caractères de large : l'heure aujourd'hui, le + // jour sinon — soit exactement les cinq premiers de whenText dans les deux + // cas ("HH:MM" et "JJ/MM HH:MM"). Une entrée malformée est tronquée à + // cinq plutôt que rendue entière : c'est la largeur disponible de toute + // façon. + function stampText(s) { return whenText(s).slice(0, 5) } function nextText(epoch) { var n = Number(epoch) @@ -792,7 +770,7 @@ FocusScope { Text { width: parent.width text: root.plural(Number(row.modelData.exchanges || 0), "échange") - + (row.current ? ", en cours" : ", à " + root.timeOf(row.modelData.started)) + + (row.current ? ", en cours" : ", à " + String(row.modelData.started || "").slice(-5)) + (row.modelData.kind === "tâche" ? ", une tâche" : "") color: root.dim font.family: Style.font.family diff --git a/plugin/components/PillRow.qml b/plugin/components/PillRow.qml index 22ec19e..197d492 100644 --- a/plugin/components/PillRow.qml +++ b/plugin/components/PillRow.qml @@ -18,7 +18,8 @@ import qs.Ui Row { id: root - // Either a plain string[] (label == value) or [{ value, label }]. + // [{ value, label }], et rien d'autre : le format « tableau de chaînes » + // qu'acceptait optionValue()/optionLabel() n'a jamais eu d'appelant. property var options: [] property string value: "" property color foreground: Color.popups.text @@ -26,9 +27,6 @@ Row { signal changed(string value) - function optionValue(o) { return typeof o === "string" ? o : String(o.value) } - function optionLabel(o) { return typeof o === "string" ? o : String(o.label) } - spacing: Style.space(4) Repeater { @@ -38,12 +36,12 @@ Row { id: chip required property var modelData - text: root.optionLabel(chip.modelData) - active: root.optionValue(chip.modelData) === root.value + text: chip.modelData.label + active: chip.modelData.value === root.value bordered: true foreground: root.foreground fontSize: root.fontSize - onClicked: root.changed(root.optionValue(chip.modelData)) + onClicked: root.changed(chip.modelData.value) } } } diff --git a/plugin/components/PixelSprite.qml b/plugin/components/PixelSprite.qml index 6e86102..5778220 100644 --- a/plugin/components/PixelSprite.qml +++ b/plugin/components/PixelSprite.qml @@ -22,7 +22,6 @@ Item { property int frameWidth: 36 property int frameHeight: 28 property int pixelScale: 4 - property bool playing: true property int frame: 0 @@ -43,7 +42,6 @@ Item { // Rows of the frame eaten off the bottom, always whole ones. property int squash: 0 - property int squashRows: 6 width: frameWidth * pixelScale height: frameHeight * pixelScale @@ -91,7 +89,7 @@ Item { SequentialAnimation { id: cutAnim - NumberAnimation { target: root; property: "squash"; to: root.squashRows; duration: 70; easing.type: Easing.InQuad } + NumberAnimation { target: root; property: "squash"; to: 6; duration: 70; easing.type: Easing.InQuad } ScriptAction { script: { var todo = root.cutActions @@ -142,7 +140,7 @@ Item { Timer { interval: Math.max(40, Math.round(1000 / Math.max(0.1, root.shownFps))) - running: root.playing && root.visible && root.shownFrames > 1 + running: root.visible && root.shownFrames > 1 repeat: true onTriggered: root.frame = (root.frame + 1) % root.shownFrames } diff --git a/sprites/generate.py b/sprites/generate.py index fecace4..2b5f055 100644 --- a/sprites/generate.py +++ b/sprites/generate.py @@ -15,6 +15,7 @@ Canvas 72x56 at 1x; the shell scales nearest-neighbor (pixelScale 2). """ +import argparse import os import struct import sys @@ -362,21 +363,15 @@ def build(cfg, out): def parse(argv): - cfg = dict(parts.DEFAULT, pal=parts.DEFAULT_PALETTE) - out = OUT - it = iter(argv) - for a in it: - if a == "--out": - out = next(it) - elif a.startswith("--") and a[2:] in cfg: - v = next(it) - lib = parts.AXES.get(a[2:]) or (parts.PALETTES if a[2:] == "pal" else None) - if lib is None or v not in lib: - sys.exit(f"unknown {a[2:]}: {v} (choose from {', '.join(lib or [])})") - cfg[a[2:]] = v - else: - sys.exit(__doc__) - return cfg, out + # argparse tient la validation ET le message d'erreur que cette fonction + # écrivait à la main, et rend `--help` en prime. + ap = argparse.ArgumentParser(description=__doc__) + for axis, lib in parts.AXES.items(): + ap.add_argument(f"--{axis}", choices=lib, default=parts.DEFAULT[axis]) + ap.add_argument("--pal", choices=parts.PALETTES, default=parts.DEFAULT_PALETTE) + ap.add_argument("--out", default=OUT) + a = vars(ap.parse_args(argv)) + return {k: v for k, v in a.items() if k != "out"}, a["out"] def main(argv): diff --git a/sprites/parts.py b/sprites/parts.py index 5c12aeb..7503844 100644 --- a/sprites/parts.py +++ b/sprites/parts.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 """parts — the fish as pieces: a body with fixed anchors, and swappable eyes, crests and tails drawn on their own small grids. compose() stacks -them (tail, body, crest, eye) into the 52x28 body box used by -variants.py, so every combination lands on the same animation hooks: +them (tail, body, crest, eye) into the 52x28 body box, so every +combination lands on the same animation hooks: the eye socket (blink, look up), the mouth column (speak), the tail attachment (sway), the crest base (flutter). @@ -15,11 +15,34 @@ sys.path.insert(0, os.path.dirname(os.path.realpath(__file__))) import generate # noqa: E402 -import variants # noqa: E402 -BW, BH = variants.BW, variants.BH +# La géométrie et les deux aides venaient de variants.py, dont les huit +# poissons d'exploration ont fait leur travail : les quatre retenus sont +# redessinés dans BODIES ci-dessous, et le fichier est parti avec eux. +CW, CH = 72, 56 # canevas +BW, BH = 52, 28 # boîte du corps OUT = os.path.join(os.path.dirname(os.path.realpath(__file__)), "parts_out") + +def pad(rows): + rows = [r.ljust(BW, ".") for r in rows] + assert all(len(r) == BW for r in rows), [len(r) for r in rows if len(r) != BW] + while len(rows) < BH: + rows.append("." * BW) + assert len(rows) == BH, len(rows) + return rows + + +def compose(rows): + img = generate.Frame(CW, CH) + ox, oy = (CW - BW) // 2, CH - BH - 6 + for y, row in enumerate(rows): + for x, c in enumerate(row): + color = generate.PALETTE.get(c) + if color: + img.set(ox + x, oy + y, color) + return img + # ---------------------------------------------------------------- bodies # # Every body keeps its head top on row 5 (crests sit on it), fills its @@ -32,7 +55,7 @@ # head (x, y, w): the crown line the headphones band sits above BODIES = { - "B1": ("Babel", variants.pad([ + "B1": ("Babel", pad([ "", "", "", "", "", "........KYYYYYYYYYYYYYYK", ".......KKYYYYYYYYYYYYYYYYKK", @@ -56,7 +79,7 @@ "...........KKKKKKKKKKKKKK", ]), {"eye": (4, 9), "crest": (7, 0), "tail": (35, 9), "mouth": (0, 14), "head": (8, 5, 16)}), - "B2": ("Rond", variants.pad([ + "B2": ("Rond", pad([ "", "", "", "", "", "...........KKKKKKKK", "........KKKYYYHHHHKKK", @@ -81,7 +104,7 @@ ".........KKKKKKKK", ]), {"eye": (4, 9), "crest": (8, 0), "tail": (26, 9), "mouth": (0, 14), "head": (11, 5, 8)}), - "B3": ("Élancé", variants.pad([ + "B3": ("Élancé", pad([ "", "", "", "", "", ".............KKKKKKKKKKKKKKKKK", ".........KKKKDDDDDDDDDDDDDDDDDKKKK", @@ -99,7 +122,7 @@ ".........KKKKKKKKKKKKKKKKKKKKK", ]), {"eye": (3, 8), "crest": (12, 0), "tail": (38, 7), "mouth": (0, 12), "head": (13, 5, 17)}), - "B4": ("Anguille", variants.pad([ + "B4": ("Anguille", pad([ "", "", "", "", "", "..........KKKKKKK", "........KKYYYYYYYKKKK", @@ -352,8 +375,8 @@ def compose(config, only=None, crest=True, crest_dy=0): def main(): os.makedirs(OUT, exist_ok=True) def save(name, rows): - img = variants.compose(rows) - generate.write_png(os.path.join(OUT, f"{name}.png"), variants.CW, variants.CH, img.get) + img = compose(rows) + generate.write_png(os.path.join(OUT, f"{name}.png"), CW, CH, img.get) for axis, lib in AXES.items(): for key in lib: save(f"{axis}-{key}", compose(dict(DEFAULT, **{axis: key}))) diff --git a/sprites/preview.py b/sprites/preview.py deleted file mode 100755 index c2a0211..0000000 --- a/sprites/preview.py +++ /dev/null @@ -1,125 +0,0 @@ -#!/usr/bin/env python3 -"""preview — see a sprite sheet the way the artist needs to: pixel-exact. - -Two views of any sheet written by generate.py (or variants.py): - * ASCII: every composed frame rendered back to palette characters — - lossless, greppable, the only view where a stray pixel cannot hide; - * a magnified PNG (nearest-neighbor) of all frames, for the eye. - -Usage: preview.py [--fw N] [--scale N] [--out FILE] [--no-ascii] sheet.png… - --fw frame width in sheet pixels (default: generate.W, or the sheet - width when it is a single frame) - --scale magnification of the PNG (default 8) - --out PNG path (default: .preview.png next to the sheet) -""" - -import os -import struct -import sys -import zlib - -sys.path.insert(0, os.path.dirname(os.path.realpath(__file__))) -import generate # noqa: E402 (importable: rendering is behind __main__) - -# First palette name wins for shared colors (K over P: the outline). -REVERSE = {} -for _k, _v in generate.PALETTE.items(): - if _v[3] > 0 and _v not in REVERSE: - REVERSE[_v] = _k - - -def read_png(path): - """Decode the PNGs generate.write_png produces (RGBA8, filter 0).""" - data = open(path, "rb").read() - assert data[:8] == b"\x89PNG\r\n\x1a\n", path - pos, idat, w, h = 8, b"", 0, 0 - while pos < len(data): - ln = struct.unpack(">I", data[pos:pos + 4])[0] - tag, body = data[pos + 4:pos + 8], data[pos + 8:pos + 8 + ln] - pos += 12 + ln - if tag == b"IHDR": - w, h, depth, ctype = struct.unpack(">IIBB", body[:10]) - assert (depth, ctype) == (8, 6), "expected RGBA8" - elif tag == b"IDAT": - idat += body - raw = zlib.decompress(idat) - stride = w * 4 + 1 - rows = [] - for y in range(h): - line = raw[y * stride:(y + 1) * stride] - assert line[0] == 0, "only filter 0 is supported (our own writer)" - rows.append([tuple(line[1 + x * 4:5 + x * 4]) for x in range(w)]) - return w, h, rows - - -def char_for(rgba): - if rgba[3] == 0: - return "." - if rgba in REVERSE: - return REVERSE[rgba] - best, dist = "?", 1e9 - for color, ch in REVERSE.items(): - d = sum((a - b) ** 2 for a, b in zip(color[:3], rgba[:3])) + (color[3] - rgba[3]) ** 2 - if d < dist: - best, dist = ch, d - return best - - -def ascii_frames(rows, fw): - h, w = len(rows), len(rows[0]) - n = max(1, w // fw) - out = [] - for i in range(n): - out.append(["".join(char_for(px) for px in row[i * fw:(i + 1) * fw]) for row in rows]) - return out - - -def main(argv): - fw, scale, out, ascii_on, sheets = None, 8, None, True, [] - it = iter(argv) - for a in it: - if a == "--fw": - fw = int(next(it)) - elif a == "--scale": - scale = int(next(it)) - elif a == "--out": - out = next(it) - elif a == "--no-ascii": - ascii_on = False - else: - sheets.append(a) - if not sheets: - print(__doc__) - return 1 - decoded = [(p, *read_png(p)) for p in sheets] - if ascii_on: - for path, w, h, rows in decoded: - frames = ascii_frames(rows, fw or (generate.W if w % generate.W == 0 and w > generate.W else w)) - print(f"== {os.path.basename(path)} {w}x{h} {len(frames)} frame(s)") - for y in range(h): - print(" ".join(f[y] for f in frames)) - print() - gap = 2 - total_h = sum(h for _, _, h, _ in decoded) + gap * (len(decoded) - 1) - max_w = max(w for _, w, _, _ in decoded) - canvas = {} - y0 = 0 - for _, w, h, rows in decoded: - for y in range(h): - for x in range(w): - if rows[y][x][3]: - canvas[(x, y0 + y)] = rows[y][x] - y0 += h + gap - bg = (32, 48, 63, 255) - - def px(x, y): - return canvas.get((x // scale, y // scale), bg) - - out = out or (sheets[0] + ".preview.png") - generate.write_png(out, max_w * scale, total_h * scale, px) - print(f"png: {out} ({max_w * scale}x{total_h * scale})") - return 0 - - -if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) diff --git a/sprites/variants.py b/sprites/variants.py deleted file mode 100644 index 0cc9bbd..0000000 --- a/sprites/variants.py +++ /dev/null @@ -1,306 +0,0 @@ -#!/usr/bin/env python3 -"""variants — four deliberately different fish at twice the native -resolution (52x28 body on a 72x56 canvas, shown at pixelScale 2 — the -same size on screen as today's 26x14 at 4), for the human to judge. - -Each is one idle frame. The current fish is rendered alongside, upscaled -2x, as the control. Output: sprites/variants/.png + sheet.png. -""" - -import os -import sys - -sys.path.insert(0, os.path.dirname(os.path.realpath(__file__))) -import generate # noqa: E402 -from preview import read_png # noqa: E402 - -CW, CH = 72, 56 # canvas -BW, BH = 52, 28 # body box -OUT = os.path.join(os.path.dirname(os.path.realpath(__file__)), "variants") - - -def pad(rows): - rows = [r.ljust(BW, ".") for r in rows] - assert all(len(r) == BW for r in rows), [len(r) for r in rows if len(r) != BW] - while len(rows) < BH: - rows.append("." * BW) - assert len(rows) == BH, len(rows) - return rows - - -# --------------------------------------------------------------------------- -# A — « Rondouillard » : chibi tamagotchi, corps rond, œil immense avec -# reflet, joues, petites nageoires. Le mignon assumé. -RONDOUILLARD = pad([ - ".................KKKKKKK", - "..............KKKYYYHHHKKK", - "............KKYYYYHHHHHYYKK", - "..........KKYYYYYYHHHHYYYYYKK", - ".........KYYYYYYYYYHHYYYYYYYYK", - "........KYYYKKKKYYYYYYYYYYYYYYK", - ".......KYYKKWWWWKKYYYYYYYYYYYYYK.......KK", - "......KYYKWWWWWWWWKYYYYYYYYYYYYYK.....KFFK", - "......KYKWWWLLWWWWWKYYYYYYYYYYYYYK...KFFFK", - ".....KYYKWWLLWWPPWWKYYYYYYYYYYYYYYK.KFFFFK", - ".....KYYKWWWWWWPPPWKYYYYYYYYYYYYYYKKFFFFK", - ".....KYYKWWWWWWPPPWKYYYYYYYYYYYYYYFFFFFK", - ".....KYYYKWWWWWWWWKYYYYYYYYYYYYYYYFFFFK", - ".....KYYYYKKWWWWKKYYYYYYYYYYYYYYYFFFFFK", - ".....KRRYYRRKKKKYYYYYYYYYYYYYYYYYYFFFFFK", - ".....KRRYYYRRYYYYYYKFFFKYYYYYYYYYYKFFFFK", - "......KYYYYYYYYYYYKFFFFFKYYYYYYYYYKKFFFFK", - "......KYOOYYYYYYYYKFFFFFKYYYYYYYYYK.KFFFFK", - ".......KOOOOYYYYYYYKFFFKYYYYYYYYYK...KFFFK", - "........KOOOOOOYYYYYKKKYYYYYYYYYK.....KFFK", - ".........KOOOOOOOOOYYYYYYYYYYYYK.......KK", - "..........KKOOOOOOOOOOOOYYYYYKK", - "............KKOOOOOOOOOOOOKKK", - "..............KKKOOOOOOKKK", - ".................KKKKKKK", -]) - -# --------------------------------------------------------------------------- -# B — « Élancé » : fusiforme, dos sombre (contre-ombrage), dorsale, ligne -# de lumière, queue en croissant. Le poisson élégant. -ELANCE = pad([ - "", - "", - "", - "", - "", - "", - ".........................KK", - "........................KFFK", - ".......................KFFFFK", - ".............KKKKKKKKKKFFFFFFKKKKK", - ".........KKKKDDDDDDDDDDDDDDDDDDDDDKKKK", - "......KKKDDDDDDDDDDDDDDDDDDDDDDDDDDDYYKKK", - "....KKYYDDKKKDYYYYYYYYYYYYYYYYYYYYYYYYYYKK....KK", - "..KKYYYYKWWWKYYYYYYYYYYYYYYYYYYYYYYYYYYYYKK..KFFK", - ".KYYYYYKWLWPKYYYYYYYYYYYYYYYYYYYYYYYYYYYYYKKKFFFK", - "KYYYYYYKWWPPKYYYYYHHHHHHHHHHHHHHHHHHHYYYYYYFFFFK", - "KRYYYYYYKKKYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYFFFFK", - "KRYYYYYYYYYYYYYYKFFFKYYYYYYYYYYYYYYYYYYYYYFFFFK", - ".KYYYYYYYYYYYYYYKFFFFKYYYYYYYYYYYYYYYYYYYYKFFFFK", - "..KKOOOOOOOOOOOOOKFFFKOOOOOOOOOOOOOOOOOOKKKKFFFK", - "....KKOOOOOOOOOOOOKKKOOOOOOOOOOOOOOOOKKKK...KFFK", - "......KKKOOOOOOOOOOOOOOOOOOOOOOOKKKKK.......KK", - ".........KKKKKKKKKKKKKKKKKKKKKKK", -]) - -# --------------------------------------------------------------------------- -# C — « Babel » : fidèle au poisson du Guide — corps de sangsue effilé en -# pointe, panache de branchies sur la tête, grand œil, ventre clair. -BABEL = pad([ - ".........B..B..B", - "........KB.KB.KB", - ".......KYKKYKKYK", - "......KKYYYYYYYKK", - "....KKYYYHHHHHYYYKK", - "...KYYYHHHHHHHHHHYYKKK", - "..KYYHHHHHHHHHHHHHYYYYKKKK", - ".KYYHHKKKKHHHHHHHHHYYYYYYYKKKK", - ".KYHKWWWWWKHHHHHHHHYYYYYYYYYYYKKKK", - "KYHKWWLLWWWKHHHHHHHYYYYYYYYYYYYYYYKKKK", - "KYHKWWLLWPPWKHHHHHHYYYYYYYYYYYYYYYYYYYKKKK", - "KYHKWWWWWPPPKHHHHHYYYYYYYYYYYYYYYYYYYYYYYYKKK", - "KYYKWWWWWWPPKYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYKKK", - "KRYYKWWWWWWKYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYKKK", - "KRYYYKKKKKKYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYOOOOKK", - "KYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYOOOOOOOKK", - ".KYYYYYYYYYYYYKFFKYYYYYYYYYYYYYYYYYYYOOOOOOOOKKK", - ".KYOOYYYYYYYYKFFFFKYYYYYYYYYYYYYOOOOOOOOOOKKK", - "..KOOOOYYYYYYKFFFFKYYYYYYYYYOOOOOOOOOOKKKK", - "...KOOOOOOOYYYKFFKOOOOOOOOOOOOOOOOKKKK", - "....KKOOOOOOOOOKKOOOOOOOOOOOOKKKKK", - "......KKOOOOOOOOOOOOOOOOOKKKKK", - "........KKKOOOOOOOOOKKKKK", - "...........KKKKKKKKKK", -]) - -# --------------------------------------------------------------------------- -# D — « Game Boy » : quatre tons, contour double, formes franches, œil -# sans blanc. Le rétro net. -GAMEBOY = pad([ - "", - "", - "", - "", - "", - "..........KKKKKKKKKKKKKKK", - "........KKYYYYYYYYYYYYYYYKK", - "......KKYYYYYYYYYYYYYYYYYYYKK", - ".....KKYYYYYYYYYYYYYYYYYYYYYKK", - "....KKYYYKKKKYYYYYYYYYYYYYYYYKK.........KKK", - "...KKYYYKHHHHKYYYYYYYYYYYYYYYYKK......KKOOKK", - "...KYYYYKHKKHKYYYYYYYYYYYYYYYYYKK....KKOOOOK", - "..KKYYYYKHKKHKYYYYYYYYYYYYYYYYYYKK..KKOOOOKK", - "..KYYYYYKHHHHKYYYYYYYYYYYYYYYYYYYKKKKOOOOKK", - "..KYYYYYYKKKKYYYYYYYYYYYYYYYYYYYYYOOOOOOKK", - "..KKKYYYYYYYYYYYYYYYYYYYYYYYYYYYYYOOOOOKK", - "..KYYYYYYYYYYYYYKKKYYYYYYYYYYYYYYYOOOOOOKK", - "..KYYYYYYYYYYYYKOOOKYYYYYYYYYYYYYKKKOOOOKK", - "..KKOOOYYYYYYYYKOOOKYYYYYYYYYYYYKK..KKOOOOK", - "...KKOOOOOYYYYYKKKKYYYYYYYYYYYKK.....KKOOKK", - "....KKOOOOOOOOOOOOOOOOOOOOOOOKK........KKK", - ".....KKOOOOOOOOOOOOOOOOOOOOKK", - ".......KKOOOOOOOOOOOOOOOKKK", - ".........KKKKKKKKKKKKKKK", -]) - -# =========================================================================== -# The Babel direction, explored: four sub-variants, one axis each. - -# C1 — « Ruban » : la sangsue, mais la queue finit en ruban translucide -# (F sans contour) au lieu d'une pointe nue — le voile du film. -RUBAN = pad([ - "........B..B..B..B", - ".......KYKKYKKYKKY", - "......KKYYYYYYYYYKK", - "....KKYYYHHHHHHYYYYKKK", - "...KYYYHHHHHHHHHYYYYYYKKK", - "..KYYHHHHHHHHHHHHYYYYYYYYKKK", - ".KYYHHKKKKKHHHHHHHYYYYYYYYYYKKK", - ".KYHKWWWWWWKHHHHHHYYYYYYYYYYYYYKKK..........FF", - "KYHKWWLLWWWWKHHHHHYYYYYYYYYYYYYYYYKKK.......FFFF", - "KYHKWWLLWWPPWKHHHHYYYYYYYYYYYYYYYYYYKKK...FFFFF", - "KYHKWWWWWWPPPKHHHYYYYYYYYYYYYYYYYYYYYYKKKFFFFFFF", - "KYYKWWWWWWWPPKYYYYYYYYYYYYYYYYYYYYYYYYYYKFFFFFFFFF", - "KRYYKWWWWWWWKYYYYYYYYYYYYYYYYYYYYYYYYYYYYKFFFFFFF", - "KRYYYKKKKKKKYYYYYYYYYYYYYYYYYYYYYYYYYYOOOKFFFFF", - "KYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYOOOOKKFFFF", - ".KYYYYYYYYYYYYKFFKYYYYYYYYYYYYYYYOOOOOOKK.FF", - ".KYOOYYYYYYYYKFFFFKYYYYYYYYYYYOOOOOOOKK", - "..KOOOOYYYYYYKFFFFKYYYYYYYOOOOOOOOKKK", - "...KOOOOOOYYYYKFFKOOOOOOOOOOOOOKKK", - "....KKOOOOOOOOOKKOOOOOOOOOKKKK", - "......KKOOOOOOOOOOOOOOKKKK", - "........KKKOOOOOOOKKKK", - "...........KKKKKKK", -]) - -# C2 — « Panache » : corps compact de poisson, mais la crinière de -# branchies devient LE signe : cinq filaments hauts, en éventail arrière. -PANACHE = pad([ - "..............B....B....B", - ".............KY...KY...KY...B", - "............KYK..KYK..KYK..KY", - "..........B.KYK.KYK..KYK..KY", - ".........KYKKYYKKYKKKYK..KY", - "........KYYYYYYYYYYYYYYKKKY", - ".......KKYYYYYYYYYYYYYYYYKK", - ".....KKYYYHHHHHHHHYYYYYYYYKKK", - "....KYYYHHHHHHHHHHHHYYYYYYYYYKK", - "...KYYHKKKKKHHHHHHHHHYYYYYYYYYYKK", - "..KYYHKWWWWWKHHHHHHHHYYYYYYYYYYYYKK.....KK", - ".KYYHKWWLLWWWKHHHHHHHYYYYYYYYYYYYYYK...KFFK", - ".KYHKWWLLWWPPWKHHHHHHYYYYYYYYYYYYYYYK.KFFFK", - "KYYHKWWWWWWPPPKHHHHHYYYYYYYYYYYYYYYYYKKFFFK", - "KRYYKWWWWWWWPPKYYYYYYYYYYYYYYYYYYYYYYFFFFK", - "KRYYYKWWWWWWWKYYYYYYYYYYYYYYYYYYYYYYYFFFK", - "KYYYYYKKKKKKKYYYYYYYYYYYYYYYYYYYYYYYYFFFFK", - ".KYYYYYYYYYYYYYYKFFFKYYYYYYYYYYYYYYYYKFFFFK", - ".KYOOYYYYYYYYYYKFFFFFKYYYYYYYYYYYYYYK.KFFFK", - "..KOOOOYYYYYYYYKFFFFFKYYYYYYYYYYYYYK...KFFK", - "...KOOOOOOYYYYYYKFFFKYYYYYYYYYYOOOKK....KK", - "....KKOOOOOOOOOOOKKKOOOOOOOOOOOOKK", - "......KKOOOOOOOOOOOOOOOOOOOOOKKK", - "........KKKOOOOOOOOOOOOOOKKKK", - "...........KKKKKKKKKKKKKK", -]) - - -def translucent(rows): - """C3 — « Translucide » : vu comme dans le film — contour ocre foncé - au lieu de noir (sauf l'œil), un cœur clair qui affleure au milieu - du corps, comme une lampe dans la chair.""" - out = [] - for y, row in enumerate(rows): - cells = list(row) - for x, c in enumerate(cells): - in_eye = 6 <= y <= 13 and 3 <= x <= 13 - if c == "K" and not in_eye: - cells[x] = "D" - if 9 <= y <= 13: - lo, hi = 16 + abs(11 - y) * 3, 40 - abs(11 - y) * 4 - for x in range(lo, hi): - if cells[x] == "Y": - cells[x] = "H" - out.append("".join(cells)) - return out - - -TRANSLUCIDE = translucent(RUBAN) - -# C4 — « Anguille » : très allongé, bas, un ruban dorsal sur tout le dos -# et la queue qui ondule vers le haut ; petit œil, trois filaments. -ANGUILLE = pad([ - "", - "", - "", - "", - "", - "", - ".....B..B..B", - "....KB.KB.KB", - "...KYKKYKKYK......FFFF...FFFF", - "..KKYYYYYYYKKKK.FFFFFFFFFFFFFFFFF", - ".KYYHHHHHHYYYYYKKKKKFFFFFFFFFFFFFKKKK", - ".KYHKKKKHHHYYYYYYYYYKKKKKK.....KKKYYYYKK", - "KYHKWWWWKHHYYYYYYYYYYYYYYYKKKKKKYYYYYYYYYKKK", - "KYHKWLWPPKHHYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYKKKK", - "KYYKWWWPPKYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYKKK", - "KRYYKKKKKYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYK", - "KRYYYYYYYYYYYKFFKYYYYYYYYYYYYYYOOOOOOOOOOOOOOOOOOOOK", - ".KYOOYYYYYYYKFFFFKYYYYYYYOOOOOOOOOOOOOOOOOOOOKKKKKK", - "..KOOOOOYYYYYKFFKYYOOOOOOOOOOOOOOOOOOKKKKKKK", - "...KKOOOOOOOOOOKKOOOOOOOOOOOOOOKKKKKK", - ".....KKKOOOOOOOOOOOOOOOOKKKKKKK", - "........KKKKKKKKKKKKKKKK", -]) - -VARIANTS = [("A-rondouillard", RONDOUILLARD), ("B-elance", ELANCE), - ("C-babel", BABEL), ("D-gameboy", GAMEBOY), - ("C1-ruban", RUBAN), ("C2-panache", PANACHE), - ("C3-translucide", TRANSLUCIDE), ("C4-anguille", ANGUILLE)] - - -def compose(rows): - img = generate.Frame(CW, CH) - ox, oy = (CW - BW) // 2, CH - BH - 6 - for y, row in enumerate(rows): - for x, c in enumerate(row): - color = generate.PALETTE.get(c) - if color: - img.set(ox + x, oy + y, color) - return img - - -def current_2x(): - """Today's idle frame 0, doubled: the control, at the same on-screen size.""" - w, h, px = read_png(os.path.join(generate.OUT, "idle.png")) - img = generate.Frame(CW, CH) - ox, oy = (CW - generate.W * 2) // 2, CH - generate.H * 2 - for y in range(generate.H): - for x in range(generate.W): - c = px[y][x] - if c[3]: - for dy in (0, 1): - for dx in (0, 1): - img.set(ox + 2 * x + dx, oy + 2 * y + dy, c) - return img - - -def main(): - os.makedirs(OUT, exist_ok=True) - frames = [("actuel-2x", current_2x())] + [(n, compose(r)) for n, r in VARIANTS] - for name, img in frames: - generate.write_png(os.path.join(OUT, f"{name}.png"), CW, CH, img.get) - def px(x, y): - return frames[x // CW][1].get(x % CW, y) - generate.write_png(os.path.join(OUT, "sheet.png"), CW * len(frames), CH, px) - print("variants:", ", ".join(n for n, _ in frames)) - - -if __name__ == "__main__": - main() diff --git a/systemd/jarvis-brain.service b/systemd/jarvis-brain.service new file mode 100644 index 0000000..864be1b --- /dev/null +++ b/systemd/jarvis-brain.service @@ -0,0 +1,37 @@ +[Unit] +Description=Jarvis — cerveau local (llama-server) +Documentation=https://github.com/macarchy/jarvis +After=graphical-session.target + +[Service] +Type=exec +# --reasoning off n'est pas une préférence : Qwen3.5 réfléchit par défaut, +# et sa réflexion part dans reasoning_content, en anglais, jusqu'à épuiser +# le budget de tokens. Mesuré : la réponse n'arrivait JAMAIS. Sans ce +# drapeau le poisson est muet et rien ne dit pourquoi. +# +# -c 8192 suffit à un fil parlé (persona ~1 700 tokens + une vingtaine de +# tours courts) et garde le cache KV petit. Monter le contexte coûte de la +# RAM que la machine préfère donner au reste. +ExecStart=%h/.local/bin/llama-server \ + --model %h/.local/share/jarvis/models/Qwen3.5-4B-Q4_K_M.gguf \ + --host 127.0.0.1 --port 8099 \ + --n-gpu-layers 99 --ctx-size 8192 \ + --reasoning off --no-webui +# Le prefill de la persona coûte ~16 s la première fois. Le payer ici plutôt +# qu'au premier « dis-moi » est toute la différence entre un poisson qui +# répond en deux secondes et un poisson qui fait attendre. +# +# `omarchy-jarvis warmup` et pas un curl : il envoie EXACTEMENT la persona +# que l'échange enverra (SOUL.md + memory/OFFLINE_PROMPT.md). Mesuré — un +# préchauffage à « bonjour » sans prompt système réchauffe un préfixe que +# personne n'utilise, et le premier échange coûtait encore 23 s. +# +# `-` : un préchauffage raté ne doit pas faire tomber le serveur. +ExecStartPost=-%h/.local/bin/omarchy-jarvis warmup +Restart=on-failure +RestartSec=10 +Nice=5 + +[Install] +WantedBy=default.target diff --git a/tests/bin/claude b/tests/bin/claude index 7b79ae3..24bc447 100755 --- a/tests/bin/claude +++ b/tests/bin/claude @@ -3,7 +3,6 @@ # FAKE_CLAUDE_STREAM= cat this stream-json fixture to stdout # FAKE_CLAUDE_REPLY= print this plain reply # FAKE_CLAUDE_FICHE=1 write the fiche named by the digest prompt -# FAKE_CLAUDE_EXIT= exit status (default 0) # FAKE_CLAUDE_SLEEP= think for that long before answering — which is # what makes `thinking` a state a test can press # a key in, and what a killed group looks like @@ -18,4 +17,4 @@ if [[ ${FAKE_CLAUDE_FICHE:-} == 1 && $# -gt 0 ]]; then fi [[ -n ${FAKE_CLAUDE_STREAM:-} ]] && cat "$FAKE_CLAUDE_STREAM" [[ -n ${FAKE_CLAUDE_REPLY:-} ]] && printf '%s' "$FAKE_CLAUDE_REPLY" -exit "${FAKE_CLAUDE_EXIT:-0}" +exit 0 diff --git a/tests/bin/fake-llama b/tests/bin/fake-llama new file mode 100755 index 0000000..e09aced --- /dev/null +++ b/tests/bin/fake-llama @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Un llama-server de poche : /v1/chat/completions en SSE, et rien d'autre. + +Il existe pour que la suite prouve LE CONTRAT — que jarvis-local traduit +bien du SSE OpenAI vers les événements que jarvis-sentences attend — sans +2,7 Go de modèle ni GPU. Ce que le vrai serveur fait de plus (penser, être +lent, tomber en panne) se simule par variables d'environnement : + + /reply le texte rendu, découpé en morceaux + /think du reasoning_content émis AVANT (doit être ignoré) + /log le corps reçu, pour les assertions + +Les réglages vivent dans des FICHIERS, pas dans l'environnement, et sont +relus à chaque requête : un serveur démarré une fois par test ne peut pas +hériter d'une variable posée plus tard sur la ligne de commande de jarvis. +Le piège a coûté deux tours de suite ici. +""" +import json +import os +import sys +from http.server import BaseHTTPRequestHandler, HTTPServer + + +DIR = None + + +def knob(name, default=""): + try: + with open(os.path.join(DIR, name)) as f: + return f.read().strip() + except OSError: + return default + + +class H(BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_GET(self): + self.send_response(200) + self.end_headers() + self.wfile.write(b'{"status":"ok"}') + + def do_POST(self): + body = self.rfile.read(int(self.headers.get("Content-Length", 0))) + with open(os.path.join(DIR, "log"), "ab") as f: + f.write(body + b"\n") + + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.end_headers() + + def chunk(field, text): + payload = {"choices": [{"delta": {field: text}, "index": 0}]} + self.wfile.write(b"data: " + json.dumps(payload).encode() + b"\n\n") + self.wfile.flush() + + for word in knob("think").split(): + chunk("reasoning_content", word + " ") + reply = knob("reply", "Bonjour. Ça va bien.") + # Mot à mot : un vrai serveur ne rend jamais la phrase d'un bloc, et + # c'est justement le découpage qui met jarvis-sentences à l'épreuve. + for word in reply.split(" "): + chunk("content", word + " ") + self.wfile.write(b"data: [DONE]\n\n") + self.wfile.flush() + + +if __name__ == "__main__": + DIR = sys.argv[2] + os.makedirs(DIR, exist_ok=True) + HTTPServer(("127.0.0.1", int(sys.argv[1])), H).serve_forever() diff --git a/tests/fixtures/reflexes-cases.txt b/tests/fixtures/reflexes-cases.txt new file mode 100644 index 0000000..7efbe49 --- /dev/null +++ b/tests/fixtures/reflexes-cases.txt @@ -0,0 +1,129 @@ +# Cas de la table des réflexes : transcript rangée attendue, +# ou AUCUNE quand la demande doit atteindre le cerveau. +# +# Les négatifs sont le cœur du fichier. Une table qui mange les vraies +# questions est pire que pas de table — cette liste est ce qui grandit +# le jour où une rangée se révèle trop gourmande, comme pour NOISE.md. + +quelle heure est-il heure +Quelle heure est-il ? heure +Quel heure est-il ? heure +qu'elle oeuf est-il heure +quelle heure et il ? heure +il est quelle heure heure +quelle heure il est heure +t'as l'heure heure +donne-moi l'heure heure +dis-moi quelle heure il est heure +quel leur est-il heure +on est quel jour date +c'est quel jour aujourd'hui date +quelle est la date du jour date +donne-moi la date date +on est le combien date +Quel est le pourcentage de batterie ? batterie +Quel est le niveau de batterie ? batterie +niveau de batterie batterie +il me reste combien de batterie batterie +combien de batterie il me reste batterie +la batterie est à combien batterie +how much battery left batterie +Le mode ne pas déranger est-il actif ? dnd +le mode ne pas déranger et il actif dnd +est-ce que le ne pas déranger est activé dnd +mets le son à 30 son-n +le son à 30 son-n +règle le volume sur 45 % son-n +mets le son à 500 son-n +monte le son son-plus +plus fort son-plus +un peu plus fort son-plus +est-ce que tu peux monter le son son-plus +baisse le son son-moins +moins fort son-moins +un peu moins fort son-moins +coupe le son son-mute +mets en sourdine son-mute +éteins la musique son-mute +remets le son son-demute +enlève la sourdine son-demute +coupe la sourdine son-demute +mets le thème clair theme-clair +mais le thème clair theme-clair +mode clair theme-clair +mode sombre theme-sombre +passe en mode sombre theme-sombre +lance l'aquarium aquarium-on +rallume l'aquarium aquarium-on +aquarium on aquarium-on +coupe l'aquarium aquarium-off +aquarium of aquarium-off +éteins l'aquarium aquarium-off +Verrouille mon ordinateur. verrouille +verrouille l'écran verrouille +verrouille la machine verrouille +est-ce que tu peux verrouiller mon ordi verrouille +quelle heure est-il à Tokyo AUCUNE +quelle est l'heure du japon AUCUNE +à quelle heure est mon rendez-vous AUCUNE +rappelle-moi l'heure du train AUCUNE +Règle le fuseau horaire sur Bruxelles AUCUNE +il est l'heure de partir AUCUNE +quelle est la date de sortie du film AUCUNE +quelle est la date limite AUCUNE +la date AUCUNE +quel jour AUCUNE +pourquoi la batterie se vide si vite AUCUNE +quel est le niveau de batterie de mon casque AUCUNE +batterie AUCUNE +Ne pas déranger AUCUNE +active le mode ne pas déranger AUCUNE +pourquoi le son est-il si fort ? AUCUNE +parle plus fort AUCUNE +est-ce que tu peux parler plus fort AUCUNE +mets ça sur 3 AUCUNE +mets ça à 20 AUCUNE +son 30 AUCUNE +volume 3 AUCUNE +mets le son à trente pour cent AUCUNE +remets la musique AUCUNE +éteins tout AUCUNE +coupe tout AUCUNE +coupe le wifi AUCUNE +mets l'écran sur 2 AUCUNE +sois plus clair AUCUNE +mets ça plus clair AUCUNE +mais clair AUCUNE +c'est clair AUCUNE +j'aime bien le mode sombre des applis AUCUNE +quel thème est actif AUCUNE +est-ce que l'aquarium tourne AUCUNE +Verrouiller l'écran AUCUNE +verrouille AUCUNE +bloque mon ordinateur AUCUNE +Parfait, referme-le AUCUNE +ouvre le centre de notifications AUCUNE +qu'est-ce que j'ai raté ouvre les notifications AUCUNE +quelle est la meteo du jour? AUCUNE +Quel temps fera-t-il demain ? AUCUNE +Sous-titres réalisés par la communauté d'Amara.org AUCUNE +C'est un peu plus... C'est vraiment un peu plus... AUCUNE +Qui es-tu ? Réponds en une phrase. AUCUNE +regarde mon écran et dis-moi en une phrase ce qu'il y a dessus AUCUNE +baisse la luminosité AUCUNE +mets la luminosité à cinquante AUCUNE + +# — ajoutés à la main : de vraies demandes du corpus +quelle heure est-il à New York ? AUCUNE +explique-moi pourquoi ma batterie se vide si vite AUCUNE +que penses tu de macarchy? AUCUNE +comment on règle la luminosité sur cette machine ? AUCUNE +est-ce que tu peux baisser le son quand je regarde un film ? AUCUNE +raconte-moi une histoire sur une horloge AUCUNE + +# — quel cerveau répond +quel cerveau tu utilises ? cerveau +tu tournes sur quel modèle cerveau +which brain are you using cerveau +explique-moi comment marche ton cerveau AUCUNE +est-ce que tu peux changer de cerveau ? AUCUNE diff --git a/tests/fixtures/reflexes.md b/tests/fixtures/reflexes.md new file mode 100644 index 0000000..abed3d6 --- /dev/null +++ b/tests/fixtures/reflexes.md @@ -0,0 +1,15 @@ + + +# une rangée sans slot +quelle heure est[- ]il ?[ ?!.]* echo 14:00 - Il est {}. | It is {}. + +# une rangée avec slot numérique +(mets|r[ée]gle) le son [aà] [0-9]{1,3} ?%?[ .!]* echo vol={n} num Son à {n} pour cent. | Volume at {n} percent. + +# une commande interdite : doit être refusée AU CHARGEMENT +formate le disque rm -rf /tmp/jamais - fait | done + +# une commande qui échoue : doit rendre la main au cerveau +teste un [ée]chec false - jamais dit | never said diff --git a/tests/run b/tests/run index 60a161d..03a1aa7 100755 --- a/tests/run +++ b/tests/run @@ -17,8 +17,15 @@ pass=0 fail=0 skipped=0 current="" # mktemp trees (one per test, they used to pile up in /tmp) nor the real # recorders and players a press leaves running on purpose. sandboxes=() +# Les faux llama-server vivent hors des sandbox (ce sont des processus, pas +# des répertoires) : sans cette liste, un test interrompu laisserait un +# serveur accroché à son port jusqu'au prochain redémarrage. +llama_pids=() cleanup() { - local sb pidfile pgid + local sb pidfile pgid pid + for pid in ${llama_pids+"${llama_pids[@]}"}; do + kill "$pid" 2>/dev/null + done for sb in ${sandboxes+"${sandboxes[@]}"}; do # Whole groups, the way bin/jarvis kills them: a bare pid would leave # the recorder holding the microphone behind its little session shell. @@ -48,6 +55,18 @@ sandbox() { # has not asked for one of its own must never inherit the doctored copy # left exported by the previous test. unset JARVIS_SOUL + # Le cerveau est épinglé sur le nuage, c'est-à-dire sur le faux `claude`. + # Sans ça, tout test qui met BRAIN_STATUS à `quota` ou `down` bascule en + # route locale et va frapper un vrai llama-server sur 127.0.0.1 : la + # suite cesserait d'être hermétique et passerait ou échouerait selon ce + # qui tourne sur la machine du développeur. L'URL pointe dans le vide + # pour que même une erreur de route ne puisse joindre personne. + export JARVIS_BRAIN=nuage JARVIS_BRAIN_URL="http://127.0.0.1:9" + # Réflexes coupés par défaut, pour la même raison : les tests du chemin + # vocal parlent au cerveau, et « quelle heure est-il » est justement + # l'énoncé d'exemple de cinq d'entre eux. Les tests des réflexes + # rallument la table eux-mêmes. + export JARVIS_REFLEX=0 mkdir -p "$JARVIS_RUN" "$JARVIS_STATE" "$JARVIS_MEMORY/knowledge" cp "$TESTS_DIR"/../memory/*_PROMPT.md "$JARVIS_MEMORY/" echo test >"$JARVIS_MEMORY/knowledge/VERSION" @@ -862,13 +881,13 @@ t "aparté : une ronde attend son tour et arrive quand même" # mid-answer used to wipe the bubble the user was reading. FAKE_CLAUDE_STREAM="$FIX/report.jsonl" "$JARVIS" heartbeat assert "rien n'est passé au poisson" bash -c "! grep -q 'macarchy.jarvis reply' '$FAKE_SHELL_LOG' 2>/dev/null" -assert "l'aparté est garé" grep -q 'disque' "$JARVIS_STATE/aside-pending" +assert "la réponse est garée" grep -q 'disque' "$JARVIS_STATE/reply-pending" assert "l'émotion est garée" test "$(cat "$JARVIS_STATE/emote-pending")" = worried "$JARVIS" cancel assert "l'aparté est arrivé" grep -q 'macarchy.jarvis reply.*disque' "$FAKE_SHELL_LOG" assert "l'aparté est sur la Touch Bar" grep -q 'macarchy.jarvis reply.*disque' "$FAKE_DFR_LOG" assert "l'émotion aussi" grep -q 'macarchy.jarvis emote worried' "$FAKE_SHELL_LOG" -assert "plus rien en attente" test ! -e "$JARVIS_STATE/aside-pending" +assert "plus rien en attente" test ! -e "$JARVIS_STATE/reply-pending" t "un échec passager n'efface pas la conversation" printf 'idle' >"$JARVIS_RUN/state" @@ -1149,7 +1168,10 @@ assert "toutes les planches attendues" bash -c ' for sheet in idle listening thinking speaking cancel sleeping tired dnd worried proud curious celebrate; do [[ -e "$1/$sheet.png" ]] || { echo "planche manquante : $sheet"; exit 1; } done' _ "$JARVIS_SPRITES" -assert "canevas 72 de large" bash -c "python3 '$TESTS_DIR/../sprites/preview.py' --no-ascii --scale 1 --out /dev/null '$JARVIS_SPRITES/tired.png' | grep -q '144x56'" +# La largeur se lit dans l'IHDR du PNG, seize octets après l'en-tête : c'est +# tout ce que ce test demandait à sprites/preview.py, qui rendait « 144x56 » +# (le canevas 72 doublé) au prix d'un décodeur PNG maison de 125 lignes. +assert "canevas 144 de large (72 doublé)" bash -c "python3 -c \"import struct,sys;print(struct.unpack('>II', open(sys.argv[1],'rb').read()[16:24])[0])\" '$JARVIS_SPRITES/tired.png' | grep -qx '144'" assert "le poisson a rechargé ses planches" grep -q 'macarchy.jarvis reload' "$FAKE_SHELL_LOG" # ---- every shape configuration, every part-wise move, attached @@ -1330,6 +1352,197 @@ printf 'idle' >"$JARVIS_RUN/state" assert "boîte échafaudée" test -d "$JARVIS_BOX/input/.work" assert "aucun appel cerveau" test ! -s "$FAKE_CLAUDE_LOG" +# ---- le cerveau local +# +# Ce que ces tests protègent tient en une phrase : le chemin de la voix ne +# doit pas savoir quel cerveau lui parle. jarvis-local traduit du SSE OpenAI +# vers les événements de `claude -p --output-format stream-json`, et si cette +# traduction dérive, le poisson devient muet sans que rien ne le dise. + +# Le faux serveur se pilote par FICHIERS sous $LLAMA_DIR, jamais par +# l'environnement : il est forke une fois par test, et une variable posee +# ensuite sur la ligne de commande de jarvis ne l'atteindrait pas. +llama_say() { printf '%s' "$1" >"$LLAMA_DIR/reply"; } +llama_think(){ printf '%s' "$1" >"$LLAMA_DIR/think"; } +llama_log() { printf '%s' "$LLAMA_DIR/log"; } + +llama_up() { + LLAMA_PORT=$((18000 + RANDOM % 1000)) + LLAMA_DIR="$SB/llama" + mkdir -p "$LLAMA_DIR" + "$TESTS_DIR/bin/fake-llama" "$LLAMA_PORT" "$LLAMA_DIR" & + LLAMA_PID=$! + llama_pids+=("$LLAMA_PID") + export JARVIS_BRAIN=local JARVIS_BRAIN_URL="http://127.0.0.1:$LLAMA_PORT" + local i=0 + until curl -sf "http://127.0.0.1:$LLAMA_PORT/health" >/dev/null 2>&1; do + i=$((i + 1)); ((i > 100)) && return 1; sleep 0.05 + done +} + +t "local: la voix parle, et le nuage n'est jamais appelé" +llama_up || skip "faux serveur non démarré" +llama_say "Il fait beau. Profites-en." +"$JARVIS" ask "raconte-moi" +assert "réponse prononcée" grep -q "Il fait beau" "$FAKE_PIPER_LOG" +assert "deuxième phrase aussi" grep -q "Profites-en" "$FAKE_PIPER_LOG" +assert "aucun appel au nuage" test ! -s "$FAKE_CLAUDE_LOG" + +t "local: la réflexion d'un modèle thinking n'est jamais prononcée" +llama_up || skip "faux serveur non démarré" +llama_think "Okay so the user is asking about the weather" +llama_say "Je ne peux pas lire la météo hors ligne." +"$JARVIS" ask "la météo ?" +assert "la réponse est dite" grep -q "hors ligne" "$FAKE_PIPER_LOG" +assert "la réflexion ne l'est pas" bash -c "! grep -qi 'Okay so the user' '$FAKE_PIPER_LOG'" + +t "local: l'âme et OFFLINE.md partent bien dans le prompt système" +llama_up || skip "faux serveur non démarré" +"$JARVIS" ask --quiet "qui es-tu" +assert "prompt système envoyé" grep -q '"role": *"system"' "$(llama_log)" +assert "la règle d'or y est" grep -q "N.INVENTE JAMAIS" "$(llama_log)" +assert "CLAUDE.md n'y est PAS" bash -c "! grep -q 'omarchy theme set' '$(llama_log)'" + +t "local: un serveur éteint est un cerveau injoignable, pas un silence" +JARVIS_BRAIN=local JARVIS_BRAIN_URL="http://127.0.0.1:9" \ + "$JARVIS" ask "tu es là ?" +assert "excuse dite à voix haute" grep -q "cerveau" "$FAKE_PIPER_LOG" + +t "local: le fil se souvient, et reset l'oublie" +llama_up || skip "faux serveur non démarré" +"$JARVIS" ask --quiet "je m'appelle Philippe" +assert "historique écrit" test -s "$JARVIS_STATE/local-history.json" +"$JARVIS" ask --quiet "comment je m'appelle ?" +assert "le tour précédent est renvoyé" grep -q "Philippe" "$(llama_log)" +"$JARVIS" reset +assert "historique effacé" test ! -e "$JARVIS_STATE/local-history.json" + +t "local: un tour local n'efface JAMAIS un quota du nuage" +llama_up || skip "faux serveur non démarré" +until_epoch=$(( $(date +%s) + 3600 )) +printf 'quota %s' "$until_epoch" >"$JARVIS_STATE/brain" +"$JARVIS" ask --quiet "dis-moi quelque chose" +assert "le quota tient toujours" grep -q "quota $until_epoch" "$JARVIS_STATE/brain" + +t "auto: un nuage tombé depuis longtemps est retenté, pas condamné" +printf 'down' >"$JARVIS_STATE/brain" +touch -d "1 hour ago" "$JARVIS_STATE/brain" +route=$(JARVIS_BRAIN=auto "$JARVIS" status | sed -n 's/^route=//p') +assert "retour au nuage après le délai" test "$route" = nuage +touch "$JARVIS_STATE/brain" +route=$(JARVIS_BRAIN=auto "$JARVIS" status | sed -n 's/^route=//p') +assert "mais pas tout de suite" test "$route" = local + +# ---- les réflexes +# +# Le mécanisme se tient sur une table-fixture, pas sur celle qui est livrée : +# memory/REFLEXES.md est un fichier de POLITIQUE, il bougera, et un test du +# mécanisme qui casse à chaque rangée ajoutée finit par être désactivé. Ce +# que la vraie table doit garantir est tenu séparément, tout en bas. + +reflexes_fixture() { + cp "$FIX/reflexes.md" "$JARVIS_MEMORY/REFLEXES.md" + export JARVIS_REFLEX=1 +} + +t "réflexe : répond sans jamais appeler le cerveau" +reflexes_fixture +printf 'idle' >"$JARVIS_RUN/state" +"$JARVIS" ask "quelle heure est-il" +assert "phrase prononcée" grep -q "Il est 14:00" "$FAKE_PIPER_LOG" +assert "aucun appel cerveau" test ! -s "$FAKE_CLAUDE_LOG" +assert "retour à idle" state_is idle +assert "porté au journal" journal_has "réflexe" + +t "réflexe : le slot num est substitué, et borné à 100" +reflexes_fixture +assert "42 passe" bash -c "\"$JARVIS\" 'reflexe?' --run 'mets le son à 42' | grep -q 'Son à 42 pour cent'" +# Un « mets le son à 500 » mal entendu ne doit pas pouvoir sortir de la +# plage : le bornage est dans reflex_do, avant toute substitution. +assert "500 est ramené à 100" bash -c "\"$JARVIS\" 'reflexe?' --run 'mets le son à 500' | grep -q 'Son à 100 pour cent'" + +t "réflexe : une commande interdite est refusée au chargement" +reflexes_fixture +assert "la rangée ne mord pas" bash -c "! \"$JARVIS\" 'reflexe?' 'formate le disque'" +assert "et le log le dit" grep -q "réflexe refusé" "$JARVIS_STATE/jarvis.log" + +t "réflexe : une commande qui échoue rend la main au cerveau" +reflexes_fixture +printf 'idle' >"$JARVIS_RUN/state" +FAKE_CLAUDE_STREAM="$FIX/digest-ok.jsonl" "$JARVIS" ask "teste un échec" +assert "la phrase du réflexe n'est PAS dite" bash -c "! grep -q 'jamais dit' '$FAKE_PIPER_LOG'" +assert "le cerveau a bien été appelé" test -s "$FAKE_CLAUDE_LOG" + +# L'ancrage, exactement comme pour NOISE.md : le motif décrit le transcript +# ENTIER. Sans lui, « pourquoi le son est-il si fort ? » réglerait le volume, +# et une table qui mange les vraies questions est pire que pas de table. +t "réflexe : l'ancrage protège les vraies questions" +reflexes_fixture +for phrase in \ + "pourquoi quelle heure est-il" \ + "quelle heure est-il à Tokyo ?" \ + "peux-tu me dire si mets le son à 42 est une commande valide" \ + "explique-moi comment régler le son"; do + assert "« $phrase » atteint le cerveau" bash -c "! \"$JARVIS\" 'reflexe?' \"\$1\"" _ "$phrase" +done + +# ---- la table livrée +# +# Ici on ne teste plus le mécanisme mais la POLITIQUE : ce que les rangées +# réellement livrées doivent, et surtout ne doivent pas, attraper. + +# `skip` compte et raconte, mais n'interrompt pas : le corps du test doit +# donc être derrière la garde, pas après elle. +# La graine livrée, pas la copie de travail : memory/REFLEXES.md est +# gitignoré et appartient à l'utilisateur, exactement comme NOISE.md. Ce +# qu'on tient ici est ce que le dépôt promet, pas ce que la machine a. +SHIPPED="$TESTS_DIR/../memory/scaffold/REFLEXES.md" + +t "table livrée : bien formée, et lisible par le docteur" +if [[ -e $SHIPPED ]]; then + cp "$SHIPPED" "$JARVIS_MEMORY/REFLEXES.md" + export JARVIS_REFLEX=1 + assert "le docteur ne la refuse pas" bash -c "! \"$JARVIS\" doctor --quiet 2>&1 | grep -q 'table des réflexes'" +else + skip "pas encore de table livrée" +fi + +t "table livrée : chaque cas tombe où il doit" +if [[ -e $SHIPPED ]]; then + cp "$SHIPPED" "$JARVIS_MEMORY/REFLEXES.md" + export JARVIS_REFLEX=1 + # Un seul fichier de cas, positifs ET négatifs. Les négatifs sont le + # cœur : ce sont de vraies demandes qui doivent atteindre le cerveau, + # et c'est cette liste qui grandit le jour où une rangée se révèle trop + # gourmande. Aucune commande n'est exécutée ici — `reflexe?` sans + # --run répond en code de sortie, donc le volume ne bouge pas et + # l'écran ne se verrouille pas pendant la suite. + cases_ko=0 + while IFS=$'\t' read -r phrase attendu; do + [[ -z ${phrase:-} || -z ${attendu:-} || $phrase == \#* ]] && continue + if "$JARVIS" 'reflexe?' "$phrase" >/dev/null 2>&1; then got=rangee; else got=AUCUNE; fi + [[ $attendu == AUCUNE ]] && want=AUCUNE || want=rangee + if [[ $got != "$want" ]]; then + cases_ko=$((cases_ko + 1)) + printf ' · « %s » : attendu %s (%s), obtenu %s\n' "$phrase" "$want" "$attendu" "$got" + fi + done <"$FIX/reflexes-cases.txt" + assert "les $(grep -cvE '^[[:space:]]*(#|$)' "$FIX/reflexes-cases.txt") cas passent" test "$cases_ko" -eq 0 +else + skip "pas encore de table livrée" +fi + +t "transcript : sans session claude, le journal prend le relais" +# Le cerveau local ne tient pas de session : l'onglet « Aujourd'hui » du +# journal restait vide alors que les échanges avaient bien eu lieu. +mkdir -p "$JARVIS_MEMORY/journal" +printf '# Journal\n\n- 09:12 échange : « quelle heure » → « Il est neuf heures. »\n' \ + >"$JARVIS_MEMORY/journal/$(date +%F).md" +out=$("$JARVIS" transcript --json 30 2>&1) +assert "la question ressort" grep -q 'quelle heure' <<<"$out" +assert "la réponse aussi" grep -q 'neuf heures' <<<"$out" +assert "et la forme est celle du panneau" grep -q '"thoughts"' <<<"$out" + if ((skipped > 0)); then printf '\n%d réussite(s), %d échec(s), %d sauté(s)\n' "$pass" "$fail" "$skipped" else diff --git a/tests/test_touchbar.py b/tests/test_touchbar.py index b115ca2..5584469 100644 --- a/tests/test_touchbar.py +++ b/tests/test_touchbar.py @@ -45,7 +45,12 @@ def _engine_root(): class Hooks: - context = None + # Le double du Bar, réduit à ce que plugin/touchbar.py emprunte vraiment. + # Le moteur expose aussi context/on_context/keys/open_group/close_group/ + # is_group_open/slide_into ; aucun n'était atteint par ce module, et les + # garder ici revenait à entretenir sept no-op. Si le module s'en sert un + # jour, le test tombera en AttributeError — bruyamment, ce qui est le + # comportement voulu. painter = None def __init__(self): @@ -58,13 +63,6 @@ def show_scene(self, module_id, name, factory, **k): self.calls.append(("show", name, k)) self.layouts[name] = factory(self.host.apis[module_id]) def hide_scene(self, n): self.calls.append(("hide", n)); self.layouts.pop(n, None) - def on_context(self, fn): pass - def off_context(self, fn): pass - def keys(self, names): self.calls.append(("keys", names)) - def open_group(self, n): pass - def close_group(self): pass - def is_group_open(self, n): return False - def slide_into(self, n, x, y): pass def wake(self): self.calls.append(("wake",)) diff --git a/voxtype.toml b/voxtype.toml index 3d7f42a..9c4320e 100644 --- a/voxtype.toml +++ b/voxtype.toml @@ -1,101 +1,16 @@ # Jarvis STT config: multilingual whisper (FR+EN), used via `voxtype -c`. -# Voxtype Configuration # -# Location: ~/.config/voxtype/config.toml -# All settings can be overridden via CLI flags +# Trois lignes, et c'est tout ce que `voxtype … transcribe` lit. Ce fichier +# a longtemps porté la configuration d'exemple du fournisseur — state_file, +# [hotkey], [audio], [output], [text] : quatre-vingt-neuf lignes pour un +# démon que Jarvis ne lance pas, un raccourci clavier qu'Hyprland gère, et +# des retours audio que la voix fait elle-même. Vérifié en transcrivant le +# même wav avec l'ancien fichier et celui-ci : sortie identique, exit 0. # -# State file for external integrations (Waybar, polybar, etc.) -# Use "auto" for default location ($XDG_RUNTIME_DIR/voxtype/state), -# a custom path, or "disabled" to turn off. The daemon writes state -# ("idle", "recording", "transcribing") to this file whenever it changes. -# Required for `voxtype record toggle` and `voxtype status` commands. -state_file = "auto" - -[hotkey] -# Hotkey is configured in Hyprland. Default is Super + Ctrl + X -enabled = false - -[audio] -# Audio input device ("default" uses system default) -# List devices with: pactl list sources short -device = "default" - -# Sample rate in Hz (whisper expects 16000) -sample_rate = 16000 - -# Maximum recording duration in seconds (safety limit) -max_duration_secs = 60 - -# Pause MPRIS media players during recording -pause_media = true - -# [audio.feedback] -# Enable audio feedback sounds (beeps when recording starts/stops) -# enabled = true -# -# Sound theme: "default", "subtle", "mechanical", or path to custom theme directory -# theme = "default" -# -# Volume level (0.0 to 1.0) -# volume = 0.7 +# `language` est réécrite à la volée par bin/jarvis (voir do_transcribe) : +# l'âme épingle fr, en ou auto, parce que la détection automatique de +# whisper est peu fiable sur les clips courts. [whisper] -# Model to use for transcription -# Options: tiny, tiny.en, base, base.en, small, small.en, medium, medium.en, large-v3, large-v3-turbo -# .en models are English-only but faster and more accurate for English -# large-v3-turbo is faster than large-v3 with minimal accuracy loss (recommended for GPU) -# Or provide absolute path to a custom .bin model file model = "small" - -# Language for transcription -# Use "en" for English, "auto" for auto-detection -# See: https://github.com/openai/whisper#available-models-and-languages language = "auto" - -# Translate non-English speech to English -translate = false - -# Number of CPU threads for inference (omit for auto-detection) -# threads = 4 - -[output] -# Primary output mode: "type" or "clipboard" -# - type: Simulates keyboard input at cursor position (requires ydotool) -# - clipboard: Copies text to clipboard (requires wl-copy) -mode = "type" - -# Fall back to clipboard if typing fails -fallback_to_clipboard = true - -# Delay between typed characters in milliseconds -# 0 = fastest possible, increase if characters are dropped -type_delay_ms = 1 - -# Post-processing command (optional) -# Pipe transcribed text through an external command for cleanup before output. -# The command receives text on stdin and outputs processed text on stdout. -# Useful for LLM-based text cleanup, grammar correction, filler word removal. -# On any failure (timeout, error), falls back to original transcription. -# -# [output.post_process] -# command = "ollama run llama3.2:1b 'Clean up this dictation. Fix grammar, remove filler words. Output only the cleaned text:'" -# timeout_ms = 30000 # 30 second timeout (generous for LLM) - -[output.notification] -# Show notification when recording starts (hotkey pressed) -on_recording_start = false - -# Show notification when recording stops (transcription beginning) -on_recording_stop = false - -# Show notification with transcribed text after transcription completes -on_transcription = false - -# [text] -# Text processing options (word replacements, spoken punctuation) -# -# Enable spoken punctuation conversion (e.g., say "period" to get ".") -# spoken_punctuation = false -# -# Custom word replacements (case-insensitive) -# replacements = { "hyperwhisper" = "hyprwhspr" }