feat(game-mode): keep VibeThinker-3B available on CPU instead of stopping it - #162
Open
hectormr206 wants to merge 241 commits into
Open
feat(game-mode): keep VibeThinker-3B available on CPU instead of stopping it#162hectormr206 wants to merge 241 commits into
hectormr206 wants to merge 241 commits into
Conversation
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.
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Game mode previously masked + stopped
llama-vtto 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--cpuflag (mirrorsaxi-llama-launch --cpu) → forces-ngl 0.axi-game-on: writes allama-vtCPU 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 isqwen35-4b; stops it for the big 35B (VRAM mutual-exclusion guard preserved). Also unmasks (recovers a VT masked by an older game-on).Verification
bash -n.AXI_DRY_RUN=1 axi-vt-launch --cpu→-ngl 0; default →-ngl 999.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.