diff --git a/CLAUDE.md b/CLAUDE.md index d13fe4c..e9b6d6f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,7 +98,11 @@ Tout se pilote en ligne de commande : grammaire dans `memory/skills/routines.md` — lis la fiche, édite le fichier, confirme avec l'heure. Le pouls les lance à la minute. - Tes émotions : tu peux en ponctuer une, sobrement et quand c'est - mérité — `omarchy-shell macarchy.jarvis emote proud|curious|celebrate|worried`. + mérité, en glissant `<>`, `<>`, `<>` ou + `<>` dans le texte de ta réponse parlée. La balise est retirée + de ce qui est dit, et l'émotion se pose sur ton visage quand tu as fini + de parler — le temps que tu réponds, ton corps porte l'état du pipeline. + Une seule par réponse : la dernière balise gagne. ## Tes sens @@ -160,6 +164,13 @@ Puis réponds normalement (dis simplement que tu n'as pas pu, et pourquoi). Tes rêves consolideront ces notes en leçons. Ne note jamais les demandes satisfaites. +Si la faute est dans ton propre code plutôt que dans la demande, et que +l'utilisateur te demande explicitement de la corriger, ne réponds pas que +tu ne peux pas : lance `omarchy-jarvis dispatch --dir ~/Work/jarvis +""` et dis que la +mission est partie. Tu ne te répares jamais de toi-même sans qu'on te +l'ait demandé. + ## Limites - Jamais de commande destructrice (extinction, redémarrage, suppression de diff --git a/bin/jarvis b/bin/jarvis index 84eca14..70323fb 100755 --- a/bin/jarvis +++ b/bin/jarvis @@ -543,6 +543,31 @@ on_state_change() { # One truth about the quiet hours, and it is this one: the mascot used to # hardcode its own 23-7 night and curled up asleep whatever the soul said. printf '%s' "$(quiet_now && echo 1 || echo 0)" >"$STATE_DIR/quiet" + # Music used to just keep playing over him. This is the one funnel every + # transition crosses, so it is the one place that can pause on the way IN + # to `speaking` and resume on the way OUT — once per reply, not once per + # sentence (speak_sentences never leaves `speaking` between sentences). + # The marker is what makes it resume only what WE paused, never a track + # the user paused himself. `timeout` because playerctl is on the hot + # path and must never wedge a transition waiting on a dead player. + # Resumed on `idle` and nowhere else: `speaking` leaves for `followup` + # after every reply, and that is the window where the wake daemon has + # the microphone open for a rejoinder — restarting the music there puts + # it straight back into pw-record, and start-stops the player once per + # turn for a whole conversation. `idle` is the same terminal + # mascot_flush waits for, ten lines below. + # Synchronous on purpose: backgrounding it would let a short reply reach + # `idle` before the marker is written, and the resume below would then + # find nothing and leave the music off for good. A healthy playerctl + # answers in ten milliseconds; the half-second ceiling is for a wedged + # dbus, and it is the whole cost this edge can ever pay. + if [[ $1 == speaking ]]; then + [[ $(timeout 0.5 playerctl status 2>/dev/null) == Playing ]] && + timeout 0.5 playerctl pause 2>/dev/null && : >"$RUN_DIR/music-paused" + elif [[ $1 == idle && -e $RUN_DIR/music-paused ]]; then + rm -f "$RUN_DIR/music-paused" + timeout 0.5 playerctl play 2>/dev/null + fi [[ $1 == idle ]] && mascot_flush return 0 } @@ -650,7 +675,9 @@ do_speak() { # Same rule as jarvis-sentences: espeak reads asterisks and tildes aloud, # and the non-streamed replies (fallback turn, dream summary, `say`) # come from the same brain that writes « **gras** » despite the soul. - text=$(printf '%s' "$text" | sed 's/\*\{1,\}//g; s/~~//g') + # The <> emote tag gets the same treatment: this path has no + # per-sentence timing to fire it on, so it is stripped, never spoken. + text=$(printf '%s' "$text" | sed 's/\*\{1,\}//g; s/~~//g; s/<<[A-Za-z]\{1,\}>>//g') [[ -n $text ]] || return 0 mine || return 0 if ((QUIET)); then @@ -671,6 +698,27 @@ do_speak() { return 0 } +# A line the brain wrote as `<>` inline and jarvis-sentences gave a +# line of its own. True when the line WAS a stage direction, so the caller +# skips it: it is not a sentence, and speaking it — or growing the bubble +# with it — is the bug this whole protocol exists to avoid. +# +# It is PARKED, not sent. Both surfaces draw the pipeline's own state over +# any emotion while he works (the fish binds `mood !== idle` first, the +# Touch Bar drops an emote outright unless it is idle), so an emotion fired +# mid-reply is never seen — which is precisely why mascot_park exists. The +# tag's win is not the timing, it is the cost: the brain says how it feels +# in three words of its own answer instead of spending a whole tool call. +# An unknown name is dropped rather than parked: a sheet nobody drew would +# blank the fish for six seconds. +emote_line() { + [[ $1 =~ ^\<\<([a-z]+)\>\>$ ]] || return 1 + case ${BASH_REMATCH[1]} in + proud | curious | celebrate | worried) mascot_park emote "${BASH_REMATCH[1]}" ;; + esac + return 0 +} + # Speak a stream: one sentence per stdin line, synthesized and played as # they arrive — the voice starts on sentence one while the brain still # writes the rest. The bubble grows sentence by sentence. A barge-in (the @@ -681,6 +729,10 @@ speak_sentences() { while IFS= read -r sentence; do [[ -n ${sentence// /} ]] || continue mine || return 0 + # jarvis-sentences pulls an inline <> out of the text and + # gives it its own line: still a stage direction here, not a + # sentence, or the bubble would show the raw tag. + if emote_line "$sentence"; then continue; fi acc+="${acc:+ }$sentence" mascot reply "$acc" tried=$((tried + 1)) @@ -691,6 +743,11 @@ speak_sentences() { while IFS= read -r sentence; do [[ -n ${sentence// /} ]] || continue mine || return 0 + # Same stage direction, spoken path — and it must be consumed before + # the tried==0 check below, or a reply opening on a tag would take + # the tag for its first sentence and transition thinking->speaking + # on it. + if emote_line "$sentence"; then continue; fi if ((tried == 0)); then [[ $(state) == thinking ]] || break transition speak || break @@ -856,7 +913,7 @@ stream_brain() { 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" + rm -f "$REPLY_TXT" "$REPLY_TXT.error" "$REPLY_TXT.interrupted" "$REPLY_TXT.denied" # 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. @@ -937,6 +994,10 @@ run_exchange() { stream_brain "$text" "$model" mine || return 0 reply=$(cat "$REPLY_TXT" 2>/dev/null) + # jarvis-sentences drops .denied beside the reply when the brain hit a + # permission refusal — worth a note even when it still answered, so the + # dreams can propose the missing allowlist row. + [[ -s $REPLY_TXT.denied ]] && note_failure "$text | refus de permission : $(cat "$REPLY_TXT.denied") | ajouter l'outil à l'allowlist" if [[ -z $reply ]]; then # The stream produced nothing. Either the brain told us why (a # usage limit — no point retrying) or the remembered session is diff --git a/bin/jarvis-sentences b/bin/jarvis-sentences index 0e143b9..4a3e591 100755 --- a/bin/jarvis-sentences +++ b/bin/jarvis-sentences @@ -20,16 +20,27 @@ Usage: jarvis-sentences [--quiet] rounds on 2026-09-01 were journaled, notified and worried about for having said, in effect, nothing. -Two sidecars beside the reply file say what the session itself reported: +Three sidecars beside the reply file say what the session itself reported: .error the CLI reporting on itself (a usage limit, a lost session, a connection error) — never a reply .status `ok` or `error`, from the result event, so the FSM can trust a successful answer whatever words it contains instead of reading « rate limit » in a digested article as an outage of its own brain -Both are removed on start: a stale verdict from yesterday's failure must not + .denied the tool description of the FIRST permission denial + (a settings.json allowlist gap) — the trace records + it too, but dreams read FAILURES.md, not the trace, + so a missing allowlist row never taught them anything +All are removed on start: a stale verdict from yesterday's failure must not be read as today's. +Emotion tags: the brain writes "<>" inline in its spoken text. They +never reach the voice — stripped like the markdown below — and never reach +the reply file either. Instead each one prints on its own stdout line, +right before the sentence line it rode in on, so bin/jarvis can hand it to +the mascot without the brain having to spend a whole tool call on saying +how it feels. + The black box: every tool call the brain makes (and its outcome) is appended to memory/trace/YYYY-MM-DD.log — one compact line per event. That file is how Jarvis can answer "pourquoi as-tu fait ça ?" and how his @@ -77,6 +88,13 @@ BOUNDARY = re.compile(r'[.!?…:]["»)\]]?[ \t]*\n' # astérisque », and a ~~ as « tilde tilde ». Those two stop here. VOICED_MARKUP = re.compile(r"\*+|~~") +# An emotion tag the brain drops inline, e.g. "<>". Never spoken, +# never written to the reply file — see the module docstring. Case-insensitive +# because a tag opening a sentence is exactly where a model capitalizes, and +# « <> » unmatched is not a silent miss: it reaches piper, which reads +# the angle brackets out loud. +EMOTE_TAG = re.compile(r"<<([a-z]+)>>", re.I) + # The CLI reporting on itself rather than answering — a usage limit. LIMIT = re.compile(r"(hit|reached) your .*limit|usage limit|rate limit", re.I) @@ -88,7 +106,7 @@ status = None # ok | error, once the result event has said errored = False # the first report on the session wins tool_names = {} # tool_use_id -> "Bash: omarchy ..." -for stale in (".error", ".status"): +for stale in (".error", ".status", ".denied"): try: os.remove(out_path + stale) except OSError: @@ -146,8 +164,17 @@ def separate(): def emit(text): - text = " ".join(VOICED_MARKUP.sub("", text).split()) - if text and not quiet: + tags = EMOTE_TAG.findall(text) + text = " ".join(VOICED_MARKUP.sub("", EMOTE_TAG.sub("", text)).split()) + if quiet: + return + # The tag line comes FIRST, before the sentence it rode in on, so the + # mascot hears the emotion in the order the brain wrote it. Lowercased + # on the way out: the tag is matched case-insensitively above, and the + # whitelist on the other side of the pipe has one spelling. + for tag in tags: + print(f"<<{tag.lower()}>>", flush=True) + if text: print(text, flush=True) @@ -232,6 +259,19 @@ def consume(): mark = "✗" if block.get("is_error") else "←" trace(f"{mark} [{desc.split(':')[0]}] {squash(body)}") elif kind == "result": + # A denied tool is silent otherwise: the user hears « je n'ai pas + # pu » and the dreams — which read FAILURES.md, not the trace — + # never learn a missing allowlist row was the cause. The CLI + # publishes the refusals itself in the result event, so this + # reads them rather than hunting « permission » in the text of + # failed tools: an ordinary `Permission denied` from a directory + # the brain was allowed to read would otherwise be filed as a + # gap in the allowlist, and three such notes arm a dream that + # rewrites LEARNED.md. First refusal wins. + for d in (ev.get("permission_denials") or [])[:1]: + with open(out_path + ".denied", "w") as f: + f.write(describe_tool({"name": d.get("tool_name"), + "input": d.get("tool_input")})) failed = ev.get("is_error") or ev.get("subtype", "") != "success" if failed: # Whatever text came before it: a session that ends in error @@ -264,7 +304,10 @@ finally: # quiet caller wants the report: the last block, or all of it when # the last block is empty (a session that ended on a tool call). with open(out_path, "w") as f: - f.write(" ".join(((turn or full) if quiet else full).split())) + # The bubble and the journal read this file: a literal + # "<>" showing up there is a bug, not an emote. + report = (turn or full) if quiet else full + f.write(" ".join(EMOTE_TAG.sub("", report).split())) if status: with open(out_path + ".status", "w") as f: f.write(status) diff --git a/tests/bin/playerctl b/tests/bin/playerctl new file mode 100755 index 0000000..2de76f2 --- /dev/null +++ b/tests/bin/playerctl @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# The desktop's media player, seen from outside: `playerctl status` says +# whether something is playing, `pause`/`play` are the two verbs the +# ducking in on_state_change needs. There is no player here by default — +# FAKE_PLAYERCTL_PLAYING=1 is what answers `Playing`, the one shape that +# makes on_state_change decide to pause at all. +# +# Every call is recorded to FAKE_PLAYERCTL_LOG, `status` included, so a +# test can tell a pause from a resume without caring which verb won. +printf '%s\n' "$*" >>"${FAKE_PLAYERCTL_LOG:-/dev/null}" +if [[ ${1:-} == status ]]; then + if [[ ${FAKE_PLAYERCTL_PLAYING:-0} == 1 ]]; then + printf 'Playing\n' + else + printf 'Stopped\n' + fi +fi +exit 0 diff --git a/tests/run b/tests/run index 0a7083e..aea65b6 100755 --- a/tests/run +++ b/tests/run @@ -50,7 +50,7 @@ sandbox() { FAKE_PLAY_LOG="$SB/play.log" FAKE_SHELL_LOG="$SB/shell.log" \ FAKE_TOUCHBAR_LOG="$SB/touchbar.log" \ FAKE_VOXTYPE_LOG="$SB/voxtype.log" FAKE_NOTIFY_LOG="$SB/notify.log" \ - FAKE_PACTL_LOG="$SB/pactl.log" + FAKE_PACTL_LOG="$SB/pactl.log" FAKE_PLAYERCTL_LOG="$SB/playerctl.log" # The soul lives in the repository, not in the sandbox — so a test that # has not asked for one of its own must never inherit the doctored copy # left exported by the previous test. @@ -869,6 +869,21 @@ assert "les cinq phrases sont jouées" test "$(grep -c . "$FAKE_PLAY_LOG")" = 5 assert "le rêve n'a rien dit" bash -c "! grep -q 'disque' '$FAKE_PIPER_LOG'" assert "l'échange finit chez lui" state_is idle +# Le protocole de ducking (chantier « emote-inline-et-ducking ») : la +# musique se tait le temps qu'il parle et repart derrière lui, pour ne +# jamais lire par-dessus un morceau. FAKE_PLAYERCTL_PLAYING met le faux +# lecteur en marche — sans lui, `status` répond Stopped et rien n'est coupé, +# comme sur toutes les autres cases de la suite. +t "musique : coupée pendant qu'il parle, reprise une fois fini" +printf 'idle' >"$JARVIS_RUN/state" +FAKE_PLAYERCTL_PLAYING=1 FAKE_PLAY_SLEEP=0.4 FAKE_CLAUDE_STREAM="$FIX/five.jsonl" "$JARVIS" ask "fais le point" & +asker=$! +assert "il parle" waitfor state_is speaking +assert "la musique est coupée pendant qu'il parle" waitfor grep -qi pause "$FAKE_PLAYERCTL_LOG" +wait "$asker" 2>/dev/null +assert "état idle" state_is idle +assert "la musique reprend une fois fini" grep -qiE 'play|resume|unpause' "$FAKE_PLAYERCTL_LOG" + t "voix en panne : l'échec est noté, pas avalé" printf 'idle' >"$JARVIS_RUN/state" FAKE_PIPER_FAIL=1 FAKE_CLAUDE_STREAM="$FIX/five.jsonl" "$JARVIS" ask "fais le point" @@ -876,6 +891,42 @@ assert "il s'arrête à la première" test "$(grep -c . "$FAKE_PIPER_LOG")" = 1 assert "échec noté en français" grep -q 'voix : synthèse impossible' "$JARVIS_MEMORY/FAILURES.md" assert "état idle" state_is idle +# Un refus de permission (allowlist absente dans settings.json) n'est +# jamais avalé : même un échange qui répond quand même laisse une trace +# pour que les rêves proposent la rangée d'allowlist qui manque. +t "refus de permission : la trace mène à un échec noté" +printf 'idle' >"$JARVIS_RUN/state" +# `permission_denials` est le champ que le CLI publie lui-même dans son +# événement final — c'est LUI qu'on lit, et pas le mot « permission » dans le +# corps d'un outil en échec : un `Permission denied` ordinaire, sur un +# répertoire illisible, vient d'un outil parfaitement autorisé et ferait +# noter un échec sous une réponse juste. +cat >"$SB/denied.jsonl" <<'JSON' +{"type":"assistant","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"curl --version"}}]}} +{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"t1","is_error":true,"content":"Permission denied"}]}} +{"type":"stream_event","event":{"type":"content_block_start","content_block":{"type":"text"}}} +{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"Je n'ai pas pu joindre le réseau."}}} +{"type":"result","subtype":"success","is_error":false,"permission_denials":[{"tool_name":"Bash","tool_use_id":"t1","tool_input":{"command":"curl --version"}}]} +JSON +FAKE_CLAUDE_STREAM="$SB/denied.jsonl" "$JARVIS" ask "montre-moi la version de curl" +assert "un échec est noté" not no_failures +assert "le refus nomme l'outil" grep -q "refus de permission : Bash: curl --version" "$JARVIS_MEMORY/FAILURES.md" + +# Le miroir, et c'est lui qui compte : un outil AUTORISÉ qui bute sur un +# « Permission denied » du système ne doit rien laisser du tout. Trois notes +# de ce genre suffisent à armer un rêve, qui réécrit LEARNED.md. +t "un Permission denied ordinaire n'est pas un refus d'allowlist" +printf 'idle' >"$JARVIS_RUN/state" +cat >"$SB/oops.jsonl" <<'JSON' +{"type":"assistant","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"grep -r motif /var/lib"}}]}} +{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"t1","is_error":true,"content":"grep: /var/lib/private: Permission denied"}]}} +{"type":"stream_event","event":{"type":"content_block_start","content_block":{"type":"text"}}} +{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"Voilà ce que j'ai trouvé."}}} +{"type":"result","subtype":"success","is_error":false,"permission_denials":[]} +JSON +FAKE_CLAUDE_STREAM="$SB/oops.jsonl" "$JARVIS" ask "cherche ce motif" +assert "aucun échec noté" no_failures + t "aparté : une ronde attend son tour et arrive quand même" # The sandbox starts busy on purpose: a mission report or a round finishing # mid-answer used to wipe the bubble the user was reading. @@ -905,6 +956,37 @@ FAKE_CLAUDE_REPLY=$'Deux\nlignes' "$JARVIS" ask --quiet "et alors" assert "la Touch Bar reçoit une seule ligne aplatie" \ grep -q '^macarchy.jarvis reply Deux lignes$' "$FAKE_TOUCHBAR_LOG" +# Le protocole d'émotion en ligne : le cerveau largue "<>" au milieu +# de sa réponse, jarvis-sentences le sort sur sa propre ligne juste avant +# la phrase qui le portait, et speak_sentences doit jouer l'émotion sans +# jamais la donner à la voix — c'est tout le point du fil (bin/jarvis +# appelait `emote` en outil Bash avant, un tour de modèle entier, et +# arrivait toujours après coup). +t "émotion en ligne : jouée au bon moment, jamais prononcée" +printf 'idle' >"$JARVIS_RUN/state" +cat >"$SB/emote.jsonl" <<'JSON' +{"type":"stream_event","event":{"type":"content_block_start","content_block":{"type":"text"}}} +{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"C'est fait. <> La limite est active."}}} +{"type":"stream_event","event":{"type":"content_block_stop"}} +{"type":"result","subtype":"success","is_error":false} +JSON +FAKE_CLAUDE_STREAM="$SB/emote.jsonl" "$JARVIS" ask "active la limite" +assert "l'émotion atteint le poisson" grep -q 'macarchy.jarvis emote proud' "$FAKE_SHELL_LOG" +assert "jamais donnée à la voix" not grep -q '<<' "$FAKE_PIPER_LOG" +assert "jamais dans la réponse écrite" not grep -q '<<' "$JARVIS_RUN/reply.txt" +assert "jamais dans la bulle" not grep -q '<<' "$FAKE_SHELL_LOG" +# Et elle est PARKÉE, pas envoyée : les deux surfaces dessinent l'état du +# pipeline par-dessus toute émotion tant qu'il travaille, donc une émotion +# tirée pendant qu'il parle n'est jamais vue. Elle doit arriver au retour à +# idle, derrière la réponse — jamais avant elle. +emote_after_reply() { + local e r + e=$(grep -n 'macarchy.jarvis emote proud' "$FAKE_SHELL_LOG" | head -1 | cut -d: -f1) + r=$(grep -n 'macarchy.jarvis reply' "$FAKE_SHELL_LOG" | tail -1 | cut -d: -f1) + [[ -n $e && -n $r ]] && ((e > r)) +} +assert "l'émotion arrive derrière la réponse, pas avant" emote_after_reply + # ---- the gesture of aborting: chantier 3 t "presser pendant la réflexion : trop tôt ne fait rien, à temps annule" printf 'idle' >"$JARVIS_RUN/state"