Skip to content

feat(game-mode): keep VibeThinker-3B available on CPU instead of stopping it - #162

Open
hectormr206 wants to merge 241 commits into
mainfrom
feat/game-mode-vt-cpu
Open

feat(game-mode): keep VibeThinker-3B available on CPU instead of stopping it#162
hectormr206 wants to merge 241 commits into
mainfrom
feat/game-mode-vt-cpu

Conversation

@hectormr206

Copy link
Copy Markdown
Owner

Summary

Game mode previously masked + stopped llama-vt to free its ~3.3 GB VRAM, which meant losing VT-3B's reasoning during play. This relocates VT-3B to CPU (ngl=0, CUDA hidden) — the same pattern the brain co-pilot already uses — so all of Axi stays available (slower) without losing reasoning, while the game still owns the full 12 GB VRAM.

Changes

  • axi-vt-launch: new --cpu flag (mirrors axi-llama-launch --cpu) → forces -ngl 0.
  • axi-game-on: writes a llama-vt CPU drop-in (CUDA_VISIBLE_DEVICES= + ExecStart→axi-vt-launch --cpu) instead of mask/stop.
  • axi-game-off: removes the drop-in and restarts VT on GPU when the restored primary is qwen35-4b; stops it for the big 35B (VRAM mutual-exclusion guard preserved). Also unmasks (recovers a VT masked by an older game-on).

Verification

  • All 3 scripts pass bash -n.
  • AXI_DRY_RUN=1 axi-vt-launch --cpu-ngl 0; default → -ngl 999.
  • Live toggle (axi-game-on / axi-game-off) is the user's to run — it restarts services and swaps the active brain.

Tradeoff (accepted)

VT-3B on CPU is noticeably slower (~5-15 tok/s vs GPU) and competes for CPU with the game, but only when invoked. Chosen deliberately: keep all of Axi usable during play over max FPS.

Bugs reportados:
1. "Me dormí a las 3 y desperté a las 8 y media" → guardó 5.0h, debería 5.5h.
   Causa: el regex de sleep no parseaba "y media" / "y cuarto".
2. Los entries muestran solo el title — los campos internos (pulso, IMC,
   grasa, etc.) son invisibles aunque estén guardados.
3. Modelo no sabe que un número con % es porcentaje vs kg vs bpm.

Cambios:

A. Sleep regex con minutos en palabras (lifeos/health/ingestion.py)
   - Named groups (re.VERBOSE) para start_h/start_min/end_h/end_min.
   - Acepta minutos en 3 formas: ':MM' digit, 'y media' (30), 'y cuarto' (15),
     'y N' (N minutos arbitrarios).
   - _parse_minutes_word() convierte palabra → int.

B. Plausibility ranges
   - _BODY_FIELD_RANGES dict con (min, max) por campo. Multi-field parser
     dropea campos fuera de rango (mantiene los otros).
   - Single-field weight parser ahora valida 25-300 kg (antes capturaba
     'weight 500' literal).
   - Esto le da al sistema "entendimiento" implícito de unidades: si el
     número está fuera del rango fisiológico, no se interpreta como ese
     campo. Equivalente a decir "64 no puede ser peso en kg si dijiste
     'FAT 64'" — la sanity ladder rechaza ese mapping.

C. Chips visuales en /health (templates/health.html)
   - dataChips(r.data) helper construye chips labeled de cada campo.
   - Esquema fijo: cada metric tiene su label y unit ('Sistólica mmHg',
     'Pulso bpm', 'IMC', 'Grasa %', 'Visceral', 'RM kcal', etc.).
   - Solo se renderean campos que existen — si el user no mandó visceral,
     no aparece chip de visceral.
   - Sleep especial: muestra hora de dormido/despertó como 'HH:MM' clock.

D. Tests nuevos
   - test_natural_sleep_y_media (8:30 → 5.5h)
   - test_natural_sleep_y_cuarto (11:15)
   - test_body_composition_plausibility_rejects_extreme

Pendiente real:
- Para "entender" inputs ambiguos como "20 grasa" vs "20 kg", el camino
  correcto es nano-agent extractor (PRD-nano-agents). El regex+ranges
  cubre casos comunes pero no los ambiguos.
Arranca el PRD-nano-agents-v1. Setup mínimo funcional, sin wire al
fast-path todavía. Validado con los mismos 10 casos naturales que el
regex actual falla.

Setup model selection (try-smallest-first):

  SmolLM2-135M-Instruct (101MB, 601MB RAM, 301ms):
    OUTPUT: basura (repite el input). Insuficiente para Spanish+JSON.
  SmolLM2-360M-Instruct (259MB, 795MB RAM, 530ms):
    OUTPUT: JSON-ish pero inventa campos, no respeta schema.
  Qwen3.5-0.8B-Q4_K_M (508MB, 1354MB RAM, ~1000ms):
    OUTPUT: JSON válido siguiendo schema. ELEGIDO.
  Qwen3.5-2B-Q4_K_M (probado, 2266MB RAM, ~1500ms):
    Marginalmente mejor accuracy. No vale el doble de RAM por +10%.

Key learning: max_tokens=300 hacía que Qwen 0.8B retornara CONTENT vacío
porque emite tokens internos que comen el budget. max_tokens=800 lo arregla
(margen para JSON estructurado).

Components new

systemd: ~/.config/systemd/user/llama-nano.service
  - llama-server en puerto 8090 (paralelo al brain principal en 8080)
  - CPU only (-ngl 0), 4 threads, --no-mmap, ctx 4096
  - MemoryMax=2G hardlimit
  - Auto-enabled in default.target

Módulos lifeos/src/lifeos/agents/:

  runtime.py
    - HTTP client al puerto 8090 vía OpenAI /v1/chat/completions
    - call_nano(system, user, ...) → NanoResult(ok, content, latency, error)
    - NEVER raises — errores capturados a NanoResult.ok=False
    - is_alive() probe en /health (1.5s timeout)
    - LIFEOS_NANO_ENDPOINT env var para override

  extractor.py
    - extract(text) → ExtractionResult | None
    - SYSTEM_PROMPT con 5 ejemplos few-shot cubriendo cada dominio
    - Reglas explícitas para evitar confusiones comunes
      (ej: "caminé con esposa" → exercise no relationships)
    - _try_parse_json robusto a markdown fences + balanced-brace extraction
    - Validación de domain whitelist
    - Coerce a tipos esperados (amount float, items con name+amount+category)
    - confidence=0.65 (nano agents starting moderate)

Smoke validation (los mismos casos donde el regex falla):

  10/10 domains correctos en primera pasada:
    boda            → events    (1 person: Daniela) ✓
    discusión       → relationships
    conocer Diego   → relationships
    caminé esposa   → exercise (45min, Daniela) ✓ — la regla del prompt funcionó
    caminata fam    → exercise (60min)
    aprendiendo     → learning
    estudié Rust    → learning (60min)
    libro Habits    → learning (James Clear)
    Aurrera item    → finance (1850 total, 3 items detectados!) ✓
    cable HDMI PC   → finance (500)

  Latencia: 1.1 - 2.6s (mean ~1.85s). Aceptable para nano fallback path.

Lo que NO está hecho (próximas sesiones):

  1. Wire al chat fast-path: cuando regex falla → call extract() → si
     devuelve resultado → persistir a la store del dominio.
     RAZÓN PARA NO WIREAR HOY: los items mapean a sub-category pero
     finance.entries.create no acepta items list todavía. Requiere
     extender el schema/DAO de cada dominio.

  2. Golden set + eval harness:
     - lifeos/agents/eval/golden_sets/extractor.jsonl con 50 casos reales
     - eval.py que mide accuracy per-field
     - Iterar prompt hasta ≥85% accuracy

  3. Pequeños bugs detectados (no críticos):
     - "Mi mamá" se captura como person="mamá" (debería ser rol, no persona)
     - "Caminata familiar" agrega Daniela en people (hallucination del prev context)
     - "Claude Code" → people=[James Clear] (hallucination)
     Todos arreglables con stronger rule en prompt v2.

Suite 303/303 verde (no cambios en código existente).
Cuando ninguno de los regex parsers (purchase_consult, events, learning,
spirituality, exercise, relationships, health, finance, reminders)
matchea, ahora se llama al nano-agent extractor (Qwen3.5-0.8B en :8090)
ANTES de caer al brain principal. Si el extractor identifica un dominio
con datos válidos, persiste a la store correspondiente y retorna como
respuesta normal del chat.

Cascada de prioridades (fast → slow):
  1. Regex parsers      (5-50ms,  high precision, narrow coverage)
  2. Nano extractor     (~2s,     medium precision, wide coverage)  ← NEW
  3. Brain principal    (3-5s,    only conversation, no persistence)

Wire (axi/dashboard.py)

_try_nano_extract(text, location_tag) → dict | None
  - Llama al extractor.
  - Switch en domain:
    * finance: si items ≥ 2 con amount → crea N entries (1 por ítem,
      cada uno con su category). Si solo total → 1 entry.
    * exercise: crea sesión con duration_minutes + kind mapeado.
    * learning: crea entry con title + author (primer person).
    * events: parsea dates_text[0] vía dateparser, crea entry con when.
    * relationships: requiere ≥ 1 person; find_or_create + interaction.
    * health / spirituality: skip por ahora.
  - Wrapped in try/except — nano falla → fall through al brain.

Insertion point: justo antes del bloque del brain, dentro del
`if not image_b64`. Si retorna dict, se persiste, se registra metric
(stage = "nano_<domain>"), y se retorna. Si retorna None, sigue al brain.

Prompt v2 (lifeos/agents/extractor.py)

Bugs detectados en v1 + arreglos:
- "mi mamá", "mi esposa", "mi hermano" se metían como people.
  → regla CRÍTICA: solo nombres propios capitalizados literales en
    el texto, NUNCA roles ("mi X").
- "Estoy aprendiendo Claude Code" → hallucinaba people=["James Clear"]
  o similar.
  → "NUNCA inventes nombres que no aparezcan literalmente."
- "caminata familiar" → hallucinaba people=["Daniela"] del context.
  → ejemplo nuevo + regla.

5 ejemplos few-shot ahora (antes 4), incluyendo casos negativos
("mi mamá" → people=[]).

Tests propios del prompt (10 casos reales que el regex falló):
  domain accuracy: 10/10  ✅
  people accuracy: 10/10  ✅
Mean latency: ~2.2s.

Finance: itemized inputs ahora declinan en regex (lifeos/finance/ingestion.py)

_ITEMIZED_LIST_RE: detecta patrón ":<num> <word>,<num> <word>" — eso
es "ticket itemizado" (ej: "Aurrera: 320 detergente, 450 papel"). El
regex single-entry capturaba todo en una sola entry con amount=total
y perdía el detalle por ítem. Ahora declina → nano hace el split.

Live verified
  "Gasté 1850 en Aurrera: 320 detergente, 450 papel higiénico, 500 cable HDMI"
  → 3 entries:
    detergente       320 MXN  cat=hogar       merchant=Aurrera
    papel higiénico  450 MXN  cat=hogar       merchant=Aurrera
    cable HDMI       500 MXN  cat=electrónica merchant=Aurrera

  "gasté 250 en café" → regex still wins (15ms, single entry, kind=expense)
  "Mi esposa Daniela y yo nos casamos el 15 de junio de 2018"
  → events entry "casamiento con Daniela", when=2018-06-15
  "Caminé en el parque con mi esposa Daniela 45 min"
  → exercise session, kind=walk, 45min, body texto
  "Empecé el libro Atomic Habits de James Clear"
  → learning entry, kind=book, title=Atomic Habits, author=James Clear

Limitations conocidas (próximas iter):
- "Tuve discusión con mi mamá" → nano correcto domain=relationships
  pero people=[] (regla del prompt). El wire helper rechaza por falta
  de person_id. Cae al brain → guardrail anti-hallucinación → "no pude
  registrar". HONESTO pero deficiente — debería poder anclar a un
  "rol_alias" (mamá, esposa, etc.) en la próxima iter.
- Schemas no extendidos: items/dates_text/duration_minutes se mapean
  a campos existentes; no hay tabla dedicada para "items por ticket".
  Suficiente para v1 — finance ya soporta entries individuales.

Suite 303/303 verde.
Bug del wire v1: el nano-extractor a veces capturaba "mi mamá" / "mi
esposa" como nombre propio en `people` (a pesar de la regla del prompt
v2). Esto creaba personas literalmente llamadas "mi esposa" en
/relationships — basura.

Solución dos capas:

1. Defense in depth (axi/dashboard.py)
   _strip_role_pseudo_names(): filtra del `people` cualquier
   pseudo-nombre. Drops:
     - "mi <X>" (cualquier cosa empezando con "mi ")
     - palabras de parentesco solas: "mamá", "papá", "esposa", "esposo",
       "hermano", "hermana", "abuela", "abuelo", "suegra", "suegro",
       "tía", "tío", "primo", "primo", "novio", "novia", "señora",
       "señor", "jefa", "jefe", "yo", "mí", "mi"
     - Cualquier name que NO empiece con mayúscula (proper noun heuristic)
   Aplica BEFORE de decidir el path en el wire de relationships.

2. Role-alias resolver
   _ROLE_ALIASES: lista (regex, canonical_name, role_label) cubriendo
   parentescos comunes en español. Cuando el texto matchea uno de los
   patterns y `real_names` quedó vacío, find_or_create un person
   CANÓNICO por rol:
     mi mamá   → Person(name="Mamá",   role="madre")
     mi papá   → Person(name="Papá",   role="padre")
     mi esposa → Person(name="Esposa", role="esposa")
     mi esposo → Person(name="Esposo", role="esposo")
     mi hermano/hermana/hija/hijo/abuela/abuelo/suegra/suegro/tía/tío/
     prima/primo/novia/novio/jefe/jefa → todos cubiertos
   La persona se crea UNA sola vez; las interacciones siguientes la
   reusan vía find_by_name. El usuario puede renombrarla después
   (e.g., "Mamá" → "María Elena") y el role-alias sigue agarrando
   "mi mamá" (resolveria a la persona canónica que ya existe — pero
   con find_by_name buscando "Mamá", si la renombró el alias dejaría
   de funcionar — esto es OK porque ya hay un nombre real entonces).

Health domain wire
  Para los casos conversacionales que no cae al regex (todo lo
  estructurado lo agarra antes — presión X/Y, RM N, dormí Xh, etc.).
  Crea kind="note" con title del extractor y body=texto. El usuario
  puede editar después.

Spirituality domain wire
  Bajo frecuencia pero presente. Crea entry con kind=reflection|
  gratitude|prayer|meditation|retro según result.kind.

Live verified end-to-end
  IN  "Tuve discusión con mi mamá"     → relationships/conflict con Mamá
  IN  "Hablé con mi esposa"            → relationships/conv con Esposa
  IN  "Hablé con mi papá"              → relationships/conv con Papá
  IN  "Hablé con Diego"                → regex path (15ms) con Diego
  IN  "hoy me sentí cansado"           → health/note
  IN  "no dormí bien anoche"           → health/note

  /relationships/people al final:
    Diego  role=-       (regex creation)
    Esposa role=esposa  (role-alias)
    Mamá   role=madre   (role-alias)
    Papá   role=padre   (role-alias)

Suite 303/303 verde.
Bug descubierto: aunque el systemd service tenía -ngl 0 (cero layers
GPU), el binario llama-server-cuda inicializaba un contexto CUDA al
arrancar y reservaba ~916 MiB de VRAM para runtime libs/KV-cache
fallback. Esto contradice el principio del PRD-nano-agents: los nano
deben vivir EXCLUSIVAMENTE en CPU+RAM para no competir con el brain
principal por VRAM.

Fix: agregar Environment="CUDA_VISIBLE_DEVICES=" al systemd unit. Esto
oculta el GPU completamente al proceso. llama-server cae en CPU-only
sin tocar CUDA libs aunque alguien edite -ngl en el futuro.

Verificación live:
  ANTES: nvidia-smi mostraba PID nano usando 916 MiB VRAM
  DESPUÉS: nano NO aparece en nvidia-smi. RSS = 903 MB CPU.
  Smoke /health OK, chat completion OK.

El brain principal (port 8080, sigue con sus 7.9 GB VRAM) intacto. No
comparten.

Principio establecido: TODO nano-agent corre CPU+RAM only. Cualquier
service de nano (este y futuros) debe tener Environment=
"CUDA_VISIBLE_DEVICES=" para garantizar el contrato. La línea está
documentada inline en el service file.

axi/systemd/llama-nano.service: version-controlled ahora. Antes solo
existía en ~/.config/systemd/user/. Próxima instalación puede usar:
  cp axi/systemd/llama-nano.service ~/.config/systemd/user/
  systemctl --user daemon-reload && systemctl --user enable --now llama-nano
CachyOS update pulló pyannote.audio 4.0.4. Entre 3.x y 4.x renombraron
el kwarg de auth: use_auth_token → token. Nuestro código tenía el viejo
y la llamada fallaba con TypeError silencioso al cargar el pipeline,
matando la diarización V2 sin loguear nada visible.

Síntoma reportado por Héctor (meeting #4 del 21-may, prueba con 2 voces):
  El meeting completó OK con transcript + summary.
  PERO todos los segments del system channel quedaron con
  speaker_label=null (solo el mic obtuvo "Héctor").
  diarization_v2_enabled=True en config, HF token presente, gated
  repos aceptados — pero la pipeline NUNCA cargaba.

Fix: introspect la firma de Pipeline.from_pretrained y elegir el kwarg
correcto en runtime. Funciona en pyannote 3.x (use_auth_token) y 4.x
(token) sin cambios cuando vuelvan a renombrar.

Verificación:
  inspect.signature(Pipeline.from_pretrained) en 4.0.4 expone
  ['checkpoint','revision','hparams_file','token','cache_dir'].
  Pipeline.from_pretrained(..., token=hf_token) → ✅ carga
  SpeakerDiarization correctamente.

Pendiente separado: el bug de segmentos duplicados en el system
channel (meeting #4 tenía 2 segments 60-120s casi idénticos). Eso
es del meeting service, no de pyannote — investigación aparte.
Bug latente revelado por test meeting #5:

axi.diarize WARNING preprocess_wav failed: No module named 'resemblyzer'

resemblyzer (V0 diarization fallback) NUNCA estuvo en pyproject.toml —
históricamente se instaló manualmente y desapareció con cualquier
re-install fresca del venv. El fix de pyannote V2 destapó esto porque
ahora la cascada V2→V0 efectivamente intenta el fallback.

Cambios pyproject:
  resemblyzer>=0.1.4    — V0 fallback siempre disponible
  pyannote.audio>=3.1   — V2 ya estaba implícito, ahora explícito

Ambos asegurados para futuros `uv pip install -e .` desde cero.

Sobre meeting #5 (test rápido):
  Solo 35s, mic dijo "Vamos a iniciar, listo, iniciada la reunión..."
  System channel capturó únicamente "Gracias" (8 chars de hallucinación
  Whisper sobre ruido de fondo). pyannote V2 corrió correctamente —
  device=cpu, processed 35.5s — pero no encontró clusters porque NO
  HAY UN SEGUNDO HABLANTE REAL en el audio del system channel.
  Diarización 0/0 es comportamiento correcto cuando no hay otra voz.
Segundo bug de la migración a pyannote.audio 4.0.4:

3.x: pipeline(audio) → pyannote.core.Annotation (.itertracks)
4.x: pipeline(audio) → DiarizeOutput (.speaker_diarization → Annotation)

El código antiguo iteraba directo sobre el resultado del pipeline:

  for turn, _, label in diarization.itertracks(yield_label=True):

En 4.x eso lanza AttributeError porque DiarizeOutput no tiene
itertracks. El except amplio en diarize_meeting() capturaba, logueaba
en events, y delegaba a V0 — sin que el log subiera a journalctl ni
events surface en /events (porque events.log_error queda en cola).

Síntoma observado en meeting #6 (reunión real de 39min, 3+ voces):
  - axi.diarize_v2 INFO diarize_v2: meeting 6, 33 segments, 1978.6s
  - Inmediatamente: axi.diarize INFO diarizing meeting 6 (V0 fallback)
  - V0 con threshold 0.40 dio 11 clusters, todos colapsados a
    "Persona 1" global (matched against 18 utterances pre-existentes)
  - 32/33 system segments terminaron etiquetados "Persona 1"

Fix:
  result = pipeline({"waveform": ..., "sample_rate": sr})
  diarization = getattr(result, "speaker_diarization", result)
  for turn, _, label in diarization.itertracks(yield_label=True):

`getattr(..., default=result)` cubre AMBAS APIs:
  - 3.x: result ya es Annotation → fallback al mismo objeto
  - 4.x: result.speaker_diarization → Annotation interno

Verificación con audio dummy en pyannote 4.0.4:
  result type: DiarizeOutput
  result.speaker_diarization: Annotation
  iteración: OK
…ND_DISPLAY

Bug latente revelado por meeting #6 (39 min, 0 screenshots capturados):
config tenía meeting_screen_interval_s=2 + meeting_screen_dedup_hamming=5,
spectacle estaba instalado y funcionaba desde el shell, pero el
_screen_loop NUNCA registró ni un solo screenshot durante la reunión.

Causa raíz: la daemon axi-voice arranca via systemd --user, que NO tiene
WAYLAND_DISPLAY en su env (`systemctl --user show-environment` lo
confirma). Spectacle en Wayland sin WAYLAND_DISPLAY retorna rc=0 SIN
crear archivo — silent failure. El check `if tmp.exists() and stat>0`
en el loop saltea inmediatamente, no hay log de error, y el contador
queda en 0.

Plasma normalmente publica WAYLAND_DISPLAY al systemd user manager via
`systemctl --user import-environment`, pero por alguna razón no se ejecutó
en este sesión (post-update CachyOS, posible regresión).

Fix: nueva función _build_display_env() que construye el env explícito
para spectacle:
  1. Copia env actual
  2. Si falta WAYLAND_DISPLAY, busca sockets `wayland-N` en
     $XDG_RUNTIME_DIR y usa el primero
  3. DISPLAY default ':0'
  4. XDG_SESSION_TYPE default 'wayland'
El _screen_loop ahora pasa este env explícitamente a subprocess.run
y loggea el WAYLAND_DISPLAY resuelto al inicio del loop. Si el log dice
"WAYLAND_DISPLAY=(missing)" sabremos al toque que el bug volvió.

Verificación live:
  - _build_display_env() → WAYLAND_DISPLAY=wayland-0, DISPLAY=:0
  - subprocess.run con ese env crea PNG de 200KB rc=0 ✓

Próxima reunión va a poblar meeting_screenshots correctamente.

Limitación: la reunión #6 ya no se puede arreglar — el audio se grabó
sin pantallas. La summary del brain entendió el contexto vía transcript
así que igual quedó usable.

Suite 303/303 verde.
- Migration #8: notif_log table with indexes on sent_at and hash+sent_at
- New module notif_budget: BudgetConfig, load_config, evaluate, record, cleanup_old
- evaluate(): critical bypasses all rules; ambient respects 5/day cap + 1h dedup;
  cap hit fires ONE coalesce digest per window, subsequent suppressed silently
- push.send_to_all: new priority kwarg; budget check before fanout; record after;
  return dict now includes suppressed key (0 or 1) and reason on suppress
- Replace plain sqlite3 with sqlcipher3 in store.py; PRAGMA key applied
  immediately on every connect() before any query, matching health/finance pattern
- Add key_path() and load_key() helpers; key auto-generated as 32-byte random
  hex on first access, written to ~/.local/state/lifeos/lifeos.key (chmod 600);
  overridable via LIFEOS_KEY_PATH env var for tests
- New module db_migrate.py with migrate_to_encrypted(): detects plain DB,
  backs up to .pre-encrypt.<timestamp>.bak, copies all tables row-by-row into
  a new encrypted DB, verifies row counts, then atomic-renames into place;
  dry_run=True skips the final swap; already-encrypted DB is a no-op
- CLI entry: python -m lifeos.db_migrate (run once manually to migrate live data)
- Add LIFEOS_KEY_PATH to all 12 existing test fixtures that set LIFEOS_DB_PATH
- New test_db_encryption.py: 7 tests covering encryption-at-rest, key file
  generation/persistence, all 8 migrations on encrypted DB, plain→encrypted
  migration, idempotency, and dry_run

Test result: 326 passed (319 existing + 7 new), 0 regressions
- Add optional `brain_fallback: Callable[[str, str], datetime | None] | None = None`
  parameter to `parse_reminder()` in lifeos — pure DI, no axi import, fully
  backward-compatible (default None)
- Fallback is invoked when dateparser returns None on the when-expression OR
  when no canonical time marker is found in the phrase at all (e.g. "después
  del almuerzo", "cuando termine el gym")
- Naive datetime from fallback is rejected with a log line (timezone-aware required)
- Any exception raised by the fallback is caught; parser returns None
- Create `axi/src/axi/reminder_brain.py` exporting `parse_when_brain(when_text, tz)`
  — calls brain.ask with a tight JSON-only prompt, strips markdown fences,
  validates the ISO 8601 response, and records a fastpath metric on success
- Wire `parse_when_brain` into `axi/src/axi/dashboard.py` reminder fast-path
- 6 new tests in `lifeos/tests/test_parser.py` (332 total, was 326)
- 6 new tests in `axi/tests/test_reminder_brain.py`
- Add axi/reminder_voice.py with try_create_reminder() that calls
  parse_reminder() (brain_fallback=parse_when_brain), persists via
  reminders.create(), schedules via get_scheduler().schedule(), and
  fires a notify() confirmation with local-time formatting.
- Wire fastpath into daemon._stop_and_transcribe BEFORE the intent
  classifier; gated by config key reminder_voice_enabled (default True).
- Register reminder_voice_enabled in config_schema.py.
- Add 6 tests in tests/test_reminder_voice.py covering: no-match,
  happy path (create+schedule+notify+rid), parser-None, create failure,
  brain_fallback wiring, and pretty_when local-tz format.
- Add 'axie', 'hexi', 'jaxi', 'jaxie', 'jexi' to _TRIGGER alternation
- Reorder alternation longest-first so 'axi' doesn't shadow 'axie'
- 6 new test_intents cases covering the new variants

The user's whisper_initial_prompt was also updated out-of-band to mention
Axi explicitly (it was an older version that lacked the wake-word context)
so Whisper biases toward the correct transcription instead of the nearest
Spanish word.
- Append 'después de', 'despues de', 'cuando ', 'tras ' to _WHEN_MARKERS
- These idioms aren't parseable by dateparser but the brain fallback can
  resolve them. Adding them as markers ensures the parser splits message
  from when-text BEFORE invoking the brain — otherwise the brain receives
  the full phrase (message + time) and returns null because it can't
  identify which part to convert
- 1 new test locks the split behavior for both 'después de comer' and
  'cuando termine la reunión'
- 333 lifeos tests passing (was 332)
FastAPI deprecated @app.on_event in favor of an async lifespan
context manager. Consolidates startup (scheduler + 7 store
migrations + insights/posture crons) and shutdown logic into a
single asynccontextmanager. Behavior unchanged; DeprecationWarning
removed.
Eliminates naming collision with the LifeOS sibling package's own
~/.local/state/lifeos/lifeos.db (encrypted store). The axi knowledge
graph now lives at ~/.local/state/axi/memory.db.

Includes one-shot migration script scripts/axi-db-rename-memory
that stops the four axi systemd-user services, moves the three
SQLite files (.db, .db-shm, .db-wal), and restarts. Already run
locally; the migration is idempotent.
RealtimeTTS' PiperEngine spawned the piper CLI binary (and reloaded
the ONNX model) on every call to synthesize() — measured at ~50-100ms
of boot per sentence, the main source of audible gaps in the live
translator.

PiperPythonEngine uses the piper-tts Python bindings instead: loads
PiperVoice once at boot (~650ms cold), then synthesizes each
sentence in ~70ms warm with no IPC. The dynamic length_scale policy
(speed up Piper when the queue grows) is preserved through a
get_length_scale callable read fresh per call.

Removes the subprocess.run monkey-patch that the old engine needed
to inject --length-scale into every CLI invocation.
Unify the three previously siloed pieces — patterns, graph edges, and
decision engines — behind a CorrelationBundle that callers inject into
their prompts. Purchase consults and symptom surfacing now see active
cross-domain context (sleep/health signals), not just their own domain.

- correlate.py: build_bundle + render_summary + hourly snapshot cron
- edges.py: add correlates-with / pattern-active-at relations
- decide/{purchase,symptom}: accept optional bundle, inject edge_summary
- cron.py: register correlation_snapshot job
- dashboard: pass build_bundle() into the live chat consult/symptom paths,
  plus a read-only /api/insights/context endpoint

Tests: test_correlate, test_decide_with_bundle, test_insights_context_api,
test_chat_consult_bundle — all green (lifeos 350, axi 296).
Make the diarizer and the nano-agent endpoint runtime-configurable, and
guard the entity extractor against its noisiest failure mode.

- config_schema: add diarize_version (auto/v2/v0) and nano_endpoint fields
- meeting: dispatch the diarizer by diarize_version, fall back v2 -> v0 on
  failure instead of hard-coding diarize_v2
- dashboard: apply nano_endpoint from config on startup (no restart needed);
  skip the nano extractor on <12-char input and drop empty "spirituality"
  classifications (verified noise: 30 of 129 calls defaulted there)

Tests: test_config_schema, test_config_nano, test_diarize_dispatch,
test_diarize_v2 — 29 passed, 1 skipped.
test_capture.py is a hand-run diagnostic, not a test — pytest was
collecting it. Rename to _probe_capture.py; the leading underscore keeps
it out of collection while still importable as `python -m axi._probe_capture`.
- PRD-nano-agents-v1: bump to v1.1 reality update — reflect what is actually
  built (N0 runtime + N2 entity extractor) and the Ollama -> llama-server
  deviation
- PRD-life-companion-v1: mark the six §9 open questions as RESOLVED
- INSTALL.md: minor correction
Drop the hardcoded /home/hectormr/.../axi-192.png path. Read the icon from
LIFEOS_NOTIFY_ICON (set by the axi dashboard / systemd unit); fall back to
the themed "dialog-information" name. Keeps lifeos free of axi's layout.
- lifeos as an editable path dependency so `uv sync` keeps it installed
- pyaudio for piper_python_engine + translate audio I/O
- webrtcvad-wheels to override resemblyzer's broken webrtcvad wheel under uv
- track uv.lock; add pytest dev group
The speak=True path moved from a background speak() thread to synchronous
synthesize_wav_bytes returned in the response (so audio plays over VPN/mobile,
not the laptop). Update the chat-multimodal and piper-engine tests to match.
…4.3)

Build the eval harness §4.3 needed to decide whether to keep or archive the
nano entity-extractor. Production metrics never store input text, so accuracy
can only be measured against a labeled golden set run through the live model.

- scoring.py: pure precision/recall/F1 + accuracy + confusion over domain
  classification, system-agnostic (nano / regex baseline / brain). None and
  "null" both map to a single null class so no-action cases are scored.
- golden_sets/domain_classification.jsonl: 36 labeled Spanish cases across all
  7 domains + 8 null trap cases (short/ambiguous inputs that historically
  leaked to spirituality — the bug fixed in 4776acd)
- _run_eval.py: hand-run live eval (port 8090), out of pytest collection
- test_eval_scoring.py: 32 tests on the pure scoring layer (no live model)

Tests: 382 passed (+32), no regressions.
Add a `layer` field to the golden set (nano/regex/guard) so the eval
reports nano-eligible accuracy separately from cases handled by the
regex finance parser or filtered by the dashboard guards. Raw accuracy
mixed three pipeline layers and understated the nano; nano-eligible is
the real decision metric.

- GoldenCase.layer + load_golden_set parsing (default "nano")
- score_by_layer() reuses score_domain over each subset
- format_segmented_report() highlights nano-eligible accuracy
- _run_eval exit threshold now applies to nano-eligible (0.85 bar)
Two natural-language finance inputs were falling through to the nano
fallback instead of being caught by the regex parser:
- "Compré los tenis en $1299, los de descuento" — the amount boundary
  lookahead excluded the comma, so a number followed by "," never matched.
- "Salió 3400 la cena..." — "salió" was in no verb pattern.

Add sali[óoò] to the outflow verbs, include the comma in the amount
boundary of _OUTFLOW_RE and _PURCHASE_RE, and make the `what` connector
optional so connector-less trailing text is captured. Number-adjacency
preserves precision: "salió a correr" / "salió el sol" stay unmatched.
Closes the last gap in PRD §8 (list/edit/delete) — list and delete
already shipped; edit was missing across all layers.

- lifeos DAO: update() updates all fields WHERE status='pending',
  returns None otherwise; tz-aware validation mirrors create().
- axi: PATCH /api/reminders/{rid} validates like POST (ISO8601, cron,
  channel, limits), 404 when not pending, and re-schedules the
  APScheduler job to reflect the new time/recurrence.
- UI: per-pending-card "Editar" button reuses the compose form via
  editingId (prefill + "Guardar cambios" + "Cancelar edición"),
  reusing the existing recurrence/end-condition modals.
- Add tests/test_reminders_e2e.py (TestClient): create/list/edit/
  delete cycle + 404 + validation paths.

Push-subscribe flow untouched. Verified end-to-end in a real browser.
- test_parser: freeze the clock in the brain-fallback test. parse_reminder
  shifts a target +1 day when its time has already passed today, so the
  test passed in the morning and failed in the afternoon. Freezing to
  08:00 keeps the 15:30 target in the future; assertion unchanged.
- test_scheduler: the cancel test raced schedule() against cancel() under
  full-suite load. Assert the job is gone from the scheduler after cancel
  (deterministic) and widen the lead time; the dispatcher-not-fired check
  is kept as defense.
hectormr206 and others added 29 commits June 19, 2026 20:43
…ce 4)

- request_id ContextVar + ReqIdFilter (injects req_id into every log line) +
  FastAPI middleware (uuid per request / X-Request-Id header). A chat turn now
  traces end-to-end by req_id.
- brain.py: route/fallback/error events (engine, trigger, vt_down reason).
- extractor + embed-drain failures emit events with context.
- thread request_id copy pattern at spawn sites.
… axi events CLI (Slice 5)

- store.query_events(source, since_ts, level, limit, offset): query the full events
  SQLite table (not just the 200 ring buffer), parameterized, newest-first.
- /api/events gains source/since_ts/level/offset filters (backward-compatible).
- events_cli.py: standalone 'axi events' CLI (--source/--level/--since 1h) hitting the API.
Completes the observability change: any error is now queryable from the logs.
- req_id now in the log format (the correlation feature was dead — set but never emitted).
- _repair_corrupt_db step 4 guarded (recovery last-resort no longer crashes startup).
- tray restart runs off the Qt main thread (no 30s UI freeze).
- heartbeat bare log.debug wrapped (a logging failure can't stop the watchdog).
- logfmt values escaped (tracebacks no longer corrupt log lines).
- managed_systemctl logs failures; events queue bounded (maxsize, non-blocking drop).
- query_events bounds + parse_since rejects negatives; ContextVar token.reset.
- SIGSEGV FIX: stop_embed_worker() + conftest teardown stops the embed-worker daemon
  thread so it can't touch sqlcipher during interpreter shutdown.
The recovery ladder backed up the live DB to memory.db.corrupt-<pid>.bak,
then skipped every .corrupt-* candidate by filename when restoring. Because
corruption is almost always WAL-only or cross-process, that snapshot is
usually a perfectly healthy copy of the main file — so recovery discarded
recoverable data and rebuilt an empty schema, causing total memory loss
(2026-06-20 incident: 142 nodes / 12 meetings wiped, the set-aside backup
was integ=ok).

Validate every backup candidate by CONTENT via a full PRAGMA integrity_check
(new _backup_passes_integrity helper) instead of by name, and restore the
newest healthy one. An empty DB is worse than a corrupt one you can salvage.
lsof showed three processes (daemon, dashboard, heartbeat) all writing
memory.db — the observability work had made heartbeat a high-frequency
events writer. Mixing disposable telemetry from three writers into the
SQLCipher file holding irreplaceable user memory drove the cross-process
WAL contention behind the recurring corruption.

Move the events table into its own events.db (thread-local _connect_events,
WAL, busy_timeout; rebuilt empty on corruption since telemetry is
disposable). Repoint insert_event/trim_events/query_events. Migrate legacy
events memory.db -> events.db once at init_db, then drop them from memory.db
so only the daemon ever writes it. A dedicated _events_migrate_lock avoids
the non-reentrant _conn_lock deadlock.
Non-destructive memory.db recovery + events.db telemetry isolation.
Fixes the 2026-06-20 corruption/data-loss incident at the root.
…dge (slice 1/3) (#139)

* feat(store): add node_limit param to backfill_similar_to_edges

Add optional node_limit: int | None = None to cap how many vec_nodes are
processed per run. When set, uses ORDER BY node_id DESC LIMIT ? to process
the most-recent embedded nodes first. Default (None) preserves full iteration.

Tests: test_domain_bridge.py Phase 1.1 — node_limit=2 caps to 2 nodes;
no limit processes all nodes.

* feat(domain_bridge): add DomainConfig registry, health renderer, bridge_entry

New module axi/domain_bridge.py:
- DomainConfig dataclass (renderer, extra_data_fn)
- _health_renderer: raw_utterance -> title -> structured fallback (120 char cap)
- _DOMAIN_CONFIGS: registry with 'health' and 'relationships' entries
- create_fact_node_for_entry(domain, entry): idempotent via domain_node_map
- bridge_entry(domain, entry): best-effort wrapper, swallows exceptions
- create_fact_node_for_interaction(interaction): backward-compat shim

Thread-safe: all writes via store._tx() (thread-local), embed via queue.

* feat(meeting): bridge meeting to graph with kind=fact after summarization

meeting.py:964 (_tx block) now creates a fact node (kind='fact') for the
meeting summary and updates meetings.node_id. The idempotent guard checks
node_id IS NULL before bridging so re-runs are no-ops.

kind='fact' ensures meeting nodes enter the same-day linker's pool
(WHERE kind='fact') in addition to the already kind-agnostic happened-at
linker. Embedding is async via trigger_embed_for_node.

Tests: test_meeting_bridge.py — node_id set, kind=fact, idempotent,
visible to same-day linker query.

* feat(health): wire all 4 health create() sites into semantic graph

Call domain_bridge.bridge_entry('health', entry) immediately after each
health entry creation:

- dashboard.py:2977 (nano-router create)
- dashboard.py:3594 (second nano batch create)
- dashboard.py:4541 (manual form POST /api/health/entries)
- mcp_tools.py:126 (log_health_entry): split return _jsonable(he.create(...))
  into two statements to capture the entry before returning

All call sites use best-effort try/except — errors log a warning but never
break the original write path.

Test: test_domain_bridge.py Phase 1.7 — POST /api/health/entries produces
a domain_node_map row with domain='health'.

* fix(meeting): extract bridge_meeting_node, serialize race via UPDATE WHERE node_id IS NULL

FIX 1: Inline bridge block extracted into bridge_meeting_node(meeting_id, summary)
callable. process_meeting delegates to it — behavior unchanged.

FIX 2: Race safety via atomic UPDATE meetings SET node_id=? WHERE id=? AND node_id
IS NULL. If a concurrent call wins (changes()==0), the orphan node is deleted.
No SAVEPOINT nesting needed.

Tests updated to import and call the real bridge_meeting_node (deleted local
_bridge_meeting_node re-implementation). New test:
test_meeting_bridge_double_call_no_orphan_node asserts exactly 1 node after 2
sequential calls.

* fix(domain_bridge): make store shim delegate to domain_bridge; add relationships DomainConfig

FIX 3: store.create_fact_node_for_interaction is now a thin shim calling
create_fact_node_for_entry('relationships', interaction). Entry ids always
stringified as str(entry.id) — consistent between store and domain_bridge,
preventing '1' vs 1 collision in domain_node_map.

Relationships DomainConfig added with _relationships_renderer and
_relationships_extra_data (preserves person_id, interaction_id, body fields).

FIX 5: _health_renderer and _relationships_renderer both treat whitespace-only
strings as absent via .strip() before truthiness test; output label is trimmed.

* test(domain_bridge): add FIX 4 chat-path test, FIX 5 whitespace tests, FIX 6 dead scaffolding

FIX 4: test_chat_health_fast_path_creates_domain_node_map_row drives the
health ingestion fast-path in api_chat_ask (dashboard.py:3602 bridge_entry)
with 'glucosa 110 mg/dL' and asserts a domain_node_map row is created.

FIX 5: three whitespace-only label tests for _health_renderer and
_relationships_renderer (whitespace raw_utterance falls back to title;
whitespace title falls back to structured string).

FIX 6: removed dead conn.row_factory = store._connect().__class__.__mro__
block from test_same_day_linker_connects_health_and_meeting_nodes; replaced
with correct sqlcipher3.Row assignment.
…(slice 2/3) (#140)

* feat(domain_bridge): add renderers for finance/exercise/spirituality/learning/lifeos-events (slice 2)

* feat(finance): wire all 5 finance create() sites into semantic graph (slice 2)

* test(domain_bridge): add integration coverage for nano/chat bridge_entry sites

Add test_domain_bridge_slice2_review.py with 11 new tests that guard the
unguarded nano-extract and chat-ask bridge_entry call sites for the 5 domains
introduced in Slice 2 (finance, exercise, spirituality, learning, lifeos-events).

- 5 nano-extract path tests: monkeypatch extractor.extract to force a domain
  result, suppress conflicting fast-path parsers, assert domain_node_map row.
- 5 chat-ask fast-path tests: drive real ingestion parsers (parse_exercise,
  parse_spirituality, parse_learning, parse_event, parse_finance) via HTTP,
  assert domain_node_map row is created.
- 1 real-pipeline cross-domain same-day linkage test: uses real create()
  for finance + exercise, run_same_day_linker, asserts same-day edge forms.

Discovery: axi telemetry store creates {LIFEOS_STATE_DIR}/events.db as plain
SQLite, conflicting with the lifeos events sqlcipher DB at the same path.
Events nano/chat tests redirect lifeos events DB via LIFEOS_EVENTS_DB_PATH
and LIFEOS_EVENTS_KEY_PATH within the fresh_db tmp_path to avoid collision.

Also updates events renderer tests in test_domain_bridge_slice2.py to match
the new natural-language format introduced by FIX 3.

* fix(mcp_tools): remove dead try/except wrapper around finance bridge_entry

The try/except around bridge_entry("finance", entry) in log_finance_entry
was dead code — bridge_entry never raises (it swallows internally and logs).
The wrapper was silencing log output and inconsistent with the dashboard.py
sites which call bridge_entry naked. Call bridge_entry directly.

* fix(domain_bridge): harden events renderer format and degenerate fallback

_events_renderer changes:
- Format: natural-language prose "{title} ({kind}) en {location}" instead of
  pipe-separated "title | kind | location". Kind and location only appended
  when present — no empty parens, no trailing "en".
- Degenerate fallback: absent/whitespace title now emits "event: {kind or 'other'}"
  instead of bare "event", providing a more useful seed for embedding.

New unit tests guard: no-kind produces no parens, no-location produces no
"en {location}", whitespace title triggers fallback, absent kind in fallback
produces "event: other" not "event".
…ixes (slice 3/3) (#141)

* fix(meeting): delete stale nodes_fts row on race-loser orphan cleanup (HYG-1)

In bridge_meeting_node, the race-loser path deleted the orphan from `nodes`
but did not clean its mirrored row from `nodes_fts`. This left a stale FTS
entry pointing to a non-existent node, which can cause phantom FTS results.

Added `DELETE FROM nodes_fts WHERE rowid=?` in the same transaction as the
existing `DELETE FROM nodes WHERE id=?`.

* fix(store): stringify interaction.id before domain_node_map lookup in backfill (HYG-2)

backfill_domain_fact_nodes called get_node_for_domain_entry with a raw int
(interaction.id) but the stored key is always str(interaction.id). The guard
always missed, causing redundant create_fact_node_for_interaction calls on
every backfill run (no duplicate nodes due to the inner idempotency guard, but
wasted DB round-trips and log noise).

Fix: pass str(interaction.id) so the lookup hits correctly.

* feat(domain_bridge): implement backfill_all_domains + Slice 3 tests (Part A)

Adds:
- _fetch_domain_entries(domain, *, days, limit): injectable fetch seam that
  routes to each lifeos domain's list_recent() (events uses days_back). Tests
  patch this to inject fake entries without touching real stores.
- backfill_all_domains(*, days=90, batch_size=50, sleep_s=0.1, node_limit=None):
  bounded, rate-limited, idempotent historical backfill across all domains in
  _DOMAIN_CONFIGS. Returns dict[domain, nodes_created]. Already-bridged entries
  are skipped via get_node_for_domain_entry (idempotent). node_limit caps total
  new nodes across all domains. sleep_s rate-limits the embed queue (cap=500).
  Thread-safe: all writes go through store._tx() (thread-local connections).

Also adds tests/test_domain_bridge_slice3.py with 10 tests:
- HYG-1: race-loser FTS cleanup
- HYG-2: str(id) idempotency guard
- 3.1.x: backfill_all_domains bounding, idempotency, skip-bridged
- 3.2/3.3: shim and re-export compat

* test(domain_bridge): add RED tests for starvation, delegation, unknown-domain warn

* fix(domain_bridge): add unknown-domain warning in _fetch_domain_entries (FIX 3)

* fix(store): backfill_domain_fact_nodes delegates to backfill_all_domains (FIX 2)

* test(backfill): update test seam from _fetch_recent_interactions to _fetch_domain_entries after delegation

* fix(domain_bridge): restore generous fetch limit in backfill_all_domains

backfill_all_domains was calling _fetch_domain_entries without an explicit
limit, silently capping each domain's fetch at its store default (e.g. 300
for relationships). This meant entries older than the N most recent were
never candidates for bridging, defeating the historical backfill.

Add _BACKFILL_FETCH_LIMIT = 10_000 constant and pass it to every
_fetch_domain_entries call inside backfill_all_domains. The per-run
node_limit cap on node CREATION is unchanged.

Add two guard tests that catch the regression:
- test_fetch_domain_entries_uses_generous_limit_for_relationships
- test_backfill_all_domains_fetches_with_generous_limit
…exists (defense-in-depth) (#142)

* fix(store): refuse to wipe memory.db when healthy backups exist but restore fails

Introduce RecoveryError and a healthy_backup_seen flag in _repair_corrupt_db.
Step 3 now tracks whether any backup passed integrity_check. If step 4 would
wipe db_path but healthy_backup_seen is True, it raises RecoveryError instead
of unlinking and rebuilding an empty schema — preventing silent data loss
when disk I/O errors block restore (the 2026-06-23 incident pattern).

When no healthy backup exists (healthy_backup_seen=False), step 4 preserves
the existing empty-schema rebuild so Axi can still start after total loss.

* fix(memory): make RecoveryError loud — CRITICAL log + notify + re-raise

When store.init_db raises RecoveryError (healthy backup exists but every
restore failed), ConversationMemory.__init__ now:
  - catches RecoveryError before the broad except Exception
  - logs at CRITICAL with recovery instructions (memory.db path + backups)
  - fires a best-effort desktop notify() so the user sees it immediately
  - re-raises so the daemon fails to start loudly, not silently amnesiac

Runtime methods (add, messages, turn_count) receive the same treatment:
except RecoveryError before except Exception → CRITICAL log + notify, then
return the existing safe default (no mid-conversation crash).

Tests (TDD RED→GREEN):
  - test_init_reraises_recovery_error_not_silent_degrade
  - test_init_fires_critical_log_on_recovery_error
  - test_init_fires_notification_on_recovery_error
  - test_generic_exception_still_degrades_gracefully (regression guard)
  - test_add_logs_critical_and_notifies_on_recovery_error
…143)

On a Wayland session DISPLAY is often still set, which leads Qt to load the
xcb platform plugin; without libxcb-cursor0 spectacle aborts (SIGABRT) and
every screen capture fails with a coredump. Force QT_QPA_PLATFORM=wayland for
the spectacle subprocess when WAYLAND_DISPLAY is present, leaving pure-X
sessions untouched. Verified end-to-end: capture_active_window_b64 now returns
a valid PNG in a KDE Wayland session.
…ypoint (#144)

* test(backfill): RED tests for durable checkpoint before return

Add test_backfill_durable.py with four tests (all fail against current code):
- D.1: backfill_all_domains calls store.checkpoint() after bridging
- D.1 triangulation: checkpoint called even when 0 entries bridged
- D.2: checkpoint failure does not suppress the result dict
- D.3: CLI __main__ calls backfill + checkpoint + close in order

* fix(backfill): checkpoint WAL before returning + add CLI entrypoint

backfill_all_domains() now calls store.checkpoint() (reusing the existing
helper at store.py:729) after all bridging is done, before returning the
result dict. A checkpoint failure is logged as a warning and swallowed so
callers always receive the per-domain counts.

This makes EVERY caller — daemon or standalone — durable by default: WAL
frames are folded into the main DB file before the function returns, so a
standalone process that exits immediately after will not lose writes.

Also adds axi/src/axi/backfill.py, a tiny CLI entrypoint (python -m
axi.backfill) with belt-and-suspenders durability: backfill_all_domains +
explicit store.checkpoint() + store.close() before exit. Docstring notes
the single-writer requirement (stop daemon services first).

Fixes: standalone backfill silently losing ~23/27 nodes on process exit
because WAL was never checkpointed (reproduced 2026-06-23).
…tion (#145)

btrfs CoW + compress=zstd + SQLite/SQLCipher random writes is a proven
toxic combo that caused two memory.db data-loss incidents (2026-06-20,
2026-06-23). The root fix is chattr +C on the state directory so every
DB created inside (memory.db, events.db) inherits NoCoW automatically.

- Add _ensure_nocow_dir(path) helper: calls `chattr +C <dir>` via
  subprocess, best-effort — swallows ALL failures (FileNotFoundError,
  CalledProcessError, OSError, PermissionError) so startup never breaks
  on ext4/xfs/tmpfs/CI or machines without the chattr binary.
- Hook into _connect() right after STATE_DIR.mkdir() so the directory
  is NoCoW before any DB file is created on a fresh install.
- Add tests/test_nocow.py: 7 TDD tests (RED→GREEN) covering chattr
  invocation, non-existent-dir guard, failure-swallowing (4 exc types),
  and connect-path integration.

Does NOT touch DB-open/recovery logic or existing-file conversion (the
live machine was already converted manually). lifeos domain DBs in
~/.local/state/lifeos need equivalent treatment in the lifeos package.
…/...:7799) (#146)

The CLI defaulted to http://127.0.0.1:7799, but the dashboard serves HTTPS on
8081 with a self-signed loopback cert — so the CLI could never reach it (wrong
scheme AND wrong port). Default to https://127.0.0.1:8081 and use a
non-verifying SSL context for the loopback self-signed cert. Verified
end-to-end against the live dashboard.
Mirrors the btrfs corruption fix from axi (PR #145, commit 5f9755f) into
the lifeos package. All 9 domain stores now call ensure_nocow_dir(p.parent)
immediately after mkdir, setting chattr +C on ~/.local/state/lifeos so every
DB/key file created there inherits NoCoW.

- Add lifeos/_common/nocow.py with ensure_nocow_dir() — best-effort,
  idempotent, swallows all failures (ext4/xfs/tmpfs/CI/missing chattr)
- Hook all 9 connect() sites: store.py + health/finance/exercise/
  spirituality/learning/events/posture/relationships stores
- 16 TDD tests in tests/test_nocow.py (RED→GREEN confirmed)
  - helper calls chattr +C on existing dir
  - swallows FileNotFoundError/CalledProcessError/OSError/PermissionError (×4)
  - no-op when dir missing (no call, no raise)
  - each store's connect() calls ensure_nocow_dir (parametrized ×9)
The one-shot CLI hardcoded days=90 / node_limit=500, so it silently dropped
history older than a quarter and couldn't be tuned without editing source.
Default to days=3650 and unbounded node_limit (round-robin fairness keeps an
unbounded run safe), and add --days / --node-limit flags. main() now accepts
argv for testability.
…bal store (#149)

Clicking the brain organ (#organ-brain, in the avatarWidget() Alpine scope) set
'brain3dOpen', but the modal overlay lives in the dashboardStore() scope — a
different component. The variable existed in one scope and was 'not defined' in
the other, so the modal's x-show could never see it and the click did nothing
(ReferenceError: brain3dOpen is not defined).

Move the state to a global Alpine store ($store.brain3d.open) that every scope
can read/write. Verified end-to-end with a headless browser: clicking the brain
now opens the modal and loads the /brain3d iframe (169 nodos). Bumped the
service-worker CACHE_VERSION so clients pick up the fixed shell.
#150)

Two chained layout bugs left the graph rendering in a ~150px band with a black
void below:
1. The modal's inner flex column had BOTH x-show and inline display:flex —
   Alpine's x-show overwrote display:flex with block when shown, so the iframe
   was no longer in a flex container and collapsed to its 150px intrinsic
   height. Removed the redundant x-show (the outer #brain3d-modal already gates
   visibility) so the flex column survives, and wrapped the iframe in a
   flex:1/min-height:0 box with height:100%.
2. 3d-force-graph locks its canvas to the container size at creation and never
   resized, so even once the container grew the canvas stayed tiny. Added a
   ResizeObserver that syncs the graph canvas to the container.

Verified end-to-end (headless browser): iframe 150px -> 948px, canvas
150px -> 709px; the full graph renders (169 nodos · 143 relaciones).
test_sw_cache_version_bumped hardcoded 'axi-shell-v10', so it broke the moment
the cache version was legitimately bumped (v11, brain3d fix). Assert a numbered
axi-shell-v<N> pattern instead — it verifies a versioned shell cache exists
without snapshotting a specific number.
… a failed recovery (#152)

* fix(store): atomic restore in _repair_corrupt_db via temp file + os.replace

Previously, shutil.copy2(candidate, db_path) overwrote the live file
before verifying the restored copy could be opened. When _try_open failed
afterward (e.g. transient btrfs I/O error), db_path was left containing
stale backup bytes — the June-24 incident that regressed 169 nodes to 136.

Fix: copy to a PID-stamped temp file first, call _try_open on the temp,
only then clear WAL sidecars and atomically swap via os.replace. If the
temp open fails the temp is unlinked and db_path is never touched. The
finally block ensures no temp file is ever left behind.

- healthy_backup_seen / RecoveryError guard (step 4) unchanged
- Steps 1, 2, 4 unchanged
- Two new RED→GREEN tests discriminate old (clobbers) vs new (preserves)
- Updated two companion tests to use _try_open patching (not shutil.copy2
  patching) since atomic restore no longer writes directly to db_path

* fix(store): thread-safe temp name and accurate recovery messaging

- Temp file name now includes thread id and 4-byte token hex to prevent
  races when two threads in the same process recover concurrently
  (was pid-only, which collides within the same process).
- Block comment on the atomic-restore path corrected: the previous
  wording said db_path is 'left exactly as it was' on any failure,
  which was inaccurate for the swap-ok-but-reopen-fails sub-path.
  Comment now states the precise invariant: db_path is never left
  holding unverified or partial bytes; the only mutation is an atomic
  swap to a backup that already passed open-verification.
- RecoveryError message updated from 'every restore attempt failed'
  (inaccurate when a swap did succeed) to 'could not bring memory.db
  back to a healthy open state from any backup'.

* test(store): lock production-path and corner-case recovery behaviors

- Remove unused shutil import (tests patch _try_open, not shutil.copy2).
- Add test_try_open_raises_database_error_on_temp_leaves_db_path_untouched:
  reproduces the 2026-06-24 btrfs incident where _try_open RAISES
  DatabaseError (not returns None) on the temp file; asserts RecoveryError
  is raised, db_path is untouched, and no .restore-tmp-* lingers.
- Add test_swap_ok_but_reopen_fails_leaves_db_path_with_verified_backup_bytes:
  locks the swap-ok-but-reopen-fails corner where db_path ends up holding
  verified backup bytes (strictly better than corrupt original) and
  RecoveryError is still raised.
…nreadable (no more blank UI) (#153)

* test(dashboard): RED tests for snapshot graceful degradation on DB failure

Adds test_snapshot_graceful_degradation.py covering:
- /api/snapshot returns 200 (not 500) on RecoveryError and DatabaseError
- memory.degraded=True and safe defaults (counts=0, lists=[]) on failure
- _fact_count and _recent_facts unit-level safe-default behavior
- Happy path: real counts flow through, degraded absent/False

* fix(dashboard): guard all DB-touching snapshot helpers against RecoveryError

- _fact_count(): wrap store._connect() + query in try/except → return 0
- _recent_facts(): wrap store._connect() + query in try/except → return []
- _memory_snapshot(): new helper that tracks whether any DB read failed
  and sets memory.degraded=True + memory.degraded_reason on failure;
  replaces inline _safe_conversation_count/_fact_count calls in snapshot()
- /api/snapshot now returns HTTP 200 with safe defaults even when
  memory.db raises RecoveryError or DatabaseError
- dashboard.html: add discreet amber banner in memory popover when
  memory.degraded is true; guard Object.entries(services || {})

* fix(dashboard): null-guard brain-modal memory fields + drop dead _fact_count

Review polish: the brain-modal counters accessed $store.snap.memory.facts_count
/ .conversation_turns without a null guard (safe today since _memory_snapshot
always returns the dict, but not defensive). Guard them like the popover. Remove
_fact_count() which is now dead — superseded by _memory_snapshot().
…gs (#154)

Add graph_bridge_conversations (default false) and graph_bridge_meetings
(default false) to the config schema. Both flags are OFF by default, keeping
the semantic graph clean (structured life-facts only). Callers that set either
flag to true restore the previous bridging behavior exactly.

- config_schema.py: two new boolean ConfigFields with default=False
- memory.py ConversationMemory.add(): only creates a kind='conversation' node
  when graph_bridge_conversations=True; always saves the conversations row;
  returns (conv_id, None) when bridge is skipped (callers already handled None)
- meeting.py process_meeting(): only calls bridge_meeting_node() when
  graph_bridge_meetings=True
- tests/test_graph_bridge_gates.py: strict-TDD RED→GREEN tests covering
  both gates (OFF default and ON regression)
- tests/test_memory.py: updated test_add_returns_conversation_and_node_ids
  to reflect new default (node_id is None when bridge is OFF)
…licated header in modal (#155)

* feat(brain3d): add es/en label layer to Cerebro 3D graph

Domain names, node kinds, and edge types now display in Spanish by default
(driven by the existing language config) with English available when the
config is set to an en-* locale. Raw canonical DB keys are replaced at all
four render sites (legend, node kind, node domain, edge kind). Introduces
LABELS map, tLabel() and tEdge() helpers, and lang injection from the route.

* fix(dashboard): hide global nav header when Cerebro 3D is embedded in the modal

The brain modal loads /brain3d in an iframe; /brain3d extends _base.html which
renders the global nav header (LifeOS logo, clock, VRAM, menu). Inside the modal
— which already has its own header — that nav appeared a second time, looking
like a nested/duplicated header. Detect the iframe context (window.self !==
window.top) and hide the base header via CSS when embedded. Standalone /brain3d
still shows the full nav. Verified in a headless browser.
…int (#156)

* test(brain3d): RED tests for static UI i18n and vendor nav-info disable

Add test_brain3d_ui_i18n.py with 14 Strict TDD RED tests covering:
- LABELS.ui sub-map existence and completeness (es + en)
- tUi() helper definition and minimum call-site count
- Hardcoded Spanish text nodes must be gone from HTML
- Rendered HTML contains English UI strings for en-US locale
- showNavInfo(false) or CSS hide for vendor control hint

* feat(brain3d): i18n all static UI strings via LABELS.ui + tUi(), disable vendor nav-info

- Add LABELS.es.ui and LABELS.en.ui sub-maps with 15 keys each (title,
  nodes, relations, loading, loadingSub, empty, emptyHint, domainLegend,
  controls, clickNode, typeLabel, domainLabel, yes, connections, system)
- Add tUi(key) helper mirroring tLabel/tEdge with es fallback
- Replace every hardcoded Spanish text node with x-text="tUi('...')" or
  JS expression using tUi() at 11 render sites in the HTML
- Add .showNavInfo(false) to ForceGraph3D chain to suppress vendor
  English control hint; our own translated tUi('controls') hint stays
…flag (#157)

Add config field graph_bridge_chat_facts (boolean, default False). Gate
extract_and_store() at the top — before the brain_ask LLM call — so
neither the expensive inference nor any kind='fact' node creation happens
when the flag is off.

This keeps the semantic graph to structured life-domain facts only
(health, finance, etc. via domain_bridge); arbitrary-domain facts from
chat conversations are no longer written by default. Reversible by
setting graph_bridge_chat_facts=true in user config.

TDD: RED (brain_ask called unexpectedly) → GREEN (early-return skips
LLM) confirmed for all 5 extractor_gate tests. Full suite: 1609 passed,
6 skipped, 1 known flake (test_stop_embed_worker_stops_thread).
…er enter the graph (#158)

* feat(bridge): add low-value entry filter to domain→graph bridge

Garbage entries (bare-keyword labels with no raw_utterance, empty data,
and no numeric fields) are now skipped by create_fact_node_for_entry.
_is_low_value() returns True only for clearly contentless entries:
empty/whitespace labels, or single short tokens (≤14 chars) with no
raw_utterance, no data dict, and no amount/duration fields.

All labels containing a digit are always kept; multi-word labels are
always kept; entries with any real content field are kept.

- 23 new TDD tests (unit table + bridge skip/keep + bridge_entry)
- 69 pre-existing domain_bridge tests still green
- Full suite: 1633 passed, 6 skipped

* test(bridge): RED tests for low-value filter edge cases

Add RED tests for FIX 1 (falsy-zero in has_numeric), FIX 2 (backfill
counter skips low-value entries), FIX 3 (body as content signal).
…-check) — cuts the recovery cascade (#159)

* test(store): RED — flock+recheck serializes _repair_corrupt_db across processes

Add 4 failing tests (Layer 5) in TestRecoveryFlockSerialization:
- recheck skips recovery when DB already healthy (Step 1 not reached)
- recovery still runs when re-check fails (regression guard)
- lock is best-effort / never deadlocks when flock fails
- _recovery_lock acquires/releases LOCK_EX flock correctly

Update test_corrupt_backup_files_created_on_recovery to fail the re-check
via WAL + _try_open patch so Step 1 backup-creation still runs.
Update two test_obs_slice3 tests to return None on re-check call so the
detection/WAL events are still exercised.

TDD RED phase: tests fail against current store.py.

* fix(store): serialize _repair_corrupt_db with flock + re-check

Prevent the recovery cascade where multiple processes (axi-dashboard,
axi-voice, axi-heartbeat) each detect the same transient disk I/O error
and independently trigger destructive recovery — last-writer-wins
os.replace and repeated RecoveryErrors causing API 500s.

Changes:
- Add _recovery_lock(db_path): context manager that acquires LOCK_EX
  flock on memory.db.recovery.lock with a 60s bounded wait. Best-effort:
  if fcntl is unavailable or flock errors, logs WARNING and yields
  without a lock — recovery always proceeds.
- Refactor _repair_corrupt_db to wrap its body in _recovery_lock and
  call new _repair_corrupt_db_locked.
- Add re-check immediately after acquiring the lock: _remove_wal_sidecars
  then _try_open. If the DB is already healthy (another process recovered
  it while we waited), log and return the connection without running any
  destructive step (Step 1-4 are skipped).
- All existing steps (#142 RecoveryError guard, #152 atomic restore) are
  completely unchanged.

This completes the 2026-06-24 data-integrity arc (#649).

* fix(store): restore test coverage and forensic completeness after flock re-check

- test_repair_corrupt_db_emits_event_after_backup: patch _try_open with
  call-count stub so re-check fails and Step-1 backup event is actually emitted
- test_wal_sidecar_removal_recovers_after_corruption: same pattern ensures
  Step-2 WAL reset is the recovery path exercised, not the re-check skip
- test_forensic_wal_snapshot_preserved_before_recheck_removal: new test
  asserting corrupt WAL bytes are captured before _remove_wal_sidecars runs
- store._repair_corrupt_db_locked: best-effort forensic snapshot of WAL/SHM
  sidecars before the re-check's _remove_wal_sidecars call; wrapped in
  try/except so it never aborts recovery
- _recovery_lock: suppress duplicate timeout log when flock raises a hard
  OSError (only one attempt was made, not a full 60s wait)
- _recovery_lock: close lock_fd before nulling on outer-except path (fd hygiene)
- _recovery_lock docstring and stale test comment updated to match reality
…mezone (fixes 'todo ligado') (#160)

* feat(graph): store and link by real event date (occurred_at) instead of insertion date

Fixes the 'todo ligado' bug: same-day and happened-at linkers were grouping
nodes by created_at (graph-insertion time), so backfilled entries — all
inserted on the same day — incorrectly formed a dense mesh.

Changes:
- store.py: add nullable occurred_at REAL column to nodes via idempotent
  migration (migrate_nodes_occurred_at); update add_node() to accept and
  store occurred_at; add idx_nodes_occurred index
- domain_bridge.py: extract real event timestamp from entry.ts (then
  entry.created_at as fallback) via _entry_occurred_at() and pass it to
  add_node; add backfill_node_occurred_at() migration helper
- linkers.py: both run_same_day_linker and run_happened_at_linker now use
  COALESCE(occurred_at, created_at) for event-date grouping and window math
- dashboard.py: /api/graph/full includes occurred_at in each node object
- brain3d.html: node detail panel shows Fecha/Date when occurred_at present;
  forward occurred_at through gfNodes; add 'date' i18n key to LABELS

* fix(graph): extend backfill migration window to ~100 years and use rowcount

- backfill_node_occurred_at: default days changed from 365 to 36500 so
  all existing entries (including those >1 year old) are reached by the
  one-time migration; the dense-mesh bug persisted for any node whose real
  event date was outside the old 365-day window
- UPDATE rowcount now tracked via cur.rowcount instead of unconditional
  += 1, preventing inflated counts under concurrent callers

* fix(graph): bound happened-at fact scan by meeting range; update stale comments

- run_happened_at_linker: replace unbounded SELECT of all fact nodes with
  a COALESCE(occurred_at, created_at) > fact_cutoff filter, where
  fact_cutoff = oldest_meeting_start - window_s; no legitimate match is
  excluded because the cutoff tracks the actual meeting time range
- module docstring: update happened-at description to COALESCE expression
- same-day linker comments: replace 'created_at' references with
  'event_ts / COALESCE(occurred_at, created_at)' to match real behaviour

* test(graph): add T5d (generous default window) and T5e (rowcount accuracy)

T5d: asserts the default call covers entries 800 days old by verifying
the days= arg passed to _fetch_domain_entries is >= 800 and the node
gets its occurred_at populated; RED against the old days=365 default.

T5e: asserts first call returns count==2 (two nodes updated) and second
call returns 0 (all already set); RED against unconditional += 1 logic.

* feat(config): auto-detect system timezone as default via tzlocal

Replace hardcoded 'America/Mexico_City' default with _detect_system_timezone()
which reads the IANA name from tzlocal.get_localzone_name() at import time.
Falls back to 'UTC' if tzlocal is unavailable or returns None.
User-overridable via config.timezone (unchanged mechanism).

* feat(linkers): group same-day edges by configured local timezone, not UTC

Add tz_name parameter to run_same_day_linker() and run_auto_linkers().
Calendar-day bucket (day_key) now uses ZoneInfo(tz_name) so an 11pm-local
reading counts as that local calendar day, not the next UTC day.
Resolves tz once before the grouping loop (not per row).
Invalid tz strings log a warning and fall back to UTC without crashing.
store.py passes config.get('timezone') when calling run_auto_linkers.

* feat(brain3d): display occurred_at dates in user's configured timezone

brain3d_page() route now passes tz=config.timezone to the template context.
brain3d.html injects it as const TZ and uses toLocaleDateString(locale, {timeZone:TZ})
so the date shown for each node's occurred_at matches the local calendar day
used by the same-day linker for grouping.

* test(timezone): RED→GREEN TDD cycle for timezone-aware grouping and display

TZ-1: config timezone default reads from tzlocal (patched reload discriminates
      against hardcoded literal). Fallback to UTC when tzlocal raises.
TZ-2: same-day linker groups by local tz, not UTC — cross-midnight case:
      22h and 23h local (same local day, different UTC day) must link;
      23h local and 00:30 next local day (same UTC day) must NOT link.
TZ-3: invalid tz_name does not raise — linker continues with UTC fallback.
TZ-4: brain3d route embeds configured tz in rendered HTML.
Game mode previously masked+stopped llama-vt to free its ~3.3 GB VRAM, losing VT-3B's reasoning during play. Now it relocates VT-3B to CPU (ngl=0, CUDA hidden) like the brain co-pilot — all of Axi stays available (slower) without losing reasoning, while the game still owns the VRAM. axi-vt-launch gains a --cpu flag (mirrors axi-llama-launch); axi-game-on writes a llama-vt CPU drop-in; axi-game-off removes it and restores GPU when the primary is qwen35-4b (VRAM mutual-exclusion guard preserved for the 35B). Scripts pass bash -n; --cpu forces -ngl 0 verified via AXI_DRY_RUN. Live toggle (axi-game-on) is the user's to run.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant