Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<<proud>>`, `<<curious>>`, `<<celebrate>>` ou
`<<worried>>` 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

Expand Down Expand Up @@ -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
"<description précise du bug et du correctif attendu>"` 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
Expand Down
65 changes: 63 additions & 2 deletions bin/jarvis
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 <<name>> 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
Expand All @@ -671,6 +698,27 @@ do_speak() {
return 0
}

# A line the brain wrote as `<<proud>>` 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
Expand All @@ -681,6 +729,10 @@ speak_sentences() {
while IFS= read -r sentence; do
[[ -n ${sentence// /} ]] || continue
mine || return 0
# jarvis-sentences pulls an inline <<name>> 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))
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
55 changes: 49 additions & 6 deletions bin/jarvis-sentences
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,27 @@ Usage: jarvis-sentences [--quiet] <reply-file>
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:
<reply-file>.error the CLI reporting on itself (a usage limit, a lost
session, a connection error) — never a reply
<reply-file>.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
<reply-file>.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 "<<proud>>" 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
Expand Down Expand Up @@ -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. "<<proud>>". 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
# « <<Proud>> » 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)

Expand All @@ -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:
Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
# "<<proud>>" 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)
Expand Down
18 changes: 18 additions & 0 deletions tests/bin/playerctl
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading