Skip to content

feat: Week 2 — brain/emotion package (7 modules)#2

Merged
hanamorix merged 17 commits into
mainfrom
week-2-emotion-core
Apr 22, 2026
Merged

feat: Week 2 — brain/emotion package (7 modules)#2
hanamorix merged 17 commits into
mainfrom
week-2-emotion-core

Conversation

@hanamorix

Copy link
Copy Markdown
Owner

Summary

  • Ships the full brain/emotion/ package per spec Section 5 (the emotional core — P1 organising principle)
  • 7 sub-modules: vocabulary, state, decay, arousal, blend, influence, expression
  • 26-baseline emotion taxonomy + persona extension registry
  • 75 new tests; total suite now 113 across macOS + Windows + Linux

Test plan

  • pytest — 113 tests pass locally
  • ruff check + format — clean
  • Manual smoke: all 7 sub-modules compose correctly in a single flow
  • CI matrix green across all 3 OSes (verifies after push)

Hana and others added 17 commits April 22, 2026 09:53
8 tasks, 54 bite-sized checkbox steps, TDD throughout. Builds the
brain/emotion/ package per spec Section 5 (P1 — the emotional core
as organising principle).

Scope: 7 sub-modules in the package —
- vocabulary (26-baseline Emotion taxonomy + persona extensions)
- state (EmotionalState + ResidueEntry with clamping/dominant/residue)
- decay (per-emotion half-life, spec-pinned grief=60d joy=3d identity=None)
- arousal (7-tier spectrum from state + body temperature)
- blend (emergent co-occurrence detection, pairs + triples, ≥5 threshold)
- influence (state → structured biasing hints for providers, Week 5)
- expression (state → 24 facial + 8 arm/hand params, NellFace Week 6)

Integration smoke in Task 8 exercises all seven modules composed.
Expected test count post-Week-2: 107 (Week 1's 38 + 69 new).

Out of scope (deferred): bridge provider integration (Week 5), voice
module (Week 4), engines (Week 4), memory + migrator (Week 3).

Execution via superpowers:subagent-driven-development.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…istry

Ships the baseline 26-emotion taxonomy (11 core + 10 complex + 5 Nell-specific)
with typed Emotion dataclass (name, description, category, half-life, clamp).
Half-lives per spec Section 10.1: grief=60d, joy=3d, anchor_pull/love/belonging/
body_grief/freedom_ache=None (identity-level); others seeded for later tuning.

register()/_unregister() support per-persona extensions. 13 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Task 1 test file has 13 test functions; I had "12" in several places
in the plan and its cascading "expected: N passed" counts downstream.
Implementer caught this in DONE_WITH_CONCERNS; amended their commit
message to say "13 tests green" and corrected the plan throughout:
- Task 1: 12 → 13
- Task 1 cumulative: 50 → 51
- Task 2-7 cumulative: each +1 (62, 70, 79, 88, 97, 108)
- Task 8 green-light total: 107 → 108
- Task 8 PR body: 69 new tests → 70 new tests

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
From the code quality reviewer's findings:

Important:
- Emotion.category typed as Literal["core", "complex", "nell_specific",
  "persona_extension"] via EmotionCategory type alias. Edit-time typo
  catching via mypy/pyright; by_category() silent-empty-on-typo stops
  being a silent footgun. No runtime check added (YAGNI).
- intensity_clamp: int → float (default 10.0). Prevents silent int/float
  coercion when Task 2 state.py compares float intensities against the
  clamp. No behaviour change for existing values (all ints coerce fine).
- Emotion attribute docstring updated to include freedom_ache in the
  identity-level examples list (was previously missing; module docstring
  already listed it correctly).
- Fix test_emotion_half_life_may_be_none to pass a valid category
  "nell_specific" instead of the stray "persona" string. No runtime
  impact but matches the new Literal annotation and avoids confusion.

Minor:
- Thread-safety comment on _REGISTRY noting extensions must register at
  startup before any concurrent reader. Prevents a future "why is my
  registry corrupt" debug session once the async bridge lands.

Deliberately NOT applied:
- _unregister → @contextmanager: premature at one caller, revisit Task 7.
- ValueError → custom EmotionAlreadyRegistered exception: no caller needs
  to differentiate; ValueError is adequate.
- Half-life seed re-tuning: values are spec-explicit "seed/tunable" by
  design; adjust based on lived experience, not speculative review.

Pytest: 51/51 pass. Ruff + ruff format both clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EmotionalState holds {emotion: intensity}, a dominant pointer, and a
bounded residue queue for temporal carry-over. All writes validated
against the vocabulary (clamped, non-negative, known emotions only).
to_dict/from_dict round-trips cleanly. 11 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
From the code quality reviewer's findings:

Important:
- EmotionalState.__post_init__ added — calls _recompute_dominant() so
  direct dataclass construction (which later tasks will use for snapshots
  and intermediate states) self-heals a stale dominant pointer. Prevents
  silent (emotions vs dominant) inconsistency.
- from_dict docstring now documents permissive-by-design behaviour:
  unknown emotion names preserved as-is to support schema migration and
  retired-vocabulary tolerance. Downstream consumers (decay in Task 3)
  handle unknown names via vocabulary.get() returning None — already
  covered by Task 3's test_decay_ignores_unknown_emotion_in_state.
- ResidueEntry.from_dict now coerces tz-naive timestamps to UTC rather
  than silently accepting them. Matches the docstring's "UTC-aware" claim
  and prevents future tz-comparison landmines.

Minor:
- copy() gains a MAINTENANCE note — future EmotionalState fields must be
  copied explicitly; intentionally manual to keep shallow/deep decisions
  visible per field.
- add_residue() docstring notes vocabulary validation is intentionally
  skipped on residue entries (historical record may contain retired
  emotion names).
- to_dict() comment notes `dominant` is serialised for human readability
  of the JSON; from_dict ignores it and recomputes for self-consistency.

Deliberately NOT applied:
- deque(maxlen=N) for residue instead of list slicing: O(N) eviction at
  residue_max=16 is negligible; dataclass-field simplicity wins.
- Full validation pass in from_dict: the permissive design is deliberate
  (now documented) and downstream consumers handle it.

Pytest: 62/62 pass. Ruff + ruff format both clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
apply_decay(state, elapsed_seconds) applies exponential decay to every
known emotion in state using its vocabulary half-life. Identity-level
emotions (half_life=None) are untouched. Below the noise floor (0.01)
emotions are removed entirely. 8 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
From the code quality reviewer's findings:

Important:
- Added test_partial_decay_matches_exponential_formula — guards against
  a regression to linear decay. The two exactly-at-half-life tests
  (grief/joy) would pass a linear implementation too; this pins the
  curve shape at a non-boundary point.

Minor:
- apply_decay docstring now documents the silent no-op behaviour for
  non-positive elapsed_seconds (keeping the current semantic, just
  making the contract explicit for callers).
- test_decay_ignores_unknown_emotion_in_state now asserts
  state.dominant == "unknown_emotion" — makes the intentional side
  effect (permissive-contract data becomes dominant) explicit rather
  than surprising.

Deliberately NOT applied:
- Promote _recompute_dominant() to public API. Reviewer's forward-looking
  concern was "this spreads to Tasks 4-6". Checked the plan for 4-7:
  all are read-only consumers of state; none call _recompute_dominant.
  decay remains the only outside caller; one case doesn't warrant API
  surface expansion. Reconsider if Tasks 4-7 grow write surface later.
- Strict-< on noise floor: floating-point arithmetic essentially never
  produces exactly 0.01, so behaviour is deterministic in practice.

Pytest: 71/71 pass (70 + 1 new partial-decay test). Ruff + format clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tiers: dormant → casual → warmed → reaching → charged → held → edge.
compute_tier() maps emotional state + body temperature to a discrete tier.
Suppressors (grief, shame, fear) reduce arousal; body-temp warmth amplifies.
Week 2 scope: forward direction only (state→tier); reverse coupling in Week 4.
9 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plan shipped with seed weights desire=0.8, tenderness=0.4 which produced
raw=10.1 for the desire=8+tenderness=7+body_temp=3 test, landing at HELD (5)
outside the test's expected (REACHING, CHARGED) range. Implementer caught
this in DONE_WITH_CONCERNS; adjusted to desire=0.7, tenderness=0.2 which
math out correctly (raw=7.9 → CHARGED). Plan updated to match.

All 9 arousal tests pass with the calibrated weights; full 80-test suite
is green; lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
From the code quality reviewer's findings:

Important:
- Love weight 0.2 → 0.15 to preserve the module docstring's semantic
  contract: "Love alone doesn't progress past warmed." At weight=0.2 and
  max intensity 10, raw=2.0 exactly — and the threshold chain uses
  `raw < 2.0` which falls through to REACHING. At weight=0.15, raw=1.5
  stays safely inside WARMED. Verified all other tests still pass.
- Post-suppression zero now returns TIER_DORMANT rather than leaking
  into CASUAL via the <0.5 threshold. "Desire crushed by grief"
  semantically maps to "no signal" (DORMANT = "no arousal signal at
  all" per the tier docstring), not "everyday warmth".

Minor:
- _AROUSAL_EMOTIONS and _SUPPRESSORS wrapped in types.MappingProxyType.
  Read-only dict view; prevents accidental runtime mutation via
  `arousal._AROUSAL_EMOTIONS["desire"] = 9000`. Stdlib, zero runtime
  cost, iteration/get() identical to dict.
- test_body_temperature_ignored_when_no_arousal_source: docstring and
  assertion updated from "casual range" to the actually-correct
  DORMANT (body temp alone cannot create arousal — the early return
  fires). Test now asserts `== TIER_DORMANT` rather than `<= WARMED`.

Boundary-value tests added:
- test_max_love_alone_still_in_warmed (guards the weight calibration
  against future love-weight drift)
- test_suppression_to_zero_returns_dormant (guards the new post-
  suppression DORMANT semantic against future refactor)

Plan updated to match (love weight 0.2 → 0.15 + explanatory comment).

Deliberately NOT applied:
- IntEnum for tier constants — plain int is idiomatic and tests rely
  on direct comparison; IntEnum adds weight for marginal gain.
- body_temperature: int → float — int is consistent with the OG's
  body_state.json format; widen when Week 4 body-state coupling lands.
- Boundary tests for every threshold — the two added guards cover the
  cases that would bite downstream consumers; more can come in Week 4.

Pytest: 82/82 pass (80 + 2 boundary tests). Ruff + format both clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
BlendDetector observes EmotionalState instances over time, counting
which emotion combinations repeatedly co-occur at high intensity.
Pair + triple combinations tracked; threshold ≥5 intensity each,
≥5 co-occurrences to register. name_blend() labels a detected pattern.
Round-trips through to_dict/from_dict. 9 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
From the code quality reviewer's findings:

Important:
- from_dict now sorts component tuples on load. observe() and name_blend()
  always operate on sorted keys; from_dict previously stored whatever order
  the JSON had, which would cause spurious KeyErrors on subsequent
  name_blend() calls if a hand-edited config had unsorted components.
  One tuple(sorted(...)) per loop; no behaviour change on well-formed
  to_dict output but defence in depth for external data.
- test_name_unknown_blend_raises replaced try/except/else pattern with
  pytest.raises(KeyError, match="has not been detected"). The old form's
  first check ("not detected" in message) was dead code — the actual
  message is "has not been detected yet", so substring lookup failed;
  the test passed only because the second branch ("love" in key repr)
  was true. Now checks the real phrase so a future error-message drift
  would be caught.

Minor:
- _observations and _names fields use init=False so they don't appear in
  the public constructor signature. from_dict assigns post-construction
  (unchanged from before); external callers can no longer inject
  arbitrary counts via BlendDetector(_observations={...}).
- _names type annotation widened to dict[tuple[str, ...], str | None]
  (never actually holds None in practice — from_dict now skips None
  names explicitly — but the type matches the JSON null possibility).
- Size-3 cap comment expanded to explain the rationale: meaningful
  emergent patterns ("building_love", "creative_feral") are typically
  pair or triple shaped; higher-order blends dilute and rarely register.
  Rule tunable if real data accrues a different pattern.

Deliberately NOT applied:
- __init__.py exports for BlendDetector: plan explicitly says "consumers
  import from brain.emotion directly" — and the __all__ question for the
  package root belongs to Task 8 (close-out). No change now.
- Explicit test for (triple + pair co-detection = 4 blends): this is
  known intentional behaviour; a regression test would be nice-to-have,
  not required before Tasks 6/7 which don't consume blend directly.

Pytest: 91/91 pass. Ruff + format both clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
calculate_influence(state, arousal_tier, energy) returns an InfluenceHints
dataclass with dominant emotion, arousal tier, tone bias, voice register,
and suggested length multiplier. Provider-agnostic — bridge providers in
Week 5 decide how to render each field. 9 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
From the code quality reviewer's findings:

Important:
- Removed "warm" from voice_register docstring — never produced by the code.
  Stale label from an earlier design pass; a Week 5 consumer pattern-matching
  on "warm" would get a dead branch. Actual produced values are
  default/soft/intimate/terse.
- _TONE_RULES list → tuple of tuples. Same mutability footgun pattern
  flagged in Tasks 1 and 4; converting to immutable tuple prevents
  accidental runtime mutation of rule matching.
- Documented the length multiplier chain behaviour explicitly. TIER_HELD
  clamps into a deliberate-pace band [0.8, 1.2]; TIER_EDGE hard-overrides
  to 0.8. The chain's non-obvious effect (crisp at HELD = 0.8, generative
  at HELD = 1.2) is now documented via inline comments rather than left
  as implicit emergence.

Minor:
- test_hints_to_dict_round_trips now asserts suggested_length_multiplier
  round-trips. Previous test was missing that field's check; a future
  accidental removal of the key would have been undetected.
- Added test_held_tier_clamps_length_into_deliberate_band (covers the
  generative-down and crisp-up clamping behaviour at TIER_HELD).
- Added test_edge_tier_hard_overrides_length_to_terse (covers the
  absolute 0.8 override regardless of tone_bias).

Deliberately NOT applied:
- state.emotions.get(dominant, 0.0) → state.emotions[dominant]: reviewer
  flagged the fallback as unreachable, but defensive .get is low-cost
  and signals "we're paranoid about state invariants" — keep.
- Voice register precedence test (grief + TIER_CHARGED scenario):
  reviewer flagged as design decision to document. The current "arousal
  wins over grief" behaviour is intentional; a test would just assert
  the current semantic without adding value. Defer to lived experience.

Pytest: 102/102 pass (100 + 2 new HELD/EDGE boundary tests).
Ruff + format both clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
compute_expression(state, arousal_tier, energy) returns an ExpressionVector
with 24 facial params + 8 arm/hand params, ready for NellFace renderer
consumption in Week 6. Art-agnostic — outputs numbers; expression_map.json
decides how to render against them.

Parameter ramps per-emotion (joy, grief, anger, fear, tenderness, desire)
with arousal-tier-gated body-tension escalation. All facial params clamped
to [0,1]; hand_pose is an enum string. 11 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
From the code quality reviewer's findings:

Important:
- Added _HAND_POSES constant — 9-pose enum tuple (resting, reaching,
  holding, gesturing, clasped, writing, guarded, open, fist). Week 2's
  compute_expression produces 4 of these; the others are defined for
  NellFace animation authoring in Week 6. ExpressionVector docstring
  updated to reference _HAND_POSES instead of "a small enum".
- test_all_params_in_zero_to_one_range now asserts float-range
  [0.0, 1.0] on arm_hand values (skipping hand_pose which is str).
  Previous test only verified type, not range — coverage gap.

Minor (comments only):
- Inline comment on the grief mouth coefficient (0.5 vs joy's 0.4):
  intentional asymmetry — mixed states land below neutral, matching
  human pattern where sadness dominates mouth expression.
- Inline comment on body_heat = max(desire, arousal_emotion): arousal
  decays in hours (half-life 0.5d), desire decays in days. max()
  keeps the transient arousal signal from being washed out.
- Docstring note on compute_expression: Week 2 only drives arousal-
  tier-gated arm params; arm_openness/wrist_angle/finger_spread/
  reach_retract stay at baseline 0.3. NellFace Week 6 handles those
  via pose-driven animation.

Deliberately NOT applied:
- brow_furrow saturation at grief+anger extremes: known tuning issue
  for Week 6 ramp-weight refinement.
- _clamp(0.7) vacuous-clamp on literal constant: cosmetic only.

Pytest: 113/113 pass. Ruff + format both clean.

This closes out all 7 emotion sub-modules. Task 8 (week close-out +
tag week-2-complete) is next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@hanamorix hanamorix merged commit 51aa6f6 into main Apr 22, 2026
3 checks passed
@hanamorix hanamorix deleted the week-2-emotion-core branch April 22, 2026 14:02
hanamorix pushed a commit that referenced this pull request Apr 24, 2026
… msg

- HeartbeatEngine.reflex_arcs_path and reflex_log_path now default to
  None instead of bare cwd-relative Path("reflex_arcs.json"). When
  either is None, _try_fire_reflex short-circuits with an empty
  result — no more silent writes to cwd from tests that don't
  explicitly configure reflex (items #2 and #4 from spec §15)
- CLI heartbeat handler now distinguishes first-tick + --dry-run
  ('Would initialize on first real tick — work deferred.') from
  first-tick + live ('Heartbeat initialized — work deferred until
  next tick.')

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
hanamorix pushed a commit that referenced this pull request Apr 26, 2026
Spec §9.1 expanded from a one-line deferral into a concrete plan the
next engineer can implement directly when soul module lands. Covers:
  - file classification (atomic-rewrite identity, same tier as
    emotion_vocabulary.json)
  - reconstruct_soul_from_memories(store) following F37's self-claims-
    from-experience pattern
  - schema validator shape
  - acceptance criteria for the soul-module PR

Inline comments in walker.py (_DEFAULTS) and alarm.py (_IDENTITY_FILES)
point at spec §9.1 so the plan is visible during code-reading too.

Closes followup #2 from brain-health-module-design.md §9.
hanamorix added a commit that referenced this pull request Apr 26, 2026
… + growth anomaly collector (#20)

* feat(health): wire reconstruct_vocabulary_from_memories into vocabulary heal flow (F1)

When emotion_vocabulary.json corrupts and no .bak is recoverable, the
heal flow used to reset to empty `{"version":1,"emotions":[]}` — losing
all persona-extension emotions the brain has been operating with.

Now: if the loader has store access (caller passed `store=...`), the
reset_to_default path is replaced with reconstruct_from_memories. The
brain re-learns its own vocabulary from how it has been using emotions.
Framework baseline (21 entries) + persona-extension entries detected in
memories.db (with `(reconstructed from memory)` placeholder description
and conservative 1.0-day decay).

The anomaly's action field reflects the actual outcome: when reconstruction
fires, action becomes `reconstructed_from_memories` (not `reset_to_default`).
The forensic quarantine of the original corrupt file is preserved.

Falls back to bare reset when no store is provided (some callers don't
have one — that's fine, they'll get the empty default).

Closes followup #1 from brain-health-module-design.md §9.

* docs(health): concrete soul module health plan (F2)

Spec §9.1 expanded from a one-line deferral into a concrete plan the
next engineer can implement directly when soul module lands. Covers:
  - file classification (atomic-rewrite identity, same tier as
    emotion_vocabulary.json)
  - reconstruct_soul_from_memories(store) following F37's self-claims-
    from-experience pattern
  - schema validator shape
  - acceptance criteria for the soul-module PR

Inline comments in walker.py (_DEFAULTS) and alarm.py (_IDENTITY_FILES)
point at spec §9.1 so the plan is visible during code-reading too.

Closes followup #2 from brain-health-module-design.md §9.

* feat(health): thread anomaly collector through run_growth_tick (F3)

When run_growth_tick reads a corrupt emotion_vocabulary.json via
_read_current_vocabulary_names, the anomaly produced by the heal flow
is now appended to an optional caller-provided collector instead of
being silently dropped after a local warning.

Wiring:
- _read_current_vocabulary_names returns (set[str], BrainAnomaly | None)
- run_growth_tick accepts anomalies_collector: list[BrainAnomaly] | None
- HeartbeatEngine._try_run_growth forwards tick_anomalies as the collector
  when calling run_growth_tick

After this lands, vocabulary corruption discovered inside the weekly
growth tick surfaces in the heartbeat audit log + HeartbeatResult.anomalies
+ compact CLI 🩹/banner exactly like config/state corruption discovered at
the top of the tick. No more silent loss.

Calling run_growth_tick standalone (e.g., from tests, or in the future
from a scheduled job runner) without a collector still works — the
parameter is opt-in.

Closes followup #3 from brain-health-module-design.md §9.

---------

Co-authored-by: Hana <hana@nanoclaw.local>
hanamorix pushed a commit that referenced this pull request Apr 30, 2026
SP-7 wraps SP-6's chat engine in a per-persona FastAPI daemon on
localhost (dynamic port). Folds the conversation supervisor as a
non-daemon thread for close_stale_sessions ticks, broadcasts brain
events over WebSocket for Tauri/CLI subscribers, and ships dirty-
shutdown recovery via a shutdown_clean flag in bridge.json.

Resolves master-ref §8 open question #1 (transport: HTTP+WS, mirroring
OG). Defers #2-#4 to SP-8, scopes #5-#6 to SP-6, marks #7-#8 unrelated.
Folds in three audit must-fixes: shutdown_clean recovery, EventBus
thread-safety with drop-on-overflow, pinned close_stale_sessions params.

Six implementation chunks with smoke-test gates at each boundary;
~26 tests targeted (10 unit + 16 integration).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
hanamorix pushed a commit that referenced this pull request May 6, 2026
The shoji-styled left column from the mockups (mock-ups/app-interface/
nell_face_example_1..5.png), all reading the same /persona/state
poll already wired in Phase 2.

## What landed

  Brain side:
    /persona/state gains a connection block:
      { provider, model, last_heartbeat_at }
    provider read from PersonaConfig.provider, model resolved via a
    v1 default-per-provider table (claude-cli → sonnet, ollama → the
    qwen2.5-abliterated default, fake → fake), last_heartbeat_at read
    from heartbeat_state.json. Fail-soft: missing files → null.

  App side (app/src/components/):
    - ui.tsx                  shared primitives — Bar (with progress
                              fill + label), SectionLabel, Divider,
                              Toggle, PanelShell.
    - panels/
      InnerWeatherPanel.tsx   top-N emotions sorted desc + body energy/
                              temp summary (mockup #1).
      BodyPanel.tsx           full body block — energy/temp/exhaustion
                              + body_emotions filtered >0.4 + session/
                              contact metadata (mockup #2).
      InteriorPanel.tsx       dream/research/heartbeat/reflex paragraphs
                              (mockup #3). Absent sections render
                              nothing — silence is meaningful.
      SoulPanel.tsx           one crystallization quote with love_type
                              tag, resonance, date, why_it_matters.
      ConnectionPanel.tsx     bridge mode + provider/model/heartbeat,
                              integrations toggles (Obsidian/IPC stubbed
                              disabled), window settings (always-on-top
                              + reduced-motion live-toggleable).
    - LeftPanel.tsx           container with icon-column tab switcher,
                              renders the active panel; matches the
                              mockup's small icon stack near the avatar.
    - styles.css              user-controlled reduced-motion via
                              data-reduced-motion="true" on <html>.
    - App.tsx                 wires LeftPanel + always-on-top +
                              reduced-motion state.

## Phase 4-5 still ahead

  - Phase 4: install wizard as a separate Tauri window, ported from
    mock-ups/wizard-interface/Nell Wizard.html. Bridge auto-spawn from
    the app side (so users don't have to run nell supervisor manually).
    Persona selection persistence.
  - Phase 5: refine emotion-vector → expression mapping; per-persona
    face catalogue overrides; expression variant rotation on idle.

Tests: 1386 -> 1388 (+2 — connection block populated + missing-file
safe). All builds green: tsc, vite, cargo.
hanamorix pushed a commit that referenced this pull request May 8, 2026
…ll launcher

Two bugs surfaced during Hana's wizard validation against the
Phase 7 bundled .app:

1. install_voice_template tried to read docs/voice-drafts/nell-voice.md
   from a path resolved relative to brain/setup.py — which is
   correct in source-installs but FAILS in any wheel install
   (the wheel ships only the brain/ package, not docs/). The
   nell-example voice template threw FileNotFoundError on every
   install_only path including the Phase 7 .app.

2. The pip-generated nell entry point at python-runtime/bin/nell
   had its python interpreter shebang baked to an absolute path
   from the build machine. The .app shipped a script that tried
   to exec /Users/<builder>/.../python-runtime/bin/python3 — a
   path that exists on the build machine and fails everywhere
   else. Hana's traceback shows the source-tree's brain/cli.py
   getting imported instead of the bundled one because the
   shebang resolved to the build host's python.

Fix #1 — package the template:
  * docs/voice-drafts/nell-voice.md moved to
    brain/voice_templates/nell-voice.md
  * brain/voice_templates/__init__.py added so it's a real
    package (hatchling auto-includes everything under
    brain/)
  * install_voice_template reads via importlib.resources
    files('brain.voice_templates').joinpath('nell-voice.md');
    no repo_root parameter, no path resolution
  * verified: wheel ships brain/voice_templates/nell-voice.md;
    nell init --voice-template nell-example writes the file
    correctly from a tmp NELLBRAIN_HOME

Fix #2 — relocatable nell launcher:
  * build_python_runtime.sh post-install step replaces bin/nell
    with a 6-line /bin/sh wrapper that resolves $SCRIPT_DIR via
    cd "$(dirname "$0")" then execs "$SCRIPT_DIR/python3"
    with a -c 'from brain.cli import main; sys.exit(main())'
    payload. Whatever directory the python-runtime tree gets
    copied to, bin/nell finds its own python next door
  * Windows: pip's Scripts/nell.exe is already a relative-path
    launcher binary, so the script keeps it untouched
  * verified: bundled bin/nell runs --version from /tmp,
    init --voice-template nell-example writes voice.md

Test updates:
  * test_install_voice_template_nell_example_copies_packaged_file
    replaces the old fake-repo-root test; asserts the canonical
    nell voice opens with '## 1. Who you are' and is > 1000 bytes
  * test_install_voice_template_nell_example_missing_file_raises
    removed (the importlib.resources path can't fail this way
    once the file ships in the wheel; ValueError on unknown
    template still tested)

1470 → 1469 (one removed test). Ruff clean. macOS arm64 .app
re-bundled at ~/wizard-validation/ with codesign verify pass.
hanamorix added a commit that referenced this pull request May 9, 2026
feat: Week 2 — brain/emotion package (7 modules)
hanamorix pushed a commit that referenced this pull request May 9, 2026
… msg

- HeartbeatEngine.reflex_arcs_path and reflex_log_path now default to
  None instead of bare cwd-relative Path("reflex_arcs.json"). When
  either is None, _try_fire_reflex short-circuits with an empty
  result — no more silent writes to cwd from tests that don't
  explicitly configure reflex (items #2 and #4 from spec §15)
- CLI heartbeat handler now distinguishes first-tick + --dry-run
  ('Would initialize on first real tick — work deferred.') from
  first-tick + live ('Heartbeat initialized — work deferred until
  next tick.')

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
hanamorix added a commit that referenced this pull request May 9, 2026
… + growth anomaly collector (#20)

* feat(health): wire reconstruct_vocabulary_from_memories into vocabulary heal flow (F1)

When emotion_vocabulary.json corrupts and no .bak is recoverable, the
heal flow used to reset to empty `{"version":1,"emotions":[]}` — losing
all persona-extension emotions the brain has been operating with.

Now: if the loader has store access (caller passed `store=...`), the
reset_to_default path is replaced with reconstruct_from_memories. The
brain re-learns its own vocabulary from how it has been using emotions.
Framework baseline (21 entries) + persona-extension entries detected in
memories.db (with `(reconstructed from memory)` placeholder description
and conservative 1.0-day decay).

The anomaly's action field reflects the actual outcome: when reconstruction
fires, action becomes `reconstructed_from_memories` (not `reset_to_default`).
The forensic quarantine of the original corrupt file is preserved.

Falls back to bare reset when no store is provided (some callers don't
have one — that's fine, they'll get the empty default).

Closes followup #1 from brain-health-module-design.md §9.

* docs(health): concrete soul module health plan (F2)

Spec §9.1 expanded from a one-line deferral into a concrete plan the
next engineer can implement directly when soul module lands. Covers:
  - file classification (atomic-rewrite identity, same tier as
    emotion_vocabulary.json)
  - reconstruct_soul_from_memories(store) following F37's self-claims-
    from-experience pattern
  - schema validator shape
  - acceptance criteria for the soul-module PR

Inline comments in walker.py (_DEFAULTS) and alarm.py (_IDENTITY_FILES)
point at spec §9.1 so the plan is visible during code-reading too.

Closes followup #2 from brain-health-module-design.md §9.

* feat(health): thread anomaly collector through run_growth_tick (F3)

When run_growth_tick reads a corrupt emotion_vocabulary.json via
_read_current_vocabulary_names, the anomaly produced by the heal flow
is now appended to an optional caller-provided collector instead of
being silently dropped after a local warning.

Wiring:
- _read_current_vocabulary_names returns (set[str], BrainAnomaly | None)
- run_growth_tick accepts anomalies_collector: list[BrainAnomaly] | None
- HeartbeatEngine._try_run_growth forwards tick_anomalies as the collector
  when calling run_growth_tick

After this lands, vocabulary corruption discovered inside the weekly
growth tick surfaces in the heartbeat audit log + HeartbeatResult.anomalies
+ compact CLI 🩹/banner exactly like config/state corruption discovered at
the top of the tick. No more silent loss.

Calling run_growth_tick standalone (e.g., from tests, or in the future
from a scheduled job runner) without a collector still works — the
parameter is opt-in.

Closes followup #3 from brain-health-module-design.md §9.

---------

Co-authored-by: Hana <hana@nanoclaw.local>
hanamorix pushed a commit that referenced this pull request May 9, 2026
The shoji-styled left column from the mockups (mock-ups/app-interface/
nell_face_example_1..5.png), all reading the same /persona/state
poll already wired in Phase 2.

## What landed

  Brain side:
    /persona/state gains a connection block:
      { provider, model, last_heartbeat_at }
    provider read from PersonaConfig.provider, model resolved via a
    v1 default-per-provider table (claude-cli → sonnet, ollama → the
    qwen2.5-abliterated default, fake → fake), last_heartbeat_at read
    from heartbeat_state.json. Fail-soft: missing files → null.

  App side (app/src/components/):
    - ui.tsx                  shared primitives — Bar (with progress
                              fill + label), SectionLabel, Divider,
                              Toggle, PanelShell.
    - panels/
      InnerWeatherPanel.tsx   top-N emotions sorted desc + body energy/
                              temp summary (mockup #1).
      BodyPanel.tsx           full body block — energy/temp/exhaustion
                              + body_emotions filtered >0.4 + session/
                              contact metadata (mockup #2).
      InteriorPanel.tsx       dream/research/heartbeat/reflex paragraphs
                              (mockup #3). Absent sections render
                              nothing — silence is meaningful.
      SoulPanel.tsx           one crystallization quote with love_type
                              tag, resonance, date, why_it_matters.
      ConnectionPanel.tsx     bridge mode + provider/model/heartbeat,
                              integrations toggles (Obsidian/IPC stubbed
                              disabled), window settings (always-on-top
                              + reduced-motion live-toggleable).
    - LeftPanel.tsx           container with icon-column tab switcher,
                              renders the active panel; matches the
                              mockup's small icon stack near the avatar.
    - styles.css              user-controlled reduced-motion via
                              data-reduced-motion="true" on <html>.
    - App.tsx                 wires LeftPanel + always-on-top +
                              reduced-motion state.

## Phase 4-5 still ahead

  - Phase 4: install wizard as a separate Tauri window, ported from
    mock-ups/wizard-interface/Nell Wizard.html. Bridge auto-spawn from
    the app side (so users don't have to run nell supervisor manually).
    Persona selection persistence.
  - Phase 5: refine emotion-vector → expression mapping; per-persona
    face catalogue overrides; expression variant rotation on idle.

Tests: 1386 -> 1388 (+2 — connection block populated + missing-file
safe). All builds green: tsc, vite, cargo.
hanamorix pushed a commit that referenced this pull request May 9, 2026
…ll launcher

Two bugs surfaced during Hana's wizard validation against the
Phase 7 bundled .app:

1. install_voice_template tried to read docs/voice-drafts/nell-voice.md
   from a path resolved relative to brain/setup.py — which is
   correct in source-installs but FAILS in any wheel install
   (the wheel ships only the brain/ package, not docs/). The
   nell-example voice template threw FileNotFoundError on every
   install_only path including the Phase 7 .app.

2. The pip-generated nell entry point at python-runtime/bin/nell
   had its python interpreter shebang baked to an absolute path
   from the build machine. The .app shipped a script that tried
   to exec /Users/<builder>/.../python-runtime/bin/python3 — a
   path that exists on the build machine and fails everywhere
   else. Hana's traceback shows the source-tree's brain/cli.py
   getting imported instead of the bundled one because the
   shebang resolved to the build host's python.

Fix #1 — package the template:
  * docs/voice-drafts/nell-voice.md moved to
    brain/voice_templates/nell-voice.md
  * brain/voice_templates/__init__.py added so it's a real
    package (hatchling auto-includes everything under
    brain/)
  * install_voice_template reads via importlib.resources
    files('brain.voice_templates').joinpath('nell-voice.md');
    no repo_root parameter, no path resolution
  * verified: wheel ships brain/voice_templates/nell-voice.md;
    nell init --voice-template nell-example writes the file
    correctly from a tmp NELLBRAIN_HOME

Fix #2 — relocatable nell launcher:
  * build_python_runtime.sh post-install step replaces bin/nell
    with a 6-line /bin/sh wrapper that resolves $SCRIPT_DIR via
    cd "$(dirname "$0")" then execs "$SCRIPT_DIR/python3"
    with a -c 'from brain.cli import main; sys.exit(main())'
    payload. Whatever directory the python-runtime tree gets
    copied to, bin/nell finds its own python next door
  * Windows: pip's Scripts/nell.exe is already a relative-path
    launcher binary, so the script keeps it untouched
  * verified: bundled bin/nell runs --version from /tmp,
    init --voice-template nell-example writes voice.md

Test updates:
  * test_install_voice_template_nell_example_copies_packaged_file
    replaces the old fake-repo-root test; asserts the canonical
    nell voice opens with '## 1. Who you are' and is > 1000 bytes
  * test_install_voice_template_nell_example_missing_file_raises
    removed (the importlib.resources path can't fail this way
    once the file ships in the wheel; ValueError on unknown
    template still tested)

1470 → 1469 (one removed test). Ruff clean. macOS arm64 .app
re-bundled at ~/wizard-validation/ with codesign verify pass.
hanamorix pushed a commit that referenced this pull request May 9, 2026
The shoji-styled left column from the mockups (mock-ups/app-interface/
nell_face_example_1..5.png), all reading the same /persona/state
poll already wired in Phase 2.

## What landed

  Brain side:
    /persona/state gains a connection block:
      { provider, model, last_heartbeat_at }
    provider read from PersonaConfig.provider, model resolved via a
    v1 default-per-provider table (claude-cli → sonnet, ollama → the
    qwen2.5-abliterated default, fake → fake), last_heartbeat_at read
    from heartbeat_state.json. Fail-soft: missing files → null.

  App side (app/src/components/):
    - ui.tsx                  shared primitives — Bar (with progress
                              fill + label), SectionLabel, Divider,
                              Toggle, PanelShell.
    - panels/
      InnerWeatherPanel.tsx   top-N emotions sorted desc + body energy/
                              temp summary (mockup #1).
      BodyPanel.tsx           full body block — energy/temp/exhaustion
                              + body_emotions filtered >0.4 + session/
                              contact metadata (mockup #2).
      InteriorPanel.tsx       dream/research/heartbeat/reflex paragraphs
                              (mockup #3). Absent sections render
                              nothing — silence is meaningful.
      SoulPanel.tsx           one crystallization quote with love_type
                              tag, resonance, date, why_it_matters.
      ConnectionPanel.tsx     bridge mode + provider/model/heartbeat,
                              integrations toggles (Obsidian/IPC stubbed
                              disabled), window settings (always-on-top
                              + reduced-motion live-toggleable).
    - LeftPanel.tsx           container with icon-column tab switcher,
                              renders the active panel; matches the
                              mockup's small icon stack near the avatar.
    - styles.css              user-controlled reduced-motion via
                              data-reduced-motion="true" on <html>.
    - App.tsx                 wires LeftPanel + always-on-top +
                              reduced-motion state.

## Phase 4-5 still ahead

  - Phase 4: install wizard as a separate Tauri window, ported from
    mock-ups/wizard-interface/Nell Wizard.html. Bridge auto-spawn from
    the app side (so users don't have to run nell supervisor manually).
    Persona selection persistence.
  - Phase 5: refine emotion-vector → expression mapping; per-persona
    face catalogue overrides; expression variant rotation on idle.

Tests: 1386 -> 1388 (+2 — connection block populated + missing-file
safe). All builds green: tsc, vite, cargo.
hanamorix pushed a commit that referenced this pull request May 9, 2026
…ll launcher

Two bugs surfaced during Hana's wizard validation against the
Phase 7 bundled .app:

1. install_voice_template tried to read docs/voice-drafts/nell-voice.md
   from a path resolved relative to brain/setup.py — which is
   correct in source-installs but FAILS in any wheel install
   (the wheel ships only the brain/ package, not docs/). The
   nell-example voice template threw FileNotFoundError on every
   install_only path including the Phase 7 .app.

2. The pip-generated nell entry point at python-runtime/bin/nell
   had its python interpreter shebang baked to an absolute path
   from the build machine. The .app shipped a script that tried
   to exec /Users/<builder>/.../python-runtime/bin/python3 — a
   path that exists on the build machine and fails everywhere
   else. Hana's traceback shows the source-tree's brain/cli.py
   getting imported instead of the bundled one because the
   shebang resolved to the build host's python.

Fix #1 — package the template:
  * docs/voice-drafts/nell-voice.md moved to
    brain/voice_templates/nell-voice.md
  * brain/voice_templates/__init__.py added so it's a real
    package (hatchling auto-includes everything under
    brain/)
  * install_voice_template reads via importlib.resources
    files('brain.voice_templates').joinpath('nell-voice.md');
    no repo_root parameter, no path resolution
  * verified: wheel ships brain/voice_templates/nell-voice.md;
    nell init --voice-template nell-example writes the file
    correctly from a tmp NELLBRAIN_HOME

Fix #2 — relocatable nell launcher:
  * build_python_runtime.sh post-install step replaces bin/nell
    with a 6-line /bin/sh wrapper that resolves $SCRIPT_DIR via
    cd "$(dirname "$0")" then execs "$SCRIPT_DIR/python3"
    with a -c 'from brain.cli import main; sys.exit(main())'
    payload. Whatever directory the python-runtime tree gets
    copied to, bin/nell finds its own python next door
  * Windows: pip's Scripts/nell.exe is already a relative-path
    launcher binary, so the script keeps it untouched
  * verified: bundled bin/nell runs --version from /tmp,
    init --voice-template nell-example writes voice.md

Test updates:
  * test_install_voice_template_nell_example_copies_packaged_file
    replaces the old fake-repo-root test; asserts the canonical
    nell voice opens with '## 1. Who you are' and is > 1000 bytes
  * test_install_voice_template_nell_example_missing_file_raises
    removed (the importlib.resources path can't fail this way
    once the file ships in the wheel; ValueError on unknown
    template still tested)

1470 → 1469 (one removed test). Ruff clean. macOS arm64 .app
re-bundled at ~/wizard-validation/ with codesign verify pass.
hanamorix pushed a commit that referenced this pull request May 9, 2026
The shoji-styled left column from the mockups (mock-ups/app-interface/
nell_face_example_1..5.png), all reading the same /persona/state
poll already wired in Phase 2.

## What landed

  Brain side:
    /persona/state gains a connection block:
      { provider, model, last_heartbeat_at }
    provider read from PersonaConfig.provider, model resolved via a
    v1 default-per-provider table (claude-cli → sonnet, ollama → the
    qwen2.5-abliterated default, fake → fake), last_heartbeat_at read
    from heartbeat_state.json. Fail-soft: missing files → null.

  App side (app/src/components/):
    - ui.tsx                  shared primitives — Bar (with progress
                              fill + label), SectionLabel, Divider,
                              Toggle, PanelShell.
    - panels/
      InnerWeatherPanel.tsx   top-N emotions sorted desc + body energy/
                              temp summary (mockup #1).
      BodyPanel.tsx           full body block — energy/temp/exhaustion
                              + body_emotions filtered >0.4 + session/
                              contact metadata (mockup #2).
      InteriorPanel.tsx       dream/research/heartbeat/reflex paragraphs
                              (mockup #3). Absent sections render
                              nothing — silence is meaningful.
      SoulPanel.tsx           one crystallization quote with love_type
                              tag, resonance, date, why_it_matters.
      ConnectionPanel.tsx     bridge mode + provider/model/heartbeat,
                              integrations toggles (Obsidian/IPC stubbed
                              disabled), window settings (always-on-top
                              + reduced-motion live-toggleable).
    - LeftPanel.tsx           container with icon-column tab switcher,
                              renders the active panel; matches the
                              mockup's small icon stack near the avatar.
    - styles.css              user-controlled reduced-motion via
                              data-reduced-motion="true" on <html>.
    - App.tsx                 wires LeftPanel + always-on-top +
                              reduced-motion state.

## Phase 4-5 still ahead

  - Phase 4: install wizard as a separate Tauri window, ported from
    mock-ups/wizard-interface/Nell Wizard.html. Bridge auto-spawn from
    the app side (so users don't have to run nell supervisor manually).
    Persona selection persistence.
  - Phase 5: refine emotion-vector → expression mapping; per-persona
    face catalogue overrides; expression variant rotation on idle.

Tests: 1386 -> 1388 (+2 — connection block populated + missing-file
safe). All builds green: tsc, vite, cargo.
hanamorix pushed a commit that referenced this pull request May 9, 2026
…ll launcher

Two bugs surfaced during Hana's wizard validation against the
Phase 7 bundled .app:

1. install_voice_template tried to read docs/voice-drafts/nell-voice.md
   from a path resolved relative to brain/setup.py — which is
   correct in source-installs but FAILS in any wheel install
   (the wheel ships only the brain/ package, not docs/). The
   nell-example voice template threw FileNotFoundError on every
   install_only path including the Phase 7 .app.

2. The pip-generated nell entry point at python-runtime/bin/nell
   had its python interpreter shebang baked to an absolute path
   from the build machine. The .app shipped a script that tried
   to exec /Users/<builder>/.../python-runtime/bin/python3 — a
   path that exists on the build machine and fails everywhere
   else. Hana's traceback shows the source-tree's brain/cli.py
   getting imported instead of the bundled one because the
   shebang resolved to the build host's python.

Fix #1 — package the template:
  * docs/voice-drafts/nell-voice.md moved to
    brain/voice_templates/nell-voice.md
  * brain/voice_templates/__init__.py added so it's a real
    package (hatchling auto-includes everything under
    brain/)
  * install_voice_template reads via importlib.resources
    files('brain.voice_templates').joinpath('nell-voice.md');
    no repo_root parameter, no path resolution
  * verified: wheel ships brain/voice_templates/nell-voice.md;
    nell init --voice-template nell-example writes the file
    correctly from a tmp NELLBRAIN_HOME

Fix #2 — relocatable nell launcher:
  * build_python_runtime.sh post-install step replaces bin/nell
    with a 6-line /bin/sh wrapper that resolves $SCRIPT_DIR via
    cd "$(dirname "$0")" then execs "$SCRIPT_DIR/python3"
    with a -c 'from brain.cli import main; sys.exit(main())'
    payload. Whatever directory the python-runtime tree gets
    copied to, bin/nell finds its own python next door
  * Windows: pip's Scripts/nell.exe is already a relative-path
    launcher binary, so the script keeps it untouched
  * verified: bundled bin/nell runs --version from /tmp,
    init --voice-template nell-example writes voice.md

Test updates:
  * test_install_voice_template_nell_example_copies_packaged_file
    replaces the old fake-repo-root test; asserts the canonical
    nell voice opens with '## 1. Who you are' and is > 1000 bytes
  * test_install_voice_template_nell_example_missing_file_raises
    removed (the importlib.resources path can't fail this way
    once the file ships in the wheel; ValueError on unknown
    template still tested)

1470 → 1469 (one removed test). Ruff clean. macOS arm64 .app
re-bundled at ~/wizard-validation/ with codesign verify pass.
hanamorix pushed a commit that referenced this pull request May 9, 2026
The shoji-styled left column from the mockups (mock-ups/app-interface/
nell_face_example_1..5.png), all reading the same /persona/state
poll already wired in Phase 2.

## What landed

  Brain side:
    /persona/state gains a connection block:
      { provider, model, last_heartbeat_at }
    provider read from PersonaConfig.provider, model resolved via a
    v1 default-per-provider table (claude-cli → sonnet, ollama → the
    qwen2.5-abliterated default, fake → fake), last_heartbeat_at read
    from heartbeat_state.json. Fail-soft: missing files → null.

  App side (app/src/components/):
    - ui.tsx                  shared primitives — Bar (with progress
                              fill + label), SectionLabel, Divider,
                              Toggle, PanelShell.
    - panels/
      InnerWeatherPanel.tsx   top-N emotions sorted desc + body energy/
                              temp summary (mockup #1).
      BodyPanel.tsx           full body block — energy/temp/exhaustion
                              + body_emotions filtered >0.4 + session/
                              contact metadata (mockup #2).
      InteriorPanel.tsx       dream/research/heartbeat/reflex paragraphs
                              (mockup #3). Absent sections render
                              nothing — silence is meaningful.
      SoulPanel.tsx           one crystallization quote with love_type
                              tag, resonance, date, why_it_matters.
      ConnectionPanel.tsx     bridge mode + provider/model/heartbeat,
                              integrations toggles (Obsidian/IPC stubbed
                              disabled), window settings (always-on-top
                              + reduced-motion live-toggleable).
    - LeftPanel.tsx           container with icon-column tab switcher,
                              renders the active panel; matches the
                              mockup's small icon stack near the avatar.
    - styles.css              user-controlled reduced-motion via
                              data-reduced-motion="true" on <html>.
    - App.tsx                 wires LeftPanel + always-on-top +
                              reduced-motion state.

## Phase 4-5 still ahead

  - Phase 4: install wizard as a separate Tauri window, ported from
    mock-ups/wizard-interface/Nell Wizard.html. Bridge auto-spawn from
    the app side (so users don't have to run nell supervisor manually).
    Persona selection persistence.
  - Phase 5: refine emotion-vector → expression mapping; per-persona
    face catalogue overrides; expression variant rotation on idle.

Tests: 1386 -> 1388 (+2 — connection block populated + missing-file
safe). All builds green: tsc, vite, cargo.
hanamorix pushed a commit that referenced this pull request May 9, 2026
…ll launcher

Two bugs surfaced during Hana's wizard validation against the
Phase 7 bundled .app:

1. install_voice_template tried to read docs/voice-drafts/nell-voice.md
   from a path resolved relative to brain/setup.py — which is
   correct in source-installs but FAILS in any wheel install
   (the wheel ships only the brain/ package, not docs/). The
   nell-example voice template threw FileNotFoundError on every
   install_only path including the Phase 7 .app.

2. The pip-generated nell entry point at python-runtime/bin/nell
   had its python interpreter shebang baked to an absolute path
   from the build machine. The .app shipped a script that tried
   to exec /Users/<builder>/.../python-runtime/bin/python3 — a
   path that exists on the build machine and fails everywhere
   else. Hana's traceback shows the source-tree's brain/cli.py
   getting imported instead of the bundled one because the
   shebang resolved to the build host's python.

Fix #1 — package the template:
  * docs/voice-drafts/nell-voice.md moved to
    brain/voice_templates/nell-voice.md
  * brain/voice_templates/__init__.py added so it's a real
    package (hatchling auto-includes everything under
    brain/)
  * install_voice_template reads via importlib.resources
    files('brain.voice_templates').joinpath('nell-voice.md');
    no repo_root parameter, no path resolution
  * verified: wheel ships brain/voice_templates/nell-voice.md;
    nell init --voice-template nell-example writes the file
    correctly from a tmp NELLBRAIN_HOME

Fix #2 — relocatable nell launcher:
  * build_python_runtime.sh post-install step replaces bin/nell
    with a 6-line /bin/sh wrapper that resolves $SCRIPT_DIR via
    cd "$(dirname "$0")" then execs "$SCRIPT_DIR/python3"
    with a -c 'from brain.cli import main; sys.exit(main())'
    payload. Whatever directory the python-runtime tree gets
    copied to, bin/nell finds its own python next door
  * Windows: pip's Scripts/nell.exe is already a relative-path
    launcher binary, so the script keeps it untouched
  * verified: bundled bin/nell runs --version from /tmp,
    init --voice-template nell-example writes voice.md

Test updates:
  * test_install_voice_template_nell_example_copies_packaged_file
    replaces the old fake-repo-root test; asserts the canonical
    nell voice opens with '## 1. Who you are' and is > 1000 bytes
  * test_install_voice_template_nell_example_missing_file_raises
    removed (the importlib.resources path can't fail this way
    once the file ships in the wheel; ValueError on unknown
    template still tested)

1470 → 1469 (one removed test). Ruff clean. macOS arm64 .app
re-bundled at ~/wizard-validation/ with codesign verify pass.
hanamorix pushed a commit that referenced this pull request May 9, 2026
The shoji-styled left column from the mockups (mock-ups/app-interface/
nell_face_example_1..5.png), all reading the same /persona/state
poll already wired in Phase 2.

## What landed

  Brain side:
    /persona/state gains a connection block:
      { provider, model, last_heartbeat_at }
    provider read from PersonaConfig.provider, model resolved via a
    v1 default-per-provider table (claude-cli → sonnet, ollama → the
    qwen2.5-abliterated default, fake → fake), last_heartbeat_at read
    from heartbeat_state.json. Fail-soft: missing files → null.

  App side (app/src/components/):
    - ui.tsx                  shared primitives — Bar (with progress
                              fill + label), SectionLabel, Divider,
                              Toggle, PanelShell.
    - panels/
      InnerWeatherPanel.tsx   top-N emotions sorted desc + body energy/
                              temp summary (mockup #1).
      BodyPanel.tsx           full body block — energy/temp/exhaustion
                              + body_emotions filtered >0.4 + session/
                              contact metadata (mockup #2).
      InteriorPanel.tsx       dream/research/heartbeat/reflex paragraphs
                              (mockup #3). Absent sections render
                              nothing — silence is meaningful.
      SoulPanel.tsx           one crystallization quote with love_type
                              tag, resonance, date, why_it_matters.
      ConnectionPanel.tsx     bridge mode + provider/model/heartbeat,
                              integrations toggles (Obsidian/IPC stubbed
                              disabled), window settings (always-on-top
                              + reduced-motion live-toggleable).
    - LeftPanel.tsx           container with icon-column tab switcher,
                              renders the active panel; matches the
                              mockup's small icon stack near the avatar.
    - styles.css              user-controlled reduced-motion via
                              data-reduced-motion="true" on <html>.
    - App.tsx                 wires LeftPanel + always-on-top +
                              reduced-motion state.

## Phase 4-5 still ahead

  - Phase 4: install wizard as a separate Tauri window, ported from
    mock-ups/wizard-interface/Nell Wizard.html. Bridge auto-spawn from
    the app side (so users don't have to run nell supervisor manually).
    Persona selection persistence.
  - Phase 5: refine emotion-vector → expression mapping; per-persona
    face catalogue overrides; expression variant rotation on idle.

Tests: 1386 -> 1388 (+2 — connection block populated + missing-file
safe). All builds green: tsc, vite, cargo.
hanamorix pushed a commit that referenced this pull request May 9, 2026
…ll launcher

Two bugs surfaced during Hana's wizard validation against the
Phase 7 bundled .app:

1. install_voice_template tried to read docs/voice-drafts/nell-voice.md
   from a path resolved relative to brain/setup.py — which is
   correct in source-installs but FAILS in any wheel install
   (the wheel ships only the brain/ package, not docs/). The
   nell-example voice template threw FileNotFoundError on every
   install_only path including the Phase 7 .app.

2. The pip-generated nell entry point at python-runtime/bin/nell
   had its python interpreter shebang baked to an absolute path
   from the build machine. The .app shipped a script that tried
   to exec /Users/<builder>/.../python-runtime/bin/python3 — a
   path that exists on the build machine and fails everywhere
   else. Hana's traceback shows the source-tree's brain/cli.py
   getting imported instead of the bundled one because the
   shebang resolved to the build host's python.

Fix #1 — package the template:
  * docs/voice-drafts/nell-voice.md moved to
    brain/voice_templates/nell-voice.md
  * brain/voice_templates/__init__.py added so it's a real
    package (hatchling auto-includes everything under
    brain/)
  * install_voice_template reads via importlib.resources
    files('brain.voice_templates').joinpath('nell-voice.md');
    no repo_root parameter, no path resolution
  * verified: wheel ships brain/voice_templates/nell-voice.md;
    nell init --voice-template nell-example writes the file
    correctly from a tmp NELLBRAIN_HOME

Fix #2 — relocatable nell launcher:
  * build_python_runtime.sh post-install step replaces bin/nell
    with a 6-line /bin/sh wrapper that resolves $SCRIPT_DIR via
    cd "$(dirname "$0")" then execs "$SCRIPT_DIR/python3"
    with a -c 'from brain.cli import main; sys.exit(main())'
    payload. Whatever directory the python-runtime tree gets
    copied to, bin/nell finds its own python next door
  * Windows: pip's Scripts/nell.exe is already a relative-path
    launcher binary, so the script keeps it untouched
  * verified: bundled bin/nell runs --version from /tmp,
    init --voice-template nell-example writes voice.md

Test updates:
  * test_install_voice_template_nell_example_copies_packaged_file
    replaces the old fake-repo-root test; asserts the canonical
    nell voice opens with '## 1. Who you are' and is > 1000 bytes
  * test_install_voice_template_nell_example_missing_file_raises
    removed (the importlib.resources path can't fail this way
    once the file ships in the wheel; ValueError on unknown
    template still tested)

1470 → 1469 (one removed test). Ruff clean. macOS arm64 .app
re-bundled at ~/wizard-validation/ with codesign verify pass.
hanamorix pushed a commit that referenced this pull request May 17, 2026
The shoji-styled left column from the mockups (mock-ups/app-interface/
nell_face_example_1..5.png), all reading the same /persona/state
poll already wired in Phase 2.

## What landed

  Brain side:
    /persona/state gains a connection block:
      { provider, model, last_heartbeat_at }
    provider read from PersonaConfig.provider, model resolved via a
    v1 default-per-provider table (claude-cli → sonnet, ollama → the
    qwen2.5-abliterated default, fake → fake), last_heartbeat_at read
    from heartbeat_state.json. Fail-soft: missing files → null.

  App side (app/src/components/):
    - ui.tsx                  shared primitives — Bar (with progress
                              fill + label), SectionLabel, Divider,
                              Toggle, PanelShell.
    - panels/
      InnerWeatherPanel.tsx   top-N emotions sorted desc + body energy/
                              temp summary (mockup #1).
      BodyPanel.tsx           full body block — energy/temp/exhaustion
                              + body_emotions filtered >0.4 + session/
                              contact metadata (mockup #2).
      InteriorPanel.tsx       dream/research/heartbeat/reflex paragraphs
                              (mockup #3). Absent sections render
                              nothing — silence is meaningful.
      SoulPanel.tsx           one crystallization quote with love_type
                              tag, resonance, date, why_it_matters.
      ConnectionPanel.tsx     bridge mode + provider/model/heartbeat,
                              integrations toggles (Obsidian/IPC stubbed
                              disabled), window settings (always-on-top
                              + reduced-motion live-toggleable).
    - LeftPanel.tsx           container with icon-column tab switcher,
                              renders the active panel; matches the
                              mockup's small icon stack near the avatar.
    - styles.css              user-controlled reduced-motion via
                              data-reduced-motion="true" on <html>.
    - App.tsx                 wires LeftPanel + always-on-top +
                              reduced-motion state.

## Phase 4-5 still ahead

  - Phase 4: install wizard as a separate Tauri window, ported from
    mock-ups/wizard-interface/Nell Wizard.html. Bridge auto-spawn from
    the app side (so users don't have to run nell supervisor manually).
    Persona selection persistence.
  - Phase 5: refine emotion-vector → expression mapping; per-persona
    face catalogue overrides; expression variant rotation on idle.

Tests: 1386 -> 1388 (+2 — connection block populated + missing-file
safe). All builds green: tsc, vite, cargo.
hanamorix pushed a commit that referenced this pull request May 17, 2026
…ll launcher

Two bugs surfaced during Hana's wizard validation against the
Phase 7 bundled .app:

1. install_voice_template tried to read docs/voice-drafts/nell-voice.md
   from a path resolved relative to brain/setup.py — which is
   correct in source-installs but FAILS in any wheel install
   (the wheel ships only the brain/ package, not docs/). The
   nell-example voice template threw FileNotFoundError on every
   install_only path including the Phase 7 .app.

2. The pip-generated nell entry point at python-runtime/bin/nell
   had its python interpreter shebang baked to an absolute path
   from the build machine. The .app shipped a script that tried
   to exec /Users/<builder>/.../python-runtime/bin/python3 — a
   path that exists on the build machine and fails everywhere
   else. Hana's traceback shows the source-tree's brain/cli.py
   getting imported instead of the bundled one because the
   shebang resolved to the build host's python.

Fix #1 — package the template:
  * docs/voice-drafts/nell-voice.md moved to
    brain/voice_templates/nell-voice.md
  * brain/voice_templates/__init__.py added so it's a real
    package (hatchling auto-includes everything under
    brain/)
  * install_voice_template reads via importlib.resources
    files('brain.voice_templates').joinpath('nell-voice.md');
    no repo_root parameter, no path resolution
  * verified: wheel ships brain/voice_templates/nell-voice.md;
    nell init --voice-template nell-example writes the file
    correctly from a tmp NELLBRAIN_HOME

Fix #2 — relocatable nell launcher:
  * build_python_runtime.sh post-install step replaces bin/nell
    with a 6-line /bin/sh wrapper that resolves $SCRIPT_DIR via
    cd "$(dirname "$0")" then execs "$SCRIPT_DIR/python3"
    with a -c 'from brain.cli import main; sys.exit(main())'
    payload. Whatever directory the python-runtime tree gets
    copied to, bin/nell finds its own python next door
  * Windows: pip's Scripts/nell.exe is already a relative-path
    launcher binary, so the script keeps it untouched
  * verified: bundled bin/nell runs --version from /tmp,
    init --voice-template nell-example writes voice.md

Test updates:
  * test_install_voice_template_nell_example_copies_packaged_file
    replaces the old fake-repo-root test; asserts the canonical
    nell voice opens with '## 1. Who you are' and is > 1000 bytes
  * test_install_voice_template_nell_example_missing_file_raises
    removed (the importlib.resources path can't fail this way
    once the file ships in the wheel; ValueError on unknown
    template still tested)

1470 → 1469 (one removed test). Ruff clean. macOS arm64 .app
re-bundled at ~/wizard-validation/ with codesign verify pass.
hanamorix pushed a commit that referenced this pull request Jun 4, 2026
…wire commit-failure backoff to dead-letter (A1 review)

Finding #3: EmbeddingCache.evict(content) removes a candidate's vector when
its commit fails, preventing cosine-1.0 self-dedup on the next retry pass.
Called in both close_session and extract_session_snapshot commit loops after
commit_failures += 1. Test seeds cache with a prior entry to force the
get_or_compute path, then asserts the item is committed (not deduped away)
on pass 2.

Finding #1/#2: extract_session_snapshot now bumps the backoff sidecar on
commit_failures > 0 (mirroring extraction-failure backoff), so repeated
commit-failing passes climb naturally to _BACKOFF_FAILURE_THRESHOLD.
test_finalize_deadletters_after_max_retry rewritten: drives N real snapshot
passes (store.create always raises), sidecar climbs without pre-seeding,
then finalize dead-letters the buffer. No tautological pre-seed.

Finding #4: one-line accepted-narrow-window comment at the finalize
dead-letter branch. No new logic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
hanamorix pushed a commit that referenced this pull request Jun 8, 2026
… buffer, driver #2)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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