From 9f242b47bc00907ed34994ed37b040f2d9794c0d Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Sat, 5 Sep 2026 02:07:01 +0200 Subject: [PATCH 1/4] =?UTF-8?q?refactor:=20audit=20d'exc=C3=A8s=20d'ing?= =?UTF-8?q?=C3=A9nierie=20=E2=80=94=20630=20lignes=20en=20moins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un audit paresseux de tout l'arbre, chaque coupe vérifiée avant d'être appliquée plutôt que crue sur parole. Les trois grosses : - `sprites/variants.py` : huit poissons d'exploration qui ont fait leur travail — les quatre retenus sont redessinés dans `parts.BODIES` depuis longtemps. Les six symboles encore vivants (CW/CH, BW/BH, pad, compose) remontent en tête de parts.py, et le fichier part avec le reste. - `sprites/preview.py` : un décodeur PNG écrit à la main pour un seul appelant, un test qui voulait la largeur d'une planche. `struct.unpack` sur l'IHDR la lit en une ligne. (La planche fait 144, pas 72 : generate rend en 2x — l'ancien test le disait, le nouveau aussi.) - `voxtype.toml` : 103 lignes dont 89 étaient la configuration d'exemple du fournisseur — un démon que Jarvis ne lance pas, un raccourci qu'Hyprland gère, des retours audio que la voix fait elle-même. Vérifié en transcrivant le même wav avec l'ancien fichier et le nouveau : sortie identique, exit 0. Le reste : deux parseurs d'arguments maison remplacés par argparse (qui rend `--help` et de meilleurs messages en prime), les quatre sondes jumelles de Journal.qml en un `component`, le second format d'options de PillRow que ses cinq appelants n'ont jamais utilisé, deux réglages de PixelSprite que personne ne règle, sept crochets morts du double de test du Touch Bar, `--all-voices` (changer `voix:` et relancer suffit), et deux options release-please inertes au-dessus de 1.0.0. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ls94YXz6tFuYvTzRW6if3t --- bootstrap.sh | 11 +- plugin/components/Journal.qml | 104 ++++------ plugin/components/PillRow.qml | 12 +- plugin/components/PixelSprite.qml | 6 +- sprites/generate.py | 25 +-- sprites/parts.py | 43 ++++- sprites/preview.py | 125 ------------ sprites/variants.py | 306 ------------------------------ tests/bin/claude | 3 +- tests/test_touchbar.py | 14 +- voxtype.toml | 103 +--------- 11 files changed, 109 insertions(+), 643 deletions(-) delete mode 100755 sprites/preview.py delete mode 100644 sprites/variants.py 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/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/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/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" } From 6a4c7f7a8a90498bdaf1a821f737eded28890f68 Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Sat, 5 Sep 2026 02:07:11 +0200 Subject: [PATCH 2/4] =?UTF-8?q?feat(journal):=20une=20vraie=20fen=C3=AAtre?= =?UTF-8?q?=20Hyprland=20au=20lieu=20d'une=20surface=20de=20couche?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le journal flottait au-dessus du travail en cours (`WlrLayer.Top`, `exclusiveZone: 0`) : il fallait le fermer pour relire ce qu'il cachait. En `FloatingWindow` — l'idiome que le greffon dev-gallery du shell emploie déjà — Hyprland le tuile comme n'importe quelle application, l'écran se partage, et il se déplace et se redimensionne avec les raccourcis que l'utilisateur connaît déjà. Vérifié : `class=org.quickshell`, `floating=False`, 780x953 à [807,34], et le terminal voisin réduit d'autant. Le clavier vient désormais du compositeur, donc toute la danse WlrKeyboardFocus disparaît avec la couche. Les deux gestionnaires de `visible` sont fusionnés — QML n'en garde qu'un : le focus à l'ouverture, et `journalOpen` remis à false quand c'est le gestionnaire de fenêtres qui ferme, sans quoi le prochain appui sur `journal` ne ferait rien. Au passage, trois verbes IPC sans aucun émetteur (`ping`, `toggle`, `journalClose`) et un `id` non référencé, trouvés par l'audit. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ls94YXz6tFuYvTzRW6if3t --- plugin/Service.qml | 44 ++++++++++++++++++++++++-------------------- 1 file changed, 24 insertions(+), 20 deletions(-) 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 } From 5280156dfdfaf91b17d713ea07e7b9c60ce74d7b Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Sat, 5 Sep 2026 02:07:40 +0200 Subject: [PATCH 3/4] =?UTF-8?q?feat(cerveau):=20un=20cerveau=20local=20con?= =?UTF-8?q?figurable,=20et=20une=20table=20de=20r=C3=A9flexes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jarvis peut désormais répondre sans réseau. Son oreille (voxtype) et sa voix (piper) étaient déjà locales ; seul le cerveau sortait. ## Le pont `bin/jarvis-local` parle à n'importe quel serveur compatible OpenAI — llama-server, LM Studio, Ollama, LocalAI, vLLM — et surtout n'invente pas sa propre sortie : il imite exactement les événements stream-json de `claude -p`, que `bin/jarvis-sentences` avale déjà. Le chemin de la voix ne sait pas quel cerveau lui parle, et c'est ce qui rend la bascule possible en une dizaine de lignes. Il n'a aucun outil, délibérément : un modèle de 4 milliards de paramètres qui pilote un shell est un risque qu'on ne prend pas. Trois lignes dans l'âme (`cerveau`, `cerveau-url`, `cerveau-modele`) et une section « Cerveau » dans le Control Center. Une seule entrée « compatible OpenAI » plutôt que vingt fournisseurs : ils parlent tous le même protocole. ## Ce qui a été mesuré, et qui a décidé du reste Sur ce M2 sous Asahi, Vulkan/honeykrisp bat le CPU d'un facteur 1,7 en prefill et 1,3 en génération. Avec Qwen3.5-4B en Q4_K_M, la première phrase prononcée arrive en 1,5 à 4,6 s à chaud. Deux pièges qui rendent un cerveau local muet sans rien dire : - un modèle « thinking » envoie sa réflexion dans `reasoning_content`, épuise son budget de tokens et la réponse n'arrive JAMAIS. D'où `--reasoning off` dans l'unité, obligatoire, et le filtrage côté pont. - le prefill de la persona coûte ~16 s. `omarchy-jarvis warmup` le paie au démarrage du serveur, en envoyant EXACTEMENT la persona que l'échange enverra : 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 (mesuré). 23 376 ms → 2 581 ms. ## La table des réflexes Interrogé hors ligne sur l'heure, le modèle 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. `memory/REFLEXES.md` répond donc à l'heure, la date, la batterie, le ne-pas-déranger, le son, les thèmes — sans modèle, en deux cents millisecondes, et exactement. Motifs ancrés sur le transcript entier comme `NOISE.md`, slot numérique borné 0-100, liste d'interdits appliquée au chargement (ce chemin ne passe pas par settings.json, c'est sa seule porte), et `omarchy-jarvis reflexe?` pour l'asserter sans micro. 118 cas figés, dont 56 négatifs : de vraies demandes qui doivent atteindre le cerveau. C'est cette liste qui grandit quand une rangée se révèle trop gourmande. ## Trois bugs que la route locale a révélés - « Oublier » ne connaissait que les sessions claude, donc échouait sur toute la route locale — et silencieusement, le panneau appelant `undo --quiet`. Il retire maintenant la dernière paire du fil JSON, et un refus se dit par notification. - `reset` effaçait le fil mais pas `$LAST_ASK`/`$LAST_REPLY` : la carte du panneau continuait d'afficher l'échange d'avant, ce qui se lit comme « il ne s'est rien passé ». - L'onglet « Aujourd'hui » lisait le JSONL de claude et restait donc vide en local. Il se replie sur le journal du jour, qui porte l'heure et la paire, et que les deux routes écrivent. ## Aussi `brain_set` ne touche plus `$BRAIN_STATUS` sur un tour local — il décrit le nuage, et un succès local y écrivait `ok` par-dessus un quota encore valide. Le mode `auto` retente le nuage toutes les dix minutes : sans ce délai, la garde ci-dessus condamnait Jarvis à rester local à vie. `set_setting` utilise `|` comme séparateur sed : une URL contient des barres obliques, et le premier enregistrement d'adresse depuis le panneau aurait corrompu l'âme. Enfin, la part de l'audit qui touchait `bin/jarvis` et `bin/jarvis-transcript` est ici faute de pouvoir séparer les fichiers : mascot_emote/mascot_aside fusionnés, un heredoc python remplacé par `tac | awk | tac`, sept surcharges JARVIS_DEADLINE_* et JARVIS_MODEL que rien ne posait, quatre clés de `status` que le panneau ne lit pas, deux échafaudages refaits, et argparse dans jarvis-transcript. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ls94YXz6tFuYvTzRW6if3t --- .gitignore | 1 + CLAUDE.md | 8 + README.fr.md | 77 ++++- README.md | 73 ++++- SOUL.md | 3 + bin/jarvis | 515 +++++++++++++++++++++++++----- bin/jarvis-local | 228 +++++++++++++ bin/jarvis-transcript | 87 ++--- memory/OFFLINE_PROMPT.md | 57 ++++ memory/scaffold/REFLEXES.md | 102 ++++++ plugin/ControlCenterModule.qml | 154 ++++++++- systemd/jarvis-brain.service | 37 +++ tests/bin/fake-llama | 72 +++++ tests/fixtures/reflexes-cases.txt | 129 ++++++++ tests/fixtures/reflexes.md | 15 + tests/run | 221 ++++++++++++- 16 files changed, 1650 insertions(+), 129 deletions(-) create mode 100755 bin/jarvis-local create mode 100644 memory/OFFLINE_PROMPT.md create mode 100644 memory/scaffold/REFLEXES.md create mode 100644 systemd/jarvis-brain.service create mode 100755 tests/bin/fake-llama create mode 100644 tests/fixtures/reflexes-cases.txt create mode 100644 tests/fixtures/reflexes.md 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..c35b0d6 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,16 @@ 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}") + return vars(ap.parse_args(argv)) def project_dir(opts): @@ -68,20 +60,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 +179,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 +249,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/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/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/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 From b767c6f8bc01078996d61c1a7a63ade4c64f5e3e Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Sat, 5 Sep 2026 02:17:40 +0200 Subject: [PATCH 4/4] =?UTF-8?q?fix(transcript):=20parse=5Fintermixed=5Farg?= =?UTF-8?q?s,=20sinon=20le=20N=20final=20est=20ignor=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `show --projects … --json 30` : argparse regroupe les positionnels au PREMIER cluster, remplit `cmd` avec « show », et déclare le 30 final « unrecognized arguments ». Le comportement dépend de la version de Python — vert en local sur 3.14, rouge en CI — ce qui est précisément le genre de différence qu'une suite hors-ligne doit attraper, et qu'elle a attrapée. `parse_intermixed_args` existe pour ce cas exact. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ls94YXz6tFuYvTzRW6if3t --- bin/jarvis-transcript | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bin/jarvis-transcript b/bin/jarvis-transcript index c35b0d6..f558221 100755 --- a/bin/jarvis-transcript +++ b/bin/jarvis-transcript @@ -51,7 +51,12 @@ def parse_args(argv): ap.add_argument("--json", action="store_true") for opt in ("session", "projects", "dir", "current", "journal"): ap.add_argument(f"--{opt}") - return vars(ap.parse_args(argv)) + # 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):